单任务受控执行(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。
144 lines
4.8 KiB
Python
144 lines
4.8 KiB
Python
"""快照与正交维度模型的不变量。
|
|
|
|
重点覆盖那些"容易被合并成单一状态枚举"的维度:进度、失败原因、请求动作。
|
|
"""
|
|
|
|
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
|