Files
vrsub/tests/app/test_db/test_database.py
T
cat-shark dcdc5e8604 feat: 批量分块流水线、本地模型显存让渡与任务列表分工
批量引擎改为「分块流水线」:视频按 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 用例)。
2026-09-18 10:31:52 +08:00

384 lines
15 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.
"""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 → QUEUEDPAUSED 保持不变。"""
# 数据: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"