初始提交: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:
2026-10-08 23:47:50 +08:00
commit f4de43c743
85 changed files with 25809 additions and 0 deletions
+439
View File
@@ -0,0 +1,439 @@
"""``@task`` 装饰器与 ``TaskContext`` 的能力测试。
三块:
1. **``@task``**——元数据从装饰器来;不产生全局状态(导入无副作用);
2. **进度**——进快照、进存储、**不碰版本号**;
3. **上游数据**——管道节点之间真正能传值,且失败的上游不传 ``None``。
"""
from __future__ import annotations
import logging
import time
from typing import Any
import pytest
from chongming_task.controller import Controller, TaskRegistry
from chongming_task.decorators import get_task_spec, task
from chongming_task.errors import ConfigurationError
from chongming_task.pipeline import Pipeline
from chongming_task.policies import RetryPolicy, Timeout
from chongming_task.states import TaskStatus
from chongming_task.stores.memory import InMemoryTaskStore
_WINDOW = 15.0
# ---------------------------------------------------------------------------
# 1. @task
# ---------------------------------------------------------------------------
@task
def bare(ctx: Any) -> str:
"""裸装饰器。"""
del ctx
return "bare"
@task(tags={"io", "nightly"}, retry=RetryPolicy(max_attempts=4), timeout=Timeout(per_attempt=9.0))
def decorated(ctx: Any) -> str:
"""带元数据的任务。
第二行不该出现在描述里。
"""
del ctx
return "decorated"
@task(name="renamed")
def original_name(ctx: Any) -> str:
del ctx
return "renamed"
def undecorated(ctx: Any) -> str:
del ctx
return "nope"
def test_bare_decorator_works() -> None:
"""``@task`` 不带括号也太自然,不支持会让人反复踩空。"""
spec = get_task_spec(bare)
assert spec is not None
assert spec.name == "bare"
assert spec.description == "裸装饰器。"
def test_decorator_captures_metadata() -> None:
spec = get_task_spec(decorated)
assert spec is not None
assert spec.tags == frozenset({"io", "nightly"})
assert spec.retry is not None
assert spec.retry.max_attempts == 4
assert spec.timeout is not None
assert spec.timeout.per_attempt == 9.0
def test_description_defaults_to_the_first_docstring_line() -> None:
spec = get_task_spec(decorated)
assert spec is not None
assert spec.description == "带元数据的任务。", "只取第一行"
assert "第二行" not in spec.description
def test_name_override() -> None:
spec = get_task_spec(original_name)
assert spec is not None
assert spec.name == "renamed"
def test_undecorated_function_has_no_spec() -> None:
assert get_task_spec(undecorated) is None
def test_decorating_does_not_create_global_state() -> None:
"""规格只挂在函数对象上。
全局注册表意味着 **import 即注册**,而 import 可能发生在任何地方(包括测试收集
阶段),那种隐式行为极难排查。
"""
registry = TaskRegistry()
assert registry.names() == ()
assert get_task_spec(bare) is not None, "规格依然取得到,但没有登记进任何注册表"
def test_registry_registers_decorated_functions() -> None:
registry = TaskRegistry()
registration = registry.register_decorated(decorated)
assert registration.name == "decorated"
assert registration.tags == frozenset({"io", "nightly"})
assert registration.retry.max_attempts == 4
def test_registry_rejects_an_undecorated_function() -> None:
registry = TaskRegistry()
with pytest.raises(ConfigurationError, match="没有用 @task 装饰过"):
registry.register_decorated(undecorated)
def test_register_module_picks_up_every_decorated_function() -> None:
import sys
module = sys.modules[__name__]
registry = TaskRegistry()
registered = registry.register_module(module)
names = {item.name for item in registered}
assert {"bare", "decorated", "renamed"} <= names
assert "undecorated" not in names
def test_register_module_rejects_a_module_with_nothing_decorated() -> None:
import json
registry = TaskRegistry()
with pytest.raises(ConfigurationError, match="没有用 @task 装饰过的函数"):
registry.register_module(json)
def test_controller_registers_a_module_end_to_end() -> None:
import sys
controller = Controller(max_parallel=2)
controller.register_module(sys.modules[__name__])
run = controller.submit("bare")
outcome = controller.wait(run.snapshot.task_id, timeout=_WINDOW)
assert outcome.ok
assert outcome.value == "bare"
controller.close(timeout=_WINDOW)
def test_decorated_retry_actually_applies() -> None:
"""装饰器上写的重试次数必须真的生效,而不只是躺在元数据里。"""
store = InMemoryTaskStore()
controller = Controller(store=store, max_parallel=2)
registry = TaskRegistry()
registry.register(
"flaky",
_flaky,
retry=RetryPolicy(max_attempts=3, backoff=_no_backoff()),
)
controller.register("flaky", _flaky, retry=RetryPolicy(max_attempts=3, backoff=_no_backoff()))
run = controller.submit("flaky")
outcome = controller.wait(run.snapshot.task_id, timeout=_WINDOW)
assert outcome.ok, "第三次应成功"
snapshot = store.get(run.snapshot.task_id)
assert snapshot is not None
assert snapshot.attempt == 3
controller.close(timeout=_WINDOW)
# ---------------------------------------------------------------------------
# 2. TaskContext:进度与日志
# ---------------------------------------------------------------------------
def test_progress_reaches_the_snapshot_without_bumping_the_version() -> None:
"""进度若参与乐观并发,会与状态写入持续冲突。"""
controller = Controller(max_parallel=2)
controller.register("reporter", _reporter)
run = controller.submit("reporter")
outcome = controller.wait(run.snapshot.task_id, timeout=_WINDOW)
assert outcome.ok
snapshot = outcome.snapshot
assert snapshot.progress is not None
assert snapshot.progress.done == 3
assert snapshot.progress.total == 3
assert snapshot.progress.unit == "rows"
# 状态迁移只有 4 次(queued/starting/running/succeeded),进度不该挤进去。
assert snapshot.status_version == 4
def test_progress_is_persisted_and_visible_in_queries() -> None:
"""进度要经 touch 旁路落到存储,否则看板的 PROGRESS 列永远是空的。"""
store = InMemoryTaskStore()
controller = Controller(store=store, max_parallel=2, command_poll_interval=0.05)
controller.register("slow_reporter", _slow_reporter)
run = controller.submit("slow_reporter")
assert _wait_until(
lambda: (store.get(run.snapshot.task_id) or _stub()).progress is not None,
timeout=_WINDOW,
)
persisted = store.get(run.snapshot.task_id)
assert persisted is not None
assert persisted.progress is not None
assert persisted.progress.done >= 1
controller.stop(run.snapshot.task_id)
controller.wait_all(timeout=_WINDOW)
controller.close(timeout=_WINDOW)
def test_progress_on_an_unsupported_context_is_silently_dropped() -> None:
"""没有回写通道时静默丢弃,而不是抛异常。
直接构造上下文(不经过执行器)是测试与工具里的常见用法,不该因此报错。
"""
from chongming_task.runtime.cancellation import CancellationToken
from chongming_task.runtime.context import RuntimeContext
from chongming_task.runtime.heartbeat import Heartbeat
context = RuntimeContext(
task_id="t",
name="t",
attempt=1,
token=CancellationToken(),
heartbeat=Heartbeat(),
)
context.progress(1, total=2)
context.log("hello")
assert list(context.logs) == ["hello"]
def test_log_records_into_the_buffer_and_the_stdlib_logger(
caplog: pytest.LogCaptureFixture,
) -> None:
# caplog 默认只捕获 WARNING 及以上,而 INFO 才是我们最常用的级别。
caplog.set_level(logging.INFO, logger="chongming_task.task")
controller = Controller(max_parallel=2)
controller.register("talker", _talker)
run = controller.submit("talker")
outcome = controller.wait(run.snapshot.task_id, timeout=_WINDOW)
assert outcome.ok
assert outcome.value == "done"
messages = [record.message for record in caplog.records]
assert "hello" in messages, "INFO 日志应当走到标准库 logging"
assert "careful" in messages
record = next(r for r in caplog.records if r.message == "hello")
assert record.task_id == run.snapshot.task_id
assert record.task_name == "talker"
assert record.attempt == 1
def test_task_context_is_the_runtime_context() -> None:
"""公开名字与内部名字是**同一个类**。
任务作者只面对一个 ``ctx``;两个类会立刻带来"我该用哪个"的困惑。
"""
from chongming_task import RuntimeContext, TaskContext
assert TaskContext is RuntimeContext
def test_checkpoint_still_works_alongside_progress() -> None:
controller = Controller(max_parallel=2)
controller.register("stopme", _stopme)
run = controller.submit("stopme")
assert _wait_until(lambda: run.status is TaskStatus.RUNNING)
controller.stop(run.snapshot.task_id, reason="收工")
outcome = controller.wait(run.snapshot.task_id, timeout=_WINDOW)
assert outcome.status is TaskStatus.STOPPED
controller.close(timeout=_WINDOW)
# ---------------------------------------------------------------------------
# 3. 上游数据(管道传值)
# ---------------------------------------------------------------------------
def test_downstream_receives_upstream_values() -> None:
controller = Controller(max_parallel=4)
controller.register("produce", _produce)
controller.register("consume", _consume)
pipeline = Pipeline("data")
source = pipeline.node("produce", node_id="source")
pipeline.node("consume", node_id="sink", depends_on=[source])
run = controller.submit_pipeline(pipeline, pipeline_run_id="d1")
outcome = run.wait(timeout=_WINDOW)
assert outcome.status.value == "succeeded"
assert outcome.nodes["sink"] is TaskStatus.SUCCEEDED
snapshot = controller.get("d1:sink")
assert snapshot is not None
assert snapshot.outcome == {"value": 42}, "下游应当拿到上游的返回值 42"
controller.close(timeout=_WINDOW)
def test_a_failed_upstream_contributes_no_value() -> None:
"""失败的上游不塞 ``None``:那会让下游以为"上游返回了 None"。"""
controller = Controller(max_parallel=4)
controller.register("boom", _boom)
controller.register("inspect", _inspect)
pipeline = Pipeline("missing")
bad = pipeline.node("boom", node_id="bad")
pipeline.node("inspect", node_id="probe", depends_on=[bad], join=_all_settled())
run = controller.submit_pipeline(pipeline, pipeline_run_id="d2")
run.wait(timeout=_WINDOW)
snapshot = controller.get("d2:probe")
assert snapshot is not None
assert snapshot.outcome == {"value": []}, "上游没有值,ctx.upstream 必须是空的"
controller.close(timeout=_WINDOW)
def test_input_can_be_read_with_a_default() -> None:
controller = Controller()
controller.register("use_default", _use_default)
run = controller.submit("use_default")
outcome = controller.wait(run.snapshot.task_id, timeout=_WINDOW)
assert outcome.value == "fallback"
controller.close(timeout=_WINDOW)
def test_inputs_cannot_be_injected_after_the_task_started() -> None:
controller = Controller(max_parallel=2)
controller.register("stopme", _stopme)
run = controller.submit("stopme")
assert _wait_until(lambda: run.status is TaskStatus.RUNNING)
with pytest.raises(RuntimeError, match="已经开始执行"):
run.set_inputs({"x": 1})
controller.stop(run.snapshot.task_id)
controller.wait_all(timeout=_WINDOW)
controller.close(timeout=_WINDOW)
# ---------------------------------------------------------------------------
# 任务体
# ---------------------------------------------------------------------------
def _stub() -> Any:
from chongming_task.models import TaskSnapshot
return TaskSnapshot(task_id="stub", name="stub")
def _no_backoff() -> Any:
from chongming_task.policies import FixedBackoff
return FixedBackoff(0.0)
def _flaky(ctx: Any) -> str:
state = getattr(_flaky, "_count", 0) + 1
_flaky._count = state # type: ignore[attr-defined]
if state < 3:
raise RuntimeError(f"第 {state} 次失败")
return "ok"
def _reporter(ctx: Any) -> str:
for index in range(1, 4):
ctx.checkpoint()
ctx.progress(index, total=3, unit="rows")
return "reported"
def _slow_reporter(ctx: Any) -> str:
for index in range(1, 200):
ctx.checkpoint()
ctx.progress(index, total=200, unit="rows")
time.sleep(0.01)
return "done"
def _talker(ctx: Any) -> str:
ctx.log("hello")
ctx.warning("careful")
return "done"
def _stopme(ctx: Any) -> str:
for _ in range(1_000_000):
ctx.checkpoint()
time.sleep(0.005)
return "never"
def _produce(ctx: Any) -> int:
del ctx
return 42
def _consume(ctx: Any) -> int:
value = ctx.input("source")
assert value == 42, f"预期 42,收到 {value!r}"
return int(value)
def _inspect(ctx: Any) -> list[str]:
return sorted(ctx.upstream)
def _use_default(ctx: Any) -> str:
return str(ctx.input("nobody", "fallback"))
def _boom(ctx: Any) -> str:
del ctx
raise RuntimeError("故意失败")
def _all_settled() -> Any:
from chongming_task.policies import JoinMode
return JoinMode.ALL_SETTLED
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