Files
vrsub/tests/test_batch.py
T
cat-shark c063aed1f1 fix: 批量任务残留PENDING仍误标COMPLETED——置完成前校验无残留+崩溃恢复批量任务
事故(batch_969fabe74b83 等 3 个任务实测):批量引擎串行处理到 9.9GB
大视频时中断,_run_job 无条件收尾把任务置 COMPLETED,留下"N 个 PENDING
待处理却已完成"的僵尸状态——3 个任务遗留的 PENDING 完全相同(kiwvr-887/
mdvr-413/savr-1077 共 10 个大视频),且均卡在大文件前从未被真正处理。

- batch.py: _run_job 置 COMPLETED 前校验全部非 SKIPPED 视频已结束
  (无 PENDING/PAUSED 残留),否则保持 RUNNING 交引擎下一轮续跑
- db.py/main.py: 新增 recover_interrupted_batch_jobs,重启时把 RUNNING
  批量任务恢复 QUEUED(否则停在 RUNNING 永远不会被再次拾起)
- tests: 新增 2 条 TDD 回归(残留 PENDING 不置 COMPLETED、批量崩溃恢复)
- scripts/fix_zombie_batch_jobs.py: 历史僵尸数据修复脚本(置回 QUEUED 续跑)
- AGENTS.md: 补充完成任务判定与崩溃恢复约定
2026-09-07 11:25:33 +08:00

