Files
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

561 lines
21 KiB
Python
Raw Permalink 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/scheduler.py 的模块级测试(数据 → 测试过程 → 验证结果)。
被测模块:`src/wov_app/scheduler.py`DAG 拓扑调度、断点续跑、暂停语义),
可独立调用。用例使用真实 SQLite、真实存储目录与真实节点(echo/echo 派生),
仅对需要外部服务的节点不做测试。
"""
from __future__ import annotations
from pathlib import Path
import pytest
from wov_app import registry
from wov_app.db import Database
from wov_app.scheduler import WorkflowScheduler, topological_sort
from wov_sdk.models import WorkflowDefinition
@pytest.fixture(autouse=True)
def _isolate_registry():
"""用例前清空注册表、用例后恢复快照:保证用例看到的是干净基线,
不受其他模块(如 main 生命周期 register_all)的注册结果影响。"""
snapshot = dict(registry._registry)
registry._registry.clear()
yield
registry._registry.clear()
registry._registry.update(snapshot)
def _definition(nodes: list[dict], edges: list[dict], **extra) -> WorkflowDefinition:
"""构造真实 WorkflowDefinition(节点 ID/类型/输入与边)。"""
payload = {
"name": "测试流程",
"version": 1,
"nodes": nodes,
"edges": edges,
"entry_inputs": {"video_uri": "file"},
"final_outputs": extra.pop("final_outputs", {}),
**extra,
}
return WorkflowDefinition.from_dict(payload)
def _db_with_workflow(tmp_path: Path, definition: WorkflowDefinition, workflow_id: str = "wf") -> Database:
"""建好工作流 + 版本记录的临时库(任务表有外键约束)。"""
db = Database(tmp_path / "wov.db")
db.upsert_workflow({"id": workflow_id, "name": "流程", "description": ""})
db.create_workflow_version(workflow_id, 1, definition.to_dict())
return db
def _run(run_id: str, input_uri: str, **overrides) -> dict:
"""构造真实任务记录。"""
record = {
"id": run_id, "workflow_id": "wf", "workflow_version": 1, "status": "QUEUED",
"current_node_id": None, "progress": 0.0, "error": None, "input_uri": input_uri,
"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 _scheduler(db: Database, storage: Path) -> WorkflowScheduler:
"""构造调度器(不启动后台线程,直接调用 execute_run)。"""
return WorkflowScheduler(db, storage, interval_seconds=999)
# ---------------------------------------------------------------------------
# 拓扑排序
# ---------------------------------------------------------------------------
def test_topological_sort_linear_chain() -> None:
"""线性链按依赖顺序返回。"""
# 数据:a → b → c。
definition = _definition(
nodes=[
{"id": "a", "node_type": "echo", "inputs": {}},
{"id": "b", "node_type": "echo", "inputs": {"file_uri": "a.file_uri"}},
{"id": "c", "node_type": "echo", "inputs": {"file_uri": "b.file_uri"}},
],
edges=[{"from": "a", "to": "b"}, {"from": "b", "to": "c"}],
)
# 测试过程
order = topological_sort(definition)
# 验证结果
assert order == ["a", "b", "c"]
def test_topological_sort_diamond() -> None:
"""菱形依赖中,汇合节点排在其全部前驱之后。"""
# 数据:a → (b, c) → d。
definition = _definition(
nodes=[
{"id": "a", "node_type": "echo", "inputs": {}},
{"id": "b", "node_type": "echo", "inputs": {"file_uri": "a.file_uri"}},
{"id": "c", "node_type": "echo", "inputs": {"file_uri": "a.file_uri"}},
{"id": "d", "node_type": "echo", "inputs": {"file_uri": "b.file_uri"}},
],
edges=[
{"from": "a", "to": "b"}, {"from": "a", "to": "c"},
{"from": "b", "to": "d"}, {"from": "c", "to": "d"},
],
)
# 测试过程
order = topological_sort(definition)
# 验证结果:a 最先、d 最后,b/c 在中间。
assert order[0] == "a"
assert order[-1] == "d"
assert set(order[1:3]) == {"b", "c"}
def test_execute_run_stop_after_leaves_run_running_and_resumes(tmp_path: Path) -> None:
"""分阶段执行:stop_after 指定阶段节点后停下(保持 RUNNING、不收尾),再次调用续跑完成。"""
# 数据:a → b → c 三段 echo 链 + 最终别名。
definition = _definition(
nodes=[
{"id": "a", "node_type": "echo", "inputs": {"file_uri": "input.video_uri"}},
{"id": "b", "node_type": "echo", "inputs": {"file_uri": "a.file_uri"}},
{"id": "c", "node_type": "echo", "inputs": {"file_uri": "b.file_uri"}},
],
edges=[{"from": "a", "to": "b"}, {"from": "b", "to": "c"}],
final_outputs={"result": "c.file_uri"},
)
storage = tmp_path / "storage"
db = _db_with_workflow(tmp_path, definition)
source = tmp_path / "input.txt"
source.write_text("分阶段内容", encoding="utf-8")
db.create_run(_run("run-stage", str(source)))
registry.register_all()
scheduler = _scheduler(db, storage)
# 测试过程:第一阶段只执行到 b 为止。
scheduler.execute_run("run-stage", stop_after="b")
# 验证结果:任务保持 RUNNING 未收尾,a/b 产物已登记,c 与最终别名都没有。
staged = db.get_run("run-stage")
assert staged["status"] == "RUNNING"
assert staged["current_node_id"] == "b"
names = {artifact["name"] for artifact in db.list_artifacts("run-stage")}
assert {"a.file_uri", "b.file_uri"} <= names
assert "c.file_uri" not in names
assert "result" not in names
# 测试过程:不传 stop_after 时整条 DAG 跑完并收尾。
scheduler.execute_run("run-stage")
# 验证结果:完成、进度 1.0、最终别名登记。
finished = db.get_run("run-stage")
assert finished["status"] == "COMPLETED"
assert finished["progress"] == 1.0
assert "result" in {artifact["name"] for artifact in db.list_artifacts("run-stage")}
def test_execute_run_stop_after_unknown_node_marks_failed(tmp_path: Path) -> None:
"""stop_after 指向不存在的节点时任务标 FAILED(不留下永远 RUNNING 的任务)。"""
# 数据:单 echo 节点任务。
definition = _definition(
nodes=[{"id": "step", "node_type": "echo", "inputs": {"file_uri": "input.video_uri"}}],
edges=[],
final_outputs={"result": "step.file_uri"},
)
storage = tmp_path / "storage"
db = _db_with_workflow(tmp_path, definition)
source = tmp_path / "input.txt"
source.write_text("内容", encoding="utf-8")
db.create_run(_run("run-bad-stage", str(source)))
registry.register_all()
# 测试过程
_scheduler(db, storage).execute_run("run-bad-stage", stop_after="nope")
# 验证结果:FAILED 且错误说明阶段节点不存在。
stored = db.get_run("run-bad-stage")
assert stored["status"] == "FAILED"
assert "stop_after" in (stored["error"] or "")
# ---------------------------------------------------------------------------
# 执行:成功路径
# ---------------------------------------------------------------------------
def test_execute_run_completes_and_records_artifacts(tmp_path: Path) -> None:
"""单节点任务执行成功:状态 COMPLETED、产物登记、进度到位。"""
# 数据:echo 单节点工作流 + 真实输入文件。
definition = _definition(
nodes=[{"id": "step", "node_type": "echo", "inputs": {"file_uri": "input.video_uri"}}],
edges=[],
final_outputs={"result": "step.file_uri"},
)
storage = tmp_path / "storage"
db = _db_with_workflow(tmp_path, definition)
source = tmp_path / "input.txt"
source.write_text("输入内容", encoding="utf-8")
db.create_run(_run("run-1", str(source)))
registry.register_all()
# 测试过程
_scheduler(db, storage).execute_run("run-1")
# 验证结果:任务完成、进度 1.0、节点产物与最终别名都已登记。
stored = db.get_run("run-1")
assert stored["status"] == "COMPLETED"
assert stored["progress"] == 1.0
names = {a["name"] for a in db.list_artifacts("run-1")}
assert "step.file_uri" in names
assert "result" in names
def test_execute_run_multi_node_chain_passes_artifacts(tmp_path: Path) -> None:
"""多节点链:后序节点通过 URI 拿到前序产物(节点间只经产物交换数据)。"""
# 数据:step1 → step2 两节点链。
definition = _definition(
nodes=[
{"id": "step1", "node_type": "echo", "inputs": {"file_uri": "input.video_uri"}},
{"id": "step2", "node_type": "echo", "inputs": {"file_uri": "step1.file_uri"}},
],
edges=[{"from": "step1", "to": "step2"}],
final_outputs={"out": "step2.file_uri"},
)
storage = tmp_path / "storage"
db = _db_with_workflow(tmp_path, definition)
source = tmp_path / "input.txt"
source.write_text("链式内容", encoding="utf-8")
db.create_run(_run("run-2", str(source)))
registry.register_all()
# 测试过程
_scheduler(db, storage).execute_run("run-2")
# 验证结果:两节点产物都存在,且 step2 的产物内容来自 step1(传递一致)。
assert db.get_run("run-2")["status"] == "COMPLETED"
artifacts = {a["name"]: a["uri"] for a in db.list_artifacts("run-2")}
assert "step1.file_uri" in artifacts and "step2.file_uri" in artifacts
assert Path(artifacts["step2.file_uri"]).read_text(encoding="utf-8") == "链式内容"
assert Path(artifacts["step2.file_uri"]).parent != Path(artifacts["step1.file_uri"]).parent
def test_execute_run_creates_final_alias_with_stable_name(tmp_path: Path) -> None:
"""最终产物按 上传文件名.标识.时间戳 生成别名,并保留节点原始文件。"""
# 数据:单节点 + target_language 参数(决定别名标识)。
definition = _definition(
nodes=[{
"id": "step", "node_type": "echo",
"inputs": {"file_uri": "input.video_uri"},
"params": {"target_language": "zh-CN"},
}],
edges=[],
final_outputs={"cn_srt_uri": "step.file_uri"},
)
storage = tmp_path / "storage"
db = _db_with_workflow(tmp_path, definition)
source = tmp_path / "test01.mp4"
source.write_text("数据", encoding="utf-8")
db.create_run(_run("run-3", str(source)))
registry.register_all()
# 测试过程
_scheduler(db, storage).execute_run("run-3")
# 验证结果:别名指向 finals 下的稳定路径,含 zh-CN 标识;节点原文件仍在。
artifacts = {a["name"]: a["uri"] for a in db.list_artifacts("run-3")}
final = Path(artifacts["cn_srt_uri"])
assert final.is_file()
assert "zh-CN" in final.name
assert "finals" in final.parts
assert Path(artifacts["step.file_uri"]).is_file()
# ---------------------------------------------------------------------------
# 失败与无效 DAG
# ---------------------------------------------------------------------------
def test_execute_run_fails_when_workflow_version_missing(tmp_path: Path) -> None:
"""工作流版本记录丢失时任务失败(不留 QUEUED 堵塞队列)。"""
# 数据:工作流存在但没有 v1 版本记录(外键仍满足)。
db = Database(tmp_path / "wov.db")
storage = tmp_path / "storage"
db.upsert_workflow({"id": "wf-no-version", "name": "流程", "description": ""})
db.create_run(_run("run-x", "input", workflow_id="wf-no-version"))
# 测试过程
_scheduler(db, storage).execute_run("run-x")
# 验证结果
stored = db.get_run("run-x")
assert stored["status"] == "FAILED"
assert "workflow version not found" in stored["error"]
def test_execute_run_marks_failed_on_cycle_and_does_not_block_queue(tmp_path: Path) -> None:
"""环形 DAG(历史无效版本)立即失败且不堵塞后续任务(R04 回归)。"""
# 数据:A→B→A 的环形定义。
definition = _definition(
nodes=[
{"id": "a", "node_type": "echo", "inputs": {"file_uri": "b.file_uri"}},
{"id": "b", "node_type": "echo", "inputs": {"file_uri": "a.file_uri"}},
],
edges=[{"from": "a", "to": "b"}, {"from": "b", "to": "a"}],
)
storage = tmp_path / "storage"
db = _db_with_workflow(tmp_path, definition)
db.create_run(_run("run-cycle", "input"))
registry.register_all()
# 测试过程
_scheduler(db, storage).execute_run("run-cycle")
# 验证结果:任务 FAILED,且队首前移(不再返回该任务)。
assert db.get_run("run-cycle")["status"] == "FAILED"
assert db.next_queued_run() is None
def test_execute_run_fails_when_input_reference_missing(tmp_path: Path) -> None:
"""输入引用无法解析(前序产物缺失)时任务失败并记录原因。"""
# 数据:节点引用不存在的产物。
definition = _definition(
nodes=[{"id": "step", "node_type": "echo", "inputs": {"file_uri": "ghost.file_uri"}}],
edges=[],
)
storage = tmp_path / "storage"
db = _db_with_workflow(tmp_path, definition)
db.create_run(_run("run-4", "input"))
registry.register_all()
# 测试过程
_scheduler(db, storage).execute_run("run-4")
# 验证结果
stored = db.get_run("run-4")
assert stored["status"] == "FAILED"
assert "missing input" in stored["error"]
def test_execute_run_fails_when_node_returns_failed(tmp_path: Path) -> None:
"""节点返回 failed 时任务失败并保留错误信息。"""
# 数据:注册一个总是失败的节点。
from wov_sdk.models import InvokeResponse, NodeManifest
definition = _definition(
nodes=[{"id": "step", "node_type": "boom", "inputs": {}}],
edges=[],
)
storage = tmp_path / "storage"
db = _db_with_workflow(tmp_path, definition)
db.create_run(_run("run-5", "input"))
registry.register(
NodeManifest(id="boom", name="失败节点", version="1", capability="c", command=["python"]),
lambda request: InvokeResponse(status="failed", error="节点内部错误"),
)
# 测试过程
_scheduler(db, storage).execute_run("run-5")
# 验证结果
stored = db.get_run("run-5")
assert stored["status"] == "FAILED"
assert "节点内部错误" in stored["error"]
# ---------------------------------------------------------------------------
# 暂停语义
# ---------------------------------------------------------------------------
def test_execute_run_leaves_paused_run_untouched(tmp_path: Path) -> None:
"""以 PAUSED 进入时直接返回保持暂停(修复"点击暂停反而开始任务")。"""
# 数据:PAUSED 状态的任务。
definition = _definition(
nodes=[{"id": "step", "node_type": "echo", "inputs": {}}],
edges=[],
)
storage = tmp_path / "storage"
db = _db_with_workflow(tmp_path, definition)
db.create_run(_run("run-6", "input", status="PAUSED"))
registry.register_all()
# 测试过程
_scheduler(db, storage).execute_run("run-6")
# 验证结果:仍为 PAUSED,且未产生任何产物。
assert db.get_run("run-6")["status"] == "PAUSED"
assert db.list_artifacts("run-6") == []
def test_execute_run_stops_at_node_boundary_when_paused(tmp_path: Path) -> None:
"""运行中被暂停:在当前节点边界停下保持 PAUSED,不标 FAILED。"""
# 数据:两节点链;第一个节点执行时把任务置 PAUSED。
from wov_sdk.models import InvokeResponse, NodeManifest
definition = _definition(
nodes=[
{"id": "step1", "node_type": "pause-me", "inputs": {}},
{"id": "step2", "node_type": "echo", "inputs": {}},
],
edges=[{"from": "step1", "to": "step2"}],
)
storage = tmp_path / "storage"
db = _db_with_workflow(tmp_path, definition)
db.create_run(_run("run-7", "input"))
registry.register_all()
def pause_handler(request):
"""模拟用户在该节点执行期间点击暂停。"""
db.pause_run("run-7", "t2")
return InvokeResponse(status="completed", outputs={"file_uri": "/tmp/x"})
registry.register(
NodeManifest(id="pause-me", name="暂停节点", version="1", capability="c", command=["python"]),
pause_handler,
)
# 测试过程
_scheduler(db, storage).execute_run("run-7")
# 验证结果:保持 PAUSED,step2 未执行。
assert db.get_run("run-7")["status"] == "PAUSED"
names = {a["name"] for a in db.list_artifacts("run-7")}
assert not any(name.startswith("step2") for name in names)
def test_execute_run_clears_stale_pause_flag(tmp_path: Path) -> None:
"""执行前清理残留的 paused.flag(避免误触发节点内暂停)。"""
# 数据:单节点任务 + 已存在的 paused.flag。
definition = _definition(
nodes=[{"id": "step", "node_type": "echo", "inputs": {}}],
edges=[],
)
storage = tmp_path / "storage"
db = _db_with_workflow(tmp_path, definition)
db.create_run(_run("run-8", "input"))
registry.register_all()
run_root = storage / "runs" / "run-8"
run_root.mkdir(parents=True)
(run_root / "paused.flag").write_text("", encoding="utf-8")
# 测试过程
_scheduler(db, storage).execute_run("run-8")
# 验证结果:标志被清除,任务正常完成。
assert not (run_root / "paused.flag").exists()
assert db.get_run("run-8")["status"] == "COMPLETED"
# ---------------------------------------------------------------------------
# 断点续跑与参数覆盖
# ---------------------------------------------------------------------------
def test_execute_run_resumes_from_existing_artifacts(tmp_path: Path) -> None:
"""断点续跑:已有产物的节点被跳过,只执行剩余节点。"""
# 数据:两节点链,step1 产物已登记。
calls: list[str] = []
def tracking_handler(node_id: str):
def handler(request):
from wov_sdk.models import InvokeResponse
calls.append(node_id)
return InvokeResponse(status="completed", outputs={"file_uri": f"/tmp/{node_id}"})
return handler
definition = _definition(
nodes=[
{"id": "step1", "node_type": "track1", "inputs": {}},
{"id": "step2", "node_type": "track2", "inputs": {}},
],
edges=[{"from": "step1", "to": "step2"}],
)
storage = tmp_path / "storage"
db = _db_with_workflow(tmp_path, definition)
db.create_run(_run("run-9", "input"))
db.create_artifact({
"run_id": "run-9", "node_id": "step1", "name": "step1.file_uri",
"uri": "/tmp/step1", "kind": "file",
})
from wov_sdk.models import NodeManifest
registry.register(
NodeManifest(id="track1", name="t1", version="1", capability="c", command=["python"]),
tracking_handler("step1"),
)
registry.register(
NodeManifest(id="track2", name="t2", version="1", capability="c", command=["python"]),
tracking_handler("step2"),
)
# 测试过程
_scheduler(db, storage).execute_run("run-9")
# 验证结果:只调用了 step2。
assert calls == ["step2"]
assert db.get_run("run-9")["status"] == "COMPLETED"
def test_execute_run_applies_param_overrides(tmp_path: Path) -> None:
"""param_overrides 按节点 ID 合并进节点参数(前端框选 crop 的通道)。"""
# 数据:节点参数与覆盖值同时存在。
received: list[dict] = []
def handler(request):
from wov_sdk.models import InvokeResponse
received.append(dict(request.params))
return InvokeResponse(status="completed", outputs={"file_uri": "/tmp/x"})
definition = _definition(
nodes=[{
"id": "step", "node_type": "param-node",
"inputs": {}, "params": {"interval_seconds": 0.5, "crop": [0, 0, 1, 1]},
}],
edges=[],
)
storage = tmp_path / "storage"
db = _db_with_workflow(tmp_path, definition)
db.create_run(_run("run-10", "input", param_overrides={"step": {"crop": [0, 0.75, 1, 0.25]}}))
from wov_sdk.models import NodeManifest
registry.register(
NodeManifest(id="param-node", name="p", version="1", capability="c", command=["python"]),
handler,
)
# 测试过程
_scheduler(db, storage).execute_run("run-10")
# 验证结果:覆盖值生效,未覆盖的参数保持原样。
assert received == [{"interval_seconds": 0.5, "crop": [0, 0.75, 1, 0.25]}]
# ---------------------------------------------------------------------------
# 线程生命周期
# ---------------------------------------------------------------------------
def test_start_and_stop_are_idempotent(tmp_path: Path) -> None:
"""start 重复调用不产生多余线程;stop 正常结束。"""
# 数据:空库 + 调度器。
db = Database(tmp_path / "wov.db")
scheduler = WorkflowScheduler(db, tmp_path / "storage", interval_seconds=999)
# 测试过程
scheduler.start()
first = scheduler._thread
scheduler.start()
second = scheduler._thread
scheduler.stop()
# 验证结果
assert first is second
assert scheduler._thread is None