批量引擎改为「分块流水线」:视频按 WOV_BATCH_STAGE_GROUP_SIZE(默认 8)分组, 组内按 DAG 拓扑序跑完全部视频(全部 extract → 全部 ASR → 全部翻译 → 全部 ASS) 再进入下一组,本地模型每组只加载一次、卸载一次,而不是每个视频来回加载卸载; 产物仍按组增量落到视频旁。调度器新增 execute_run(run_id, stop_after=节点): 该节点完成后任务保持 RUNNING 不收尾,下一次调用从产物表跳过已完成节点继续, 用于实现阶段边界。 - nodes/llm.py:翻译节点结束释放本机 Ollama 显存(node 参数 unload_after > LLM_UNLOAD_AFTER > 本机 loopback 端点默认卸载,云端端点不卸载;卸载失败只告警), 新增 keep_model.flag 语义(阶段内保持常驻)与 release_local_model(); 新增节点内暂停(按批 20 行检查 paused.flag,抛 PauseRequested,调度器保持 PAUSED)。 - src/wov_app/batch.py:分组阶段执行与阶段末统一释放显存;失败视频只在它失败 节点的那个阶段重试(避免 LLM 已常驻时重跑 ASR 抢显存);任务没有明细时保持 QUEUED 等登记完成、仍有未完成视频时置回 QUEUED 自愈(原先留 RUNNING 会卡死: 引擎只拾取 QUEUED,任务停在“运行中但没人推进”);无失败视频时删除任务级空目录; 每个阶段开始前清理 paused.flag / keep_model.flag,避免强杀残留影响后续阶段。 - src/wov_app/config.py:新增 WOV_BATCH_STAGE_GROUP_SIZE(设为 1 即旧的每视频全链路)。 - 任务列表与批量页分工:GET /api/runs 默认排除 source=batch(一个批量任务会产生 N 条单视频 run,会把 20 条窗口占满;且任务管理页的暂停/重试/删除对批量 run 语义不成立),需要排查时用 include_batch=1;作为补偿批量页详情新增阶段列 (阶段 i/N · 中文标签,由该视频 run 的 current_node_id 在 DAG 拓扑序中的位置 推导,节点类型映射中文标签)。阶段只有节点边界粒度,句级进度不落库、只在日志。 - 顺带纳入此前未提交的批量僵尸状态恢复:recover_interrupted_batch_jobs 除 RUNNING 外也把「COMPLETED 但仍含未结束视频」的任务置回 QUEUED;fix_zombie_batch_jobs.py 改为按条件扫描并支持 --apply 预览;批量页明细只列本批真正处理过的视频。 测试新增/更新:分块流水线调用顺序(组内按节点跑完再下一组)、每组只释放一次模型、 阶段内保持常驻标志、翻译按批暂停、失败视频不跨阶段推进、任务无明细/中途登记视频时 置回 QUEUED、任务工作空间与残留信号清理、任务列表默认过滤批量 run、详情阶段字段、 前端阶段列渲染;全量 507 passed(唯一失败为既有素材缺失的 integration 用例)。
384 lines
15 KiB
Python
384 lines
15 KiB
Python
"""src/wov_app/db.py 的模块级测试(数据 → 测试过程 → 验证结果)。
|
||
|
||
被测模块:`src/wov_app/db.py`(SQLite Repository:工作流/版本/任务/产物/
|
||
批量任务),可独立调用。每个用例在临时目录创建独立数据库文件(真实 SQLite),
|
||
不依赖全局 conftest。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from wov_app.db import Database
|
||
|
||
|
||
@pytest.fixture()
|
||
def db(tmp_path: Path) -> Database:
|
||
"""每个用例一个独立 SQLite 库(真实文件,非内存桩)。"""
|
||
return Database(tmp_path / "wov.db")
|
||
|
||
|
||
@pytest.fixture()
|
||
def db_with_workflow(db: Database) -> Database:
|
||
"""已建好工作流(含 v1 版本)的库:任务表对 workflow_id 有外键约束。"""
|
||
db.upsert_workflow(_workflow("wf"))
|
||
db.create_workflow_version("wf", 1, {"nodes": []})
|
||
return db
|
||
|
||
|
||
def _workflow(workflow_id: str = "wf", name: str = "流程") -> dict:
|
||
"""构造真实工作流记录字段。"""
|
||
return {"id": workflow_id, "name": name, "description": ""}
|
||
|
||
|
||
def _run(run_id: str = "run-1", **overrides) -> dict:
|
||
"""构造真实任务记录字段(默认 upload 来源、QUEUED 状态)。"""
|
||
record = {
|
||
"id": run_id,
|
||
"workflow_id": "wf",
|
||
"workflow_version": 1,
|
||
"status": "QUEUED",
|
||
"current_node_id": None,
|
||
"progress": 0.0,
|
||
"error": None,
|
||
"input_uri": None,
|
||
"param_overrides": None,
|
||
"source": "upload",
|
||
"created_at": "2026-09-01T00:00:00+00:00",
|
||
"updated_at": "2026-09-01T00:00:00+00:00",
|
||
}
|
||
record.update(overrides)
|
||
return record
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 工作流与版本
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_upsert_and_get_workflow(db: Database) -> None:
|
||
"""工作流写入后可读回,重复写入同 ID 覆盖而不报错。"""
|
||
# 数据:一条工作流记录。
|
||
db.upsert_workflow(_workflow("wf-1", "初版"))
|
||
|
||
# 测试过程
|
||
stored = db.get_workflow("wf-1")
|
||
db.upsert_workflow(_workflow("wf-1", "改名"))
|
||
renamed = db.get_workflow("wf-1")
|
||
|
||
# 验证结果
|
||
assert stored["name"] == "初版"
|
||
assert renamed["name"] == "改名"
|
||
|
||
|
||
def test_list_and_delete_workflow(db: Database) -> None:
|
||
"""列出全部工作流;删除后不再出现在列表与查询中。"""
|
||
# 数据:两条工作流。
|
||
db.upsert_workflow(_workflow("wf-1"))
|
||
db.upsert_workflow(_workflow("wf-2"))
|
||
|
||
# 测试过程
|
||
before = {w["id"] for w in db.list_workflows()}
|
||
db.delete_workflow("wf-1")
|
||
after = {w["id"] for w in db.list_workflows()}
|
||
|
||
# 验证结果
|
||
assert before == {"wf-1", "wf-2"}
|
||
assert after == {"wf-2"}
|
||
assert db.get_workflow("wf-1") is None
|
||
|
||
|
||
def test_workflow_versions_and_latest(db: Database) -> None:
|
||
"""版本按序保存,latest 返回最高版本,可按版本号精确读取。"""
|
||
# 数据:同一工作流的 v1 与 v2 定义。
|
||
db.upsert_workflow(_workflow("wf-1"))
|
||
db.create_workflow_version("wf-1", 1, {"nodes": [{"id": "a"}]})
|
||
db.create_workflow_version("wf-1", 2, {"nodes": [{"id": "a"}, {"id": "b"}]})
|
||
|
||
# 测试过程
|
||
latest = db.get_latest_workflow_version("wf-1")
|
||
first = db.get_workflow_version("wf-1", 1)
|
||
versions = db.list_workflow_versions("wf-1")
|
||
|
||
# 验证结果
|
||
assert latest["version"] == 2
|
||
assert len(latest["definition"]["nodes"]) == 2
|
||
assert first["definition"]["nodes"] == [{"id": "a"}]
|
||
assert [v["version"] for v in versions] == [2, 1]
|
||
|
||
|
||
def test_workflow_version_missing_returns_none(db: Database) -> None:
|
||
"""不存在的工作流/版本返回 None(不做隐式创建)。"""
|
||
# 数据:空库。
|
||
# 测试过程与验证结果
|
||
assert db.get_latest_workflow_version("nope") is None
|
||
assert db.get_workflow_version("nope", 1) is None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 任务:创建、读写、param_overrides
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_create_and_get_run_with_overrides(db_with_workflow: Database) -> None:
|
||
"""任务创建后可读回,param_overrides 以 JSON 存储并解析回字典。"""
|
||
# 数据:带参数覆盖的 upload 任务。
|
||
db_with_workflow.create_run(_run("r1", param_overrides={"frame-extract": {"crop": [0, 0.75, 1, 0.25]}}))
|
||
|
||
# 测试过程
|
||
stored = db_with_workflow.get_run("r1")
|
||
|
||
# 验证结果
|
||
assert stored["status"] == "QUEUED"
|
||
assert stored["source"] == "upload"
|
||
assert stored["param_overrides"] == {"frame-extract": {"crop": [0, 0.75, 1, 0.25]}}
|
||
|
||
|
||
def test_update_run_fields(db_with_workflow: Database) -> None:
|
||
"""update_run 按字段更新(状态/进度/错误)。"""
|
||
# 数据:一条任务。
|
||
db_with_workflow.create_run(_run("r1"))
|
||
|
||
# 测试过程
|
||
db_with_workflow.update_run("r1", status="RUNNING", progress=0.5, error=None, updated_at="t2")
|
||
stored = db_with_workflow.get_run("r1")
|
||
|
||
# 验证结果
|
||
assert stored["status"] == "RUNNING"
|
||
assert stored["progress"] == 0.5
|
||
|
||
|
||
def test_list_runs_orders_by_created_at_desc(db_with_workflow: Database) -> None:
|
||
"""任务列表按创建时间倒序返回(新的在前)。"""
|
||
# 数据:三条不同创建时间的任务。
|
||
db_with_workflow.create_run(_run("old", created_at="2026-09-01T00:00:00+00:00"))
|
||
db_with_workflow.create_run(_run("mid", created_at="2026-09-02T00:00:00+00:00"))
|
||
db_with_workflow.create_run(_run("new", created_at="2026-09-03T00:00:00+00:00"))
|
||
|
||
# 测试过程
|
||
ids = [r["id"] for r in db_with_workflow.list_runs()]
|
||
|
||
# 验证结果
|
||
assert ids == ["new", "mid", "old"]
|
||
|
||
|
||
def test_list_runs_excludes_batch_runs_by_default(db_with_workflow: Database) -> None:
|
||
"""任务列表默认不含批量 run:它们属于批量页的任务明细,会把 20 条窗口占满。"""
|
||
# 数据:一条上传任务 + 两条批量 run。
|
||
db_with_workflow.create_run(_run("upload", created_at="2026-09-01T00:00:00+00:00"))
|
||
db_with_workflow.create_run(_run("batch-1", source="batch", created_at="2026-09-02T00:00:00+00:00"))
|
||
db_with_workflow.create_run(_run("batch-2", source="batch", created_at="2026-09-03T00:00:00+00:00"))
|
||
|
||
# 测试过程
|
||
default_ids = [r["id"] for r in db_with_workflow.list_runs()]
|
||
all_ids = [r["id"] for r in db_with_workflow.list_runs(include_batch=True)]
|
||
|
||
# 验证结果:默认只列上传任务,显式要求时才包含批量 run。
|
||
assert default_ids == ["upload"]
|
||
assert all_ids == ["batch-2", "batch-1", "upload"]
|
||
|
||
|
||
def test_delete_run_removes_record_and_artifacts(db_with_workflow: Database) -> None:
|
||
"""删除任务同时清理其产物记录。"""
|
||
# 数据:任务 + 一条产物。
|
||
db_with_workflow.create_run(_run("r1"))
|
||
db_with_workflow.create_artifact({
|
||
"run_id": "r1", "node_id": "a", "name": "a.data_uri", "uri": "/tmp/x", "kind": "file",
|
||
})
|
||
|
||
# 测试过程
|
||
db_with_workflow.delete_run("r1")
|
||
|
||
# 验证结果
|
||
assert db_with_workflow.get_run("r1") is None
|
||
assert db_with_workflow.list_artifacts("r1") == []
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 调度用查询:只有 QUEUED 会被拾起
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_next_queued_run_returns_oldest_queued(db_with_workflow: Database) -> None:
|
||
"""按创建时间返回最早的 QUEUED 任务。"""
|
||
# 数据:一条较早的 QUEUED。
|
||
db_with_workflow.create_run(_run("a", created_at="2026-09-01T00:00:00+00:00"))
|
||
|
||
# 测试过程
|
||
picked = db_with_workflow.next_queued_run()
|
||
|
||
# 验证结果
|
||
assert picked["id"] == "a"
|
||
|
||
|
||
def test_next_queued_run_skips_paused(db_with_workflow: Database) -> None:
|
||
"""PAUSED 不被拾起(必须显式 resume;修复"点击暂停反而开始任务"回归)。"""
|
||
# 数据:一条 PAUSED(更早)+ 一条 QUEUED(更晚)。
|
||
db_with_workflow.create_run(_run("paused", status="PAUSED", created_at="2026-09-01T00:00:00+00:00"))
|
||
db_with_workflow.create_run(_run("queued", created_at="2026-09-02T00:00:00+00:00"))
|
||
|
||
# 测试过程
|
||
picked = db_with_workflow.next_queued_run()
|
||
|
||
# 验证结果:取的是 QUEUED 那条。
|
||
assert picked["id"] == "queued"
|
||
|
||
|
||
def test_next_queued_run_skips_batch_source(db_with_workflow: Database) -> None:
|
||
"""source=batch 的任务由批量引擎执行,主调度器不拾起(存储目录不同)。"""
|
||
# 数据:一条 batch 来源的 QUEUED。
|
||
db_with_workflow.create_run(_run("batch-1", source="batch"))
|
||
|
||
# 测试过程与验证结果
|
||
assert db_with_workflow.next_queued_run() is None
|
||
|
||
|
||
def test_pause_and_resume_run(db_with_workflow: Database) -> None:
|
||
"""暂停置 PAUSED,继续置回 QUEUED(等待调度器断点续跑)。"""
|
||
# 数据:一条 QUEUED。
|
||
db_with_workflow.create_run(_run("r1"))
|
||
|
||
# 测试过程
|
||
db_with_workflow.pause_run("r1", "t2")
|
||
paused = db_with_workflow.get_run("r1")["status"]
|
||
db_with_workflow.resume_run("r1", "t3")
|
||
resumed = db_with_workflow.get_run("r1")["status"]
|
||
|
||
# 验证结果
|
||
assert paused == "PAUSED"
|
||
assert resumed == "QUEUED"
|
||
|
||
|
||
def test_recover_interrupted_runs_requeues_running_only(db_with_workflow: Database) -> None:
|
||
"""重启恢复:RUNNING → QUEUED,PAUSED 保持不变。"""
|
||
# 数据:RUNNING 与 PAUSED 各一条。
|
||
db_with_workflow.create_run(_run("running", status="RUNNING"))
|
||
db_with_workflow.create_run(_run("paused", status="PAUSED"))
|
||
|
||
# 测试过程
|
||
count = db_with_workflow.recover_interrupted_runs("t2")
|
||
|
||
# 验证结果:只恢复 1 条,PAUSED 不变。
|
||
assert count == 1
|
||
assert db_with_workflow.get_run("running")["status"] == "QUEUED"
|
||
assert db_with_workflow.get_run("paused")["status"] == "PAUSED"
|
||
|
||
|
||
def test_recover_interrupted_batch_jobs_requeues_running(db_with_workflow: Database) -> None:
|
||
"""重启恢复:RUNNING 的批量任务 → QUEUED(否则永久无人拾起)。"""
|
||
# 数据:一条 RUNNING 批量任务(批量明细表对 run 有外键,先建任务记录)。
|
||
db_with_workflow.create_run(_run("bv-run-1"))
|
||
db_with_workflow.create_batch_job({
|
||
"id": "job-1", "folder_path": "/videos", "workflow_id": "wf", "recursive": False,
|
||
"status": "RUNNING", "created_at": "t1", "updated_at": "t1",
|
||
})
|
||
|
||
# 测试过程
|
||
count = db_with_workflow.recover_interrupted_batch_jobs("t2")
|
||
|
||
# 验证结果
|
||
assert count == 1
|
||
assert db_with_workflow.get_batch_job("job-1")["status"] == "QUEUED"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 产物与断点恢复
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_create_and_get_artifact(db_with_workflow: Database) -> None:
|
||
"""产物按 run + name 记录,可按名精确读取。"""
|
||
# 数据:一条任务 + 一条产物(产物对 run 有外键)。
|
||
db_with_workflow.create_run(_run("r1"))
|
||
db_with_workflow.create_artifact({
|
||
"run_id": "r1", "node_id": "ocr", "name": "ocr.srt_uri",
|
||
"uri": "/tmp/out/subtitle.srt", "kind": "file",
|
||
})
|
||
|
||
# 测试过程
|
||
stored = db_with_workflow.get_artifact("r1", "ocr.srt_uri")
|
||
|
||
# 验证结果
|
||
assert stored["uri"] == "/tmp/out/subtitle.srt"
|
||
assert db_with_workflow.get_artifact("r1", "missing") is None
|
||
|
||
|
||
def test_restore_run_outputs_strips_node_prefix(db_with_workflow: Database) -> None:
|
||
"""恢复产物时剥去"节点ID."前缀,还原为 {输出名: URI}(断点续跑依赖)。"""
|
||
# 数据:两个节点的产物。
|
||
db_with_workflow.create_run(_run("r1"))
|
||
db_with_workflow.create_artifact({"run_id": "r1", "node_id": "extract", "name": "extract.audio_uri", "uri": "/a.wav", "kind": "file"})
|
||
db_with_workflow.create_artifact({"run_id": "r1", "node_id": "asr", "name": "asr.srt_uri", "uri": "/a.srt", "kind": "file"})
|
||
|
||
# 测试过程
|
||
outputs = db_with_workflow.restore_run_outputs("r1")
|
||
|
||
# 验证结果
|
||
assert outputs == {
|
||
"extract": {"audio_uri": "/a.wav"},
|
||
"asr": {"srt_uri": "/a.srt"},
|
||
}
|
||
|
||
|
||
def test_reset_run_clears_state_and_artifacts(db_with_workflow: Database) -> None:
|
||
"""reset_run 清空产物与错误、回到 QUEUED(供失败任务重跑)。"""
|
||
# 数据:一条 FAILED 任务带产物与错误。
|
||
db_with_workflow.create_run(_run("r1", status="FAILED", error="boom"))
|
||
db_with_workflow.create_artifact({"run_id": "r1", "node_id": "a", "name": "a.x", "uri": "/x", "kind": "file"})
|
||
|
||
# 测试过程
|
||
db_with_workflow.reset_run("r1", "t2")
|
||
stored = db_with_workflow.get_run("r1")
|
||
|
||
# 验证结果
|
||
assert stored["status"] == "QUEUED"
|
||
assert stored["error"] is None
|
||
assert db_with_workflow.list_artifacts("r1") == []
|
||
|
||
|
||
def test_list_run_ids(db_with_workflow: Database) -> None:
|
||
"""列出全部任务 ID(供孤儿清理比对文件系统)。"""
|
||
# 数据:两条任务。
|
||
db_with_workflow.create_run(_run("r1"))
|
||
db_with_workflow.create_run(_run("r2"))
|
||
|
||
# 测试过程与验证结果
|
||
assert sorted(db_with_workflow.list_run_ids()) == ["r1", "r2"]
|
||
|
||
|
||
def test_recover_interrupted_batch_jobs_requeues_zombie_completed(db_with_workflow: Database) -> None:
|
||
"""重启恢复:COMPLETED 但仍有未结束视频的僵尸任务也要置回 QUEUED。
|
||
|
||
曾出现「任务已完成、视频仍未处理」的僵尸状态:完成标记先于视频收尾写出,
|
||
而引擎只拾取 QUEUED,剩余视频永久无人处理。
|
||
"""
|
||
# 数据:一个仍含 PENDING 视频的 COMPLETED 任务 + 一个全部结束的 COMPLETED 任务。
|
||
db_with_workflow.create_batch_job({
|
||
"id": "zombie", "folder_path": "/videos", "workflow_id": "wf", "recursive": False,
|
||
"status": "COMPLETED", "created_at": "t1", "updated_at": "t1",
|
||
})
|
||
db_with_workflow.create_batch_video({
|
||
"id": "zombie-v1", "job_id": "zombie", "video_path": "/videos/a.mp4",
|
||
"work_dir": "/tmp/zombie", "status": "PENDING",
|
||
"created_at": "t1", "updated_at": "t1",
|
||
})
|
||
db_with_workflow.create_batch_job({
|
||
"id": "done", "folder_path": "/videos", "workflow_id": "wf", "recursive": False,
|
||
"status": "COMPLETED", "created_at": "t1", "updated_at": "t1",
|
||
})
|
||
db_with_workflow.create_batch_video({
|
||
"id": "done-v1", "job_id": "done", "video_path": "/videos/b.mp4",
|
||
"work_dir": "/tmp/done", "status": "COMPLETED",
|
||
"created_at": "t1", "updated_at": "t1",
|
||
})
|
||
|
||
# 测试过程
|
||
count = db_with_workflow.recover_interrupted_batch_jobs("t2")
|
||
|
||
# 验证结果:只有僵尸任务被置回 QUEUED,真正完成的任务不受影响。
|
||
assert count == 1
|
||
assert db_with_workflow.get_batch_job("zombie")["status"] == "QUEUED"
|
||
assert db_with_workflow.get_batch_job("done")["status"] == "COMPLETED"
|