1466 lines
66 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""文件夹批量处理引擎与 API 测试。
覆盖:视频扫描与旁挂字幕判定、创建任务时一次性定位(已有字幕 → SKIPPED)、
运行时只消费已定位明细、产物复制到视频旁与过程文件清理、暂停/继续断点续跑、
失败视频不中断、批量 API 全端点(创建/列表/详情/暂停/继续/删除/下载/目录树)
与 404/422 分支。
"""
import json
import time
from datetime import datetime, timezone
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from wov_app import batch as batch_engine
from wov_app.batch import (
BATCH_WORK_ROOT,
MARKER_NAME,
PAUSE_FLAG,
BatchWorker,
create_job,
list_sidecar_subtitles,
load_marker,
remove_job_workspace,
scan_videos,
)
from wov_app.config import STORAGE_DIR
from wov_app.db import Database
from wov_app.main import app
from wov_sdk.models import WorkflowDefinition
def _now_iso() -> str:
"""返回当前 UTC 时间的 ISO 格式字符串。"""
return datetime.now(timezone.utc).isoformat()
def _db(tmp_path) -> Database:
"""在临时目录创建独立数据库。"""
return Database(tmp_path / "wov.db")
def _seed_echo_workflow(db: Database, workflow_id: str = "echo-app", published: bool = True) -> None:
"""创建引用 echo 节点的单节点工作流(真实数据流:输入文件复制为产物)。
幂等:先删除同 ID 的旧工作流(含版本与任务),再重新创建。
"""
definition = {
"name": "echo-flow",
"version": 1,
"nodes": [
{"id": "step", "node_type": "echo", "inputs": {"file_uri": "input.video_uri"}}
],
"edges": [],
"entry_inputs": {"video_uri": "file"},
"final_outputs": {"result": "step.file_uri"},
}
# 批量测试会真实执行 echo 节点,注册表由 conftest 每测试隔离,需显式注册。
_register_echo()
if db.get_workflow(workflow_id) is not None:
db.delete_workflow(workflow_id)
db.upsert_workflow(
{
"id": workflow_id,
"name": "Echo",
"description": "",
"published": 1 if published else 0,
"latest_version": 1,
}
)
db.create_workflow_version(workflow_id, 1, definition)
def _register_echo() -> None:
"""把内置 echo 节点注册到进程内注册表(conftest 每个测试隔离注册表)。"""
from wov_app import registry
from nodes.echo import invoke
from wov_sdk.models import NodeManifest
root = Path(__file__).resolve().parent.parent
registry.register(NodeManifest.load(str(root / "manifests" / "echo.json")), invoke)
def _video_folder(tmp_path, names=("a.mp4", "b.mp4")) -> Path:
"""创建含视频文件的文件夹:内容为真实文本(echo 节点按文本读入)。"""
folder = tmp_path / "videos"
folder.mkdir()
for index, name in enumerate(names, start=1):
(folder / name).write_text(f"视频 {name} 的测试内容 {index}\n", encoding="utf-8")
return folder
def _make_job(db: Database, folder: Path, workflow_id: str = "echo-app", recursive: bool = True) -> dict:
"""通过 create_job 创建批量任务并返回任务记录。"""
job_id = create_job(db, str(folder), workflow_id, recursive)
return db.get_batch_job(job_id)
def _create_run(
db: Database,
run_id: str,
folder: Path,
workflow_id: str = "echo-app",
status: str = "QUEUED",
name: str = "a",
) -> None:
"""创建一条 source=batch 的运行记录(input 指向 folder 下的视频文件)。"""
db.create_run(
{
"id": run_id,
"workflow_id": workflow_id,
"workflow_version": 1,
"status": status,
"progress": 0,
"input_uri": str(folder / f"{name}.mp4"),
"param_overrides": None,
"source": "batch",
"created_at": _now_iso(),
"updated_at": _now_iso(),
}
)
def _find_product(video_dir: Path, stem: str, alias: str) -> Path:
"""在同名输出位置查找最终产物文件(文件名以 主名.别名 开头)。"""
matches = list(video_dir.glob(f"{stem}.{alias}.*"))
assert matches, f"未找到产物 {stem}.{alias}.* in {video_dir}"
return matches[0]
# ---------------------------------------------------------------------------
# 扫描 / 旁挂字幕判定 / 完成标记读取
# ---------------------------------------------------------------------------
def test_scan_videos_recursive_and_top_level(tmp_path) -> None:
"""递归/非递归扫描只返回视频文件,隐藏目录与普通文件不参与。"""
folder = tmp_path / "media"
(folder / "sub").mkdir(parents=True)
(folder / "a.mp4").write_text("a", encoding="utf-8")
(folder / "b.MKV").write_text("b", encoding="utf-8")
(folder / "readme.txt").write_text("c", encoding="utf-8")
(folder / "sub" / "c.avi").write_text("d", encoding="utf-8")
(folder / "sub" / "notes.md").write_text("e", encoding="utf-8")
recursive = scan_videos(folder, recursive=True)
assert [p.name for p in recursive] == ["a.mp4", "b.MKV", "c.avi"]
flat = scan_videos(folder, recursive=False)
assert [p.name for p in flat] == ["a.mp4", "b.MKV"]
def test_list_sidecar_subtitles_matches(tmp_path) -> None:
"""视频旁"文件名含视频名"的字幕文件全部命中;不相关/非字幕/别的目录不算。"""
folder = tmp_path / "media"
(folder / "sub").mkdir(parents=True)
video = folder / "movie.mp4"
video.write_text("v", encoding="utf-8")
# 命中:同目录、字幕扩展名、文件名含视频主名(大小写不敏感)。
(folder / "movie.srt").write_text("s", encoding="utf-8")
(folder / "movie.CN.srt").write_text("s", encoding="utf-8")
(folder / "MOVIE.CN_dual_eye.ass").write_text("s", encoding="utf-8")
(folder / "movie.zh-CN.20260819120000.srt").write_text("s", encoding="utf-8")
# 不命中:不含视频主名、非字幕扩展名、子目录里的字幕。
(folder / "other.srt").write_text("s", encoding="utf-8")
(folder / "movie.jpg").write_text("s", encoding="utf-8")
(folder / "sub" / "movie.srt").write_text("s", encoding="utf-8")
names = [p.name for p in list_sidecar_subtitles(video)]
assert names == [
"MOVIE.CN_dual_eye.ass",
"movie.CN.srt",
"movie.srt",
"movie.zh-CN.20260819120000.srt",
]
def test_list_sidecar_subtitles_short_stem_and_unrelated(tmp_path) -> None:
"""单字符视频主名只接受"主名."前缀,避免 a.mp4 误配 apple.srt。"""
folder = tmp_path / "media"
folder.mkdir()
video = folder / "a.mp4"
video.write_text("v", encoding="utf-8")
(folder / "apple.srt").write_text("s", encoding="utf-8")
(folder / "b.srt").write_text("s", encoding="utf-8")
assert list_sidecar_subtitles(video) == []
(folder / "a.srt").write_text("s", encoding="utf-8")
assert [p.name for p in list_sidecar_subtitles(video)] == ["a.srt"]
def test_list_sidecar_subtitles_handles_unreadable_entries(tmp_path, monkeypatch) -> None:
"""目录不可读返回空列表;单个子项不可读时跳过该项不影响其余匹配。"""
from pathlib import Path as RealPath
# 整目录不可读(iterdir 抛 OSError)→ 保守返回空。
class _Denied(RealPath):
"""iterdir 恒抛权限错误的子类,模拟不可读的视频目录。"""
def iterdir(self):
raise OSError("denied")
denied = _Denied(str(tmp_path / "denied"))
assert list_sidecar_subtitles(denied / "a.mp4") == []
# 单个子项不可读(is_file 抛 OSError)→ 跳过该项,其余正常返回。
folder = tmp_path / "media"
folder.mkdir()
video = folder / "a.mp4"
video.write_text("v", encoding="utf-8")
(folder / "a.srt").write_text("s", encoding="utf-8")
class _Poison(RealPath):
"""is_file 恒抛权限错误的子类,模拟不可读的目录项。"""
def is_file(self):
raise OSError("denied")
real_iterdir = RealPath.iterdir
def mixed_iterdir(path):
return list(real_iterdir(path)) + [_Poison(str(folder / "secret"))]
monkeypatch.setattr(RealPath, "iterdir", mixed_iterdir)
assert [p.name for p in list_sidecar_subtitles(video)] == ["a.srt"]
def test_load_marker_variants(tmp_path, monkeypatch) -> None:
"""完成标记缺失/损坏/非字典/读取异常时返回 None。"""
work = tmp_path / "movie"
work.mkdir()
assert load_marker(work) is None
(work / MARKER_NAME).write_text("{broken json", encoding="utf-8")
assert load_marker(work) is None
(work / MARKER_NAME).write_text("[1,2]", encoding="utf-8")
assert load_marker(work) is None
(work / MARKER_NAME).write_text('{"workflow_id": "w", "finals": {"r": "m.txt"}}', encoding="utf-8")
assert load_marker(work)["finals"]["r"] == "m.txt"
# 读取抛 OSError(权限等)时同样保守视为无标记。
from pathlib import Path as RealPath
def broken_read_text(path, **kwargs):
raise OSError("denied")
monkeypatch.setattr(RealPath, "read_text", broken_read_text)
assert load_marker(work) is None
def test_remove_job_workspace(tmp_path) -> None:
"""删除任务级私有工作空间;目录不存在时静默无副作用。"""
job_dir = BATCH_WORK_ROOT / "batch_del"
(job_dir / "runs" / "run_x").mkdir(parents=True)
remove_job_workspace("batch_del")
assert not job_dir.exists()
remove_job_workspace("batch_missing")
# ---------------------------------------------------------------------------
# create_job:校验 + 一次性定位(已有字幕 → SKIPPED)
# ---------------------------------------------------------------------------
def test_create_job_validation_errors(tmp_path) -> None:
"""文件夹不存在、未发布工作流、无版本、无视频都拒绝创建。"""
db = _db(tmp_path)
folder = _video_folder(tmp_path)
with pytest.raises(ValueError, match="folder not found"):
create_job(db, str(tmp_path / "missing"), "echo-app")
with pytest.raises(ValueError, match="published workflow not found"):
create_job(db, str(folder), "ghost-flow")
_seed_echo_workflow(db, published=False)
with pytest.raises(ValueError, match="published workflow not found"):
create_job(db, str(folder), "echo-app")
# 有版本但文件夹里没有视频。
_seed_echo_workflow(db, published=True)
empty = tmp_path / "empty"
empty.mkdir()
with pytest.raises(ValueError, match="no videos found in folder"):
create_job(db, str(empty), "echo-app")
# 已发布但没有版本的工作流。
db.delete_workflow("echo-app")
db.upsert_workflow({"id": "echo-app", "name": "E", "description": "", "published": 1, "latest_version": 0})
with pytest.raises(ValueError, match="workflow has no version"):
create_job(db, str(folder), "echo-app")
def test_create_job_locates_all_videos_once(tmp_path) -> None:
"""创建任务时一次性定位:全部视频登记明细、递归标志落库、可被引擎拾起。"""
db = _db(tmp_path)
_seed_echo_workflow(db)
(tmp_path / "videos" / "sub").mkdir(parents=True)
(tmp_path / "videos" / "a.mp4").write_text("a", encoding="utf-8")
(tmp_path / "videos" / "sub" / "b.mkv").write_text("b", encoding="utf-8")
job = _make_job(db, tmp_path / "videos", recursive=True)
assert job["status"] == "QUEUED"
videos = db.list_batch_videos(job["id"])
assert {Path(v["video_path"]).name for v in videos} == {"a.mp4", "b.mkv"}
assert all(v["status"] == "PENDING" for v in videos)
# 工作空间位于应用私有存储目录(与媒体库隔离),且每个视频独立目录。
assert all(Path(v["work_dir"]).is_relative_to(BATCH_WORK_ROOT / job["id"]) for v in videos)
assert db.next_queued_batch_job()["id"] == job["id"]
def test_create_job_skips_videos_with_existing_subtitles(tmp_path) -> None:
"""视频旁已有对应字幕文件的直接记 SKIPPED,其余 PENDING(不触发流水线)。"""
db = _db(tmp_path)
_seed_echo_workflow(db)
folder = _video_folder(tmp_path, names=("a.mp4", "b.mp4"))
# a 已处理过(视频旁有中文双目字幕),b 未处理。
(folder / "a.CN_dual_eye.ass").write_text("已处理", encoding="utf-8")
job = _make_job(db, folder)
videos = {Path(v["video_path"]).name: v for v in db.list_batch_videos(job["id"])}
assert videos["a.mp4"]["status"] == "SKIPPED"
assert videos["a.mp4"]["run_id"] is None
assert videos["b.mp4"]["status"] == "PENDING"
def test_create_job_all_videos_skipped_still_created(tmp_path) -> None:
"""文件夹里全部视频都已有字幕时任务直接视为完成(无字幕视频数为 0)。
新口径(2026-09):total = 扫描出的无字幕视频数,全被 SKIPPED 时
total=0 且没有可处理项,创建即置 COMPLETED,不排队空跑。
"""
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")
(folder / "b.CN_dual_eye.ass").write_text("x", encoding="utf-8")
job = _make_job(db, folder)
assert job["status"] == "COMPLETED"
assert job["total"] == 0 and job["done"] == 0
assert all(v["status"] == "SKIPPED" for v in db.list_batch_videos(job["id"]))
# ---------------------------------------------------------------------------
# 引擎:完整处理 / 跳过 / 失败 / 暂停续跑 / 收尾清理
# ---------------------------------------------------------------------------
def test_batch_worker_processes_all_videos_and_cleans_up(tmp_path) -> None:
"""批量引擎逐个处理视频:产物复制到视频旁、run 记录与过程工作空间清理。"""
db = _db(tmp_path)
_seed_echo_workflow(db)
folder = _video_folder(tmp_path)
job = _make_job(db, folder)
worker = BatchWorker(db, interval_seconds=0.05)
worker._process_job(job)
job = db.get_batch_job(job["id"])
assert job["status"] == "COMPLETED"
assert job["done"] == 2 and job["failed"] == 0
for name in ("a", "b"):
videos = {Path(v["video_path"]).name: v for v in db.list_batch_videos(job["id"])}
row = videos[f"{name}.mp4"]
# 最终产物复制到视频旁(与 .mp4 同目录),内容与输入一致(真实数据流)。
product = _find_product(folder, name, "result")
assert product.parent == folder
assert product.read_text(encoding="utf-8") == (folder / f"{name}.mp4").read_text(encoding="utf-8")
# 视频状态 COMPLETED 且 run 引用清空;不再写完成标记。
assert row["status"] == "COMPLETED"
assert row["run_id"] is None
assert not (Path(row["work_dir"]) / MARKER_NAME).exists()
# 过程工作空间(含 runs/steps/音频分块等)已被整体清理,不在媒体库残留。
assert not Path(row["work_dir"]).exists()
# run 记录已删除(产物已放视频旁,不再依赖原文件)。
assert db.list_runs() == []
def test_batch_worker_skips_videos_with_existing_subtitles(tmp_path) -> None:
"""已有字幕的视频在创建时记 SKIPPED,不进入 total/done,只统计真正处理的。
新口径(2026-09):total=无字幕视频数(不含 SKIPPED),done=实际完成数。
"""
db = _db(tmp_path)
_seed_echo_workflow(db)
folder = _video_folder(tmp_path, names=("a.mp4", "b.mp4"))
(folder / "a.CN_dual_eye.ass").write_text("已处理", encoding="utf-8")
job = _make_job(db, folder)
# 创建时 total 已固定为无字幕数(b 一个),SKIPPED 不计入。
assert db.get_batch_job(job["id"])["total"] == 1
BatchWorker(db, interval_seconds=0.05)._process_job(job)
videos = {Path(v["video_path"]).name: v for v in db.list_batch_videos(job["id"])}
assert videos["a.mp4"]["status"] == "SKIPPED"
assert videos["a.mp4"]["run_id"] is None
assert videos["b.mp4"]["status"] == "COMPLETED"
job = db.get_batch_job(job["id"])
# done 只计实际完成的 b(1 个),不再把 SKIPPED 的 a 计入。
assert job["done"] == 1 and job["total"] == 1
def test_batch_worker_all_skipped_job_completes(tmp_path) -> None:
"""全部视频都有字幕时创建即视为完成(total=0),不创建任何 run。"""
db = _db(tmp_path)
_seed_echo_workflow(db)
folder = _video_folder(tmp_path, names=("a.mp4", "b.mp4"))
(folder / "a.srt").write_text("x", encoding="utf-8")
(folder / "b.CN.srt").write_text("x", encoding="utf-8")
job = _make_job(db, folder)
# 新口径:无字幕视频数为 0 → 创建即 COMPLETED,引擎不需要再跑。
assert db.get_batch_job(job["id"])["status"] == "COMPLETED"
assert db.get_batch_job(job["id"])["total"] == 0
assert db.get_batch_job(job["id"])["done"] == 0
assert db.list_runs() == []
def test_batch_worker_failed_video_continues(tmp_path) -> None:
"""缺失视频文件记为 FAILED,任务继续处理后续视频。"""
db = _db(tmp_path)
_seed_echo_workflow(db)
folder = _video_folder(tmp_path, names=("gone.mp4", "b.mp4"))
job = _make_job(db, folder)
# 任务创建后、处理前删除第一个视频(模拟外部移除),第二个正常处理。
(folder / "gone.mp4").unlink()
BatchWorker(db, interval_seconds=0.05)._process_job(job)
videos = {Path(v["video_path"]).name: v for v in db.list_batch_videos(job["id"])}
assert videos["gone.mp4"]["status"] == "FAILED"
assert videos["gone.mp4"]["error"] == "video file not found"
assert videos["b.mp4"]["status"] == "COMPLETED"
job = db.get_batch_job(job["id"])
assert job["status"] == "COMPLETED"
assert job["done"] == 1 and job["failed"] == 1
def test_batch_worker_process_exception_marks_video_failed(tmp_path, monkeypatch) -> None:
"""单视频执行抛异常:该视频 FAILED 带错误信息,任务继续处理后续视频。"""
db = _db(tmp_path)
_seed_echo_workflow(db)
folder = _video_folder(tmp_path, names=("a.mp4", "b.mp4"))
job = _make_job(db, folder)
class _BoomScheduler:
"""execute_run 直接抛异常的假调度器,触发单视频兜底分支。"""
def __init__(self, db: Database, work_dir: Path) -> None:
self.db = db
def execute_run(self, run_id: str) -> None:
raise RuntimeError("boom")
monkeypatch.setattr("wov_app.batch.WorkflowScheduler", _BoomScheduler)
BatchWorker(db, interval_seconds=0.05)._process_job(job)
videos = {Path(v["video_path"]).name: v for v in db.list_batch_videos(job["id"])}
assert videos["a.mp4"]["status"] == "FAILED"
assert "boom" in videos["a.mp4"]["error"]
assert videos["b.mp4"]["status"] == "FAILED"
assert db.get_batch_job(job["id"])["status"] == "COMPLETED"
class _PausingScheduler:
"""把 execute_run 模拟为"被暂停"的假调度器:置 run 为 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:
self.db.update_run(run_id, status="PAUSED", updated_at=_now_iso())
def test_batch_worker_pause_then_resume_continues(tmp_path, monkeypatch) -> None:
"""暂停后重新开始:PAUSED 视频从断点续跑,未开始的视频接着处理。"""
db = _db(tmp_path)
_seed_echo_workflow(db)
folder = _video_folder(tmp_path, names=("a.mp4", "b.mp4"))
job = _make_job(db, folder)
worker = BatchWorker(db, interval_seconds=0.05)
# 第一次执行:第一个视频处理中被暂停(假调度器把 run 置为 PAUSED)。
monkeypatch.setattr("wov_app.batch.WorkflowScheduler", _PausingScheduler)
worker._process_job(job)
videos = {Path(v["video_path"]).name: v for v in db.list_batch_videos(job["id"])}
assert videos["a.mp4"]["status"] == "PAUSED"
assert videos["b.mp4"]["status"] == "PENDING"
assert db.get_batch_job(job["id"])["status"] == "PAUSED"
# 恢复真实调度器并继续:a 从断点完成(收尾清理),b 接着处理,任务 COMPLETED。
monkeypatch.undo()
worker.resume_job(job["id"])
worker._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 videos["b.mp4"]["status"] == "COMPLETED"
assert db.get_batch_job(job["id"])["status"] == "COMPLETED"
# 再次处理(已完成视频在循环里直接 continue):结果不变,幂等。
worker._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 videos["b.mp4"]["status"] == "COMPLETED"
assert db.get_batch_job(job["id"])["done"] == 2
def test_batch_worker_paused_job_does_not_start_new_video(tmp_path, monkeypatch) -> None:
"""任务在视频之间被暂停:后续视频不开始,不创建 run。"""
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 videos["b.mp4"]["status"] == "PENDING"
assert videos["b.mp4"]["run_id"] is None
assert db.get_batch_job(job_id)["status"] == "PAUSED"
def test_batch_worker_failed_and_running_runs_resume(tmp_path) -> None:
"""已失败的 run 重跑、上次进程残留的 RUNNING run 恢复后继续(收尾清理)。"""
db = _db(tmp_path)
_seed_echo_workflow(db)
folder = _video_folder(tmp_path, names=("a.mp4", "b.mp4"))
job = _make_job(db, folder)
videos = {Path(v["video_path"]).name: v for v in db.list_batch_videos(job["id"])}
for name, status in (("a", "FAILED"), ("b", "RUNNING")):
run_id = f"run_{name}"
_create_run(db, run_id, folder, status=status, name=name)
db.update_batch_video(videos[f"{name}.mp4"]["id"], run_id=run_id, updated_at=_now_iso())
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 videos["b.mp4"]["status"] == "COMPLETED"
def test_batch_worker_retry_failed_keeps_completed_node_artifacts(tmp_path) -> None:
"""批量重试 FAILED 视频**保留已完成节点产物**:只重跑失败节点。
回归:此前 FAILED 走 reset_run 清空全部产物记录,重跑时 extract/ocr 等
长耗时节点从头重做(run_e2b74e89e232 的 22222 帧 OCR 被白白丢弃)。
通过改写 step1 产物内容验证:若 step1 被重跑,最终产物会恢复为源视频
文本;保留产物则最终产物内容为改写后的内容。
"""
db = _db(tmp_path)
_register_echo()
# 双节点串联工作流:step1 成功、step2 失败的场景下验证只重跑 step2。
definition = {
"name": "two-step",
"version": 1,
"nodes": [
{"id": "s1", "node_type": "echo", "inputs": {"file_uri": "input.video_uri"}},
{"id": "s2", "node_type": "echo", "inputs": {"file_uri": "s1.file_uri"}},
],
"edges": [{"from": "s1", "to": "s2"}],
"entry_inputs": {"video_uri": "file"},
"final_outputs": {"result": "s2.file_uri"},
}
db.upsert_workflow({"id": "echo-app", "name": "Echo", "description": "", "published": 1, "latest_version": 1})
db.create_workflow_version("echo-app", 1, definition)
folder = _video_folder(tmp_path, names=("a.mp4",))
job = _make_job(db, folder)
video = db.list_batch_videos(job["id"])[0]
run_id = "run_retry"
_create_run(db, run_id, folder, status="FAILED", name="a")
db.update_batch_video(video["id"], run_id=run_id, updated_at=_now_iso())
# 模拟 step1 已完成并登记产物,把其内容改写为与源视频不同(step2 会引用)。
s1_out = Path(video["work_dir"]) / "runs" / run_id / "steps" / "s1" / "echo.txt"
s1_out.parent.mkdir(parents=True)
s1_out.write_text("step1 已保留产物", encoding="utf-8")
db.create_artifact(
{
"run_id": run_id,
"node_id": "s1",
"name": "s1.file_uri",
"uri": str(s1_out),
"mime_type": "text/plain",
"size": s1_out.stat().st_size,
}
)
BatchWorker(db, interval_seconds=0.05)._process_job(db.get_batch_job(job["id"]))
video = db.list_batch_videos(job["id"])[0]
assert video["status"] == "COMPLETED"
# step1 未重跑:最终产物内容等于保留的 step1 产物,而不是源视频文本。
product = _find_product(folder, "a", "result")
assert product.read_text(encoding="utf-8") == "step1 已保留产物"
def test_batch_worker_stale_run_id_recreated(tmp_path) -> None:
"""明细里的 run_id 指向已不存在的 run 时按全新视频处理并正常完成。"""
db = _db(tmp_path)
_seed_echo_workflow(db)
folder = _video_folder(tmp_path, names=("a.mp4",))
job = _make_job(db, folder)
video = db.list_batch_videos(job["id"])[0]
db.update_batch_video(video["id"], run_id="run_dead", updated_at=_now_iso())
BatchWorker(db, interval_seconds=0.05)._process_job(job)
video = db.list_batch_videos(job["id"])[0]
assert video["status"] == "COMPLETED"
assert db.get_run("run_dead") is None
assert _find_product(folder, "a", "result").is_file()
def test_batch_worker_run_deleted_by_scheduler_returns(tmp_path, monkeypatch) -> None:
"""execute_run 期间 run 记录被删除(极端情况)时直接返回,不误标完成。"""
db = _db(tmp_path)
_seed_echo_workflow(db)
folder = _video_folder(tmp_path, names=("a.mp4",))
job = _make_job(db, folder)
class _DeletingScheduler:
"""execute_run 直接删除 run 记录的假调度器。"""
def __init__(self, db: Database, work_dir: Path) -> None:
self.db = db
def execute_run(self, run_id: str) -> None:
self.db.delete_run(run_id)
monkeypatch.setattr("wov_app.batch.WorkflowScheduler", _DeletingScheduler)
BatchWorker(db, interval_seconds=0.05)._process_job(job)
video = db.list_batch_videos(job["id"])[0]
# 不标 COMPLETED/FAILED,保持 PENDING,等待下一次处理重试。
assert video["status"] == "PENDING"
def test_batch_worker_pending_leftover_never_marks_completed(tmp_path, monkeypatch) -> None:
"""残留未处理 PENDING 视频时,任务**不得**置 COMPLETED(状态机缺陷回归)。
真实事故(batch_969fabe74b83 等 3 个任务):引擎串行处理到 9.9GB 大视频时
execute_run 异常中断,_process_video 返回但该视频未被标 FAILED/COMPLETED
(保持 PENDING),_run_job 循环照常走完剩余 SKIPPED 后**无条件**收尾置
COMPLETED——留下"10 个待处理却已完成"的僵尸状态。
本测试用假调度器复现:第一个视频 execute_run 抛异常且不标任何状态
(run 被删除的极端情形同路径),第二个视频正常;断言任务必须保持
RUNNING(而非 COMPLETED),等待引擎下一次拾起重跑剩余 PENDING。
"""
db = _db(tmp_path)
_seed_echo_workflow(db)
# a.mp4 待处理(处理中崩溃);b.mp4 旁放好字幕 → 创建即 SKIPPED,不参与处理。
folder = _video_folder(tmp_path, names=("a.mp4", "b.mp4"))
(folder / "b.CN.srt").write_text("1\n00:00:00,000 --> 00:00:01,000\nb\n", encoding="utf-8")
job = _make_job(db, folder)
videos0 = {Path(v["video_path"]).name: v for v in db.list_batch_videos(job["id"])}
assert videos0["b.mp4"]["status"] == "SKIPPED" # 前置:旁挂字幕命中跳过
class _CrashScheduler:
"""模拟进程在第一个(唯一待处理)视频处理中被杀:run 消失、视频保持 PENDING。"""
def __init__(self, db: Database, work_dir: Path) -> None:
self.db = db
def execute_run(self, run_id: str) -> None:
# 等同进程崩溃后 run 记录残留被清/丢失,视频仍是 PENDING。
self.db.delete_run(run_id)
monkeypatch.setattr("wov_app.batch.WorkflowScheduler", _CrashScheduler)
worker = BatchWorker(db, interval_seconds=0.05)
# 第一轮:a 崩溃残留 PENDING → 任务必须仍 RUNNING(允许续跑),不得 COMPLETED。
worker._process_job(job)
job = db.get_batch_job(job["id"])
videos = {Path(v["video_path"]).name: v for v in db.list_batch_videos(job["id"])}
# 缺陷行为:job 被置 COMPLETED(无条件收尾);期望保持 RUNNING。
assert videos["a.mp4"]["status"] == "PENDING"
assert videos["b.mp4"]["status"] == "SKIPPED"
assert job["status"] == "RUNNING", (
f"残留 PENDING 时任务被误置为 {job['status']}(缺陷:无条件收尾置 COMPLETED"
)
# 第二轮(引擎重启后再次拾起 RUNNING 任务):a 这次正常完成 → 全部完成才 COMPLETED。
monkeypatch.setattr("wov_app.batch.WorkflowScheduler", _CrashScheduler)
# 放开假调度器的崩溃:第二次调用时不再删 run,让真实调度器跑通。
class _RecoveringScheduler:
"""第二轮:不再崩溃,把 run 置 COMPLETED(由引擎收尾放产物)。"""
def __init__(self, db: Database, work_dir: Path) -> None:
self.db = db
def execute_run(self, run_id: str) -> None:
self.db.update_run(run_id, status="COMPLETED", updated_at=_now_iso())
monkeypatch.setattr("wov_app.batch.WorkflowScheduler", _RecoveringScheduler)
worker._process_job(job)
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"] == "COMPLETED"
def test_batch_worker_already_completed_runs_finalized(tmp_path) -> None:
"""run 已完成但视频未标记(收尾前中断):直接放置产物、清理并标记完成。
覆盖 _place_products 各分支:产物齐全(复制)、产物记录存在但文件丢失
(跳过)、无产物记录(跳过)——完成后均清理工作空间与 run 记录。
"""
db = _db(tmp_path)
_seed_echo_workflow(db)
folder = _video_folder(tmp_path, names=("a.mp4", "b.mp4", "c.mp4"))
job = _make_job(db, folder)
now = _now_iso()
videos = {Path(v["video_path"]).stem: v for v in db.list_batch_videos(job["id"])}
def complete_run(run_id: str, name: str, with_file: bool, with_artifact: bool) -> None:
"""创建 COMPLETED run;可选产物文件与产物记录。"""
_create_run(db, run_id, folder, status="COMPLETED", name=name)
work_dir = Path(videos[name]["work_dir"])
if with_artifact:
path = work_dir / "runs" / run_id / "steps" / "step" / f"{name}.result.txt"
db.create_artifact(
{
"run_id": run_id,
"node_id": "step",
"name": "result",
"uri": str(path),
"mime_type": "text/plain",
"size": 6,
}
)
if with_file:
path = work_dir / "runs" / run_id / "steps" / "step" / f"{name}.result.txt"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(f"产物 {name}", encoding="utf-8")
db.update_batch_video(videos[name]["id"], run_id=run_id, updated_at=now)
# a:产物齐全;b:产物记录存在但文件丢失;c:没有任何产物记录。
complete_run("run_done_a", "a", with_file=True, with_artifact=True)
complete_run("run_done_b", "b", with_file=False, with_artifact=True)
complete_run("run_done_c", "c", with_file=False, with_artifact=False)
BatchWorker(db, interval_seconds=0.05)._process_job(db.get_batch_job(job["id"]))
videos = {Path(v["video_path"]).stem: v for v in db.list_batch_videos(job["id"])}
assert all(videos[name]["status"] == "COMPLETED" for name in ("a", "b", "c"))
# a 的产物复制到视频旁;b/c 无产物可复制。
product = _find_product(folder, "a", "result")
assert product.read_text(encoding="utf-8") == "产物 a"
assert not list(folder.glob("b.result.*")) and not list(folder.glob("c.result.*"))
# 三个视频的工作空间与 run 记录都被清理。
for name in ("a", "b", "c"):
assert not Path(videos[name]["work_dir"]).exists()
assert db.get_run(f"run_done_{name}") is None
assert db.list_runs() == []
def test_batch_worker_place_products_branches(tmp_path) -> None:
"""_place_products:无产物记录/文件丢失跳过;.srt/.ass 按库内约定命名覆盖;
其他扩展名保留原文件名复制。"""
db = _db(tmp_path)
_seed_echo_workflow(db)
folder = _video_folder(tmp_path, names=("a.mp4",))
video = folder / "a.mp4"
run_id = "run_place"
_create_run(db, run_id, folder, name="a")
source_dir = tmp_path / "src"
source_dir.mkdir()
worker = BatchWorker(db, interval_seconds=0.05)
definition = {
"name": "multi-final",
"version": 1,
"nodes": [],
"edges": [],
"entry_inputs": {"video_uri": "file"},
# 无产物记录 / 文件丢失 / srt / ass / txt 五种 final 场景。
"final_outputs": {
"alias_none": "x.none",
"alias_lost": "x.lost",
"alias_srt": "step.srt",
"alias_ass": "step.ass",
"alias_txt": "step.txt",
},
}
# alias_lost:产物记录存在但指向的文件已丢失 → 跳过不放置。
db.create_artifact(
{"run_id": run_id, "node_id": "step", "name": "alias_lost", "uri": str(source_dir / "lost.srt"), "mime_type": "text/plain", "size": 0}
)
# alias_srt:中文 srt → <视频名>.CN.srt;目标已存在(旧内容)→ 覆盖。
src_srt = source_dir / "raw_any_name.srt"
src_srt.write_text("1\n00:00:00,000 --> 00:00:01,000\n新中文字幕\n", encoding="utf-8")
db.create_artifact(
{"run_id": run_id, "node_id": "step", "name": "alias_srt", "uri": str(src_srt), "mime_type": "text/plain", "size": src_srt.stat().st_size}
)
target_srt = video.parent / "a.CN.srt"
target_srt.write_text("旧字幕内容", encoding="utf-8")
# alias_ass:双目 ass → <视频名>.CN_dual_eye.ass;覆盖同名旧文件。
src_ass = source_dir / "whatever.ass"
src_ass.write_text("[Script Info]\n新双目字幕\n", encoding="utf-8")
db.create_artifact(
{"run_id": run_id, "node_id": "step", "name": "alias_ass", "uri": str(src_ass), "mime_type": "text/plain", "size": src_ass.stat().st_size}
)
target_ass = video.parent / "a.CN_dual_eye.ass"
target_ass.write_text("旧 ass", encoding="utf-8")
# alias_txt:其他扩展名保留原文件名(含时间戳命名)复制。
src_txt = source_dir / "a.other.20260902120000.txt"
src_txt.write_text("其余产物内容", encoding="utf-8")
db.create_artifact(
{"run_id": run_id, "node_id": "step", "name": "alias_txt", "uri": str(src_txt), "mime_type": "text/plain", "size": src_txt.stat().st_size}
)
placed = worker._place_products(run_id, video, WorkflowDefinition.from_dict(definition))
assert placed == ["a.CN.srt", "a.CN_dual_eye.ass", "a.other.20260902120000.txt"]
# srt/ass 被改名为标准名且覆盖旧文件;txt 保留原名。
assert target_srt.read_text(encoding="utf-8") == "1\n00:00:00,000 --> 00:00:01,000\n新中文字幕\n"
assert target_ass.read_text(encoding="utf-8") == "[Script Info]\n新双目字幕\n"
assert (video.parent / "a.other.20260902120000.txt").read_text(encoding="utf-8") == "其余产物内容"
# alias_none(无记录)与 alias_lost(文件丢失)未放置。
assert not (video.parent / "lost.srt").exists()
def test_batch_worker_job_validation_failures(tmp_path) -> None:
"""文件夹缺失/工作流未发布/无版本时任务置为 FAILED 并记录错误。"""
db = _db(tmp_path)
now = _now_iso()
def add_job(job_id, folder, workflow_id="echo-app"):
db.create_batch_job(
{
"id": job_id, "folder_path": folder, "workflow_id": workflow_id,
"recursive": 1, "status": "QUEUED", "progress": 0, "total": 0,
"done": 0, "failed": 0, "error": None,
"created_at": now, "updated_at": now,
}
)
worker = BatchWorker(db, interval_seconds=0.05)
add_job("job_nofolder", str(tmp_path / "missing"))
worker._process_job(db.get_batch_job("job_nofolder"))
assert db.get_batch_job("job_nofolder")["status"] == "FAILED"
assert "folder not found" in db.get_batch_job("job_nofolder")["error"]
folder = _video_folder(tmp_path)
_seed_echo_workflow(db, published=False)
add_job("job_unpublished", str(folder))
worker._process_job(db.get_batch_job("job_unpublished"))
assert db.get_batch_job("job_unpublished")["status"] == "FAILED"
assert "not found or unpublished" in db.get_batch_job("job_unpublished")["error"]
# 已发布但没有任何版本。
_seed_echo_workflow(db, published=True)
db.delete_workflow("echo-app")
db.upsert_workflow({"id": "echo-app", "name": "E", "description": "", "published": 1, "latest_version": 0})
add_job("job_noversion", str(folder))
worker._process_job(db.get_batch_job("job_noversion"))
assert db.get_batch_job("job_noversion")["status"] == "FAILED"
assert "has no version" in db.get_batch_job("job_noversion")["error"]
def test_batch_worker_catches_unexpected_job_error(tmp_path) -> None:
"""任务级兜底:DAG 解析失败(缺 name)时任务 FAILED 而不是卡死在 RUNNING。"""
db = _db(tmp_path)
_seed_echo_workflow(db)
folder = _video_folder(tmp_path, names=("a.mp4",))
# 覆盖为缺失 name 的非法定义:from_dict 抛 KeyError。
bad_definition = {"version": 1, "nodes": [], "edges": []}
db.create_workflow_version("echo-app", 2, bad_definition)
db.upsert_workflow({"id": "echo-app", "name": "E", "description": "", "published": 1, "latest_version": 2})
job = _make_job(db, folder)
BatchWorker(db, interval_seconds=0.05)._process_job(job)
assert db.get_batch_job(job["id"])["status"] == "FAILED"
assert "name" in db.get_batch_job(job["id"])["error"]
def test_batch_worker_cycle_fails_video_not_job(tmp_path) -> None:
"""DAG 环在执行期抛错:单个视频 FAILED 记录错误,批量任务继续并完成。"""
db = _db(tmp_path)
_seed_echo_workflow(db)
folder = _video_folder(tmp_path, names=("a.mp4",))
# 覆盖为带环的定义:validate 通过、调度器拓扑排序时抛"contains a cycle"。
cycle_definition = {
"name": "bad",
"version": 2,
"nodes": [
{"id": "x", "node_type": "echo", "inputs": {"file_uri": "y.file_uri"}},
{"id": "y", "node_type": "echo", "inputs": {"file_uri": "x.file_uri"}},
],
"edges": [{"from": "x", "to": "y"}, {"from": "y", "to": "x"}],
"entry_inputs": {"video_uri": "file"},
"final_outputs": {"result": "x.file_uri"},
}
db.create_workflow_version("echo-app", 2, cycle_definition)
db.upsert_workflow({"id": "echo-app", "name": "E", "description": "", "published": 1, "latest_version": 2})
job = _make_job(db, folder)
BatchWorker(db, interval_seconds=0.05)._process_job(job)
video = db.list_batch_videos(job["id"])[0]
assert video["status"] == "FAILED"
assert "cycle" in video["error"]
assert db.get_batch_job(job["id"])["status"] == "COMPLETED"
assert db.get_batch_job(job["id"])["failed"] == 1
def test_batch_worker_loop_processes_and_survives_exceptions(tmp_path, monkeypatch) -> None:
"""轮询线程:处理排队任务;轮询异常不杀死线程;重复启动/幽灵任务无害。"""
db = _db(tmp_path)
_seed_echo_workflow(db)
folder = _video_folder(tmp_path, names=("a.mp4",))
job = _make_job(db, folder)
# 不存在的任务 ID:_run_job 直接返回,不报错。
BatchWorker(db, interval_seconds=0.05)._process_job({"id": "ghost_job"})
# 第一次轮询抛异常(模拟数据库抖动),后续正常。
calls = {"n": 0}
real_next = db.next_queued_batch_job
def flaky_next():
calls["n"] += 1
if calls["n"] == 1:
raise RuntimeError("transient error")
return real_next()
monkeypatch.setattr(db, "next_queued_batch_job", flaky_next)
worker = BatchWorker(db, interval_seconds=0.05)
worker.start()
# 重复启动无副作用:线程已存在时直接返回。
worker.start()
try:
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
if db.get_batch_job(job["id"])["status"] in {"COMPLETED", "FAILED"}:
break
time.sleep(0.1)
finally:
worker.stop()
assert db.get_batch_job(job["id"])["status"] == "COMPLETED"
def test_batch_worker_pause_job_writes_flag_and_pauses_run(tmp_path) -> None:
"""pause_job:任务置 PAUSED、排队/运行中的 run 暂停并写 paused.flag。"""
db = _db(tmp_path)
_seed_echo_workflow(db)
folder = _video_folder(tmp_path, names=("a.mp4", "b.mp4", "c.mp4"))
job = _make_job(db, folder)
videos = {Path(v["video_path"]).stem: v for v in db.list_batch_videos(job["id"])}
# a:QUEUED 运行(应被暂停并写 flag);b:run 记录不存在;c:已完成的 run。
_create_run(db, "run_pause_me", folder, status="QUEUED", name="a")
_create_run(db, "run_done_c", folder, status="COMPLETED", name="c")
db.update_batch_video(videos["a"]["id"], run_id="run_pause_me", updated_at=_now_iso())
db.update_batch_video(videos["b"]["id"], run_id="run_ghost", updated_at=_now_iso())
db.update_batch_video(videos["c"]["id"], run_id="run_done_c", updated_at=_now_iso())
worker = BatchWorker(db, interval_seconds=0.05)
worker.pause_job(job["id"])
assert db.get_batch_job(job["id"])["status"] == "PAUSED"
assert db.get_run("run_pause_me")["status"] == "PAUSED"
work_dir_a = Path(videos["a"]["work_dir"])
assert (work_dir_a / "runs" / "run_pause_me" / PAUSE_FLAG).is_file()
# b 的 run 不存在、c 的 run 已完成:都被跳过,不写 flag。
assert not (Path(videos["b"]["work_dir"]) / "runs" / "run_ghost" / PAUSE_FLAG).exists()
assert not (Path(videos["c"]["work_dir"]) / "runs" / "run_done_c" / PAUSE_FLAG).exists()
# 继续:任务回到 QUEUED,由引擎从断点续跑。
worker.resume_job(job["id"])
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 视频不计入 total 也不计入 done(它不需要本批处理)。
新口径(2026-09):进度只反映"本批无字幕待处理"的视频。
"""
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"])
job = db.get_batch_job(job["id"])
assert job["done"] == 0
assert job["total"] == 0
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 分支不把它们计入 done。
新口径:total=无字幕视频数(本例 a 有字幕 SKIPPED → total=1 即 b),
done 只计实际完成(b=COMPLETED → done=1)。SKIPPED 不占分母也不占分子。
"""
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"]
# 创建即固定:total=无字幕数(仅 b=1)。
assert db.get_batch_job(job_id)["total"] == 1
# b 手工置为 COMPLETED(模拟此前已完成);a 在循环里走 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(),
)
db.update_batch_job(job_id, status="PAUSED", updated_at=_now_iso())
BatchWorker(db, interval_seconds=0.05)._run_job(job_id)
job = db.get_batch_job(job_id)
# done 只计实际完成的 b1),SKIPPED 的 a 不计;total 保持 1。
assert job["done"] == 1 and job["total"] == 1
def test_batch_worker_ghost_job_id_returns(tmp_path) -> None:
"""_run_job 在任务不存在时直接返回(幽灵任务处理无副作用)。"""
db = _db(tmp_path)
BatchWorker(db, interval_seconds=0.05)._run_job("batch_ghost")
# ---------------------------------------------------------------------------
# 批量 API
# ---------------------------------------------------------------------------
def _client_with_echo_workflow(tmp_path):
"""返回 TestClient 与包含真实视频文件的文件夹(echo 工作流已发布)。"""
folder = _video_folder(tmp_path, names=("a.mp4", "b.mp4"))
client = TestClient(app)
client.__enter__()
client.post(
"/api/admin/workflows",
json={
"id": "echo-app",
"name": "Echo App",
"description": "batch test",
"definition": {
"name": "echo-flow",
"version": 1,
"nodes": [
{"id": "step", "node_type": "echo", "inputs": {"file_uri": "input.video_uri"}}
],
"edges": [],
"entry_inputs": {"video_uri": "file"},
"final_outputs": {"result": "step.file_uri"},
},
},
)
client.post("/api/admin/workflows/echo-app/publish")
return client, folder
def test_batch_api_create_list_detail(tmp_path) -> None:
"""批量 API:创建任务、列表、详情(含 SKIPPED 状态与视频旁产物清单)。"""
client, folder = _client_with_echo_workflow(tmp_path)
try:
# a 视频旁已有字幕(创建即 SKIPPED),b 待处理。
(folder / "a.CN_dual_eye.ass").write_text("已有字幕", encoding="utf-8")
response = client.post(
"/api/batch/jobs",
json={"folder": str(folder), "workflow_id": "echo-app", "recursive": True},
)
assert response.status_code == 200
job = response.json()
assert job["status"] == "QUEUED"
by_name = {Path(v["video_path"]).name: v for v in job["videos"]}
assert by_name["a.mp4"]["status"] == "SKIPPED"
assert by_name["a.mp4"]["finals"] == {"a.CN_dual_eye.ass": "a.CN_dual_eye.ass"}
assert by_name["b.mp4"]["status"] == "PENDING"
assert by_name["b.mp4"]["finals"] == {}
listed = client.get("/api/batch/jobs").json()
assert any(item["id"] == job["id"] for item in listed)
detail = client.get(f"/api/batch/jobs/{job['id']}").json()
assert detail["workflow_id"] == "echo-app"
assert len(detail["videos"]) == 2
# 新口径:total = 无字幕需处理数(b 一个),SKIPPED 的 a 不占分母。
assert detail["total"] == 1 and detail["done"] == 0
missing = client.get("/api/batch/jobs/ghost")
assert missing.status_code == 404
finally:
client.__exit__(None, None, None)
def test_batch_api_all_skipped_created_completed(tmp_path) -> None:
"""全部视频已有字幕时,创建响应直接为 COMPLETEDtotal=0。"""
client, folder = _client_with_echo_workflow(tmp_path)
try:
# 两个视频都已有旁挂字幕 → 无任何无字幕项。
(folder / "a.CN.srt").write_text("x", encoding="utf-8")
(folder / "b.CN_dual_eye.ass").write_text("x", encoding="utf-8")
job = client.post(
"/api/batch/jobs",
json={"folder": str(folder), "workflow_id": "echo-app"},
).json()
assert job["status"] == "COMPLETED"
assert job["total"] == 0 and job["done"] == 0
assert all(v["status"] == "SKIPPED" for v in job["videos"])
finally:
client.__exit__(None, None, None)
def test_batch_api_creation_errors(tmp_path) -> None:
"""批量 API 拒绝:文件夹不存在、无视频、未发布工作流。"""
client, folder = _client_with_echo_workflow(tmp_path)
try:
bad_folder = client.post(
"/api/batch/jobs",
json={"folder": str(tmp_path / "missing"), "workflow_id": "echo-app"},
)
assert bad_folder.status_code == 422
empty = tmp_path / "empty"
empty.mkdir()
no_videos = client.post(
"/api/batch/jobs",
json={"folder": str(empty), "workflow_id": "echo-app"},
)
assert no_videos.status_code == 422
not_published = client.post(
"/api/batch/jobs",
json={"folder": str(folder), "workflow_id": "ghost"},
)
assert not_published.status_code == 422
finally:
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)
try:
job = client.post(
"/api/batch/jobs",
json={"folder": str(folder), "workflow_id": "echo-app"},
).json()
paused = client.post(f"/api/batch/jobs/{job['id']}/pause")
assert paused.status_code == 200
assert client.get(f"/api/batch/jobs/{job['id']}").json()["status"] == "PAUSED"
resumed = client.post(f"/api/batch/jobs/{job['id']}/resume")
assert resumed.status_code == 200
assert client.get(f"/api/batch/jobs/{job['id']}").json()["status"] == "QUEUED"
# 给第一个视频挂一个 run,验证删除任务时级联删除 run 记录。
db = app.state.db
video = job["videos"][0]
_create_run(db, "run_del", folder, name=Path(video["video_path"]).stem)
db.update_batch_video(video["id"], run_id="run_del", updated_at=_now_iso())
assert db.get_run("run_del") is not None
# 模拟任务遗留的私有工作空间:删除任务时一并清理。
workspace = BATCH_WORK_ROOT / job["id"]
(workspace / "runs" / "run_del").mkdir(parents=True)
(workspace / "runs" / "run_del" / "paused.flag").write_text("", encoding="utf-8")
deleted = client.delete(f"/api/batch/jobs/{job['id']}")
assert deleted.status_code == 200
assert client.get(f"/api/batch/jobs/{job['id']}").status_code == 404
assert db.get_run("run_del") is None
assert not workspace.exists()
assert client.post("/api/batch/jobs/ghost/pause").status_code == 404
assert client.post("/api/batch/jobs/ghost/resume").status_code == 404
assert client.delete("/api/batch/jobs/ghost").status_code == 404
finally:
client.__exit__(None, None, None)
def test_batch_api_worker_unavailable(tmp_path, monkeypatch) -> None:
"""批量引擎不可用时,暂停/继续接口返回 503。"""
client, folder = _client_with_echo_workflow(tmp_path)
try:
job = client.post(
"/api/batch/jobs",
json={"folder": str(folder), "workflow_id": "echo-app"},
).json()
monkeypatch.setattr("wov_app.routers.batch._get_worker", lambda: None)
assert client.post(f"/api/batch/jobs/{job['id']}/pause").status_code == 503
assert client.post(f"/api/batch/jobs/{job['id']}/resume").status_code == 503
finally:
client.__exit__(None, None, None)
def test_batch_api_download_sidecar_subtitle(tmp_path) -> None:
"""批量 API:下载视频旁的字幕文件,缺失别名/文件返回 404。"""
client, folder = _client_with_echo_workflow(tmp_path)
try:
(folder / "a.CN_dual_eye.ass").write_text("已有字幕内容", encoding="utf-8")
job = client.post(
"/api/batch/jobs",
json={"folder": str(folder), "workflow_id": "echo-app"},
).json()
video = next(v for v in job["videos"] if Path(v["video_path"]).name == "a.mp4")
ok = client.get(f"/api/batch/jobs/{job['id']}/videos/{video['id']}/download?alias=a.CN_dual_eye.ass")
assert ok.status_code == 200
assert ok.content == "已有字幕内容".encode("utf-8")
bad_alias = client.get(f"/api/batch/jobs/{job['id']}/videos/{video['id']}/download?alias=nope")
assert bad_alias.status_code == 404
bad_video = client.get(f"/api/batch/jobs/{job['id']}/videos/bv_ghost/download?alias=a.CN_dual_eye.ass")
assert bad_video.status_code == 404
finally:
client.__exit__(None, None, None)
def test_batch_api_download_sidecar_file_missing(tmp_path, monkeypatch) -> None:
"""下载时旁挂字幕文件已被外部移除(列表与下载之间的竞态)→ 404。"""
client, folder = _client_with_echo_workflow(tmp_path)
try:
(folder / "a.srt").write_text("x", encoding="utf-8")
job = client.post(
"/api/batch/jobs",
json={"folder": str(folder), "workflow_id": "echo-app"},
).json()
video = next(v for v in job["videos"] if Path(v["video_path"]).name == "a.mp4")
# 模拟列表后文件被删:让旁挂字幕扫描返回一个磁盘上已不存在的路径。
gone = folder / "a.srt"
gone.unlink()
monkeypatch.setattr(batch_engine, "list_sidecar_subtitles", lambda video_path: [gone])
missing = client.get(f"/api/batch/jobs/{job['id']}/videos/{video['id']}/download?alias=a.srt")
assert missing.status_code == 404
assert "file missing" in missing.json()["detail"]
finally:
client.__exit__(None, None, None)
def test_batch_api_download_legacy_marker(tmp_path) -> None:
"""批量 API:旧版完成标记(batch.done.json)里的语义别名仍可下载。"""
client, folder = _client_with_echo_workflow(tmp_path)
try:
job = client.post(
"/api/batch/jobs",
json={"folder": str(folder), "workflow_id": "echo-app"},
).json()
db = app.state.db
video = job["videos"][0]
# 旧版布局:work_dir 是视频的同名文件夹,里面写 batch.done.json + 产物。
work = Path(video["work_dir"])
work.mkdir(parents=True)
marker = {"workflow_id": "echo-app", "workflow_version": 1, "run_id": "run_old", "finals": {"result": "a.result.20260819000000.txt"}}
(work / MARKER_NAME).write_text(json.dumps(marker), encoding="utf-8")
(work / "a.result.20260819000000.txt").write_text("下载内容", encoding="utf-8")
# 详情页产物清单合并旧版完成标记里的语义别名。
detail = client.get(f"/api/batch/jobs/{job['id']}").json()
video_detail = next(v for v in detail["videos"] if v["id"] == video["id"])
assert video_detail["finals"]["result"] == "a.result.20260819000000.txt"
ok = client.get(f"/api/batch/jobs/{job['id']}/videos/{video['id']}/download?alias=result")
assert ok.status_code == 200
assert ok.content == "下载内容".encode("utf-8")
# 标记里别名对应的产物文件缺失 → 404(不会落到旁挂字幕解析)。
(work / "a.result.20260819000000.txt").unlink()
gone = client.get(f"/api/batch/jobs/{job['id']}/videos/{video['id']}/download?alias=result")
assert gone.status_code == 404
finally:
client.__exit__(None, None, None)
def test_batch_api_roots(tmp_path, monkeypatch) -> None:
"""目录树选择器:返回可浏览根目录(含根/家目录与 Windows 盘符分支)。"""
client, _folder = _client_with_echo_workflow(tmp_path)
try:
roots = client.get("/api/batch/roots").json()
assert isinstance(roots, list) and len(roots) >= 1
# POSIX 必有 /;Windows 必有盘符;两者都有家目录。
assert any(item["path"] in ("/", str(Path.home())) for item in roots)
assert all(item["name"] for item in roots)
# Windows 分支:模拟 os.name=nt,存在盘符时返回该驱动器。
import os
real_exists = Path.exists
def fake_exists(path):
# 盘符形式(如 C:\)视为存在,其余走真实判断。
return str(path).endswith(":\\") or real_exists(path)
monkeypatch.setattr(os, "name", "nt")
monkeypatch.setattr(Path, "exists", fake_exists)
nt_roots = client.get("/api/batch/roots").json()
assert any(str(item["path"]).endswith(":\\") for item in nt_roots)
finally:
client.__exit__(None, None, None)
def test_batch_api_dirs(tmp_path, monkeypatch) -> None:
"""目录树选择器:列出子目录、隐藏目录过滤、不存在/不可读返回空。"""
client, _folder = _client_with_echo_workflow(tmp_path)
try:
# 真实目录结构:普通子目录、隐藏目录、文件。
target = tmp_path / "media"
(target / "movies").mkdir(parents=True)
(target / "series").mkdir(parents=True)
(target / ".hidden").mkdir(parents=True)
(target / "note.txt").write_text("x", encoding="utf-8")
data = client.get(f"/api/batch/dirs?path={target}").json()
assert [item["name"] for item in data["dirs"]] == ["movies", "series"]
assert data["path"] == str(target)
# 路径指向文件 → 空列表。
file_data = client.get(f"/api/batch/dirs?path={target / 'note.txt'}").json()
assert file_data["dirs"] == []
# 目录不存在 → 空列表。
missing = client.get(f"/api/batch/dirs?path={tmp_path / 'ghost'}").json()
assert missing["dirs"] == []
from pathlib import Path as RealPath
# 单个子项不可读(is_dir 抛 OSError)→ 跳过该项,其余目录正常返回。
class _PoisonPath(RealPath):
"""is_dir 恒抛权限错误的子类,模拟不可读的子目录。"""
def is_dir(self):
raise OSError("denied")
real_iterdir = RealPath.iterdir
def mixed_iterdir(path):
return list(real_iterdir(path)) + [_PoisonPath(str(tmp_path / "poison"))]
monkeypatch.setattr(RealPath, "iterdir", mixed_iterdir)
mixed = client.get(f"/api/batch/dirs?path={target}").json()
assert [item["name"] for item in mixed["dirs"]] == ["movies", "series"]
# 整个目录不可读(iterdir 抛 OSError)→ 空列表而不是 500。
def deny(path):
raise OSError("denied")
monkeypatch.setattr(RealPath, "iterdir", deny)
denied = client.get(f"/api/batch/dirs?path={target}").json()
assert denied["dirs"] == []
finally:
client.__exit__(None, None, None)
def test_main_starts_batch_worker_when_enabled(monkeypatch) -> None:
"""WOV_BATCH_ENABLED=1 时应用生命周期启动批量引擎后台线程(退出时回收)。"""
monkeypatch.setenv("WOV_BATCH_ENABLED", "1")
client = TestClient(app)
with client:
worker = app.state.batch
assert worker is not None
assert worker._thread is not None
assert worker._thread.name == "wov-batch-worker"
# 退出应用后批量引擎线程已停止,后续测试不会被后台线程打扰。
assert worker._thread is None
# STORAGE_DIR 引用仅供静态检查使用(ensure batch 根相对应用存储目录)。
assert BATCH_WORK_ROOT == STORAGE_DIR / "batch"