合并任务排队串行,不再互相顶掉进度和杀错 ffmpeg

审查发现:全局只有一份任务进度状态,但没有任何东西拦住两个合并同时跑。

1. 为什么必须串行
   jobStart() 原来是无条件重置全部字段的。多个主播同时下播(很常见),
   或者自动合并跑着的时候在页面上点一次手动合并,就会有两个任务同时跑:
   后启动的把前一个的输出名、进度、已渲染时长全部清零;更糟的是 jobStop()
   只认最后注册的那个 ffmpeg,点「停止」杀的是别人家任务的进程,
   而自己那个继续跑;两个任务还会互相读到 stopped 标记而提前退出。
   界面上看只是一个进度条乱跳,背后是合并结果可能张冠李戴。

2. 怎么串起来的
   加一把 mergeSlot 锁占任务位:
   - 手动合并用 TryLock,抢不到直接 409 并告诉用户现在谁在跑 —— 它是一次
     HTTP 请求,不能让人干等几十分钟。
   - 自动合并用 Lock 排队等待,它本来就是后台任务,丢掉白录一场。
   jobStart 改成返回 bool:已经有任务在跑时返回 false 且一个字段都不动。
   这样即使以后有调用方忘了先拿任务位,也只是失败而不会互相覆盖。

3. 顺带修「panic 之后卡片一直转」
   原来 jobFinish() 写在 autoMerge / mergeHandler 的正常返回路径上,任务内部
   一旦 panic(外部 safeGo 的 recover 兜在外层)它就永远不会执行,任务会一直
   停在 active,界面上的进度条再也不动,ffmpeg 也失联了。
   现在统一走 runMergeExclusive():任务位用 defer 释放,panic 翻译成 error,
   保证 jobFinish 一定能跑到。

4. 之前 ban 掉的 jobActive() 接回来了
   它本来就该是这道护栏,写了却没人调用,现在用于判断要不要提示「在排队」
   以及拒绝新任务时报出正在忙的任务名。jobState 的锁改成 RWMutex 供它读。

已验证:
- 新增 4 个用例:TestJobStartRejectsWhenBusy(被拒时 Must 一个字段都不改,
  包括进度必须还是原任务的)、TestMergeSlotTryLockExclusive、
  TestMergeSlotSerializes(4 个并发任务必须全跑完且不重不漏)、
  TestMergeExclusiveReleasesOnPanic(panic 后必须拿到任务位 + 翻出错误)。
- 反向验证:把 jobStart 的占用判断临时改成 `if false && job.active`,
  TestJobStartRejectsWhenBusy 立刻报「应当返回 false」。
- 现有测试跟着走真实链路(progress_test 加了 startJobForTest 统一先收尾、
  manualmerge_test 走 execManualMerge 独占执行)—— 改之前这些用例单独跑过、
  连着跑会失败,正是因为前一个用例把任务状态留在了 active。
- gofmt / go vet / go build / GOOS=linux go build / go test / go test -race 全过。
This commit is contained in:
tamakyi
2026-09-26 02:41:29 +08:00
parent 7dd2487cb8
commit c26eb4c648
5 changed files with 217 additions and 26 deletions
+14 -1
View File
@@ -471,7 +471,9 @@ func runManualMerge(plan mergePlan) error {
for _, v := range plan.info.Videos {
inputBytes += v.Size
}
jobStart("手动合并", plan.info.OutName, inputBytes, plan.info.Duration)
if !jobStart("手动合并", plan.info.OutName, inputBytes, plan.info.Duration) {
return errors.New("已经有别的合并任务在跑,请先等它结束")
}
src, err := prepareDanmuSource(plan.danmu, true)
if err != nil {
@@ -596,12 +598,23 @@ func mergeHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "已经有一个手动合并在进行("+busyOut+"),等它结束再说", http.StatusConflict)
return
}
// 任务卡片只有一份:自动合并也可能占着它,那时手动的也得等
if !tryTakeMergeSlot() {
mergeRun.Unlock()
busy := jobBusyName()
if busy == "" {
busy = "另一个合并任务"
}
http.Error(w, "已经有一个合并在进行("+busy+"),等它结束再说", http.StatusConflict)
return
}
mergeRun.busy = true
mergeRun.outName = plan.info.OutName
mergeRun.lastErr = ""
mergeRun.Unlock()
safeGo("手动合并", func() {
defer releaseMergeSlot()
defer func() {
mergeRun.Lock()
mergeRun.busy = false