合并任务排队串行,不再互相顶掉进度和杀错 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
+116 -9
View File
@@ -3,10 +3,22 @@ package main
import (
"errors"
"strings"
"sync"
"testing"
"time"
)
// 测试用的「开一个新任务」。全局任务只有一份,用例之间必须清干净:
// 上一个没收尾的话 jobStart 会拒绝覆盖(这是刻意的防串台),断言就会读到别人的数据
func startJobForTest(t *testing.T, kind, outName string, inputBytes int64, totalSec float64) {
t.Helper()
jobFinish("")
if !jobStart(kind, outName, inputBytes, totalSec) {
t.Fatalf("jobStart 失败:还残留着未结束的任务 %q", jobBusyName())
}
t.Cleanup(func() { jobFinish("") })
}
// 阶段权重加起来必须正好是 1,否则总进度永远到不了 100%
func TestJobPhaseWeightsSumToOne(t *testing.T) {
if len(jobPhaseNames) != len(jobPhaseWeights) {
@@ -23,8 +35,7 @@ func TestJobPhaseWeightsSumToOne(t *testing.T) {
// 总进度:走过的阶段按权重满算,当前阶段按比例折算
func TestJobPercentByPhase(t *testing.T) {
jobStart("测试", "x.mp4", 0, 100)
t.Cleanup(func() { jobFinish("") })
startJobForTest(t, "测试", "x.mp4", 0, 100)
if got := jobSnapshot().Percent; got != 0 {
t.Errorf("刚开始时总进度应为 0,实际 %v", got)
@@ -50,7 +61,7 @@ func TestJobPercentByPhase(t *testing.T) {
// 阶段序号只往前走,重复设置同一阶段不会把进度打回 0
func TestJobPhaseOnlyMovesForward(t *testing.T) {
jobStart("测试", "x.mp4", 0, 100)
startJobForTest(t, "测试", "x.mp4", 0, 100)
defer jobFinish("")
jobSetPhase(jobPhaseRender)
jobRender(60, 1, 25, 0)
@@ -66,7 +77,7 @@ func TestJobPhaseOnlyMovesForward(t *testing.T) {
// 渲染阶段的 ETA = 剩余时长 ÷ 倍速
func TestJobEtaFromSpeed(t *testing.T) {
jobStart("测试", "x.mp4", 0, 200)
startJobForTest(t, "测试", "x.mp4", 0, 200)
defer jobFinish("")
jobSetPhase(jobPhaseRender)
jobRender(50, 2.0, 25, 0)
@@ -78,7 +89,7 @@ func TestJobEtaFromSpeed(t *testing.T) {
// 预计输出大小:先给按码率估的值,渲染过 5% 之后换成按已写出字节外推
func TestJobEstOutSize(t *testing.T) {
jobStart("测试", "x.mp4", 0, 100)
startJobForTest(t, "测试", "x.mp4", 0, 100)
defer jobFinish("")
jobSetPhase(jobPhaseRender)
@@ -99,7 +110,7 @@ func TestJobEstOutSize(t *testing.T) {
}
// 比例太小的时候不急着外推,免得开头抖动
jobStart("测试", "x.mp4", 0, 100)
startJobForTest(t, "测试", "x.mp4", 0, 100)
jobSetPhase(jobPhaseRender)
jobPlanSize(5000)
jobRender(1, 1, 25, 100)
@@ -110,7 +121,7 @@ func TestJobEstOutSize(t *testing.T) {
// 速度折线:最多保留固定点数,且按秒采样
func TestJobSamples(t *testing.T) {
jobStart("测试", "x.mp4", 0, 100)
startJobForTest(t, "测试", "x.mp4", 0, 100)
defer jobFinish("")
jobSetPhase(jobPhaseRender)
// 同一秒内连着报多次只留一个点
@@ -136,7 +147,7 @@ func TestJobSamples(t *testing.T) {
// 暂停 / 恢复 / 停止的状态流转;停止后闸门必须让上层拿到 errJobStopped
func TestJobPauseStopGate(t *testing.T) {
jobStart("测试", "x.mp4", 0, 100)
startJobForTest(t, "测试", "x.mp4", 0, 100)
defer jobFinish("")
if err := jobPause(); err != nil {
@@ -281,7 +292,7 @@ func TestScanFFmpegProgressTolerant(t *testing.T) {
// 截图式地看一眼任务快照的字段都填上了
func TestJobSnapshotFields(t *testing.T) {
jobStart("手动合并", "成片.mp4", 123456, 3600)
startJobForTest(t, "手动合并", "成片.mp4", 123456, 3600)
defer jobFinish("")
jobSetPhase(jobPhaseAvatar)
jobAvatars(3, 10)
@@ -306,3 +317,99 @@ func TestJobSnapshotFields(t *testing.T) {
t.Error("任务在跑的时候应该可暂停")
}
}
// ==================== 任务串行化 ====================
// 全局任务状态只有一份:两个合并同时跑的话,后启动的会把前一个的进度和输出名清掉,
// 点「停止」还会杀掉别家任务的 ffmpeg。下面几条例外情况锁住改串行之后的语义。
// jobStart 在已经有任务在跑时必须拒绝,并且一个字段都不能动
func TestJobStartRejectsWhenBusy(t *testing.T) {
startJobForTest(t, "自动合并", "甲_弹幕合并.mp4", 1000, 100)
jobSetPhase(jobPhaseRender)
jobRender(30, 1.2, 25, 5000)
if ok := jobStart("手动合并", "乙_弹幕合并.mp4", 2000, 200); ok {
t.Fatal("已经有任务在跑,jobStart 应当返回 false")
}
info := jobSnapshot()
if info.Kind != "自动合并" || info.OutName != "甲_弹幕合并.mp4" {
t.Errorf("被拒之后不该动别人的任务信息:%+v", info)
}
if info.InputBytes != 1000 || info.TotalSec != 100 {
t.Errorf("被拒之后输入大小和总时长也不该变:%+v", info)
}
if info.Percent < 25 || info.Percent > 45 {
t.Errorf("被拒之后进度必须还是原任务自己的(25~45),实际 %v", info.Percent)
}
if !info.Active {
t.Error("拒绝新任务不代表把老任务结束掉")
}
}
// 任务位被占着时手动合并要立刻知道(不能让页面干等),抢不到只能有一个赢
func TestMergeSlotTryLockExclusive(t *testing.T) {
if !tryTakeMergeSlot() {
t.Fatal("空闲时手动合并应该抢得到任务位")
}
if tryTakeMergeSlot() {
t.Fatal("任务位已经被占着,第二次 TryLock 不该成功")
}
releaseMergeSlot()
if !tryTakeMergeSlot() {
t.Fatal("释放之后应该又能抢到")
}
releaseMergeSlot()
}
// 自动合并(十几个主播同时下播很常见)必须排队而不是并发
func TestMergeSlotSerializes(t *testing.T) {
if !tryTakeMergeSlot() {
t.Fatal("前置条件:任务位本该是空的")
}
const n = 4
order := make([]int, 0, n)
var mu sync.Mutex
done := make(chan struct{}, n)
for i := 0; i < n; i++ {
idx := i
go func() {
defer func() { done <- struct{}{} }()
// 和真实链路一致:自动合并会一直等到轮到自己
_ = runMergeExclusive(func() error {
mu.Lock()
order = append(order, idx)
mu.Unlock()
time.Sleep(20 * time.Millisecond)
return nil
})
}()
}
time.Sleep(60 * time.Millisecond) // 让它们先堆在门外
releaseMergeSlot()
for i := 0; i < n; i++ {
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatalf("第 %d 个任务没跑完,队列卡住了", i)
}
}
if len(order) != n {
t.Fatalf("应当 %d 个任务都跑完,实际 %d 个", n, len(order))
}
}
// 任务内部 panic 时也必须释放任务位、并且能翻成错误
// —— 否则卡片会永远停在「进行中」,队列也被锁死
func TestMergeExclusiveReleasesOnPanic(t *testing.T) {
err := runMergeExclusive(func() error { panic("模拟内部出错") })
if err == nil {
t.Fatal("内部 panic 应当被翻成错误返回")
}
if !strings.Contains(err.Error(), "模拟内部出错") {
t.Errorf("错误信息里应带上原因,实际:%v", err)
}
if !tryTakeMergeSlot() {
t.Fatal("panic 之后任务位必须已经释放")
}
releaseMergeSlot()
}