"""DAG 管道测试。 分三层: 1. **定义期校验**——环、悬空依赖、未注册任务、quorum 都要在创建任何任务之前拒绝, 否则存储里会留下一堆看起来正常的半成品任务; 2. **推进**——屏障、扇出、扇入、三种汇合语义; 3. **收场**——fail_fast 连坐、下游跳过、整体状态推导。 约定:节点 id 一律用 ``node_id=`` 显式给出。第一个位置参数是**已注册的任务名**, 其后才是传给任务的位置参数——这两者很容易看混,显式写出来既清楚又自成文档。 """ from __future__ import annotations import threading import time from typing import Any import pytest from chongming_task.controller import Controller from chongming_task.errors import ConfigurationError from chongming_task.pipeline import ( Pipeline, PipelineStatus, render_pipeline, ) from chongming_task.policies import AggregationPolicy, JoinMode from chongming_task.states import TaskStatus from chongming_task.stores.memory import InMemoryTaskStore _WINDOW = 15.0 # --------------------------------------------------------------------------- # 1. 定义期校验 # --------------------------------------------------------------------------- def test_empty_pipeline_is_rejected() -> None: with pytest.raises(ConfigurationError, match="没有任何节点"): Pipeline("empty").validate() def test_dangling_dependency_is_rejected() -> None: pipeline = Pipeline("dangling") pipeline.node("add", node_id="a", depends_on=["no-such-node"]) with pytest.raises(ConfigurationError, match="不存在的节点"): pipeline.validate() def test_self_dependency_is_rejected() -> None: pipeline = Pipeline("self") pipeline.node("add", node_id="a", depends_on=["a"]) with pytest.raises(ConfigurationError, match="依赖了自己"): pipeline.validate() def test_cycle_is_rejected() -> None: """有环时必须显式报错。 不检测的话调度器只会安静地什么都不做(所有节点都在等对方),表现为"管道卡住了" ——那种故障最难查。 """ pipeline = Pipeline("cyclic") first = pipeline.node("add", node_id="a") second = pipeline.node("add", node_id="b", depends_on=[first]) # 人为制造环:a 反过来依赖 b。 object.__setattr__(pipeline.node_by_id("a"), "depends_on", ("b",)) assert second.depends_on == ("a",) with pytest.raises(ConfigurationError, match="环形依赖"): pipeline.validate() def test_unregistered_task_is_rejected_before_creating_anything() -> None: """控制面只接受任务名。写错的 DAG 不能留下半成品任务。""" store = InMemoryTaskStore() controller = Controller(store=store) controller.register("add", _add) pipeline = Pipeline("bad") pipeline.node("add", node_id="ok") pipeline.node("nope", node_id="typo") with pytest.raises(ConfigurationError): controller.submit_pipeline(pipeline) assert store.query() == [], "校验失败前不该创建任何任务" def test_quorum_settlement_is_rejected_with_an_explanation() -> None: """诚实拒绝,而不是把 quorum 硬套到 DAG 上发明一个没人能预测的语义。""" pipeline = Pipeline("q", aggregation=AggregationPolicy.of_quorum(2)) pipeline.node("add") with pytest.raises(ConfigurationError, match="不支持 quorum"): pipeline.validate() def test_duplicate_node_id_is_rejected() -> None: pipeline = Pipeline("dup") pipeline.node("add", node_id="same") with pytest.raises(ConfigurationError, match="节点 id 重复"): pipeline.node("add", node_id="same") def test_same_task_can_appear_many_times_with_auto_ids() -> None: """扇出靠"同名多次"表达,不该强迫调用方给每个节点起名。""" pipeline = Pipeline("fanout") pipeline.node("add") pipeline.node("add") pipeline.node("add") assert [node.node_id for node in pipeline.nodes] == ["add", "add#2", "add#3"] def test_positional_args_belong_to_the_task_not_the_node_id() -> None: """位置参数是传给任务的,不是节点名——这正是把参数叫 ``node_id`` 的原因。""" pipeline = Pipeline("args") node = pipeline.node("add", 2, 3, node_id="sum") assert node.node_id == "sum" assert node.args == (2, 3) def test_topological_order_puts_upstream_first() -> None: pipeline = Pipeline("order") last = pipeline.node("add", node_id="last") first = pipeline.node("add", node_id="first") pipeline.node("add", node_id="middle", depends_on=[first, last]) order = [node.node_id for node in pipeline.topological_order()] assert order.index("first") < order.index("middle") assert order.index("last") < order.index("middle") # --------------------------------------------------------------------------- # 2. 推进 # --------------------------------------------------------------------------- def test_a_linear_pipeline_runs_in_dependency_order() -> None: controller = Controller(max_parallel=4) controller.register("mark", _mark) pipeline = Pipeline("linear") extract = pipeline.node("mark", node_id="extract") transform = pipeline.node("mark", node_id="transform", depends_on=[extract]) pipeline.node("mark", node_id="load", depends_on=[transform]) run = controller.submit_pipeline(pipeline, pipeline_run_id="p1") assert run.wait(timeout=_WINDOW).status is PipelineStatus.SUCCEEDED assert _finished_order(controller, "p1") == ["extract", "transform", "load"] controller.close(timeout=_WINDOW) def test_a_downstream_node_waits_in_blocked_until_its_upstream_finishes() -> None: gate = threading.Event() controller = Controller(max_parallel=4) controller.register("wait_gate", _wait_gate) controller.register("mark", _mark) pipeline = Pipeline("blocking") upstream = pipeline.node("wait_gate", gate, node_id="upstream") pipeline.node("mark", node_id="downstream", depends_on=[upstream]) run = controller.submit_pipeline(pipeline, pipeline_run_id="p2") try: assert _wait_until(lambda: run.node_status("upstream") is TaskStatus.RUNNING) time.sleep(0.1) assert run.node_status("downstream") is TaskStatus.BLOCKED, "上游没完成前必须停在 blocked" assert run.status is PipelineStatus.RUNNING gate.set() assert run.wait(timeout=_WINDOW).status is PipelineStatus.SUCCEEDED finally: gate.set() controller.close(timeout=_WINDOW) def test_fan_out_runs_siblings() -> None: controller = Controller(max_parallel=8) controller.register("mark", _mark) pipeline = Pipeline("fanout") root = pipeline.node("mark", node_id="root") for index in range(4): pipeline.node("mark", node_id=f"shard{index}", depends_on=[root]) run = controller.submit_pipeline(pipeline, pipeline_run_id="p3") assert run.wait(timeout=_WINDOW).status is PipelineStatus.SUCCEEDED assert set(run.task_ids) == {"root", "shard0", "shard1", "shard2", "shard3"} controller.close(timeout=_WINDOW) def test_fan_in_with_all_succeeded_waits_for_every_upstream() -> None: controller = Controller(max_parallel=8) controller.register("mark", _mark) pipeline = Pipeline("fanin") root = pipeline.node("mark", node_id="root") left = pipeline.node("mark", node_id="left", depends_on=[root]) right = pipeline.node("mark", node_id="right", depends_on=[root]) pipeline.node("mark", node_id="join", depends_on=[left, right]) run = controller.submit_pipeline(pipeline, pipeline_run_id="p4") assert run.wait(timeout=_WINDOW).status is PipelineStatus.SUCCEEDED order = _finished_order(controller, "p4") assert order.index("join") > order.index("left") assert order.index("join") > order.index("right") controller.close(timeout=_WINDOW) def test_any_succeeded_join_runs_after_the_first_success() -> None: """先到先得:任一上游成功即可放行,不必等另一个失败的上游。""" controller = Controller(max_parallel=8) controller.register("mark", _mark) controller.register("boom", _boom) pipeline = Pipeline("any") good = pipeline.node("mark", node_id="good") bad = pipeline.node("boom", node_id="bad") winner = pipeline.node( "mark", node_id="winner", depends_on=[good, bad], join=JoinMode.ANY_SUCCEEDED ) run = controller.submit_pipeline(pipeline, pipeline_run_id="p5") outcome = run.wait(timeout=_WINDOW) assert winner.join is JoinMode.ANY_SUCCEEDED assert outcome.nodes["winner"] is TaskStatus.SUCCEEDED, "有一个上游成功就该放行" assert outcome.status is PipelineStatus.FAILED, "另一个上游失败,整条管道仍失败" controller.close(timeout=_WINDOW) def test_any_succeeded_join_skips_when_nothing_succeeds() -> None: controller = Controller(max_parallel=8) controller.register("boom", _boom) controller.register("mark", _mark) pipeline = Pipeline("any-none") bad = pipeline.node("boom", node_id="bad") pipeline.node("mark", node_id="winner", depends_on=[bad], join=JoinMode.ANY_SUCCEEDED) run = controller.submit_pipeline(pipeline, pipeline_run_id="p6") outcome = run.wait(timeout=_WINDOW) assert outcome.nodes["winner"] is TaskStatus.SKIPPED assert outcome.status is PipelineStatus.FAILED controller.close(timeout=_WINDOW) def test_all_settled_join_runs_even_after_an_upstream_failure() -> None: """清理/通知型节点:不管上游成败都要跑。""" controller = Controller(max_parallel=8) controller.register("boom", _boom) controller.register("mark", _mark) pipeline = Pipeline("cleanup", aggregation=AggregationPolicy.all_settled()) failed = pipeline.node("boom", node_id="work") cleanup = pipeline.node( "mark", node_id="cleanup", depends_on=[failed], join=JoinMode.ALL_SETTLED ) run = controller.submit_pipeline(pipeline, pipeline_run_id="p7") outcome = run.wait(timeout=_WINDOW) assert cleanup.join is JoinMode.ALL_SETTLED assert outcome.nodes["cleanup"] is TaskStatus.SUCCEEDED, "ALL_SETTLED 下游必须照跑" assert outcome.status is PipelineStatus.FAILED, "上游失败仍让整条管道失败" controller.close(timeout=_WINDOW) # --------------------------------------------------------------------------- # 3. 收场 # --------------------------------------------------------------------------- def test_failed_upstream_skips_downstream_with_a_reason() -> None: controller = Controller(max_parallel=8) controller.register("boom", _boom) controller.register("mark", _mark) pipeline = Pipeline("skip") bad = pipeline.node("boom", node_id="bad") pipeline.node("mark", node_id="after", depends_on=[bad]) run = controller.submit_pipeline(pipeline, pipeline_run_id="p8") outcome = run.wait(timeout=_WINDOW) assert outcome.nodes["after"] is TaskStatus.SKIPPED snapshot = controller.get("p8:after") assert snapshot is not None assert snapshot.status is TaskStatus.SKIPPED assert snapshot.failure is None, "跳过不是失败,不该带 failure" controller.close(timeout=_WINDOW) def test_fail_fast_cancels_independent_running_nodes() -> None: """真正需要连坐的场景:另一个分支已经在跑,必须被取消。 被连坐的必须落 ``CANCELLED`` 而不是 ``STOPPED``——"上游挂了"与"值班同学手动停的" 是完全不同的事,混成一个状态就再也分不出来了。 """ gate = threading.Event() controller = Controller(max_parallel=8) controller.register("boom", _boom) controller.register("wait_gate", _wait_gate) pipeline = Pipeline("cascade", aggregation=AggregationPolicy.fail_fast()) pipeline.node("wait_gate", gate, node_id="independent") pipeline.node("boom", node_id="bad") run = controller.submit_pipeline(pipeline, pipeline_run_id="p9") try: assert _wait_until(lambda: run.node_status("independent") is TaskStatus.RUNNING) outcome = run.wait(timeout=_WINDOW) assert outcome.status is PipelineStatus.FAILED assert outcome.nodes["independent"] is TaskStatus.CANCELLED, ( "被连坐的应当是 cancelled,而不是 stopped" ) snapshot = controller.get("p9:independent") assert snapshot is not None assert snapshot.failure is not None assert snapshot.failure.kind.value == "cancelled" finally: gate.set() controller.close(timeout=_WINDOW) def test_fail_fast_never_settles_while_a_cancelled_node_is_still_running() -> None: """``status`` 必须先看终态、再看失败标志。 顺序反了,等待者会被提前唤醒,看到一个"已经失败、但还有任务在跑"的矛盾状态。 """ gate = threading.Event() controller = Controller(max_parallel=8) controller.register("boom", _boom) controller.register("wait_gate", _wait_gate) pipeline = Pipeline("ordering", aggregation=AggregationPolicy.fail_fast()) pipeline.node("wait_gate", gate, node_id="independent") pipeline.node("boom", node_id="bad") run = controller.submit_pipeline(pipeline, pipeline_run_id="p10") try: assert _wait_until(lambda: run.node_status("independent") is TaskStatus.RUNNING) outcome = run.wait(timeout=_WINDOW) for node_id in outcome.nodes: assert outcome.nodes[node_id] in { TaskStatus.SUCCEEDED, TaskStatus.FAILED, TaskStatus.SKIPPED, TaskStatus.CANCELLED, TaskStatus.STOPPED, TaskStatus.LOST, }, f"{node_id} 在管道给出结论时还没落终态" finally: gate.set() controller.close(timeout=_WINDOW) def test_all_settled_does_not_cancel_siblings() -> None: gate = threading.Event() controller = Controller(max_parallel=8) controller.register("boom", _boom) controller.register("wait_gate", _wait_gate) pipeline = Pipeline("settled", aggregation=AggregationPolicy.all_settled()) pipeline.node("wait_gate", gate, node_id="independent") pipeline.node("boom", node_id="bad") run = controller.submit_pipeline(pipeline, pipeline_run_id="p11") try: assert _wait_until(lambda: run.node_status("independent") is TaskStatus.RUNNING) time.sleep(0.2) assert run.node_status("independent") is TaskStatus.RUNNING, "all_settled 不该连坐" gate.set() outcome = run.wait(timeout=_WINDOW) assert outcome.nodes["independent"] is TaskStatus.SUCCEEDED assert outcome.status is PipelineStatus.FAILED, "有人失败过,整体就是失败" finally: gate.set() controller.close(timeout=_WINDOW) def test_pipeline_tasks_carry_the_run_id() -> None: controller = Controller(max_parallel=4) controller.register("mark", _mark) pipeline = Pipeline("tagged") pipeline.node("mark", node_id="one") pipeline.node("mark", node_id="two") run = controller.submit_pipeline(pipeline, pipeline_run_id="tagged-1") run.wait(timeout=_WINDOW) for node_id, task_id in run.task_ids.items(): snapshot = controller.get(task_id) assert snapshot is not None assert snapshot.pipeline_run_id == "tagged-1" assert task_id == f"tagged-1:{node_id}" controller.close(timeout=_WINDOW) def test_cancelling_the_whole_pipeline() -> None: gate = threading.Event() controller = Controller(max_parallel=8) controller.register("wait_gate", _wait_gate) pipeline = Pipeline("cancelme") root = pipeline.node("wait_gate", gate, node_id="root") pipeline.node("wait_gate", gate, node_id="child", depends_on=[root]) run = controller.submit_pipeline(pipeline, pipeline_run_id="p12") try: assert _wait_until(lambda: run.node_status("root") is TaskStatus.RUNNING) assert run.cancel(reason="运维取消") >= 1 outcome = run.wait(timeout=_WINDOW) assert outcome.status is PipelineStatus.CANCELLED assert outcome.nodes["root"] is TaskStatus.CANCELLED assert outcome.nodes["child"] is TaskStatus.CANCELLED finally: gate.set() controller.close(timeout=_WINDOW) def test_wait_timeout_does_not_change_anything() -> None: gate = threading.Event() controller = Controller(max_parallel=4) controller.register("wait_gate", _wait_gate) pipeline = Pipeline("slow") pipeline.node("wait_gate", gate, node_id="root") run = controller.submit_pipeline(pipeline, pipeline_run_id="p13") try: outcome = run.wait(timeout=0.2) assert outcome.status is PipelineStatus.RUNNING assert run.node_status("root") in {TaskStatus.STARTING, TaskStatus.RUNNING} assert not run.settled finally: gate.set() controller.close(timeout=_WINDOW) def test_pipeline_run_is_registered_on_the_controller() -> None: controller = Controller(max_parallel=4) controller.register("mark", _mark) pipeline = Pipeline("reg") pipeline.node("mark", node_id="only") run = controller.submit_pipeline(pipeline, pipeline_run_id="reg-1") run.wait(timeout=_WINDOW) assert controller.get_pipeline("reg-1") is run assert controller.get_pipeline("nope") is None assert controller.pipelines() == (run,) controller.close(timeout=_WINDOW) def test_a_diamond_pipeline_works_end_to_end() -> None: """完整形状:一个源 → 两个分支 → 汇合。""" controller = Controller(max_parallel=8) controller.register("mark", _mark) pipeline = Pipeline("diamond") source = pipeline.node("mark", node_id="source") left = pipeline.node("mark", node_id="left", depends_on=[source]) right = pipeline.node("mark", node_id="right", depends_on=[source]) pipeline.node("mark", node_id="sink", depends_on=[left, right]) run = controller.submit_pipeline(pipeline, pipeline_run_id="p14") assert run.wait(timeout=_WINDOW).status is PipelineStatus.SUCCEEDED order = _finished_order(controller, "p14") assert order[0] == "source" assert order[-1] == "sink" assert "sink" in render_pipeline(run) controller.close(timeout=_WINDOW) def test_two_runs_of_the_same_pipeline_are_independent() -> None: controller = Controller(max_parallel=4) controller.register("mark", _mark) pipeline = Pipeline("reusable") pipeline.node("mark", node_id="only") first = controller.submit_pipeline(pipeline, pipeline_run_id="r1") second = controller.submit_pipeline(pipeline, pipeline_run_id="r2") assert first.wait(timeout=_WINDOW).status is PipelineStatus.SUCCEEDED assert second.wait(timeout=_WINDOW).status is PipelineStatus.SUCCEEDED assert set(first.task_ids.values()).isdisjoint(second.task_ids.values()) controller.close(timeout=_WINDOW) def test_pipeline_node_args_reach_the_task() -> None: controller = Controller(max_parallel=4) controller.register("add", _add) pipeline = Pipeline("args") pipeline.node("add", 20, 22, node_id="sum") run = controller.submit_pipeline(pipeline, pipeline_run_id="p15") assert run.wait(timeout=_WINDOW).status is PipelineStatus.SUCCEEDED snapshot = controller.get("p15:sum") assert snapshot is not None assert snapshot.outcome == {"value": 42} controller.close(timeout=_WINDOW) # --------------------------------------------------------------------------- # 与并发额度协同 # --------------------------------------------------------------------------- def test_deferred_nodes_do_not_consume_concurrency_slots() -> None: """等待上游的节点不该占额度——否则一条长管道会把额度全锁死。""" gate = threading.Event() controller = Controller(max_parallel=2) controller.register("wait_gate", _wait_gate) controller.register("mark", _mark) pipeline = Pipeline("slots") root = pipeline.node("wait_gate", gate, node_id="root") for index in range(5): pipeline.node("mark", node_id=f"child{index}", depends_on=[root]) run = controller.submit_pipeline(pipeline, pipeline_run_id="p16") try: assert _wait_until(lambda: run.node_status("root") is TaskStatus.RUNNING) time.sleep(0.1) assert controller.active_count == 1, "只有 root 该占额度" assert controller.queued_count == 0, "下游还在 blocked,不是 queued" gate.set() assert run.wait(timeout=_WINDOW).status is PipelineStatus.SUCCEEDED finally: gate.set() controller.close(timeout=_WINDOW) # --------------------------------------------------------------------------- # 工具 # --------------------------------------------------------------------------- def _finished_order(controller: Controller, run_id: str) -> list[str]: """按结束时间排出某个管道 run 里各节点的完成顺序。""" snapshots = [ item for item in controller.list_tasks() if item.pipeline_run_id == run_id and item.ended_at is not None ] snapshots.sort(key=lambda item: item.ended_at or item.created_at) # type: ignore[arg-type,return-value] return [item.name.split("/", 1)[-1] for item in snapshots] def _wait_until(predicate: Any, timeout: float = _WINDOW) -> bool: deadline = time.monotonic() + timeout while time.monotonic() < deadline: if predicate(): return True time.sleep(0.01) return False # --------------------------------------------------------------------------- # 任务体 # --------------------------------------------------------------------------- def _mark(ctx: Any) -> str: del ctx return "ok" def _add(ctx: Any, left: int, right: int) -> int: del ctx return left + right def _boom(ctx: Any) -> str: del ctx raise RuntimeError("故意失败") def _wait_gate(ctx: Any, gate: threading.Event) -> str: while not gate.wait(0.01): ctx.checkpoint() ctx.checkpoint() return "released"