合并任务排队串行,不再互相顶掉进度和杀错 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
+12 -3
View File
@@ -242,9 +242,15 @@ func remapJsonlTimes(srcFile, dstFile string, starts []int64, durs []float64, da
return nil
}
// 自动合并入口:把结果写进任务进度;日志措辞仍由内部负责
// 自动合并入口:排队跑完一场,把结果写进任务进度;日志措辞仍由内部负责
func autoMerge(uid int, segs []recordSegment, jsonlFile string, danmuStartNs int64) {
err := autoMergeJob(uid, segs, jsonlFile, danmuStartNs)
// 多个主播可能同时下播,而全局只有一份任务状态:先排队,跑到了再登记进度
if jobActive() {
lPrintf("已有合并任务在跑(%s),本场先排队等待", jobBusyName())
}
err := runMergeExclusive(func() error {
return autoMergeJob(uid, segs, jsonlFile, danmuStartNs)
})
if err == errJobStopped {
// 停止不是故障,内部不打印,这里补一句
lPrintWarn("自动合并已被手动停止" + longID(uid))
@@ -300,7 +306,10 @@ func autoMergeJob(uid int, segs []recordSegment, jsonlFile string, danmuStartNs
inputBytes += fileSizeOrZero(p)
}
}
jobStart("自动合并", filepath.Base(outFile), inputBytes, 0)
if !jobStart("自动合并", filepath.Base(outFile), inputBytes, 0) {
// 走到这里说明没先拿 mergeSlot,与其把别人的进度冲掉不如直接放弃这一场
return errors.New("开始任务失败:还有别的合并任务没结束,本场合并已跳过")
}
video, jsonl, offset, cleanup, err := prepareForMerge(ffmpegFile, segs, jsonlFile, danmuStartNs)
defer cleanup()