fix: 批量任务进度实时汇总,暂停/中断时前端不再显示 0/总数 0%
batch_jobs.done 此前只在任务整体完成时一次性汇总,处理中途(尤其被暂停) 恒为 0,导致前端显示 "0/431 完成 0%" 而实际已处理多个视频 (回归 batch_fee668175444:已处理 15 个仍显示 0/431)。 - db: 新增 sync_batch_job_progress(按明细实时重算 done=COMPLETED+SKIPPED、 failed=FAILED 并落库)与 refresh_batch_job(对齐后返回最新任务) - batch 引擎: 暂停返回、SKIPPED/COMPLETED continue、视频缺失、单视频异常后、 任务收尾等边界统一调用实时对齐,替代收尾一次性 sum - routers/batch: 列表/详情读取前实时对齐,即使引擎不在运行也返回真实进度 - tests: 新增 6 条 TDD 回归测试覆盖 db 助手、引擎暂停、SKIPPED、API 读取侧 同时按用户要求:移除 100% 行覆盖率强制门槛(pytest 不再 --cov-fail-under), 约定小改动只跑相关测试、保证功能可用即可(AGENTS.md 与 pyproject.toml)。
This commit is contained in:
@@ -882,6 +882,120 @@ def test_batch_worker_pause_job_writes_flag_and_pauses_run(tmp_path) -> None:
|
||||
assert db.get_batch_job(job["id"])["status"] == "QUEUED"
|
||||
|
||||
|
||||
|
||||
def test_batch_progress_sync_recounts_done_failed(tmp_path) -> None:
|
||||
"""sync_batch_job_progress 按明细实时重算 done(=完成+跳过) 与 failed。
|
||||
|
||||
回归:此前 batch_jobs.done 只在任务收尾一次性汇总,处理中途(尤其暂停)
|
||||
恒为 0——前端显示 0/431 0%,与实际已处理数量严重不符(真实任务
|
||||
batch_fee668175444 已处理 15 个仍显示 0/431)。本测试要求无论任务处于
|
||||
哪种状态,汇总字段都与明细实时一致。
|
||||
"""
|
||||
db = _db(tmp_path)
|
||||
_seed_echo_workflow(db)
|
||||
folder = _video_folder(tmp_path, names=("a.mp4", "b.mp4", "c.mp4", "d.mp4"))
|
||||
job = _make_job(db, folder)
|
||||
job_id = job["id"]
|
||||
videos = {Path(v["video_path"]).stem: v for v in db.list_batch_videos(job_id)}
|
||||
|
||||
# 手工构造典型中途态:a 已完成、b 处理中被暂停、c 失败、d 仍排队。
|
||||
db.update_batch_video(videos["a"]["id"], status="COMPLETED", updated_at=_now_iso())
|
||||
db.update_batch_video(videos["b"]["id"], status="PAUSED", updated_at=_now_iso())
|
||||
db.update_batch_video(videos["c"]["id"], status="FAILED", error="boom", updated_at=_now_iso())
|
||||
# 任务保持 PAUSED(引擎在视频间停下时的真实状态)。
|
||||
db.update_batch_job(job_id, status="PAUSED", updated_at=_now_iso())
|
||||
|
||||
db.sync_batch_job_progress(job_id)
|
||||
synced = db.get_batch_job(job_id)
|
||||
# done 计入已完成 + 已跳过(与收尾口径一致);失败单独计;进行中不计。
|
||||
assert synced["done"] == 1
|
||||
assert synced["failed"] == 1
|
||||
|
||||
# 幂等:重复对齐结果不变(引擎在多个边界可能重复调用)。
|
||||
db.sync_batch_job_progress(job_id)
|
||||
assert db.get_batch_job(job_id)["done"] == 1
|
||||
|
||||
|
||||
def test_batch_progress_sync_counts_skipped(tmp_path) -> None:
|
||||
"""SKIPPED 视频同样计入 done(它们不需要处理,属于已完成的工作量)。"""
|
||||
db = _db(tmp_path)
|
||||
_seed_echo_workflow(db)
|
||||
folder = _video_folder(tmp_path, names=("a.mp4",))
|
||||
job = _make_job(db, folder)
|
||||
# 把 a 改为 SKIPPED(等同创建时旁挂字幕被跳过的语义)。
|
||||
db.update_batch_video(db.list_batch_videos(job["id"])[0]["id"], status="SKIPPED", updated_at=_now_iso())
|
||||
db.sync_batch_job_progress(job["id"])
|
||||
assert db.get_batch_job(job["id"])["done"] == 1
|
||||
|
||||
|
||||
def test_batch_progress_sync_returns_refreshed_job(tmp_path) -> None:
|
||||
"""refresh_batch_job 返回对齐后的最新任务记录(供 router 读取侧使用)。"""
|
||||
db = _db(tmp_path)
|
||||
_seed_echo_workflow(db)
|
||||
folder = _video_folder(tmp_path, names=("a.mp4", "b.mp4"))
|
||||
job = _make_job(db, folder)
|
||||
job_id = job["id"]
|
||||
db.update_batch_video(db.list_batch_videos(job_id)[0]["id"], status="COMPLETED", updated_at=_now_iso())
|
||||
refreshed = db.refresh_batch_job(job_id)
|
||||
assert refreshed is not None
|
||||
assert refreshed["done"] == 1
|
||||
# 幽灵任务:返回 None,不报错。
|
||||
assert db.refresh_batch_job("batch_ghost") is None
|
||||
|
||||
|
||||
def test_batch_worker_paused_job_reports_real_done(tmp_path, monkeypatch) -> None:
|
||||
"""引擎在视频之间暂停后,job 汇总实时反映已完成数量(不等任务收尾)。
|
||||
|
||||
回归:真实任务 batch_fee668175444 手动暂停后前端仍显示 0/431 0%,
|
||||
因为 done 只在任务整体 COMPLETED 时汇总一次。修复后引擎每次停下都要
|
||||
用明细实时对齐 done/failed,暂停中前端即可看到真实进度。
|
||||
"""
|
||||
db = _db(tmp_path)
|
||||
_seed_echo_workflow(db)
|
||||
folder = _video_folder(tmp_path, names=("a.mp4", "b.mp4"))
|
||||
job = _make_job(db, folder)
|
||||
job_id = job["id"]
|
||||
from wov_app.scheduler import WorkflowScheduler
|
||||
|
||||
class _PauseAfterFirstScheduler:
|
||||
"""真实执行第一个视频后把批量任务置为 PAUSED(模拟用户处理中暂停)。"""
|
||||
|
||||
def __init__(self, db: Database, work_dir: Path) -> None:
|
||||
self.db = db
|
||||
self.work_dir = work_dir
|
||||
|
||||
def execute_run(self, run_id: str) -> None:
|
||||
WorkflowScheduler(self.db, self.work_dir).execute_run(run_id)
|
||||
self.db.update_batch_job(job_id, status="PAUSED", updated_at=_now_iso())
|
||||
|
||||
monkeypatch.setattr("wov_app.batch.WorkflowScheduler", _PauseAfterFirstScheduler)
|
||||
BatchWorker(db, interval_seconds=0.05)._process_job(db.get_batch_job(job_id))
|
||||
|
||||
videos = {Path(v["video_path"]).name: v for v in db.list_batch_videos(job_id)}
|
||||
assert videos["a.mp4"]["status"] == "COMPLETED"
|
||||
assert db.get_batch_job(job_id)["status"] == "PAUSED"
|
||||
# 关键断言:暂停瞬间 done 已是 1(a 完成),而不是停留在创建时的 0。
|
||||
assert db.get_batch_job(job_id)["done"] == 1
|
||||
|
||||
|
||||
def test_batch_worker_skipped_continue_syncs_done(tmp_path) -> None:
|
||||
"""循环里遇到 SKIPPED/COMPLETED 的 continue 分支也会把汇总实时对齐。"""
|
||||
db = _db(tmp_path)
|
||||
_seed_echo_workflow(db)
|
||||
folder = _video_folder(tmp_path, names=("a.mp4", "b.mp4"))
|
||||
(folder / "a.CN.srt").write_text("x", encoding="utf-8") # a 创建即 SKIPPED
|
||||
job = _make_job(db, folder)
|
||||
job_id = job["id"]
|
||||
# b 也手工置为 COMPLETED(引擎会因 continue 分支跳过它)。
|
||||
db.update_batch_video(
|
||||
[v for v in db.list_batch_videos(job_id) if v["video_path"].endswith("b.mp4")][0]["id"],
|
||||
status="COMPLETED", updated_at=_now_iso(),
|
||||
)
|
||||
# 任务先置 PAUSED:首轮循环在 a(SKIPPED)即停下,若 continue 前未对齐则 done=0。
|
||||
db.update_batch_job(job_id, status="PAUSED", updated_at=_now_iso())
|
||||
BatchWorker(db, interval_seconds=0.05)._run_job(job_id)
|
||||
assert db.get_batch_job(job_id)["done"] == 2
|
||||
|
||||
def test_batch_worker_ghost_job_id_returns(tmp_path) -> None:
|
||||
"""_run_job 在任务不存在时直接返回(幽灵任务处理无副作用)。"""
|
||||
db = _db(tmp_path)
|
||||
@@ -977,6 +1091,43 @@ def test_batch_api_creation_errors(tmp_path) -> None:
|
||||
client.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_batch_api_list_detail_syncs_real_progress(tmp_path) -> None:
|
||||
"""列表/详情读取前自动对齐 done/failed:暂停中的任务也显示真实进度。
|
||||
|
||||
回归:服务运行中任务被暂停(batch_fee668175444),前端轮询列表看到
|
||||
0/431 0%——因为 done 只在收尾时汇总、暂停后无人再写。读取侧兜底对齐
|
||||
保证前端拿到明细真实状态,即使引擎不在运行(进程被杀/暂停中)。
|
||||
"""
|
||||
client, folder = _client_with_echo_workflow(tmp_path)
|
||||
try:
|
||||
job = client.post(
|
||||
"/api/batch/jobs",
|
||||
json={"folder": str(folder), "workflow_id": "echo-app"},
|
||||
).json()
|
||||
job_id = job["id"]
|
||||
db = app.state.db
|
||||
|
||||
# 模拟暂停中已处理 1 个(COMPLETED)+ 1 个失败:明细变了,但 job.done 仍是 0。
|
||||
videos = db.list_batch_videos(job_id)
|
||||
db.update_batch_video(videos[0]["id"], status="COMPLETED", updated_at=_now_iso())
|
||||
db.update_batch_video(videos[1]["id"], status="FAILED", error="x", updated_at=_now_iso())
|
||||
db.update_batch_job(job_id, status="PAUSED", updated_at=_now_iso())
|
||||
assert db.get_batch_job(job_id)["done"] == 0 # 修复前:旧值
|
||||
|
||||
# 读取详情:返回的 job 已完成实时对齐。
|
||||
detail = client.get(f"/api/batch/jobs/{job_id}").json()
|
||||
assert detail["done"] == 1 and detail["failed"] == 1
|
||||
# 数据库里的汇总也一并修正(读取副作用:后续列表/引擎都看到正确值)。
|
||||
assert db.get_batch_job(job_id)["done"] == 1
|
||||
|
||||
# 列表接口同样实时对齐。
|
||||
listed = client.get("/api/batch/jobs").json()
|
||||
mine = next(item for item in listed if item["id"] == job_id)
|
||||
assert mine["done"] == 1 and mine["failed"] == 1
|
||||
finally:
|
||||
client.__exit__(None, None, None)
|
||||
|
||||
|
||||
def test_batch_api_pause_resume_delete(tmp_path) -> None:
|
||||
"""批量 API:暂停/继续切换任务状态,删除清理 DB 记录与私有工作空间。"""
|
||||
client, folder = _client_with_echo_workflow(tmp_path)
|
||||
|
||||
Reference in New Issue
Block a user