初始提交:chongming-task 任务控制库
单任务受控执行(15 态状态机 + 审计轨迹)、DAG 管道、Controller 门面。
执行器三个:线程(默认,含 async def 任务)、内联(同步 / 测试)、进程
(spawn 子进程:硬性停止、绕开 GIL、崩溃隔离)。
调度:并发额度 + 每桶限流 + 具名资源信号量(按标签)+ 优先级排队。
持久层:TaskStore 契约 + 内存 / SQLite / Postgres 实现,契约测试随包发布。
跨进程与多节点:写穿 / 读穿、指令通道与租约、owned_buckets 划界、reconcile。
CLI、12 个可直接运行的示例、docs/ 下 5 份文档。
门禁:ruff / ruff format / mypy(strict) 干净;
pytest 563 passed(44 skipped 是需要真实 Postgres 的那批);
带一台可连的 Postgres 时 607 passed。
This commit is contained in:
@@ -0,0 +1,590 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user