fix: 拒绝环形 DAG 并避免无效工作流堵塞任务队列

This commit is contained in:
2026-09-11 17:19:40 +08:00
parent 6eb65e4356
commit f99f8de171
5 changed files with 242 additions and 9 deletions
+96
View File
@@ -173,6 +173,102 @@ def test_execute_run_missing_version(tmp_path) -> None:
assert db.get_run("run_version")["status"] == "FAILED"
def test_execute_run_invalid_dag_fails_and_does_not_block_queue(tmp_path) -> None:
"""环形 DAG 的历史任务必须立即 FAILED,且不阻塞队列后续任务(R04)。
复现场景:修复前保存校验放过环形定义,执行时拓扑排序抛错但异常发生在
execute_run 的状态翻转之前 → 任务永远停在 QUEUEDnext_queued_run 每轮
都拾起同一条队首记录,后面的任务全部被堵死。断言:无效 DAG 的任务被标记
FAILED(带环错误信息),execute_run 不向调用方抛异常,队首随即前移。
"""
db = _db(tmp_path)
_register_echo()
# 直接写库模拟修复前遗留的环形版本(保存接口现在会拒绝)。
cycle_definition = {
"name": "cycle",
"version": 1,
"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"}],
"entry_inputs": {"video_uri": "file"},
"final_outputs": {"result": "a.file_uri"},
}
db.upsert_workflow({"id": "bad-flow", "name": "Bad", "published": 1, "latest_version": 1})
db.create_workflow_version("bad-flow", 1, cycle_definition)
# 队列里同时放入合法任务,验证它不被无效任务堵住。
db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1})
db.create_workflow_version("flow", 1, _echo_definition().to_dict())
input_file = tmp_path / "input.txt"
input_file.write_text("hello queue", encoding="utf-8")
db.create_run(
{
"id": "run_bad",
"workflow_id": "bad-flow",
"workflow_version": 1,
"status": "QUEUED",
"progress": 0,
"input_uri": str(input_file),
"created_at": "2026-01-01T00:00:00+00:00",
"updated_at": "2026-01-01T00:00:00+00:00",
}
)
db.create_run(
{
"id": "run_ok",
"workflow_id": "flow",
"workflow_version": 1,
"status": "QUEUED",
"progress": 0,
"input_uri": str(input_file),
"created_at": "2026-01-01T00:00:01+00:00",
"updated_at": "2026-01-01T00:00:01+00:00",
}
)
scheduler = WorkflowScheduler(db, tmp_path / "storage")
# 调度器每一轮的取任务 → 执行,不应把环形任务留在 QUEUED。
first = db.next_queued_run()
assert first["id"] == "run_bad"
scheduler.execute_run(first["id"])
bad = db.get_run("run_bad")
assert bad["status"] == "FAILED"
assert "cycle" in bad["error"]
# 队首已前移,后续合法任务正常执行完成。
assert db.next_queued_run()["id"] == "run_ok"
scheduler.execute_run("run_ok")
assert db.get_run("run_ok")["status"] == "COMPLETED"
assert db.next_queued_run() is None
def test_execute_run_unparsable_dag_fails_not_stuck(tmp_path) -> None:
"""缺 name 的非法定义同样标记 FAILED,而不是让队列卡死。"""
db = _db(tmp_path)
db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1})
# from_dict 解析缺 name 的定义会抛 KeyError。
db.create_workflow_version("flow", 1, {"version": 1, "nodes": [], "edges": []})
now = "2026-01-01T00:00:00+00:00"
db.create_run(
{
"id": "run_corrupt",
"workflow_id": "flow",
"workflow_version": 1,
"status": "QUEUED",
"progress": 0,
"created_at": now,
"updated_at": now,
}
)
scheduler = WorkflowScheduler(db, tmp_path / "storage")
scheduler.execute_run("run_corrupt")
run = db.get_run("run_corrupt")
assert run["status"] == "FAILED"
assert run["error"]
assert db.next_queued_run() is None
def test_execute_run_missing_node(tmp_path) -> None:
"""验证未注册节点被调用时任务失败。"""
db = _db(tmp_path)
+85
View File
@@ -26,6 +26,91 @@ def definition() -> dict:
}
def cyclic_definition() -> dict:
"""构造带环的 DAG 定义(a → b → a),用于验证保存/发布拒绝无效工作流。
环上的两个节点互相引用对方输出,形成了无法拓扑排序的依赖:节点 ID 唯一、
边引用的节点都存在,因此只有环检测能拦住它(R04 复现定义)。
"""
return {
"name": "cyclic",
"version": 1,
"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"}],
"entry_inputs": {"video_uri": "file"},
"final_outputs": {"result": "a.file_uri"},
}
def test_create_workflow_rejects_cycle() -> None:
"""验证保存环形 DAG 返回 422,不把无效版本写入版本表。"""
with TestClient(app) as client:
response = client.post(
"/api/admin/workflows",
json={
"id": "cyclic-flow",
"name": "Cyclic Flow",
"definition": cyclic_definition(),
},
)
assert response.status_code == 422
assert "cycle" in response.json()["detail"]
# 拒绝保存时不得留下半成品工作流/版本记录。
db = app.state.db
assert db.get_workflow("cyclic-flow") is None
assert db.list_workflow_versions("cyclic-flow") == []
# 已有工作流重新提交带环定义:同样拒绝,不追加新版本。
assert client.post(
"/api/admin/workflows",
json={"id": "cycle-check", "name": "Cycle Check", "definition": definition()},
).status_code == 200
assert client.post(
"/api/admin/workflows",
json={"id": "cycle-check", "name": "Cycle Check", "definition": cyclic_definition()},
).status_code == 422
assert db.get_workflow("cycle-check")["latest_version"] == 1
assert len(db.list_workflow_versions("cycle-check")) == 1
db.delete_workflow("cycle-check")
def test_validate_workflow_rejects_cycle() -> None:
"""验证只校验不保存的接口同样拒绝环形 DAG。"""
with TestClient(app) as client:
db = app.state.db
# 独立工作流 ID 并显式清理,避免与其他用例(共用同一个测试数据库)互相影响。
assert client.post(
"/api/admin/workflows",
json={"id": "cycle-check", "name": "Cycle Check", "definition": definition()},
).status_code == 200
response = client.post(
"/api/admin/workflows/cycle-check/validate",
json=cyclic_definition(),
)
assert response.status_code == 422
assert "cycle" in response.json()["detail"]
db.delete_workflow("cycle-check")
def test_publish_rejects_cycle_in_latest_version() -> None:
"""验证历史遗留的环形版本不能被发布(发布前重新校验最新版本定义)。"""
with TestClient(app) as client:
db = app.state.db
# 直接写库模拟修复前已保存的无效版本(保存接口现在会拒绝,只能这样构造)。
db.upsert_workflow(
{"id": "legacy-cyclic", "name": "Legacy", "published": 0, "latest_version": 1}
)
db.create_workflow_version("legacy-cyclic", 1, cyclic_definition())
response = client.post("/api/admin/workflows/legacy-cyclic/publish")
assert response.status_code == 422
assert "cycle" in response.json()["detail"]
assert db.get_workflow("legacy-cyclic")["published"] == 0
db.delete_workflow("legacy-cyclic")
def test_workflow_crud_and_publish() -> None:
"""验证工作流 CRUD、校验、发布与版本列表的完整流程。"""
with TestClient(app) as client: