"""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