初始提交: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
+139
View File
@@ -0,0 +1,139 @@
"""分桶策略测试。
最重要的一条是**跨进程稳定性**:内置 ``hash()`` 带进程级随机盐,若图省事用它,
同一个键在不同进程里会落到不同的桶,分布式下"同桶任务在同一台机器"这条约定会
**静默**失效。这里用真实子进程与不同的 ``PYTHONHASHSEED`` 把它钉死。
"""
from __future__ import annotations
import os
import subprocess
import sys
import pytest
from chongming_task.policies import BucketPolicy
def _run_with_hash_seed(script: str, seed: str) -> str:
"""在指定 ``PYTHONHASHSEED`` 的子进程里跑一段脚本,返回其标准输出。"""
environment = {**os.environ, "PYTHONHASHSEED": seed}
completed = subprocess.run(
[sys.executable, "-c", script],
capture_output=True,
text=True,
env=environment,
check=True,
timeout=60,
)
return completed.stdout.strip()
# ---------------------------------------------------------------------------
# 稳定性(分桶能用于分布式的全部前提)
# ---------------------------------------------------------------------------
def test_same_key_always_maps_to_the_same_bucket() -> None:
policy = BucketPolicy(64)
for _ in range(50):
assert policy.bucket_for("tenant-42") == policy.bucket_for("tenant-42")
def test_assignment_is_stable_across_processes_with_different_hash_seeds() -> None:
"""内置 ``hash()`` 带随机盐,本实现必须不受它影响。"""
script = (
"from chongming_task.policies import BucketPolicy;"
"print(' '.join(str(BucketPolicy(64).bucket_for(f'tenant-{i}')) for i in range(20)))"
)
first = _run_with_hash_seed(script, "0")
second = _run_with_hash_seed(script, "1")
assert first == second, "同一个键在不同进程里落到了不同的桶"
local = " ".join(str(BucketPolicy(64).bucket_for(f"tenant-{i}")) for i in range(20))
assert first == local, "子进程与本进程的结果不一致"
def test_builtin_hash_would_have_been_unusable() -> None:
"""留一条反例,说明为什么不能图省事用内置 ``hash()``。
不是"可能不一样",而是**确实不一样**——这就是那个坑本身。
"""
script = "print(' '.join(str(hash(f'k{i}') % 64) for i in range(20)))"
assert _run_with_hash_seed(script, "0") != _run_with_hash_seed(script, "1")
# ---------------------------------------------------------------------------
# 取值范围与分布
# ---------------------------------------------------------------------------
def test_bucket_is_always_within_range() -> None:
for num_buckets in (1, 2, 7, 64, 1000):
policy = BucketPolicy(num_buckets)
for index in range(200):
bucket = policy.bucket_for(f"key-{index}")
assert 0 <= bucket < num_buckets
def test_single_bucket_policy_puts_everything_in_bucket_zero() -> None:
policy = BucketPolicy(1)
assert all(policy.bucket_for(key) == 0 for key in ("a", "b", "c"))
def test_distribution_is_reasonably_uniform() -> None:
"""分桶明显倾斜会让某些机器空转、另一些排队。
这里只做一个粗检:64 个桶、4096 个键,每个桶的落点不应当偏离均值太多。
"""
policy = BucketPolicy(64)
counts = [0] * 64
for index in range(4096):
counts[policy.bucket_for(f"tenant-{index}")] += 1
expected = 4096 / 64
assert all(count > 0 for count in counts), "有桶完全没分到键,分布明显异常"
assert max(counts) < expected * 3, f"分布过于集中:max={max(counts)}"
def test_similar_keys_do_not_cluster() -> None:
"""``tenant-1`` 与 ``tenant-2`` 这类只差一个字符的键必须被打散。"""
policy = BucketPolicy(64)
buckets = {policy.bucket_for(f"tenant-{index}") for index in range(50)}
assert len(buckets) > 20, "相邻键挤在少数几个桶里,哈希的雪崩性不足"
def test_policy_validates_bucket_range() -> None:
policy = BucketPolicy(8)
assert policy.is_valid(0)
assert policy.is_valid(7)
assert not policy.is_valid(8)
assert not policy.is_valid(-1)
def test_policy_exposes_all_buckets() -> None:
assert list(BucketPolicy(4).buckets) == [0, 1, 2, 3]
# ---------------------------------------------------------------------------
# 参数校验
# ---------------------------------------------------------------------------
def test_num_buckets_must_be_positive() -> None:
with pytest.raises(ValueError, match="num_buckets"):
BucketPolicy(0)
def test_empty_key_is_rejected() -> None:
"""空键会让所有"没填租户"的任务挤进同一个桶。"""
with pytest.raises(ValueError, match="不能为空"):
BucketPolicy().bucket_for("")
def test_policy_is_hashable_and_comparable() -> None:
"""策略会作为配置传递,值语义让它能被比较与当作字典键。"""
assert BucketPolicy(64) == BucketPolicy(64)
assert BucketPolicy(64) != BucketPolicy(128)
assert len({BucketPolicy(64), BucketPolicy(64)}) == 1
+281
View File
@@ -0,0 +1,281 @@
"""协作式取消令牌的行为测试。
这里必须用**真实线程**:令牌的价值恰恰在于跨线程正确握手(任务线程阻塞在
暂停点、调度线程请求停止并唤醒它)。用假的线程模型测不出竞态。
因此这些用例带小超时并断言线程确实退出——**同时也在验证没有死锁**。
"""
from __future__ import annotations
import asyncio
import contextlib
import threading
import pytest
from chongming_task.errors import TaskCancelled
from chongming_task.runtime.cancellation import CancellationToken
_JOIN_TIMEOUT = 5.0
def _run_in_thread(target: object) -> threading.Thread:
thread = threading.Thread(target=target) # type: ignore[arg-type]
thread.daemon = True
thread.start()
return thread
# ---------------------------------------------------------------------------
# 停止
# ---------------------------------------------------------------------------
def test_checkpoint_is_noop_without_any_request() -> None:
"""没有请求时 checkpoint 必须立即返回,否则每轮循环都在付代价。"""
CancellationToken().checkpoint()
def test_checkpoint_raises_when_stop_requested() -> None:
token = CancellationToken()
token.request_stop("发布窗口")
with pytest.raises(TaskCancelled) as info:
token.checkpoint()
assert info.value.reason == "发布窗口"
def test_stop_is_idempotent_and_keeps_the_first_reason() -> None:
"""首个原因通常最接近真实起因,后续请求不应覆盖它。"""
token = CancellationToken()
token.request_stop("first")
token.request_stop("second")
with pytest.raises(TaskCancelled) as info:
token.checkpoint()
assert info.value.reason == "first"
def test_raise_if_stopped_ignores_pause() -> None:
"""用于清理路径:只响应停止,绝不阻塞。"""
token = CancellationToken()
token.request_pause()
token.raise_if_stopped() # 不应阻塞
token.request_stop()
with pytest.raises(TaskCancelled):
token.raise_if_stopped()
def test_is_stop_requested_reflects_state() -> None:
token = CancellationToken()
assert not token.is_stop_requested
token.request_stop()
assert token.is_stop_requested
# ---------------------------------------------------------------------------
# 暂停
# ---------------------------------------------------------------------------
def test_pause_blocks_until_resume() -> None:
token = CancellationToken()
token.request_pause() # 先请求,消除"任务先跑到检查点"的竞态
observed: list[str] = []
def worker() -> None:
token.checkpoint()
observed.append("resumed")
thread = _run_in_thread(worker)
assert token.wait_paused(timeout=_JOIN_TIMEOUT) is True
assert observed == [], "暂停请求生效前任务不应继续执行"
token.resume()
thread.join(_JOIN_TIMEOUT)
assert not thread.is_alive(), "resume() 必须唤醒阻塞中的任务"
assert observed == ["resumed"]
def test_stop_wakes_a_paused_task() -> None:
"""暂停中的任务必须能被停止,否则"暂停"就成了不可撤销的挂起。"""
token = CancellationToken()
token.request_pause()
reasons: list[str | None] = []
def worker() -> None:
try:
token.checkpoint()
except TaskCancelled as exc:
reasons.append(exc.reason)
thread = _run_in_thread(worker)
assert token.wait_paused(timeout=_JOIN_TIMEOUT) is True
token.request_stop("运维介入")
thread.join(_JOIN_TIMEOUT)
assert not thread.is_alive(), "停止请求必须能唤醒暂停点"
assert reasons == ["运维介入"]
def test_repeated_pause_does_not_clear_acknowledgement() -> None:
"""重复请求暂停不应把"已确认"重置掉,否则 wait_paused 会误判为未生效。"""
token = CancellationToken()
token.request_pause()
def worker() -> None:
token.checkpoint()
thread = _run_in_thread(worker)
assert token.wait_paused(timeout=_JOIN_TIMEOUT) is True
token.request_pause()
assert token.is_pause_acknowledged is True
token.resume()
thread.join(_JOIN_TIMEOUT)
def test_wait_paused_times_out_when_task_never_checkpoints() -> None:
"""调度器必须能区分"已暂停"与"暂停没生效"——后者要回滚为 RUNNING 并告警。"""
token = CancellationToken()
token.request_pause()
assert token.wait_paused(timeout=0.05) is False
assert token.is_pause_acknowledged is False
def test_wait_paused_reports_false_when_stopped_instead() -> None:
"""请求暂停后又被停止:这不是"暂停成功",必须返回 False。"""
token = CancellationToken()
token.request_pause()
token.request_stop()
assert token.wait_paused(timeout=_JOIN_TIMEOUT) is False
def test_resume_without_pause_is_noop() -> None:
token = CancellationToken()
token.resume()
assert not token.is_pause_requested
token.checkpoint() # 仍应立即返回
def _start_pausing_worker(token: CancellationToken) -> threading.Event:
"""起一个持续调用 ``checkpoint()`` 的工作线程,返回"完成过一轮"的事件。
线程必须**持续**调用 checkpoint——只调一次就退出的话,之后的暂停请求自然没人
确认,那是测试写错而不是缺陷。
"""
def worker() -> None:
# 收尾时的 stop 会让 checkpoint 抛 TaskCancelled:那正是预期行为,
# 但线程里未捕获的异常会被 pytest 转成错误。
with contextlib.suppress(TaskCancelled):
while True:
token.checkpoint()
ran.set()
ran = threading.Event()
threading.Thread(target=worker, daemon=True).start()
return ran
def test_pause_is_acknowledged_again_after_a_resume() -> None:
"""**回归用例**:暂停 → 恢复 → 再暂停里,第二次暂停曾经永久失效。
``checkpoint()`` 原先只在**进入**暂停分支时设置 ``_ack``。``resume()`` 把
``_pause`` 置回 False 并通知后,任务线程还没来得及重新取到锁,新的
``request_pause()`` 就把 ``_pause`` 又置成了 True——线程重新检查循环条件时发现
"还要暂停",于是继续等待,而它已经越过了设置 ``_ack`` 的那一行。
结果 ``wait_paused()`` 永远等不到确认,暂停超时回滚。表现出来就是"暂停一次成功,
之后再也暂停不了"。
"""
token = CancellationToken()
ran = _start_pausing_worker(token)
assert ran.wait(_JOIN_TIMEOUT), "工作线程没有跑起来"
for round_index in range(5):
token.request_pause()
assert token.wait_paused(timeout=_JOIN_TIMEOUT) is True, (
f"第 {round_index + 1} 轮暂停没有得到确认"
)
ran.clear()
token.resume()
assert ran.wait(_JOIN_TIMEOUT), f"第 {round_index + 1} 轮恢复后任务没有继续推进"
token.request_stop()
def test_a_pause_right_after_a_resume_is_still_acknowledged() -> None:
"""恢复与再次暂停之间不留任何间隔——最容易踩中的时序。"""
token = CancellationToken()
ran = _start_pausing_worker(token)
assert ran.wait(_JOIN_TIMEOUT)
token.request_pause()
assert token.wait_paused(timeout=_JOIN_TIMEOUT) is True
# 立刻恢复再立刻暂停:不给任务线程任何重新取锁的机会。
token.resume()
token.request_pause()
assert token.wait_paused(timeout=_JOIN_TIMEOUT) is True, "紧接着的第二次暂停丢了"
token.resume()
token.request_stop()
# ---------------------------------------------------------------------------
# 异步路径
# ---------------------------------------------------------------------------
def test_acheckpoint_fast_path_does_not_offload_to_thread() -> None:
"""紧密循环里的每次检查都丢线程会拖垮事件循环,快速路径必须留在本线程。"""
async def main() -> str:
token = CancellationToken()
for _ in range(2000):
await token.acheckpoint()
return "done"
assert asyncio.run(main()) == "done"
def test_acheckpoint_raises_when_stopped() -> None:
token = CancellationToken()
token.request_stop("已取消")
async def main() -> None:
await token.acheckpoint()
with pytest.raises(TaskCancelled) as info:
asyncio.run(main())
assert info.value.reason == "已取消"
def test_acheckpoint_suspends_and_resumes_without_blocking_loop() -> None:
"""暂停期间事件循环必须仍能推进——这正是异步版本存在的理由。"""
token = CancellationToken()
token.request_pause()
async def main() -> list[str]:
events: list[str] = []
async def heartbeat() -> None:
while True:
events.append("tick")
await asyncio.sleep(0.01)
ticker = asyncio.create_task(heartbeat())
waiter = asyncio.create_task(token.acheckpoint())
while not token.is_pause_acknowledged:
await asyncio.sleep(0.01)
ticks_while_paused = len(events)
assert ticks_while_paused > 0, "暂停期间事件循环不应被阻塞"
token.resume()
await asyncio.wait_for(waiter, timeout=_JOIN_TIMEOUT)
ticker.cancel()
return events
assert asyncio.run(main())
+678
View File
@@ -0,0 +1,678 @@
"""任务管理门面(Controller / Registry)的测试。
重点覆盖三件事:**白名单**(只能启动已注册的任务)、**并发额度**(超限排队而不是
无限起线程)、以及**收尾的诚实性**(排队中的任务被取消时不应真的执行任务体)。
"""
from __future__ import annotations
import time
from typing import Any
import pytest
from chongming_task.controller import Controller, StatusCounts, TaskRegistry, render_table
from chongming_task.errors import ConfigurationError
from chongming_task.policies import BucketPolicy, ConcurrencyPolicy
from chongming_task.run import TaskRun
from chongming_task.states import TaskStatus
_WINDOW = 10.0
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 _wait_for_status(run: TaskRun, status: TaskStatus, timeout: float = _WINDOW) -> bool:
return _wait_until(lambda: run.status is status, timeout)
# --- 注册用任务(模块级,便于复用) ---
def add(ctx: Any, left: int, right: int) -> int:
"""两数相加。"""
del ctx
return left + right
def slow_loop(ctx: Any) -> str:
"""持续小步推进直到被停止。"""
for _ in range(1_000_000):
ctx.checkpoint()
time.sleep(0.005)
return "done"
def never_runs(ctx: Any) -> str:
"""排队期间被取消时,这个函数体不应该被执行。"""
marker.append("ran")
return "ran"
marker: list[str] = []
# ---------------------------------------------------------------------------
# 注册表:白名单
# ---------------------------------------------------------------------------
def test_registry_rejects_duplicate_names() -> None:
"""静默覆盖会让"我改了实现,跑的还是旧的"这类问题极难发现。"""
registry = TaskRegistry()
registry.register("add", add)
with pytest.raises(ConfigurationError, match="已注册"):
registry.register("add", add)
def test_registry_requires_a_callable() -> None:
with pytest.raises(ConfigurationError, match="可调用"):
TaskRegistry().register("bad", "not-callable") # type: ignore[arg-type]
def test_registry_rejects_blank_name() -> None:
with pytest.raises(ConfigurationError, match="不能为空"):
TaskRegistry().register(" ", add)
def test_registry_picks_up_the_docstring_as_description() -> None:
registration = TaskRegistry().register("add", add)
assert registration.description == "两数相加。"
def test_submitting_an_unregistered_task_is_rejected() -> None:
"""安全底线:入口只接受**名字**,绝不接受代码。
一个能启动任意可调用对象的入口等于任意代码执行——这是将来接 CLI 还是
HTTP 都不能破的规则。
"""
controller = Controller()
with pytest.raises(ConfigurationError, match="未注册"):
controller.submit("nope")
assert controller.registry.names() == ()
def test_registry_lists_registered_names() -> None:
controller = Controller()
controller.register("add", add)
controller.register("slow", slow_loop)
assert controller.registry.names() == ("add", "slow")
assert len(controller.registry) == 2
assert "add" in controller.registry
# ---------------------------------------------------------------------------
# 提交、查询、统计
# ---------------------------------------------------------------------------
def test_submit_runs_and_reports_snapshot() -> None:
controller = Controller()
controller.register("add", add)
run = controller.submit("add", 2, 3, task_id="t1", label="加法")
assert isinstance(run, TaskRun)
outcome = controller.wait("t1", timeout=_WINDOW)
assert outcome.ok
assert outcome.value == 5
snapshot = controller.get("t1")
assert snapshot is not None
assert snapshot.task_id == "t1"
assert snapshot.name == "加法"
assert snapshot.status is TaskStatus.SUCCEEDED
def test_get_returns_none_for_unknown_task() -> None:
assert Controller().get("nope") is None
def test_stats_counts_by_status() -> None:
controller = Controller()
controller.register("add", add)
for index in range(3):
controller.submit("add", index, 1, task_id=f"t{index}")
controller.wait_all(timeout=_WINDOW)
stats = controller.stats()
assert isinstance(stats, StatusCounts)
assert stats.total == 3
assert stats.of(TaskStatus.SUCCEEDED) == 3
assert stats.finished == 3
assert stats.unfinished == 0
def test_list_filters_by_status_and_name_prefix() -> None:
controller = Controller()
controller.register("add", add)
controller.submit("add", 1, 1, task_id="a1", label="抽取-甲")
controller.submit("add", 1, 1, task_id="a2", label="抽取-乙")
controller.wait_all(timeout=_WINDOW)
assert len(controller.list_tasks()) == 2
assert len(controller.list_tasks(name_prefix="抽取")) == 2
assert controller.list_tasks(name_prefix="加载") == []
assert len(controller.list_tasks(status=TaskStatus.SUCCEEDED)) == 2
assert controller.list_tasks(status=TaskStatus.FAILED) == []
assert len(controller.list_tasks(limit=1)) == 1
def test_list_filters_by_tag() -> None:
controller = Controller()
controller.register("add", add, tags={"io"})
controller.submit("add", 1, 1, task_id="a1")
controller.submit("add", 1, 1, task_id="a2", tags={"cpu"})
controller.wait_all(timeout=_WINDOW)
assert len(controller.list_tasks(tag="io")) == 2, "注册时的标签应当被继承"
assert [s.task_id for s in controller.list_tasks(tag="cpu")] == ["a2"]
def test_duplicate_task_id_is_rejected() -> None:
controller = Controller()
controller.register("add", add)
controller.submit("add", 1, 1, task_id="dup")
with pytest.raises(ConfigurationError, match="已存在"):
controller.submit("add", 1, 1, task_id="dup")
# ---------------------------------------------------------------------------
# 并发额度
# ---------------------------------------------------------------------------
def test_excess_submissions_queue_instead_of_starting() -> None:
"""超限的任务状态是 ``queued``(已被受理、等待执行槽),不是 ``pending``。"""
controller = Controller(max_parallel=1)
controller.register("slow", slow_loop)
first = controller.submit("slow", task_id="t1")
assert _wait_for_status(first, TaskStatus.RUNNING)
second = controller.submit("slow", task_id="t2")
third = controller.submit("slow", task_id="t3")
assert second.status is TaskStatus.QUEUED
assert third.status is TaskStatus.QUEUED
assert not second.started, "排队中的任务不应被启动"
assert controller.active_count == 1
assert controller.queued_count == 2
controller.stop_all(reason="测试结束")
controller.wait_all(timeout=_WINDOW)
assert controller.queued_count == 0
def test_queued_tasks_start_as_slots_free_up() -> None:
controller = Controller(max_parallel=2)
controller.register("add", add)
runs = [controller.submit("add", index, 1, task_id=f"t{index}") for index in range(4)]
outcomes = controller.wait_all(timeout=_WINDOW)
assert len(outcomes) == 4
assert all(outcome.ok for outcome in outcomes)
assert sorted(outcome.value for outcome in outcomes) == [1, 2, 3, 4]
assert controller.active_count == 0
assert all(run.status is TaskStatus.SUCCEEDED for run in runs)
def test_max_parallel_must_be_positive() -> None:
with pytest.raises(ConfigurationError, match="max_parallel"):
Controller(max_parallel=0)
# ---------------------------------------------------------------------------
# 具名资源信号量与优先级(ConcurrencyPolicy)
#
# 这三个字段曾经只是"声明了、校验了、文档里写着支持"——调度器从不读它,于是
# ``limits`` 与 ``priority`` 设了等于没设,而且**不报任何错**。下面几条把它们钉住。
# ---------------------------------------------------------------------------
def test_named_resource_limit_caps_only_the_tagged_tasks() -> None:
"""``limits={"gpu": 1}`` 只约束带 ``gpu`` 标签的任务,不是给全局加一道闸。"""
controller = Controller(concurrency=ConcurrencyPolicy(limits={"gpu": 1}))
controller.register("slow", slow_loop)
first = controller.submit("slow", task_id="gpu-1", tags=["gpu"])
assert _wait_for_status(first, TaskStatus.RUNNING)
second = controller.submit("slow", task_id="gpu-2", tags=["gpu"])
assert second.status is TaskStatus.QUEUED, "gpu 名额已满,第二个必须排队"
# 不带该标签的任务不受它约束——具名信号量不是全局额度。
plain = controller.submit("slow", task_id="plain")
assert _wait_for_status(plain, TaskStatus.RUNNING)
assert controller.active_count == 2
controller.stop_all(reason="测试结束")
controller.wait_all(timeout=_WINDOW)
def test_a_task_needs_room_in_every_limited_tag_it_carries() -> None:
"""多标签任务要**每个**受限期资源都还有余量才放行。"""
controller = Controller(concurrency=ConcurrencyPolicy(limits={"gpu": 1, "db": 1}))
controller.register("slow", slow_loop)
gpu = controller.submit("slow", task_id="gpu-holder", tags=["gpu"])
assert _wait_for_status(gpu, TaskStatus.RUNNING)
db = controller.submit("slow", task_id="db-holder", tags=["db"])
assert _wait_for_status(db, TaskStatus.RUNNING)
both = controller.submit("slow", task_id="needs-both", tags=["gpu", "db"])
assert both.status is TaskStatus.QUEUED, "两个资源都满了,带两个标签的任务更不能放行"
# 只空出一个:db 还占着,所以它依旧不能启动。等额度记账真的落下来再断言,
# 否则这句话可能在调度器还没醒的时候"通过",什么都没证明。
assert controller.stop("gpu-holder") is True
controller.wait("gpu-holder", timeout=_WINDOW)
assert _wait_until(lambda: controller.active_count == 1), "额度应当已归还"
assert both.status is TaskStatus.QUEUED, "gpu 空了但 db 还占着,不能放行"
# 两个都空出来才轮到它。
assert controller.stop("db-holder") is True
controller.wait("db-holder", timeout=_WINDOW)
assert _wait_for_status(both, TaskStatus.RUNNING)
controller.stop_all(reason="测试结束")
controller.wait_all(timeout=_WINDOW)
def test_tags_without_a_limit_do_not_throttle_anything() -> None:
"""没写进 ``limits`` 的标签不受约束(常见误解:以为打个标签就会被限流)。"""
controller = Controller(concurrency=ConcurrencyPolicy(limits={"gpu": 1}))
controller.register("slow", slow_loop)
first = controller.submit("slow", task_id="io-1", tags=["io"])
second = controller.submit("slow", task_id="io-2", tags=["io"])
assert _wait_for_status(first, TaskStatus.RUNNING)
assert _wait_for_status(second, TaskStatus.RUNNING)
controller.stop_all(reason="测试结束")
controller.wait_all(timeout=_WINDOW)
def test_priority_picks_the_highest_first() -> None:
"""都能启动时先取优先级最高的;数值越大越优先。"""
controller = Controller(max_parallel=1)
controller.register("slow", slow_loop)
blocker = controller.submit("slow", task_id="blocker")
assert _wait_for_status(blocker, TaskStatus.RUNNING)
low = controller.submit("slow", task_id="low", priority=1)
high = controller.submit("slow", task_id="high", priority=5)
assert controller.stop("blocker") is True
controller.wait("blocker", timeout=_WINDOW)
assert _wait_for_status(high, TaskStatus.RUNNING), "高优先级的应当先被取走"
assert low.status is TaskStatus.QUEUED, "低优先级的不该插到前面"
controller.stop_all(reason="测试结束")
controller.wait_all(timeout=_WINDOW)
def test_equal_priority_keeps_submission_order() -> None:
"""同优先级仍然先进先出——优先级机制不该打乱时间序。"""
controller = Controller(max_parallel=1)
controller.register("slow", slow_loop)
blocker = controller.submit("slow", task_id="blocker")
assert _wait_for_status(blocker, TaskStatus.RUNNING)
first_queued = controller.submit("slow", task_id="first")
second_queued = controller.submit("slow", task_id="second")
assert controller.stop("blocker") is True
controller.wait("blocker", timeout=_WINDOW)
assert _wait_for_status(first_queued, TaskStatus.RUNNING), "先提交的应当先跑"
assert second_queued.status is TaskStatus.QUEUED
controller.stop_all(reason="测试结束")
controller.wait_all(timeout=_WINDOW)
def test_policy_default_priority_applies_to_submissions() -> None:
"""``ConcurrencyPolicy.priority`` 是 ``submit`` 不给 ``priority`` 时的默认值。"""
controller = Controller(concurrency=ConcurrencyPolicy(max_parallel=1, priority=7))
controller.register("slow", slow_loop)
blocker = controller.submit("slow", task_id="blocker")
assert _wait_for_status(blocker, TaskStatus.RUNNING)
assert blocker.priority == 7, "没给 priority 时应当取策略里的默认值"
explicit_low = controller.submit("slow", task_id="low", priority=1)
assert explicit_low.priority == 1, "显式给的优先级必须能覆盖默认值"
default_high = controller.submit("slow", task_id="high")
assert default_high.priority == 7
assert controller.stop("blocker") is True
controller.wait("blocker", timeout=_WINDOW)
assert _wait_for_status(default_high, TaskStatus.RUNNING)
assert explicit_low.status is TaskStatus.QUEUED
controller.stop_all(reason="测试结束")
controller.wait_all(timeout=_WINDOW)
def test_concurrency_policy_max_parallel_is_honoured() -> None:
"""策略里的 ``max_parallel`` 与 ``Controller(max_parallel=...)`` 等价。"""
controller = Controller(concurrency=ConcurrencyPolicy(max_parallel=1))
controller.register("slow", slow_loop)
first = controller.submit("slow", task_id="t1")
assert _wait_for_status(first, TaskStatus.RUNNING)
second = controller.submit("slow", task_id="t2")
assert second.status is TaskStatus.QUEUED
assert controller.active_count == 1
controller.stop_all(reason="测试结束")
controller.wait_all(timeout=_WINDOW)
def test_policy_and_max_parallel_are_mutually_exclusive() -> None:
"""两份配置只能给一份:谁说了算必须明确,不能靠"后者覆盖前者"这种猜法。"""
with pytest.raises(ConfigurationError, match="只能给一个"):
Controller(concurrency=ConcurrencyPolicy(max_parallel=2), max_parallel=1)
def test_policy_rejects_a_non_positive_resource_limit() -> None:
with pytest.raises(ValueError, match="并发上限"):
ConcurrencyPolicy(limits={"gpu": 0})
def test_a_non_integer_priority_is_rejected_at_submit_time() -> None:
"""非整数优先级必须在提交时拦下。
放过去的话,它会在**派发线程**里抛 ``TypeError``——线程直接死掉,此后所有排队
任务都不再启动,而没有任何报错指向真正的原因。
"""
controller = Controller()
controller.register("add", add)
with pytest.raises(ConfigurationError, match="priority"):
controller.submit("add", 1, 2, task_id="t1", priority="high") # type: ignore[arg-type]
# ---------------------------------------------------------------------------
# 控制
# ---------------------------------------------------------------------------
def test_stop_routes_to_the_right_task() -> None:
controller = Controller()
controller.register("slow", slow_loop)
victim = controller.submit("slow", task_id="victim")
bystander = controller.submit("slow", task_id="bystander")
assert _wait_for_status(victim, TaskStatus.RUNNING)
assert _wait_for_status(bystander, TaskStatus.RUNNING)
assert controller.stop("victim", reason="发布窗口") is True
outcome = controller.wait("victim", timeout=_WINDOW)
assert outcome.status is TaskStatus.STOPPED
assert bystander.status is TaskStatus.RUNNING, "不该误伤别的任务"
controller.stop_all()
controller.wait_all(timeout=_WINDOW)
def test_stop_returns_false_for_unknown_task() -> None:
assert Controller().stop("nope") is False
def test_pause_and_resume_route_through_the_controller() -> None:
controller = Controller()
controller.register("slow", slow_loop)
run = controller.submit("slow", task_id="p1")
assert _wait_for_status(run, TaskStatus.RUNNING)
assert controller.pause("p1", timeout=_WINDOW) is True
assert run.status is TaskStatus.PAUSED
assert controller.resume("p1") is True
assert run.status is TaskStatus.RUNNING
controller.stop_all()
controller.wait_all(timeout=_WINDOW)
def test_stop_all_reports_how_many_were_affected() -> None:
controller = Controller()
controller.register("slow", slow_loop)
for index in range(3):
controller.submit("slow", task_id=f"t{index}")
assert _wait_until(lambda: controller.active_count == 3)
assert controller.stop_all(reason="批量收工") == 3
controller.wait_all(timeout=_WINDOW)
assert controller.stop_all() == 0, "已无未落终态的任务"
def test_submit_after_close_is_rejected() -> None:
controller = Controller()
controller.register("add", add)
controller.close(timeout=_WINDOW)
with pytest.raises(ConfigurationError, match="已关闭"):
controller.submit("add", 1, 1)
# ---------------------------------------------------------------------------
# 收尾
# ---------------------------------------------------------------------------
def test_close_does_not_execute_queued_task_bodies() -> None:
"""排队中的任务被取消时不该真的跑起来——否则"停止"只是把副作用推迟了。"""
marker.clear()
controller = Controller(max_parallel=1)
controller.register("slow", slow_loop)
controller.register("never", never_runs)
controller.submit("slow", task_id="t-block")
assert _wait_until(lambda: controller.active_count == 1)
controller.submit("never", task_id="t-queued")
assert controller.queued_count == 1
outcomes = controller.close(reason="收工", timeout=_WINDOW)
assert marker == [], "排队中的任务被启动了,任务体被执行"
statuses = {outcome.snapshot.task_id: outcome.status for outcome in outcomes}
assert statuses["t-queued"] is TaskStatus.STOPPED
assert statuses["t-block"] is TaskStatus.STOPPED
def test_prune_drops_oldest_terminal_records() -> None:
controller = Controller()
controller.register("add", add)
for index in range(5):
controller.submit("add", index, 1, task_id=f"t{index}")
controller.wait_all(timeout=_WINDOW)
assert len(controller.list_tasks()) == 5
assert controller.prune(keep_terminal=2) == 3
assert len(controller.list_tasks()) == 2
assert controller.prune(keep_terminal=2) == 0
# ---------------------------------------------------------------------------
# 渲染
# ---------------------------------------------------------------------------
def test_render_table_aligns_chinese_names_by_display_width() -> None:
"""中日韩全角字符占两列;不按显示宽度对齐,中文任务名会把整张表挤歪。"""
controller = Controller()
controller.register("add", add)
controller.submit("add", 1, 1, task_id="a1", label="抽取")
controller.submit("add", 1, 1, task_id="a2", label="a-much-longer-name")
controller.wait_all(timeout=_WINDOW)
table = render_table(controller.list_tasks())
lines = table.splitlines()
assert lines[0].startswith("TASK_ID")
assert set(lines[1]) <= {"-", " "}, "表头下方应当是分隔线"
widths = {len(line.rstrip()) for line in lines}
assert len(widths) > 1, "各行列宽应当随内容变化,而不是靠不齐"
def test_render_table_reports_failure_kind() -> None:
def boom(ctx: Any) -> None:
raise RuntimeError("炸了")
controller = Controller()
controller.register("boom", boom)
controller.submit("boom", task_id="b1")
controller.wait_all(timeout=_WINDOW)
table = render_table(controller.list_tasks())
assert "exception" in table
assert "failed" in table
# ---------------------------------------------------------------------------
# 分桶
# ---------------------------------------------------------------------------
def _two_keys_in_different_buckets(policy: BucketPolicy) -> tuple[str, str]:
"""找两个确实落在不同桶里的键。
硬编码两个键是不行的:它们有约 1/64 的概率撞进同一个桶,那会让"分桶隔离"
的用例变成一句空话,而且只在特定版本上偶发失败。
"""
first = "tenant-0"
first_bucket = policy.bucket_for(first)
for index in range(1, 500):
candidate = f"tenant-{index}"
if policy.bucket_for(candidate) != first_bucket:
return first, candidate
raise AssertionError("在 500 个键里找不到第二个桶,分桶策略有问题")
def test_bucket_is_derived_from_the_key_and_recorded() -> None:
controller = Controller()
controller.register("add", add)
run = controller.submit("add", 1, 1, task_id="a1", bucket_key="tenant-42")
controller.wait_all(timeout=_WINDOW)
snapshot = run.snapshot
assert snapshot.bucket == controller.bucket_policy.bucket_for("tenant-42")
assert snapshot.bucket_key == "tenant-42", "原始键必须留存,否则改桶数时无法迁移"
def test_same_key_lands_in_the_same_bucket() -> None:
controller = Controller()
controller.register("add", add)
for index in range(3):
controller.submit("add", index, 1, task_id=f"t{index}", bucket_key="tenant-9")
controller.wait_all(timeout=_WINDOW)
buckets = {snapshot.bucket for snapshot in controller.list_tasks()}
assert len(buckets) == 1
def test_task_without_bucket_key_is_unbucketed() -> None:
controller = Controller()
controller.register("add", add)
controller.submit("add", 1, 1, task_id="a1")
controller.wait_all(timeout=_WINDOW)
assert controller.get("a1").bucket is None # type: ignore[union-attr]
assert controller.bucket_counts() == {None: 1}
def test_per_bucket_limit_isolates_buckets_from_each_other() -> None:
"""一个桶把额度占满不应饿死其他桶——这是分桶在**本地**就有的价值。
若调度器直接取队首而不是扫描可运行者,一个积压的桶会阻塞所有桶,把分桶要解决
的问题又原样引入一遍。
"""
controller = Controller(max_parallel=10, max_parallel_per_bucket=1)
controller.register("slow", slow_loop)
hot, cold = _two_keys_in_different_buckets(controller.bucket_policy)
first = controller.submit("slow", task_id="a1", bucket_key=hot)
second = controller.submit("slow", task_id="a2", bucket_key=hot)
other = controller.submit("slow", task_id="b1", bucket_key=cold)
assert _wait_for_status(first, TaskStatus.RUNNING)
assert _wait_for_status(other, TaskStatus.RUNNING), "别的桶被积压的桶挡住了"
assert second.status is TaskStatus.QUEUED, "同桶超出额度应当排队"
assert controller.active_in_bucket(controller.bucket_policy.bucket_for(hot)) == 1
controller.stop_all()
controller.wait_all(timeout=_WINDOW)
def test_per_bucket_limit_still_respects_the_global_limit() -> None:
controller = Controller(max_parallel=1, max_parallel_per_bucket=5)
controller.register("slow", slow_loop)
hot, cold = _two_keys_in_different_buckets(controller.bucket_policy)
first = controller.submit("slow", task_id="a1", bucket_key=hot)
second = controller.submit("slow", task_id="b1", bucket_key=cold)
assert _wait_for_status(first, TaskStatus.RUNNING)
assert second.status is TaskStatus.QUEUED, "总额度才是硬上限"
controller.stop_all()
controller.wait_all(timeout=_WINDOW)
def test_per_bucket_limit_must_be_positive() -> None:
with pytest.raises(ConfigurationError, match="max_parallel_per_bucket"):
Controller(max_parallel_per_bucket=0)
def test_list_tasks_filters_by_bucket_and_bucket_set() -> None:
controller = Controller()
controller.register("add", add)
hot, cold = _two_keys_in_different_buckets(controller.bucket_policy)
controller.submit("add", 1, 1, task_id="a1", bucket_key=hot)
controller.submit("add", 1, 1, task_id="b1", bucket_key=cold)
controller.submit("add", 1, 1, task_id="c1")
controller.wait_all(timeout=_WINDOW)
hot_bucket = controller.bucket_policy.bucket_for(hot)
cold_bucket = controller.bucket_policy.bucket_for(cold)
assert [s.task_id for s in controller.list_tasks(bucket=hot_bucket)] == ["a1"]
assert [s.task_id for s in controller.list_tasks(buckets=[hot_bucket, cold_bucket])] == [
"a1",
"b1",
]
assert controller.list_tasks(bucket=9999) == []
def test_bucket_counts_summarises_each_bucket() -> None:
controller = Controller()
controller.register("add", add)
hot, cold = _two_keys_in_different_buckets(controller.bucket_policy)
controller.submit("add", 1, 1, task_id="a1", bucket_key=hot)
controller.submit("add", 1, 1, task_id="a2", bucket_key=hot)
controller.submit("add", 1, 1, task_id="b1", bucket_key=cold)
controller.wait_all(timeout=_WINDOW)
counts = controller.bucket_counts()
assert counts[controller.bucket_policy.bucket_for(hot)] == 2
assert counts[controller.bucket_policy.bucket_for(cold)] == 1
def test_render_table_shows_the_bucket_column() -> None:
controller = Controller()
controller.register("add", add)
controller.submit("add", 1, 1, task_id="a1", bucket_key="tenant-42")
controller.submit("add", 1, 1, task_id="a2")
controller.wait_all(timeout=_WINDOW)
table = render_table(controller.list_tasks())
lines = table.splitlines()
assert "BUCKET" in lines[0]
expected = str(controller.bucket_policy.bucket_for("tenant-42"))
assert expected in lines[2]
assert lines[3].split()[2] == "-", "未分桶的任务应当显示 -"
+119
View File
@@ -0,0 +1,119 @@
"""内联执行器测试。
重点是两件事:它**确实**同步跑完(违反 launch 不阻塞的约定),以及它对无法
支持的暂停能力**如实返回 False** 而不是假装成功。
"""
from __future__ import annotations
import asyncio
from typing import Any
from chongming_task.models import FailureKind
from chongming_task.runtime.executors.base import ExecutionRequest
from chongming_task.runtime.executors.inline import InlineExecutor
def _request(target: Any, *args: Any) -> ExecutionRequest:
return ExecutionRequest(task_id="t-inline", name="t-inline", target=target, args=args)
def test_launch_runs_to_completion_before_returning() -> None:
"""这是它与 ``Executor`` 协议唯一的差异,也是它存在的意义。"""
order: list[str] = []
def work(ctx: Any) -> str:
del ctx
order.append("task")
return "done"
handle = InlineExecutor().launch(_request(work))
order.append("after-launch")
assert order == ["task", "after-launch"], "内联执行必须在 launch 返回前跑完"
assert handle.running is False
outcome = handle.wait()
assert outcome is not None
assert outcome.value == "done"
def test_arguments_are_forwarded_after_the_context() -> None:
def work(ctx: Any, left: int, right: int, *, scale: int = 1) -> int:
del ctx
return (left + right) * scale
handle = InlineExecutor().launch(
ExecutionRequest(
task_id="t-inline-args",
name="t-inline-args",
target=work,
args=(2, 3),
kwargs={"scale": 10},
)
)
outcome = handle.wait()
assert outcome is not None
assert outcome.value == 50
def test_failure_is_reported_like_any_other_executor() -> None:
def boom(ctx: Any) -> None:
del ctx
raise KeyError("missing")
outcome = InlineExecutor().launch(_request(boom)).wait()
assert outcome is not None
assert not outcome.ok
assert outcome.failure is not None
assert outcome.failure.kind is FailureKind.EXCEPTION
assert "KeyError" in outcome.failure.message
assert outcome.failure.traceback is not None
def test_pause_reports_false_because_it_cannot_be_honoured() -> None:
"""如实返回 False,调用方据此回滚为 RUNNING,而不是以为暂停成功了。"""
handle = InlineExecutor().launch(_request(lambda ctx: "ok"))
assert handle.pause(timeout=0.01) is False
def test_resume_is_harmless_after_the_fact() -> None:
handle = InlineExecutor().launch(_request(lambda ctx: "ok"))
handle.resume()
assert handle.running is False
def test_stop_on_a_finished_inline_task_returns_its_real_outcome() -> None:
"""已结束的任务应当返回真实结果,而不是落进"兜底放弃"分支。"""
handle = InlineExecutor().launch(_request(lambda ctx: 7))
outcome = handle.stop(graceful_timeout=1.0)
assert outcome.ok
assert outcome.value == 7
assert outcome.abandoned is False
def test_context_is_injected() -> None:
seen: dict[str, Any] = {}
def work(ctx: Any) -> str:
seen["task_id"] = ctx.task_id
seen["token"] = ctx.token
return "ok"
handle = InlineExecutor().launch(_request(work))
assert seen["task_id"] == "t-inline"
assert seen["token"] is handle.token
def test_async_target_runs_to_completion_before_launch_returns() -> None:
"""``async def`` 任务在内联执行器下同样是同步跑完的(本线程起一个事件循环)。"""
async def work(ctx: Any) -> int:
await asyncio.sleep(0)
return 42
handle = InlineExecutor().launch(_request(work))
assert handle.running is False
outcome = handle.wait()
assert outcome is not None
assert outcome.ok
assert outcome.value == 42
+143
View File
@@ -0,0 +1,143 @@
"""快照与正交维度模型的不变量。
重点覆盖那些"容易被合并成单一状态枚举"的维度:进度、失败原因、请求动作。
"""
from __future__ import annotations
import dataclasses
from datetime import UTC, datetime
import pytest
from chongming_task.models import (
AttemptRecord,
Failure,
FailureKind,
Progress,
RequestedAction,
TaskSnapshot,
TimeoutScope,
)
from chongming_task.states import TaskStatus
def test_progress_fraction_is_none_when_total_unknown() -> None:
"""total 未知必须返回 None 而非 0.0——后者会被渲染成"尚未开始"。"""
assert Progress(done=5).fraction is None
assert Progress(done=0, total=0).fraction is None
assert Progress(done=25, total=100).fraction == 0.25
def test_progress_rejects_done_beyond_total() -> None:
with pytest.raises(ValueError, match="不能超过"):
Progress(done=11, total=10)
def test_progress_rejects_negative_values() -> None:
with pytest.raises(ValueError, match="不能为负"):
Progress(done=-1)
with pytest.raises(ValueError, match="不能为负"):
Progress(total=-1)
def test_timeout_scope_maps_to_failure_kind() -> None:
"""超时是失败原因而非生命周期状态,两者的对应关系必须稳定。"""
assert FailureKind.for_timeout(TimeoutScope.START) is FailureKind.TIMEOUT_START
assert FailureKind.for_timeout(TimeoutScope.ATTEMPT) is FailureKind.TIMEOUT_ATTEMPT
assert FailureKind.for_timeout(TimeoutScope.IDLE) is FailureKind.TIMEOUT_IDLE
def test_attempt_record_numbering_starts_at_one() -> None:
now = datetime(2026, 1, 1, tzinfo=UTC)
with pytest.raises(ValueError, match="从 1 开始"):
AttemptRecord(task_id="t1", attempt=0, started_at=now)
def test_snapshot_requires_identity() -> None:
with pytest.raises(ValueError, match="task_id"):
TaskSnapshot(task_id="", name="x")
with pytest.raises(ValueError, match="name"):
TaskSnapshot(task_id="t1", name="")
def test_snapshot_rejects_attempt_beyond_max_attempts() -> None:
with pytest.raises(ValueError, match="max_attempts"):
TaskSnapshot(task_id="t1", name="x", attempt=3, max_attempts=2)
def test_snapshot_is_frozen() -> None:
"""快照不可变,才能安全跨线程/协程传递并直接作为持久化记录。"""
snap = TaskSnapshot(task_id="t1", name="x")
with pytest.raises(dataclasses.FrozenInstanceError):
snap.status = TaskStatus.RUNNING # type: ignore[misc]
def test_validate_flags_terminal_missing_ended_at() -> None:
snap = TaskSnapshot(task_id="t1", name="x", status=TaskStatus.SUCCEEDED)
assert any("ended_at" in problem for problem in snap.validate())
def test_validate_flags_leftover_requested_action_on_terminal() -> None:
"""终态残留请求会让 UI 永远显示"停止中"。"""
now = datetime(2026, 1, 1, tzinfo=UTC)
snap = TaskSnapshot(
task_id="t1",
name="x",
status=TaskStatus.STOPPED,
ended_at=now,
requested_action=RequestedAction.STOP,
)
assert any("requested_action" in problem for problem in snap.validate())
def test_retry_wait_may_carry_failure() -> None:
"""RETRY_WAIT 必须能携带上次失败原因,否则界面无法回答"它为什么在等"。"""
snap = TaskSnapshot(
task_id="t1",
name="x",
status=TaskStatus.RETRY_WAIT,
failure=Failure(kind=FailureKind.EXCEPTION, message="boom"),
)
assert snap.validate() == ()
def test_retry_wait_without_failure_is_inconsistent() -> None:
snap = TaskSnapshot(task_id="t1", name="x", status=TaskStatus.RETRY_WAIT)
assert any("RETRY_WAIT" in problem for problem in snap.validate())
def test_success_must_not_carry_failure() -> None:
now = datetime(2026, 1, 1, tzinfo=UTC)
snap = TaskSnapshot(
task_id="t1",
name="x",
status=TaskStatus.SUCCEEDED,
ended_at=now,
failure=Failure(kind=FailureKind.EXCEPTION, message="boom"),
)
assert any("SUCCEEDED" in problem for problem in snap.validate())
def test_consistent_terminal_snapshot_validates_clean() -> None:
now = datetime(2026, 1, 1, tzinfo=UTC)
snap = TaskSnapshot(
task_id="t1",
name="x",
status=TaskStatus.FAILED,
ended_at=now,
attempt=1,
max_attempts=1,
failure=Failure(kind=FailureKind.EXCEPTION, message="boom"),
)
assert snap.validate() == ()
assert snap.is_terminal
assert snap.is_revivable
def test_running_snapshot_requires_started_at_only_on_validate() -> None:
"""时间线完整性刻意不放在 __post_init__,以免逐步构造快照时处处受限。"""
snap = TaskSnapshot(task_id="t1", name="x", status=TaskStatus.RUNNING)
assert any("started_at" in problem for problem in snap.validate())
# 但构造本身不报错
assert snap.status is TaskStatus.RUNNING
+590
View File
@@ -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"
+98
View File
@@ -0,0 +1,98 @@
"""``PostgresTaskStore`` 的惰性导入契约——**不需要数据库**。
钉住两条承诺:
1. 取到 ``chongming_task.PostgresTaskStore`` 之前,iss 不导入 psycopg——核心包保持
零第三方运行时依赖(``import chongming_task`` 不会因为没装驱动而变慢或失败);
2. 没装可选依赖时,报错必须**可操作**:给出安装命令,而不是一个光秃秃的
``ModuleNotFoundError``。
真正的行为测试在 ``tests/integration/test_postgres_store.py``(需要 DSN)。
"""
from __future__ import annotations
import os
import subprocess
import sys
import pytest
import chongming_task
def _run(script: str) -> subprocess.CompletedProcess[str]:
environment = {**os.environ}
return subprocess.run(
[sys.executable, "-c", script],
capture_output=True,
text=True,
env=environment,
timeout=60,
)
def test_star_import_does_not_pull_the_driver() -> None:
"""``from chongming_task import *`` 不该要求安装数据库驱动。"""
assert "PostgresTaskStore" not in chongming_task.__all__
script = (
"import sys\n"
"from chongming_task import *\n"
"assert 'psycopg' not in sys.modules, '星号导入拉起了 psycopg'\n"
"assert 'PostgresTaskStore' not in dir(), '它不该出现在星号导入里'\n"
"print('ok')\n"
)
completed = _run(script)
assert completed.returncode == 0, completed.stderr
assert completed.stdout.strip() == "ok"
def test_lazy_attribute_does_not_import_the_driver_until_used() -> None:
script = (
"import sys\n"
"import chongming_task\n"
"assert 'psycopg' not in sys.modules, '导入核心包就拉起了 psycopg'\n"
"print('ok')\n"
)
completed = _run(script)
assert completed.returncode == 0, completed.stderr
assert completed.stdout.strip() == "ok"
def test_missing_driver_gives_an_actionable_error() -> None:
"""把 psycopg 屏蔽成"未安装",取 ``PostgresTaskStore`` 必须报出安装指引。"""
script = (
"import sys\n"
"sys.modules['psycopg'] = None\n"
"sys.modules['psycopg_pool'] = None\n"
"import chongming_task\n"
"try:\n"
" chongming_task.PostgresTaskStore\n"
"except ImportError as exc:\n"
" text = str(exc)\n"
" assert 'chongming-task[postgres]' in text, text\n"
" print('ok')\n"
"else:\n"
" raise SystemExit('没有抛出 ImportError')\n"
)
completed = _run(script)
assert completed.returncode == 0, completed.stderr
assert completed.stdout.strip() == "ok"
def test_unknown_attribute_still_raises_attribute_error() -> None:
"""``__getattr__`` 只放行已知的可选导出,其余照常抛 AttributeError。"""
with pytest.raises(AttributeError, match="no attribute"):
_ = chongming_task.NoSuchThing # type: ignore[attr-defined]
def test_postgres_store_resolves_when_the_extra_is_installed() -> None:
"""装了可选依赖时必须能取到——并在 stores 包与顶层给出同一个类。"""
pytest.importorskip("psycopg")
from chongming_task.stores import PostgresTaskStore as FromStores
from chongming_task.stores.postgres import PostgresTaskStore as FromModule
assert chongming_task.PostgresTaskStore is FromModule
assert FromStores is FromModule
+390
View File
@@ -0,0 +1,390 @@
"""进程执行器测试。
这些用例全部用**真实子进程**(``spawn``)跑:执行器的全部价值就在于子进程能不能被
停住、能不能被强杀、父进程能不能看见它。用假的进程模型测不出这些。
三条约束写在前面,它们是这套设计的一部分而不是测试的毛病:
* 目标函数必须是**模块级**的——spawn 靠"模块 + 限定名"在子进程里定位它;
* 所以子进程会重新 import 本模块(模块顶层不能有副作用);
* 每个用例给足超时(5 秒),子进程数量控制在小规模,测试整体仍然是秒级。
"""
from __future__ import annotations
import os
import threading
import time
from typing import Any
import pytest
from chongming_task.errors import ConfigurationError
from chongming_task.models import FailureKind
from chongming_task.runtime.executors.base import ExecutionRequest
from chongming_task.runtime.executors.process import ProcessExecutor
from chongming_task.testing import FakeClock
_WINDOW = 10.0
#: 子进程启动本身就要几百毫秒(新解释器 + import),协作路径的窗口要比线程宽。
_START = 15.0
def _request(target: Any, *args: Any, **kwargs: Any) -> ExecutionRequest:
return ExecutionRequest(
task_id="t-proc",
name="t-proc",
target=target,
args=args,
kwargs=kwargs,
)
# ---------------------------------------------------------------------------
# 子进程里跑的模块级函数(spawn 要求)
# ---------------------------------------------------------------------------
def _add(ctx: Any, left: int, right: int, *, scale: int = 1) -> int:
"""两数相加。"""
del ctx
return (left + right) * scale
def _boom(ctx: Any) -> None:
"""抛异常。"""
del ctx
raise ValueError("子进程里炸了")
def _describe(ctx: Any) -> dict[str, Any]:
"""把注入进来的上下文信息带回来,验证它是真的被注入了。"""
return {
"task_id": ctx.task_id,
"name": ctx.name,
"attempt": ctx.attempt,
"pid": os.getpid(),
"has_token": ctx.token is not None,
}
def _beater(ctx: Any) -> str:
"""持续 checkpoint,直到进程退出。"""
for _ in range(10_000):
ctx.checkpoint()
time.sleep(0.01)
return "never"
def _bounded_beater(ctx: Any) -> str:
"""有限次 checkpoint 后正常返回——用于验证"暂停没生效时不能把它卡住"。"""
for _ in range(5):
ctx.checkpoint()
time.sleep(0.1)
return "done"
def _late_checkpointer(ctx: Any) -> str:
"""先干一段**不 checkpoint** 的活,之后才正常推进。
用来测暂停回滚:暂停请求会在它到达第一个检查点之前就超时。
"""
time.sleep(0.5)
for _ in range(5):
ctx.checkpoint()
time.sleep(0.05)
return "done"
def _progress_reporter(ctx: Any) -> str:
"""报进度,父进程应当收到。"""
for index in range(1, 4):
ctx.progress(index, total=3, unit="批")
time.sleep(0.02)
return "ok"
def _ignore_everything(ctx: Any) -> str:
"""**不**调用 checkpoint 的死循环:只能靠强杀停下。"""
del ctx
end = time.monotonic() + 60
while time.monotonic() < end:
pass
return "never"
def _abrupt_exit(ctx: Any) -> str:
"""直接消失,连一句结论都不留。"""
del ctx
os._exit(3)
return "never" # pragma: no cover
def _unpicklable_result(ctx: Any) -> Any:
"""返回值不可 pickle(线程执行器能接住它,进程执行器不行)。"""
del ctx
return lambda: None
def _explode_on_unpickle() -> None: # pragma: no cover - 只在子进程里被调用
raise RuntimeError("反序列化时炸了")
class _CannotCrossTheBorder:
"""能 pickle 出去、却在子进程里反序列化失败的对象。
``__reduce__`` 只在**加载**时调用那个工厂函数,所以父进程的预检(只 dump)会放行
——正好用来测"子进程根本没跑起来"那条分支。
"""
def __reduce__(self) -> Any:
return (_explode_on_unpickle, ())
# ---------------------------------------------------------------------------
# 载荷校验:把"子进程里神秘失败"变成一句能照做的话
# ---------------------------------------------------------------------------
def test_a_lambda_target_is_rejected_with_an_actionable_error() -> None:
with pytest.raises(ConfigurationError, match="可 pickle"):
ProcessExecutor().launch(_request(lambda ctx: "nope"))
def test_a_closure_target_is_rejected() -> None:
captured = "closure"
def inner(ctx: Any) -> str:
del ctx
return captured
with pytest.raises(ConfigurationError, match="ThreadExecutor"):
ProcessExecutor().launch(_request(inner))
def test_an_unpicklable_argument_is_rejected() -> None:
with pytest.raises(ConfigurationError, match="参数"):
ProcessExecutor().launch(_request(_add, threading.Lock(), 1))
# ---------------------------------------------------------------------------
# 正常路径
# ---------------------------------------------------------------------------
def test_target_runs_in_a_child_process_and_returns_its_value() -> None:
handle = ProcessExecutor().launch(
ExecutionRequest(
task_id="t-args",
name="t-args",
target=_add,
args=(2, 3),
kwargs={"scale": 10},
)
)
outcome = handle.wait(timeout=_START)
assert outcome is not None
assert outcome.ok
assert outcome.value == 50
assert outcome.abandoned is False, "进程没有强杀这个词,结论是已知的"
def test_the_child_has_its_own_pid_and_gets_the_injected_context() -> None:
handle = ProcessExecutor().launch(_request(_describe))
outcome = handle.wait(timeout=_START)
assert outcome is not None and outcome.ok
payload = outcome.value
assert payload["task_id"] == "t-proc"
assert payload["name"] == "t-proc"
assert payload["has_token"] is True
assert payload["pid"] == handle.pid, "子进程里看到的 pid 就是句柄报出来的那个"
assert handle.pid != os.getpid(), "任务确实不在监督进程里跑"
def test_the_handle_knows_the_child_pid() -> None:
handle = ProcessExecutor().launch(_request(_add, 1, 1))
assert handle.pid is not None
assert handle.running is True
assert handle.wait(timeout=_START) is not None
assert handle.running is False, "有结论之后就不该再报'在运行'"
def test_a_task_failure_crosses_the_boundary_as_a_structured_failure() -> None:
outcome = ProcessExecutor().launch(_request(_boom)).wait(timeout=_START)
assert outcome is not None
assert not outcome.ok
assert outcome.failure is not None
assert outcome.failure.kind is FailureKind.EXCEPTION
assert "子进程里炸了" in outcome.failure.message
assert outcome.failure.traceback, "子进程里的 traceback 必须带回来"
def test_an_unpicklable_result_is_reported_as_a_result_problem() -> None:
"""返回值过不去,报的是"产出接不住",不是"任务出错了"——两者的修法完全不同。"""
outcome = ProcessExecutor().launch(_request(_unpicklable_result)).wait(timeout=_START)
assert outcome is not None
assert not outcome.ok
assert outcome.failure is not None
assert outcome.failure.kind is FailureKind.RESULT_INVALID
assert outcome.failure.retryable is False, "同一个返回值重跑多少次都还是过不去"
# ---------------------------------------------------------------------------
# 心跳:idle 超时靠它,而它必须真的跨过进程边界
# ---------------------------------------------------------------------------
def test_heartbeats_cross_the_process_boundary() -> None:
"""把父进程的时钟推远,再等子进程的一次 heartbeat 把它拉回来。
用假时钟是为了**确定性地**区分"子进程真的报了心跳"与"父进程自己刚记了一次"。
"""
clock = FakeClock()
handle = ProcessExecutor(clock=clock).launch(_request(_beater))
try:
assert handle.wait(timeout=0.2) is None, "子进程不该这么快结束"
clock.advance(300)
assert handle.heartbeat.idle_seconds() >= 300
deadline = time.monotonic() + _START
while handle.heartbeat.idle_seconds() >= 300 and time.monotonic() < deadline:
time.sleep(0.02)
assert handle.heartbeat.idle_seconds() == 0, "子进程的 checkpoint 应当刷新心跳"
finally:
handle.stop(graceful_timeout=_WINDOW)
def test_progress_reports_are_forwarded_to_the_parent() -> None:
seen: list[Any] = []
request = ExecutionRequest(
task_id="t-progress",
name="t-progress",
target=_progress_reporter,
on_progress=seen.append,
)
outcome = ProcessExecutor().launch(request).wait(timeout=_START)
assert outcome is not None and outcome.ok
assert [item.done for item in seen] == [1, 2, 3]
assert seen[-1].unit == "批"
# ---------------------------------------------------------------------------
# 暂停:确认了才算,没确认就必须回滚
# ---------------------------------------------------------------------------
def test_pause_suspends_the_child_and_resume_lets_it_finish() -> None:
handle = ProcessExecutor().launch(_request(_bounded_beater))
assert handle.pause(timeout=_START) is True, "子进程应当在下一次 checkpoint 停下"
# 若没真的暂停,这个任务 0.5 秒内就该跑完了。
time.sleep(0.8)
assert handle.wait(timeout=0) is None, "被暂停的任务不该继续推进"
assert handle.running is True
handle.resume()
outcome = handle.wait(timeout=_START)
assert outcome is not None and outcome.ok
assert outcome.value == "done"
def test_pause_rolls_back_when_the_child_never_reaches_a_checkpoint() -> None:
"""暂停请求超时后必须**撤销**。
不撤销的话,调用方已经按"暂停没生效"把状态回滚成 RUNNING,而子进程会在稍后的
某个检查点自己停住:那是一个没人知道、也没人会恢复的挂起——比暂停失败更糟。
"""
handle = ProcessExecutor().launch(_request(_late_checkpointer))
assert handle.wait(timeout=0.1) is None, "它还在那段不 checkpoint 的活里"
assert handle.pause(timeout=0.2) is False, "窗口内它压根没到检查点"
outcome = handle.wait(timeout=_START)
assert outcome is not None, "回滚之后它必须继续跑完,而不是停在下一个检查点上"
assert outcome.ok, f"不该被自己撤销掉的暂停影响:{outcome.failure}"
# ---------------------------------------------------------------------------
# 停止:进程执行器存在的理由
# ---------------------------------------------------------------------------
def test_cooperative_stop_ends_the_task_without_killing_it() -> None:
handle = ProcessExecutor().launch(_request(_beater))
assert handle.wait(timeout=0.3) is None # 先让它跑起来
outcome = handle.stop(graceful_timeout=_WINDOW)
assert not outcome.ok
assert outcome.abandoned is False, "它是自己收尾的,不是被放弃"
assert outcome.failure is not None
assert outcome.failure.kind is FailureKind.CANCELLED
assert handle.running is False
def test_a_task_that_never_checks_the_token_is_actually_killed() -> None:
"""**这是进程执行器唯一不可替代的能力。** 线程只能放弃等待并记 LOST;
子进程会真的被停掉,结论因此是已知的。"""
handle = ProcessExecutor().launch(_request(_ignore_everything))
pid = handle.pid
assert pid is not None
assert handle.wait(timeout=0.3) is None # 它连 checkpoint 都不调,只能强杀
outcome = handle.stop(graceful_timeout=0.3)
assert not outcome.ok
assert outcome.abandoned is False, "任务确实停了,不是'不知道停没停'"
assert outcome.failure is not None
assert outcome.failure.kind is FailureKind.CANCELLED
assert handle.running is False, "载体已经死了,不能谎报还在跑"
assert handle.process is not None and handle.process.exitcode is not None
def test_stop_is_also_delivered_through_the_handle_token() -> None:
"""控制面有几条路径直接调 ``handle.token.request_stop()``,绕过了 ``stop()``。
令牌不转发的话子进程永远收不到那次停止——表现出来就是"停止按钮没反应"。
"""
handle = ProcessExecutor().launch(_request(_beater))
assert handle.wait(timeout=0.3) is None
handle.token.request_stop("走令牌这条路")
outcome = handle.wait(timeout=_WINDOW)
assert outcome is not None
assert not outcome.ok
assert outcome.failure is not None
assert outcome.failure.kind is FailureKind.CANCELLED
# ---------------------------------------------------------------------------
# 崩溃隔离:父进程必须活着,并且如实说清楚发生了什么
# ---------------------------------------------------------------------------
def test_an_abrupt_child_exit_is_reported_with_its_exit_code() -> None:
outcome = ProcessExecutor().launch(_request(_abrupt_exit)).wait(timeout=_START)
assert outcome is not None
assert not outcome.ok
assert outcome.failure is not None
assert outcome.failure.kind is FailureKind.NONZERO_EXIT
assert outcome.failure.exit_code == 3, "退出码是唯一能解释'它为什么没了'的事实"
assert "副作用" in outcome.failure.message, "结果未知必须说清楚,别让人放心重跑"
def test_a_child_that_never_boots_is_reported_distinctly() -> None:
"""载荷在子进程里反序列化失败:任务**从未开始**,与"跑一半没了"是两回事。"""
handle = ProcessExecutor().launch(_request(_add, _CannotCrossTheBorder(), 1))
outcome = handle.wait(timeout=_START)
assert outcome is not None
assert not outcome.ok
assert outcome.failure is not None
assert outcome.failure.kind is FailureKind.NONZERO_EXIT
assert "从未开始" in outcome.failure.message
+83
View File
@@ -0,0 +1,83 @@
"""公开 API 的表面测试。
``chongming_task`` 的 ``__init__`` 是**嵌入其他应用**的入口,因此它本身的正确性
值得一条用例:漏导出某个名字、或者导出顺序导致循环导入,都会让集成方第一步就失败。
"""
from __future__ import annotations
import chongming_task
def test_version_is_exposed() -> None:
assert chongming_task.__version__
def test_all_has_no_duplicates() -> None:
"""重复导出会让"到底哪个生效"变得不确定。
顺序**不在这里**断言:``__all__`` 的排序由 ruff 的 ``RUF022``(isort 风格,
全大写常量优先)负责,那是 lint 的职责。写一条与 linter 规则不一致的断言,
只会让两边互相打架。
"""
names = chongming_task.__all__
assert len(names) == len(set(names))
def test_every_exported_name_resolves() -> None:
"""``__all__`` 里写了却取不到,等于给集成方埋了一个 ImportError。"""
missing = [name for name in chongming_task.__all__ if not hasattr(chongming_task, name)]
assert missing == []
def test_primary_integration_surface_is_importable() -> None:
"""这一段就是 README 里"嵌入你自己的应用"的最小示例。"""
from chongming_task import ( # noqa: F401 - 只为验证可导入
Controller,
InMemoryTaskStore,
SqliteTaskStore,
TaskStatus,
TaskStore,
run_task,
)
controller = Controller(max_parallel=2)
assert controller.registry.names() == ()
assert TaskStatus.PENDING.value == "pending"
assert callable(run_task)
def test_store_implementations_satisfy_the_protocol() -> None:
"""存储实现必须满足契约——第三方后端也照这条自证。"""
from chongming_task import InMemoryTaskStore, SqliteTaskStore, TaskStore
assert isinstance(InMemoryTaskStore(), TaskStore)
store = SqliteTaskStore(":memory:")
try:
assert isinstance(store, TaskStore)
finally:
# 不关掉连接会在解释器退出时触发 ResourceWarning;本项目的 pytest 配置
# 把 warning 升级为 error,因此泄漏是会被抓住的。
store.close()
def test_controller_accepts_a_store() -> None:
"""存储已经接线。
这条用例原先钉的是"尚未接线"——当 ``Controller(store=...)`` 真的开始工作时,
它会自己失败并提示删掉。现在它钉的是接线本身。
"""
from chongming_task import Controller, InMemoryTaskStore
store = InMemoryTaskStore()
controller = Controller(store=store, max_parallel=2)
assert controller.store is store
assert controller.persist_failures == ()
assert controller.owner
def test_private_helpers_are_not_exported() -> None:
"""以下划线开头的实现细节不应出现在公开表面上。"""
leaked = [name for name in chongming_task.__all__ if name.startswith("_")]
assert leaked == []
+404
View File
@@ -0,0 +1,404 @@
"""单任务受控执行的测试。
覆盖三件用户最关心的事:**失败会怎样**(重试 / 超时 / 最终失败)、**我能不能干预**
(停止 / 暂停 / 恢复),以及**结果是否诚实**(不配合的任务落 LOST 而不是"已停止")。
"""
from __future__ import annotations
import threading
import time
from typing import Any
import pytest
from chongming_task.errors import ConfigurationError
from chongming_task.models import FailureKind
from chongming_task.policies import FixedBackoff, RetryPolicy, Timeout
from chongming_task.run import TaskRun, run_task
from chongming_task.runtime.executors.inline import InlineExecutor
from chongming_task.states import TaskStatus
_WINDOW = 10.0
def _wait_for_status(run: TaskRun, status: TaskStatus, timeout: float = _WINDOW) -> bool:
"""等状态就位。
比 ``sleep`` 可靠:``start()`` 是异步的,"任务已经开始执行"与"状态机已迁移到
RUNNING"之间存在极短窗口。
"""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if run.status is status:
return True
time.sleep(0.01)
return False
# ---------------------------------------------------------------------------
# 成功路径
# ---------------------------------------------------------------------------
def test_successful_run_returns_value_and_succeeds() -> None:
outcome = run_task(lambda ctx, a, b: a + b, 2, 3, task_id="t-ok", name="加法")
assert outcome.ok
assert outcome.status is TaskStatus.SUCCEEDED
assert outcome.value == 5
assert outcome.failure is None
assert outcome.snapshot.ended_at is not None
def test_transition_history_is_complete_and_ordered() -> None:
"""状态历史只追加,且每条都带触发源——这是可审计性的基础。"""
run = TaskRun(lambda ctx: 1, task_id="t-hist", name="demo")
run.start()
assert run.wait(timeout=_WINDOW).ok
assert [t.to_status for t in run.transitions] == [
TaskStatus.QUEUED,
TaskStatus.STARTING,
TaskStatus.RUNNING,
TaskStatus.SUCCEEDED,
]
assert all(t.trigger for t in run.transitions)
assert run.transitions[-1].task_id == "t-hist"
def test_exception_is_captured_with_traceback() -> None:
def boom(ctx: Any) -> None:
raise ValueError("炸了")
outcome = run_task(boom, task_id="t-boom")
assert outcome.status is TaskStatus.FAILED
assert outcome.failure is not None
assert outcome.failure.kind is FailureKind.EXCEPTION
assert outcome.failure.traceback is not None
assert "boom" in outcome.failure.traceback
# ---------------------------------------------------------------------------
# 重试
# ---------------------------------------------------------------------------
def test_retries_until_success_and_counts_attempts() -> None:
attempts: list[int] = []
def flaky(ctx: Any, fail_times: int) -> str:
attempts.append(1)
if len(attempts) <= fail_times:
raise RuntimeError("暂时性故障")
return "ok"
outcome = run_task(
flaky,
2,
retry=RetryPolicy(max_attempts=3, backoff=FixedBackoff(0.0)),
task_id="t-flaky",
)
assert outcome.ok
assert outcome.snapshot.attempt == 3
assert len(attempts) == 3
def test_retry_exhaustion_lands_failed() -> None:
def always_fails(ctx: Any) -> None:
raise RuntimeError("一直失败")
outcome = run_task(
always_fails,
retry=RetryPolicy(max_attempts=2, backoff=FixedBackoff(0.0)),
task_id="t-always",
)
assert outcome.status is TaskStatus.FAILED
assert outcome.snapshot.attempt == 2
assert outcome.failure is not None
def test_retry_wait_is_visible_in_history() -> None:
seen: list[int] = []
def flaky(ctx: Any) -> str:
if not seen:
seen.append(1)
raise RuntimeError("第一次失败")
return "ok"
run = TaskRun(
flaky,
retry=RetryPolicy(max_attempts=2, backoff=FixedBackoff(0.0)),
task_id="t-wait",
)
run.start()
assert run.wait(timeout=_WINDOW).ok
assert TaskStatus.RETRY_WAIT in [t.to_status for t in run.transitions]
# ---------------------------------------------------------------------------
# 超时
# ---------------------------------------------------------------------------
def test_per_attempt_timeout_stops_a_slow_task() -> None:
def slow(ctx: Any) -> str:
for _ in range(1_000_000):
ctx.checkpoint()
time.sleep(0.01)
return "永远跑不完"
outcome = run_task(slow, timeout=Timeout(per_attempt=0.3), task_id="t-slow")
assert outcome.status is TaskStatus.FAILED
assert outcome.failure is not None
assert outcome.failure.kind is FailureKind.TIMEOUT_ATTEMPT
def test_idle_timeout_is_skipped_while_paused() -> None:
"""被暂停的任务不推进心跳,若照常判 idle 超时,暂停就会被误判成卡死。"""
started = threading.Event()
def work(ctx: Any) -> str:
started.set()
for _ in range(1_000_000):
ctx.checkpoint()
time.sleep(0.02)
return "done"
run = TaskRun(
work,
timeout=Timeout(idle=0.2),
task_id="t-idle-paused",
graceful_stop=2.0,
)
run.start()
assert started.wait(_WINDOW)
assert _wait_for_status(run, TaskStatus.RUNNING)
assert run.pause(timeout=_WINDOW) is True
# 暂停期间超过 idle 阈值,但不应被判超时。
time.sleep(0.5)
assert run.status is TaskStatus.PAUSED
run.resume()
run.stop(reason="测试结束")
assert run.wait(timeout=_WINDOW).status is TaskStatus.STOPPED
# ---------------------------------------------------------------------------
# 停止
# ---------------------------------------------------------------------------
def test_stop_lands_stopped_and_is_auditable() -> None:
started = threading.Event()
def work(ctx: Any) -> str:
started.set()
for _ in range(1_000_000):
ctx.checkpoint()
time.sleep(0.005)
return "done"
run = TaskRun(work, task_id="t-stop")
run.start()
assert started.wait(_WINDOW)
assert _wait_for_status(run, TaskStatus.RUNNING)
run.stop(reason="发布窗口")
outcome = run.wait(timeout=_WINDOW)
assert outcome.status is TaskStatus.STOPPED
assert outcome.failure is not None
assert outcome.failure.kind is FailureKind.CANCELLED
assert outcome.failure.retryable is False, "取消不是失败,不能重试"
stopping = [t for t in run.transitions if t.to_status is TaskStatus.STOPPING]
assert stopping, "应当能看到 STOPPING 这个中间态"
assert stopping[0].reason == "发布窗口"
def test_uncooperative_task_is_reported_as_lost_not_stopped() -> None:
"""线程停不掉时结果**未知**:任务可能仍在后台产生副作用。
记成"已停止"会让运维误判资源已释放,因此必须落 LOST。
"""
def stubborn(ctx: Any) -> str:
for _ in range(500):
time.sleep(0.01)
return "ignored"
run = TaskRun(stubborn, task_id="t-stubborn", graceful_stop=0.2)
run.start()
time.sleep(0.3)
run.stop(reason="不配合")
outcome = run.wait(timeout=_WINDOW)
assert outcome.status is TaskStatus.LOST
assert outcome.failure is not None
assert outcome.failure.kind is FailureKind.LOST
def test_stop_before_start_lands_stopped_without_burning_an_attempt() -> None:
"""都还没跑就要求停止,不该白白消耗一次尝试。"""
run = TaskRun(lambda ctx: "ok", task_id="t-pre-stop")
run.stop(reason="还没跑就不要了")
run.start()
outcome = run.wait(timeout=_WINDOW)
assert outcome.status is TaskStatus.STOPPED
assert outcome.snapshot.attempt == 0
assert all(t.to_status is not TaskStatus.RUNNING for t in run.transitions)
# ---------------------------------------------------------------------------
# 暂停与恢复
# ---------------------------------------------------------------------------
def test_pause_freezes_progress_and_resume_continues() -> None:
started = threading.Event()
progress: list[int] = []
def work(ctx: Any) -> str:
started.set()
for index in range(1_000_000):
ctx.checkpoint()
progress.append(index)
time.sleep(0.005)
return "done"
run = TaskRun(work, task_id="t-pause")
run.start()
assert started.wait(_WINDOW)
assert _wait_for_status(run, TaskStatus.RUNNING)
assert run.pause(timeout=_WINDOW) is True
assert run.status is TaskStatus.PAUSED
frozen = len(progress)
time.sleep(0.15)
assert len(progress) == frozen, "暂停生效后不应继续推进"
run.resume()
assert run.status is TaskStatus.RUNNING, "就地暂停的任务恢复后回到 RUNNING"
time.sleep(0.15)
assert len(progress) > frozen, "恢复后应继续推进"
run.stop()
assert run.wait(timeout=_WINDOW).status is TaskStatus.STOPPED
def test_pause_returns_false_when_task_never_checkpoints() -> None:
"""暂停没生效就必须如实回滚为 RUNNING,不能假装已暂停。"""
def work(ctx: Any) -> str:
for _ in range(500):
time.sleep(0.01)
return "ignored"
run = TaskRun(work, task_id="t-nopause", graceful_stop=0.2)
run.start()
assert _wait_for_status(run, TaskStatus.RUNNING)
assert run.pause(timeout=0.2) is False
assert run.status is TaskStatus.RUNNING
assert any(t.to_status is TaskStatus.PAUSING for t in run.transitions)
assert any(
t.to_status is TaskStatus.RUNNING and t.trigger == "runtime:pause_aborted"
for t in run.transitions
), "回滚应当留下 PAUSE_ABORTED 记录"
run.stop()
run.wait(timeout=_WINDOW)
def test_pause_on_a_finished_run_is_rejected() -> None:
run = TaskRun(lambda ctx: 1, task_id="t-pause-late")
run.start()
assert run.wait(timeout=_WINDOW).ok
assert run.pause(timeout=0.01) is False
assert run.status is TaskStatus.SUCCEEDED
# ---------------------------------------------------------------------------
# wait 的契约
# ---------------------------------------------------------------------------
def test_wait_timeout_raises_and_does_not_change_state() -> None:
"""把"我没等到"当成"任务失败"是最常见的误用,因此这里只抛异常。"""
release = threading.Event()
def work(ctx: Any) -> str:
release.wait(_WINDOW)
return "ok"
run = TaskRun(work, task_id="t-wait")
run.start()
with pytest.raises(TimeoutError, match="状态未被改变"):
run.wait(timeout=0.2)
assert run.status in (TaskStatus.STARTING, TaskStatus.RUNNING)
release.set()
assert run.wait(timeout=_WINDOW).ok
def test_wait_before_start_is_an_error() -> None:
run = TaskRun(lambda ctx: 1, task_id="t-not-started")
with pytest.raises(RuntimeError, match="start"):
run.wait()
def test_starting_twice_is_an_error() -> None:
run = TaskRun(lambda ctx: 1, task_id="t-twice")
run.start()
run.wait(timeout=_WINDOW)
with pytest.raises(RuntimeError, match="已经启动过"):
run.start()
# ---------------------------------------------------------------------------
# 执行器可替换
# ---------------------------------------------------------------------------
def test_executor_is_pluggable() -> None:
run = TaskRun(
lambda ctx: "inline",
task_id="t-inline",
executor=InlineExecutor(),
)
run.start()
outcome = run.wait(timeout=_WINDOW)
assert outcome.ok
assert outcome.value == "inline"
assert outcome.snapshot.executor == "inline"
class _RefusingExecutor:
"""拉不起来任何任务,只会抛错——进程执行器的"目标不可 pickle"就长这样。"""
name = "refusing"
def launch(self, request: Any) -> Any:
del request
raise ConfigurationError("目标不可 pickle:这类任务请改用线程执行器")
def test_a_launch_failure_is_recorded_as_the_tasks_own_failure() -> None:
"""执行器拉不起来时,必须如实记在**任务**上,而不是让异常逃进监督线程。
逃进去的后果有两条:任务先停在 ``starting``,最后由兜底说成"监督循环内部错误"
——那等于把一个任务侧的问题指成框架的 bug;而调用方拿到的诊断里也看不到真正
该改的东西。
"""
outcome = run_task(
lambda ctx: "never",
task_id="t-refused",
executor=_RefusingExecutor(),
)
assert outcome.status is TaskStatus.FAILED
assert outcome.failure is not None
assert "不可 pickle" in outcome.failure.message
assert "监督循环内部错误" not in outcome.failure.message, "别把任务侧的问题说成框架的"
assert outcome.failure.retryable is False, "配置类错误重试多少次都是同一个结果"
+413
View File
@@ -0,0 +1,413 @@
"""状态机行为测试。
覆盖三类东西:
1. 转移表被**真正强制执行**,而不是只写在文档里;
2. 控制动词语义(stop / pause / resume / retry / reset / force)与设计文档一致;
3. 审计记录与版本号的不变量。
"""
from __future__ import annotations
import pytest
from chongming_task.core.state_machine import StateMachine, Trigger
from chongming_task.errors import AttemptsExhausted, InvalidTransition
from chongming_task.models import Failure, FailureKind, Progress, RequestedAction
from chongming_task.states import TaskStatus
from chongming_task.testing import FakeClock
@pytest.fixture
def clock() -> FakeClock:
return FakeClock()
@pytest.fixture
def machine(clock: FakeClock) -> StateMachine:
return StateMachine.create("t1", "抽取", max_attempts=3, clock=clock)
def _run_to_running(machine: StateMachine, executor: str = "inline") -> None:
machine.mark_queued()
machine.mark_starting(executor=executor)
machine.mark_running()
# ---------------------------------------------------------------------------
# 创建与转移表强制
# ---------------------------------------------------------------------------
def test_create_starts_pending(machine: StateMachine, clock: FakeClock) -> None:
assert machine.status is TaskStatus.PENDING
assert machine.snapshot.created_at == clock.now
assert machine.snapshot.attempt == 0
assert machine.history == ()
def test_illegal_transition_raises_and_leaves_state_untouched(
machine: StateMachine,
) -> None:
before = machine.snapshot
with pytest.raises(InvalidTransition):
machine.mark_running()
assert machine.snapshot is before
assert machine.history == ()
def test_invalid_transition_message_lists_allowed_targets(machine: StateMachine) -> None:
with pytest.raises(InvalidTransition) as info:
machine.mark_succeeded()
assert info.value.current is TaskStatus.PENDING
assert info.value.requested is TaskStatus.SUCCEEDED
assert "非法状态转移" in str(info.value)
assert "queued" in str(info.value)
def test_happy_path_pending_to_succeeded(machine: StateMachine, clock: FakeClock) -> None:
_run_to_running(machine, executor="thread")
record = machine.mark_succeeded(outcome={"rows": 10})
assert record.from_status is TaskStatus.RUNNING
assert record.to_status is TaskStatus.SUCCEEDED
assert machine.snapshot.outcome == {"rows": 10}
assert machine.snapshot.ended_at == clock.now
assert machine.snapshot.attempt == 1
assert machine.snapshot.validate() == ()
assert [t.to_status for t in machine.history] == [
TaskStatus.QUEUED,
TaskStatus.STARTING,
TaskStatus.RUNNING,
TaskStatus.SUCCEEDED,
]
def test_status_version_increases_by_one_per_transition(machine: StateMachine) -> None:
_run_to_running(machine)
assert [t.version for t in machine.history] == [1, 2, 3]
assert machine.snapshot.status_version == 3
def test_terminal_state_cannot_transition_at_all(machine: StateMachine) -> None:
_run_to_running(machine)
machine.mark_succeeded()
for action in (
machine.mark_queued,
machine.mark_blocked,
machine.mark_running,
machine.mark_cancelled,
machine.mark_lost,
machine.mark_skipped,
):
with pytest.raises(InvalidTransition):
action()
assert machine.status is TaskStatus.SUCCEEDED
# ---------------------------------------------------------------------------
# 高频旁路更新
# ---------------------------------------------------------------------------
def test_heartbeat_and_progress_do_not_touch_status_version(
machine: StateMachine, clock: FakeClock
) -> None:
"""心跳是高频写入;若参与乐观并发,会与状态写入持续冲突。"""
machine.mark_queued()
before = machine.snapshot.status_version
clock.advance(5)
machine.heartbeat()
machine.update_progress(Progress(done=1, total=4))
assert machine.snapshot.status_version == before
assert machine.snapshot.last_heartbeat_at == clock.now
assert machine.snapshot.progress is not None
assert machine.snapshot.progress.done == 1
def test_transitions_use_injected_clock(machine: StateMachine, clock: FakeClock) -> None:
machine.mark_queued()
queued_at = machine.snapshot.queued_at
clock.advance(30)
machine.mark_starting(executor="inline")
assert machine.history[-1].at == clock.now
assert queued_at != machine.history[-1].at
# ---------------------------------------------------------------------------
# 重试预算
# ---------------------------------------------------------------------------
def test_attempt_is_consumed_only_when_starting(machine: StateMachine) -> None:
"""预算只在"真正要跑"的那一刻消费。"""
machine.mark_queued()
assert machine.snapshot.attempt == 0
machine.mark_starting(executor="inline")
assert machine.snapshot.attempt == 1
def test_retry_revives_from_failed_and_keeps_history(clock: FakeClock) -> None:
machine = StateMachine.create("t1", "x", max_attempts=3, clock=clock)
_run_to_running(machine)
machine.mark_failed(Failure(kind=FailureKind.EXCEPTION, message="boom"))
record = machine.retry()
assert record.trigger == Trigger.RETRY_REQUESTED
assert machine.status is TaskStatus.QUEUED
assert machine.snapshot.failure is None
assert machine.snapshot.ended_at is None
assert machine.snapshot.attempt == 1 # 历史不被改写
machine.mark_starting(executor="inline")
assert machine.snapshot.attempt == 2
def test_retry_rejected_when_budget_exhausted(clock: FakeClock) -> None:
machine = StateMachine.create("t1", "x", max_attempts=1, clock=clock)
_run_to_running(machine)
machine.mark_failed(Failure(kind=FailureKind.EXCEPTION, message="boom"))
with pytest.raises(AttemptsExhausted):
machine.retry()
def test_forced_retry_raises_the_budget(clock: FakeClock) -> None:
"""force=True 是"我知道它已失败够多次了,再试一次"。"""
machine = StateMachine.create("t1", "x", max_attempts=1, clock=clock)
_run_to_running(machine)
machine.mark_failed(Failure(kind=FailureKind.EXCEPTION, message="boom"))
machine.retry(force=True)
assert machine.snapshot.max_attempts == 2
machine.mark_starting(executor="inline")
assert machine.snapshot.attempt == 2
def test_retry_rejected_from_non_revivable_terminal(clock: FakeClock) -> None:
"""SUCCEEDED / SKIPPED 不可复活:前者重跑无意义,后者是策略决定的结果。"""
machine = StateMachine.create("t1", "x", clock=clock)
_run_to_running(machine)
machine.mark_succeeded()
assert not machine.can_retry
with pytest.raises(InvalidTransition):
machine.retry()
def test_retry_is_not_the_path_for_retry_wait(clock: FakeClock) -> None:
"""RETRY_WAIT 已安排好重试,不应再被 retry() 接受,否则会双倍消费预算。"""
machine = StateMachine.create("t1", "x", max_attempts=3, clock=clock)
_run_to_running(machine)
machine.mark_retry_wait(Failure(kind=FailureKind.EXCEPTION, message="boom"))
with pytest.raises(InvalidTransition):
machine.retry()
# ---------------------------------------------------------------------------
# 停止语义
# ---------------------------------------------------------------------------
def test_stop_on_unstarted_task_lands_stopped_without_buffer(
machine: StateMachine,
) -> None:
"""还没跑起来的任务应当立即落 STOPPED;显示"停止中"是虚假信息。"""
record = machine.request_stop(actor="ops")
assert record.to_status is TaskStatus.STOPPED
assert machine.snapshot.requested_action is None
assert machine.snapshot.is_terminal
def test_stop_on_running_task_goes_through_stopping(machine: StateMachine) -> None:
_run_to_running(machine)
record = machine.request_stop(actor="ops", reason="发布窗口")
assert record.to_status is TaskStatus.STOPPING
assert machine.snapshot.requested_action is RequestedAction.STOP
assert not machine.snapshot.is_terminal
final = machine.mark_stopped()
assert final.to_status is TaskStatus.STOPPED
assert machine.snapshot.requested_action is None
assert machine.snapshot.validate() == ()
def test_forced_stop_uses_distinct_trigger(machine: StateMachine) -> None:
_run_to_running(machine, executor="thread")
machine.request_stop(actor="ops")
record = machine.mark_stopped(forced=True, reason="优雅窗口超时")
assert record.trigger == Trigger.STOP_FORCED
assert not record.is_override
def test_task_may_still_succeed_after_stop_requested(machine: StateMachine) -> None:
"""停止请求发出后任务恰好跑完,是合法结果,不能判为失败。"""
_run_to_running(machine)
machine.request_stop(actor="ops")
record = machine.mark_succeeded()
assert record.to_status is TaskStatus.SUCCEEDED
def test_stop_capability_is_false_on_terminal(machine: StateMachine) -> None:
_run_to_running(machine)
machine.mark_succeeded()
assert not machine.can_stop
with pytest.raises(InvalidTransition):
machine.request_stop(actor="ops")
# ---------------------------------------------------------------------------
# 暂停语义
# ---------------------------------------------------------------------------
def test_pause_on_unstarted_task_lands_paused_directly(machine: StateMachine) -> None:
"""排队中的任务本就静止,pause() 应立即落 PAUSED,不该显示"暂停中"。"""
machine.mark_queued()
record = machine.request_pause(actor="ops")
assert record.to_status is TaskStatus.PAUSED
assert machine.status is TaskStatus.PAUSED
assert machine.snapshot.requested_action is None
assert machine.can_resume
def test_pause_while_pausing_is_not_short_circuited(machine: StateMachine) -> None:
"""已在 PAUSING 的任务不能被"加急"成 PAUSED:它还没到达检查点。"""
_run_to_running(machine)
machine.request_pause()
assert machine.status is TaskStatus.PAUSING
with pytest.raises(InvalidTransition):
machine.request_pause()
assert machine.status is TaskStatus.PAUSING
def test_starting_always_records_started_at(machine: StateMachine) -> None:
"""任何到达 RUNNING 的路径都必须带有 started_at(含暂停撤销路径)。"""
machine.mark_queued()
machine.mark_starting(executor="inline")
assert machine.snapshot.started_at is not None
machine.request_pause()
machine.abort_pause(reason="未到达检查点")
assert machine.status is TaskStatus.RUNNING
assert machine.snapshot.validate() == ()
def test_pause_and_resume_round_trip(machine: StateMachine) -> None:
_run_to_running(machine)
machine.request_pause(actor="ops")
assert machine.snapshot.requested_action is RequestedAction.PAUSE
machine.mark_paused()
assert machine.status is TaskStatus.PAUSED
machine.resume()
assert machine.status is TaskStatus.RUNNING
assert machine.snapshot.requested_action is None
def test_inplace_resume_returns_to_running_not_queued(machine: StateMachine) -> None:
"""就地暂停的任务始终占着执行槽,调用栈还在内存里,恢复就是回到 RUNNING。
我们没有办法把任意的 Python 调用栈换出内存再换回来,所以"暂停后重新排队"
对就地暂停并不成立——那会导致任务状态与实际执行情况脱节。
"""
_run_to_running(machine)
machine.request_pause()
machine.mark_paused()
machine.resume()
assert machine.status is TaskStatus.RUNNING
assert machine.snapshot.queued_at is None or machine.snapshot.started_at is not None
def test_resume_of_a_never_started_pause_returns_to_queued(machine: StateMachine) -> None:
"""排队期间被暂停的任务从未启动过,恢复必须重新排队。"""
machine.mark_queued()
assert machine.request_pause().to_status is TaskStatus.PAUSED
assert machine.snapshot.attempt == 0
machine.resume()
assert machine.status is TaskStatus.QUEUED
def test_resume_of_a_retry_wait_pause_returns_to_queued(clock: FakeClock) -> None:
machine = StateMachine.create("t1", "x", max_attempts=3, clock=clock)
_run_to_running(machine)
machine.mark_retry_wait(Failure(kind=FailureKind.EXCEPTION, message="boom"))
assert machine.request_pause().to_status is TaskStatus.PAUSED
machine.resume()
assert machine.status is TaskStatus.QUEUED
def test_aborted_pause_reverts_to_running(machine: StateMachine) -> None:
"""暂停没能生效时必须如实回滚,而不是假装已暂停、让用户以为资源已释放。"""
_run_to_running(machine, executor="thread")
machine.request_pause()
record = machine.abort_pause(reason="任务未调用 checkpoint")
assert record.to_status is TaskStatus.RUNNING
assert record.trigger == Trigger.PAUSE_ABORTED
assert machine.snapshot.requested_action is None
# ---------------------------------------------------------------------------
# 人工干预
# ---------------------------------------------------------------------------
def test_force_transition_requires_audit_fields(machine: StateMachine) -> None:
with pytest.raises(ValueError, match="actor"):
machine.force_transition(TaskStatus.SUCCEEDED, actor=" ", reason="ok")
with pytest.raises(ValueError, match="reason"):
machine.force_transition(TaskStatus.SUCCEEDED, actor="ops", reason="")
def test_force_transition_bypasses_table_and_marks_override(machine: StateMachine) -> None:
"""PENDING -> SUCCEEDED 在转移表中非法,人工改写必须能通过,但要留痕。"""
record = machine.force_transition(
TaskStatus.SUCCEEDED, actor="ops@example", reason="已人工核验产物"
)
assert record.to_status is TaskStatus.SUCCEEDED
assert record.is_override
assert record.actor == "ops@example"
assert machine.snapshot.overridden
assert machine.snapshot.validate() == ()
def test_reset_returns_to_pending_and_clears_attempt_state(clock: FakeClock) -> None:
machine = StateMachine.create("t1", "x", max_attempts=2, clock=clock)
_run_to_running(machine)
machine.mark_failed(Failure(kind=FailureKind.EXCEPTION, message="boom"))
machine.reset(actor="ops", reason="重新跑一遍")
snapshot = machine.snapshot
assert snapshot.status is TaskStatus.PENDING
assert snapshot.attempt == 0
assert snapshot.failure is None
assert snapshot.ended_at is None
assert snapshot.started_at is None
assert not snapshot.overridden
# ---------------------------------------------------------------------------
# 能力预检(供控制面提前置灰按钮)
# ---------------------------------------------------------------------------
def test_capability_prechecks_match_actions(machine: StateMachine) -> None:
assert machine.can_stop
assert not machine.can_pause
assert not machine.can_resume
_run_to_running(machine)
assert machine.can_stop
assert machine.can_pause
assert not machine.can_resume
machine.request_pause()
machine.mark_paused()
assert machine.can_resume
assert machine.can_stop
def test_lost_is_terminal_but_revivable(machine: StateMachine) -> None:
_run_to_running(machine, executor="thread")
machine.mark_lost(reason="心跳超时")
assert machine.snapshot.is_terminal
assert machine.can_retry
+135
View File
@@ -0,0 +1,135 @@
"""状态表的结构性不变量。
这些测试只约束 :mod:`chongming_task.states` 的数据本身,不依赖状态机实现。
它们的作用是:让"转移表是唯一真相"成为可执行的断言,而不是文档里的承诺。
"""
from __future__ import annotations
import pytest
from chongming_task.states import (
NON_TERMINAL,
REVIVABLE,
TERMINAL,
TRANSITIONS,
TaskStatus,
allowed_from,
can_transition,
is_terminal,
)
def test_transition_table_is_exhaustive() -> None:
"""每个规范状态都必须有表项,否则状态机会在运行期 KeyError。"""
assert set(TRANSITIONS) == set(TaskStatus)
def test_alias_does_not_add_a_table_key() -> None:
"""``FINISHED`` 只是别名,不应在表中占据独立条目。"""
assert len(TRANSITIONS) == len(set(TaskStatus))
def test_terminal_set_is_derived_from_the_table() -> None:
"""终态必须由表推导,避免"表"与"集合"两处维护而不一致。"""
derived = frozenset(status for status, nxt in TRANSITIONS.items() if not nxt)
assert derived == TERMINAL
def test_terminal_and_non_terminal_partition_the_enum() -> None:
partition = TERMINAL | NON_TERMINAL
assert not (TERMINAL & NON_TERMINAL)
assert partition == set(TaskStatus)
@pytest.mark.parametrize("status", sorted(TERMINAL))
def test_terminal_states_have_no_outgoing_transitions(status: TaskStatus) -> None:
assert allowed_from(status) == frozenset()
for target in TaskStatus:
assert not can_transition(status, target)
@pytest.mark.parametrize("status", sorted(NON_TERMINAL))
def test_non_terminal_states_have_outgoing_transitions(status: TaskStatus) -> None:
"""非终态若无出边,任务会永久卡死。"""
assert allowed_from(status), f"{status} 必须有至少一条出边"
def test_no_self_loops() -> None:
"""自环会掩盖状态机缺陷,并让 status_version 无意义地增长。"""
for source, targets in TRANSITIONS.items():
assert source not in targets, f"{source} 存在自环"
def test_all_targets_are_known_statuses() -> None:
known = set(TaskStatus)
for targets in TRANSITIONS.values():
assert targets <= known
@pytest.mark.parametrize("start", sorted(NON_TERMINAL))
def test_every_non_terminal_state_can_reach_a_terminal_state(start: TaskStatus) -> None:
"""不存在只能内部互跳、永远收敛不了的死角。"""
seen: set[TaskStatus] = set()
stack: list[TaskStatus] = [start]
while stack:
current = stack.pop()
if current in TERMINAL:
return
if current in seen:
continue
seen.add(current)
stack.extend(allowed_from(current))
pytest.fail(f"{start} 无法到达任何终态")
# ---------------------------------------------------------------------------
# 以下用例锁定设计决策本身,防止后续重构悄悄改变语义
# ---------------------------------------------------------------------------
def test_paused_can_resume_either_in_place_or_back_to_the_queue() -> None:
"""恢复目标取决于暂停发生在哪一层:
* 就地暂停(经 ``PAUSING``)→ ``RUNNING``,槽位从未释放;
* 未启动即暂停 → ``QUEUED``,重新排队。
"""
assert can_transition(TaskStatus.PAUSED, TaskStatus.RUNNING)
assert can_transition(TaskStatus.PAUSED, TaskStatus.QUEUED)
def test_stopping_may_still_succeed() -> None:
"""停止请求发出后任务恰好自然结束,是合法结果,不能判为失败。"""
assert can_transition(TaskStatus.STOPPING, TaskStatus.SUCCEEDED)
def test_pending_cannot_jump_straight_to_running() -> None:
"""PENDING 必须经过 QUEUED/STARTING,不允许绕过并发槽与执行器启动。"""
assert not can_transition(TaskStatus.PENDING, TaskStatus.RUNNING)
def test_running_cannot_return_to_queued_directly() -> None:
"""RUNNING 回落 QUEUED 只能经由 PAUSED,否则会绕过暂停语义。"""
assert not can_transition(TaskStatus.RUNNING, TaskStatus.QUEUED)
def test_revivable_is_a_subset_of_terminal() -> None:
assert REVIVABLE <= TERMINAL
def test_success_and_skip_are_not_revivable() -> None:
"""成功重跑没有意义;SKIPPED 是策略决定的结果,不应被 retry 复活。"""
assert TaskStatus.SUCCEEDED not in REVIVABLE
assert TaskStatus.SKIPPED not in REVIVABLE
def test_legacy_finished_alias() -> None:
assert TaskStatus.FINISHED is TaskStatus.SUCCEEDED
assert "finished" not in {s.value for s in TaskStatus}
def test_helpers_agree_with_the_table() -> None:
assert is_terminal(TaskStatus.SUCCEEDED)
assert not is_terminal(TaskStatus.RUNNING)
assert can_transition(TaskStatus.RUNNING, TaskStatus.STOPPING)
assert not can_transition(TaskStatus.SUCCEEDED, TaskStatus.RUNNING)
+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
+410
View File
@@ -0,0 +1,410 @@
"""线程执行器测试。
这些用例全部使用**真实线程与真实时间**:执行器的全部价值就在于线程能不能被
停下、能不能被暂停、放弃之后进程还能不能退出。用假的线程模型测不出这些。
为避免偶发失败,协作路径给足超时(5 秒),而"不配合"的用例刻意把任务做成
有限时长(约 5 秒后自行结束),这样即使被放弃,残留的守护线程也不会长期占用资源。
"""
from __future__ import annotations
import asyncio
import threading
import time
from typing import Any
import pytest
from chongming_task.models import FailureKind
from chongming_task.runtime.executors.base import ExecutionRequest
from chongming_task.runtime.executors.thread import ThreadExecutor
from chongming_task.testing import FakeClock
_COOPERATIVE_WINDOW = 5.0
_ABANDON_WINDOW = 0.2
def _request(task_id: str, target: Any, *args: Any) -> ExecutionRequest:
return ExecutionRequest(task_id=task_id, name=task_id, target=target, args=args)
# ---------------------------------------------------------------------------
# 上下文注入契约
# ---------------------------------------------------------------------------
def test_context_is_injected_with_the_handle_token_and_metadata() -> None:
"""令牌必须是句柄那一个,否则控制面会停错对象。
这也是"令牌由执行器注入、而非被任务闭包捕获"的直接体现——令牌每次尝试才诞生,
闭包捕获到的只可能是上一次尝试留下的那一个。
"""
seen: dict[str, Any] = {}
def work(ctx: Any) -> str:
seen["token"] = ctx.token
seen["task_id"] = ctx.task_id
seen["name"] = ctx.name
seen["attempt"] = ctx.attempt
return "ok"
handle = ThreadExecutor().launch(
ExecutionRequest(task_id="t-ctx", name="抽取", target=work, attempt=2)
)
assert handle.wait(timeout=_COOPERATIVE_WINDOW) is not None
assert seen["token"] is handle.token
assert seen["task_id"] == "t-ctx"
assert seen["name"] == "抽取"
assert seen["attempt"] == 2
def test_arguments_are_forwarded_after_the_context() -> None:
def work(ctx: Any, left: int, right: int, *, scale: int = 1) -> int:
del ctx
return (left + right) * scale
handle = ThreadExecutor().launch(
ExecutionRequest(
task_id="t-args",
name="t-args",
target=work,
args=(2, 3),
kwargs={"scale": 10},
)
)
outcome = handle.wait(timeout=_COOPERATIVE_WINDOW)
assert outcome is not None
assert outcome.value == 50
def test_checkpoint_refreshes_the_heartbeat() -> None:
"""checkpoint 就是"我还在推进"的信号,心跳绑在它上面是有意的。"""
clock = FakeClock()
observed: list[float] = []
def work(ctx: Any) -> str:
for _ in range(3):
clock.advance(30)
observed.append(ctx.heartbeat.idle_seconds())
ctx.checkpoint()
return "ok"
handle = ThreadExecutor(clock=clock).launch(_request("t-beat", work))
outcome = handle.wait(timeout=_COOPERATIVE_WINDOW)
assert outcome is not None
assert outcome.ok
assert observed == [30.0, 30.0, 30.0], "每次 checkpoint 都应把静默时长清零"
assert handle.heartbeat.idle_seconds() == 0.0
# ---------------------------------------------------------------------------
# async 任务
# ---------------------------------------------------------------------------
def test_async_target_is_actually_awaited() -> None:
"""``async def`` 任务必须真的被执行完,而不是把协程对象当返回值。
后者曾经是静默的:没有存储时它看起来"成功"了,返回值却是个 ``coroutine``;
有存储时才会以"返回值不可序列化"失败。
"""
async def work(ctx: Any) -> str:
await asyncio.sleep(0)
return "done"
handle = ThreadExecutor().launch(_request("t-async", work))
outcome = handle.wait(timeout=_COOPERATIVE_WINDOW)
assert outcome is not None
assert outcome.ok
assert outcome.value == "done"
def test_async_target_cooperates_with_stop() -> None:
"""协作式停止对 async 任务同样有效:``await ctx.acheckpoint()``。"""
async def work(ctx: Any) -> str:
for _ in range(10_000):
await ctx.acheckpoint()
await asyncio.sleep(0.005)
return "never"
handle = ThreadExecutor().launch(_request("t-async-stop", work))
outcome = handle.stop(graceful_timeout=_COOPERATIVE_WINDOW)
assert not outcome.ok, "停止请求之后不该报成功"
assert not outcome.abandoned, "它会到达检查点,应当优雅收尾而不是被放弃"
assert outcome.failure is not None
assert outcome.failure.kind is FailureKind.CANCELLED
def test_async_target_failure_is_reported_across_the_loop() -> None:
"""协程里抛的异常照常转成结构化失败,不能被事件循环吞掉。"""
async def work(ctx: Any) -> str:
await asyncio.sleep(0)
raise ValueError("协程里炸了")
handle = ThreadExecutor().launch(_request("t-async-boom", work))
outcome = handle.wait(timeout=_COOPERATIVE_WINDOW)
assert outcome is not None
assert not outcome.ok
assert outcome.failure is not None
assert outcome.failure.kind is FailureKind.EXCEPTION
assert "协程里炸了" in outcome.failure.message
# ---------------------------------------------------------------------------
# 正常路径
# ---------------------------------------------------------------------------
def test_launch_returns_immediately_without_waiting_for_the_task() -> None:
"""调度器契约:launch 必须立刻返回,否则调度循环会被单个任务卡住。"""
release = threading.Event()
def work(ctx: Any) -> str:
del ctx
release.wait(5)
return "done"
handle = ThreadExecutor().launch(_request("t-async-launch", work))
try:
assert handle.running is True
assert handle.wait(timeout=0.05) is None, "任务尚未结束,wait 应返回 None"
finally:
release.set()
outcome = handle.wait(timeout=_COOPERATIVE_WINDOW)
assert outcome is not None
assert outcome.ok
assert outcome.value == "done"
def test_successful_task_returns_its_value() -> None:
handle = ThreadExecutor().launch(_request("t-ok", lambda ctx: 6 * 7))
outcome = handle.wait(timeout=_COOPERATIVE_WINDOW)
assert outcome is not None
assert outcome.ok
assert outcome.value == 42
assert handle.running is False
def test_failing_task_reports_exception_with_traceback() -> None:
def boom(ctx: Any) -> None:
del ctx
raise ValueError("坏掉了")
handle = ThreadExecutor().launch(_request("t-boom", boom))
outcome = handle.wait(timeout=_COOPERATIVE_WINDOW)
assert outcome is not None
assert not outcome.ok
assert outcome.failure is not None
assert outcome.failure.kind is FailureKind.EXCEPTION
assert "ValueError" in outcome.failure.message
assert outcome.failure.traceback is not None
assert "boom" in outcome.failure.traceback
assert outcome.failure.retryable is True
# ---------------------------------------------------------------------------
# 协作式停止
# ---------------------------------------------------------------------------
def test_stop_completes_cooperatively_when_task_checks_the_token() -> None:
started = threading.Event()
steps: list[int] = []
def work(ctx: Any) -> str:
started.set()
for index in range(100_000):
ctx.checkpoint()
steps.append(index)
time.sleep(0.001)
return "ran to completion"
handle = ThreadExecutor().launch(_request("t-coop", work))
assert started.wait(_COOPERATIVE_WINDOW)
outcome = handle.stop(graceful_timeout=_COOPERATIVE_WINDOW, reason="发布窗口")
assert outcome.abandoned is False
assert outcome.failure is not None
assert outcome.failure.kind is FailureKind.CANCELLED
assert outcome.failure.retryable is False, "取消不是失败,绝不能被重试"
assert handle.running is False
assert len(steps) < 100_000, "任务应当在收到停止请求后提前退出"
def test_cancellation_is_not_swallowed_by_a_broad_except_in_the_task_body() -> None:
"""这正是 ``TaskCancelled`` 继承 ``BaseException`` 的理由。
若它继承 ``Exception``,下面这种极常见的写法会把停止信号吃掉,任务永远停不掉。
"""
started = threading.Event()
swallowed: list[BaseException] = []
def work(ctx: Any) -> str:
started.set()
try:
while True:
ctx.checkpoint()
time.sleep(0.001)
except Exception as exc: # 用户代码里最常见的写法
swallowed.append(exc)
return "signal was swallowed"
handle = ThreadExecutor().launch(_request("t-swallow", work))
assert started.wait(_COOPERATIVE_WINDOW)
outcome = handle.stop(graceful_timeout=_COOPERATIVE_WINDOW)
assert swallowed == [], "取消信号被 except Exception 吞掉了"
assert outcome.failure is not None
assert outcome.failure.kind is FailureKind.CANCELLED
def test_stop_is_idempotent() -> None:
def work(ctx: Any) -> str:
del ctx
return "quick"
handle = ThreadExecutor().launch(_request("t-idem", work))
first = handle.stop(graceful_timeout=_COOPERATIVE_WINDOW)
second = handle.stop(graceful_timeout=_COOPERATIVE_WINDOW)
assert first.ok
assert second.ok
assert handle.running is False
# ---------------------------------------------------------------------------
# 协作式暂停(D6 定案的语义,端到端验证)
# ---------------------------------------------------------------------------
def test_pause_suspends_a_running_task_and_resume_continues_it() -> None:
started = threading.Event()
progress: list[int] = []
def work(ctx: Any) -> str:
started.set()
for index in range(100_000):
ctx.checkpoint()
progress.append(index)
time.sleep(0.001)
return "ran to completion"
handle = ThreadExecutor().launch(_request("t-pause", work))
assert started.wait(_COOPERATIVE_WINDOW)
assert handle.pause(timeout=_COOPERATIVE_WINDOW) is True
frozen = len(progress)
assert frozen > 0, "暂停前任务应当已经推进过"
time.sleep(0.05)
assert len(progress) == frozen, "暂停生效后任务不应继续推进"
handle.resume()
time.sleep(0.05)
assert len(progress) > frozen, "恢复后任务应继续推进"
outcome = handle.stop(graceful_timeout=_COOPERATIVE_WINDOW)
assert outcome.failure is not None
assert outcome.failure.kind is FailureKind.CANCELLED
def test_pause_reports_false_when_task_never_reaches_a_checkpoint() -> None:
"""这正是"暂停没生效必须回滚为 RUNNING"的依据:如实返回 False。"""
started = threading.Event()
def work(ctx: Any) -> str:
del ctx
started.set()
for _ in range(500):
time.sleep(0.01)
return "ignored"
handle = ThreadExecutor().launch(_request("t-nopause", work))
assert started.wait(_COOPERATIVE_WINDOW)
assert handle.pause(timeout=0.2) is False
handle.stop(graceful_timeout=_ABANDON_WINDOW)
def test_stop_wakes_a_paused_task() -> None:
"""暂停中的任务必须能被停止,否则暂停就成了不可撤销的挂起。"""
started = threading.Event()
def work(ctx: Any) -> str:
started.set()
for _ in range(100_000):
ctx.checkpoint()
time.sleep(0.001)
return "ran to completion"
handle = ThreadExecutor().launch(_request("t-pause-stop", work))
assert started.wait(_COOPERATIVE_WINDOW)
assert handle.pause(timeout=_COOPERATIVE_WINDOW) is True
outcome = handle.stop(graceful_timeout=_COOPERATIVE_WINDOW, reason="运维介入")
assert outcome.abandoned is False
assert outcome.failure is not None
assert outcome.failure.kind is FailureKind.CANCELLED
assert "运维介入" in outcome.failure.message
# ---------------------------------------------------------------------------
# 无法停止:放弃
# ---------------------------------------------------------------------------
def _uncooperative(ctx: Any) -> str:
"""刻意不检查令牌;约 5 秒后自行结束,避免残留线程长期占用资源。"""
del ctx
for _ in range(500):
time.sleep(0.01)
return "ignored the stop request"
def test_uncooperative_task_is_abandoned_and_reported_as_lost() -> None:
"""线程无法强杀:必须如实上报,不能假装已停止。"""
handle = ThreadExecutor().launch(_request("t-stubborn", _uncooperative))
outcome = handle.stop(graceful_timeout=_ABANDON_WINDOW)
assert outcome.abandoned is True
assert outcome.ok is False
assert outcome.failure is not None
assert outcome.failure.kind is FailureKind.LOST
assert "无法强制终止" in outcome.failure.message
def test_abandoned_task_still_reports_running() -> None:
"""它确实还在跑。谎报为 False 会让上层以为资源已经释放。"""
handle = ThreadExecutor().launch(_request("t-still-running", _uncooperative))
handle.stop(graceful_timeout=_ABANDON_WINDOW)
assert handle.running is True
def test_abandoned_outcome_is_sticky() -> None:
"""已记为 LOST 的结论不应被后台线程后来的返回悄悄改写,否则审计自相矛盾。"""
handle = ThreadExecutor().launch(_request("t-sticky", _uncooperative))
first = handle.stop(graceful_timeout=_ABANDON_WINDOW)
assert handle.wait(timeout=_ABANDON_WINDOW) is first
assert handle.stop(graceful_timeout=_ABANDON_WINDOW) is first
def test_worker_threads_are_daemon_so_an_abandoned_task_cannot_block_shutdown() -> None:
"""``ThreadPoolExecutor`` 的工作线程会在解释器退出时被 join,一个不响应取消
的任务会让整个进程无法退出。这里用守护线程正是为了避开这一点。"""
handle = ThreadExecutor().launch(_request("t-daemon", _uncooperative))
try:
assert handle.thread.daemon is True
assert handle.thread.name == "task-t-daemon"
finally:
handle.stop(graceful_timeout=_ABANDON_WINDOW)
@pytest.mark.parametrize("graceful_timeout", [0.0, 0.05])
def test_zero_or_tiny_graceful_window_still_yields_a_conclusion(
graceful_timeout: float,
) -> None:
"""优雅窗口再短也必须给出结论,不能抛异常或永远阻塞。"""
handle = ThreadExecutor().launch(_request(f"t-window-{graceful_timeout}", _uncooperative))
outcome = handle.stop(graceful_timeout=graceful_timeout)
assert outcome.abandoned is True
assert outcome.failure is not None
+154
View File
@@ -0,0 +1,154 @@
"""心跳与超时判定的测试。
全部基于 ``FakeClock``:不 sleep、不依赖真实时间,因此既快又不会有偶发失败。
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import pytest
from chongming_task.models import FailureKind, TimeoutScope
from chongming_task.policies import Timeout
from chongming_task.runtime.heartbeat import Heartbeat
from chongming_task.runtime.timeouts import (
Deadline,
is_expired,
next_deadline,
remaining_seconds,
)
from chongming_task.testing import FakeClock
_T0 = datetime(2026, 1, 1, tzinfo=UTC)
# ---------------------------------------------------------------------------
# 心跳
# ---------------------------------------------------------------------------
def test_heartbeat_starts_at_construction() -> None:
clock = FakeClock(_T0)
beat = Heartbeat(clock)
assert beat.last_beat == _T0
assert beat.idle_seconds() == 0.0
def test_heartbeat_advances_only_on_beat() -> None:
"""心跳由 checkpoint 驱动,不额外起线程;没 beat 就不该自己变新。"""
clock = FakeClock(_T0)
beat = Heartbeat(clock)
clock.advance(30)
assert beat.idle_seconds() == 30.0
beat.beat()
assert beat.last_beat == clock.now
assert beat.idle_seconds() == 0.0
def test_heartbeat_staleness_boundary_is_inclusive() -> None:
clock = FakeClock(_T0)
beat = Heartbeat(clock)
clock.advance(59.999)
assert not beat.is_stale(60)
clock.advance(0.001)
assert beat.is_stale(60)
def test_heartbeat_rejects_non_positive_timeout() -> None:
beat = Heartbeat(FakeClock(_T0))
with pytest.raises(ValueError, match="必须为正数"):
beat.is_stale(0)
# ---------------------------------------------------------------------------
# 超时判定
# ---------------------------------------------------------------------------
def test_no_timeout_configured_yields_none() -> None:
assert next_deadline(Timeout()) is None
def test_start_scope_applies_only_before_started() -> None:
timeout = Timeout(start=30)
pending = next_deadline(timeout, submitted_at=_T0)
assert pending is not None
assert pending.scope is TimeoutScope.START
assert pending.at == _T0 + timedelta(seconds=30)
# 一旦开始执行,start 超时即失效。
assert next_deadline(timeout, submitted_at=_T0, started_at=_T0) is None
def test_start_scope_needs_submitted_at() -> None:
assert next_deadline(Timeout(start=30)) is None
def test_attempt_scope_measures_from_started_at() -> None:
deadline = next_deadline(Timeout(per_attempt=600), started_at=_T0)
assert deadline is not None
assert deadline.scope is TimeoutScope.ATTEMPT
assert deadline.at == _T0 + timedelta(seconds=600)
def test_idle_scope_follows_the_latest_heartbeat() -> None:
"""心跳推进后,idle 截止点必须随之顺延——否则长任务会被误杀。"""
timeout = Timeout(idle=60)
first = next_deadline(timeout, started_at=_T0, last_beat_at=_T0)
assert first is not None
assert first.at == _T0 + timedelta(seconds=60)
later = _T0 + timedelta(seconds=45)
second = next_deadline(timeout, started_at=_T0, last_beat_at=later)
assert second is not None
assert second.at == later + timedelta(seconds=60)
def test_idle_scope_requires_the_task_to_have_started() -> None:
"""任务还没跑起来就谈不上"卡住",否则排队时长会被误判成静默超时。"""
assert next_deadline(Timeout(idle=60), last_beat_at=_T0) is None
def test_before_start_only_the_start_scope_applies() -> None:
timeout = Timeout(start=10, per_attempt=600, idle=60)
deadline = next_deadline(timeout, submitted_at=_T0, last_beat_at=_T0)
assert deadline is not None
assert deadline.scope is TimeoutScope.START
assert deadline.at == _T0 + timedelta(seconds=10)
def test_after_start_attempt_and_idle_compete_and_earliest_wins() -> None:
"""开始执行后只剩两档竞争:宽松的一档不能掩盖已经到期的一档。"""
timeout = Timeout(per_attempt=600, idle=60)
fresh_beat = next_deadline(timeout, started_at=_T0, last_beat_at=_T0)
assert fresh_beat is not None
assert fresh_beat.scope is TimeoutScope.IDLE
# 心跳落后很久时,attempt 反而先到期。
late_beat = _T0 + timedelta(seconds=590)
stale_beat = next_deadline(timeout, started_at=_T0, last_beat_at=late_beat)
assert stale_beat is not None
assert stale_beat.scope is TimeoutScope.ATTEMPT
assert stale_beat.at == _T0 + timedelta(seconds=600)
def test_deadline_maps_to_failure_kind() -> None:
"""超时是失败原因,不是生命周期状态;映射必须稳定。"""
assert Deadline(TimeoutScope.START, _T0).kind is FailureKind.TIMEOUT_START
assert Deadline(TimeoutScope.ATTEMPT, _T0).kind is FailureKind.TIMEOUT_ATTEMPT
assert Deadline(TimeoutScope.IDLE, _T0).kind is FailureKind.TIMEOUT_IDLE
def test_is_expired_boundary_is_inclusive() -> None:
deadline = Deadline(TimeoutScope.ATTEMPT, _T0)
assert not is_expired(deadline, now=_T0 - timedelta(microseconds=1))
assert is_expired(deadline, now=_T0)
def test_remaining_seconds_goes_negative_after_expiry() -> None:
"""调度器要把负数当成"立刻到期",而不是交给 sleep——sleep(-1) 会抛异常。"""
deadline = Deadline(TimeoutScope.ATTEMPT, _T0)
assert remaining_seconds(deadline, now=_T0 - timedelta(seconds=5)) == 5.0
assert remaining_seconds(deadline, now=_T0 + timedelta(seconds=5)) == -5.0