单任务受控执行(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。
767 lines
26 KiB
Python
767 lines
26 KiB
Python
"""命令行测试。
|
||
|
||
CLI 是**另一个进程**,所以这里全部通过 ``--db`` 指向真实的 SQLite 文件,而不是把
|
||
``Controller`` 塞进去——那样测的就不是 CLI 了。
|
||
|
||
重点覆盖两件容易静默出错的事:
|
||
|
||
* **输出契约**(``--json`` 的形状、退出码),因为脚本要依赖它;
|
||
* **错误路径**,尤其是"给一个不存在的任务下指令"——它曾经会成功并留下一条永远不会
|
||
被执行的指令。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
from datetime import UTC, datetime
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from chongming_task.cli.main import (
|
||
EXIT_ERROR,
|
||
EXIT_OK,
|
||
EXIT_TASK_NOT_SUCCESSFUL,
|
||
build_parser,
|
||
main,
|
||
)
|
||
from chongming_task.errors import ConfigurationError
|
||
from chongming_task.models import TaskSnapshot
|
||
from chongming_task.states import TaskStatus
|
||
from chongming_task.stores.sqlite import SqliteTaskStore
|
||
|
||
_TASKS_MODULE = "tasks_demo"
|
||
|
||
|
||
@pytest.fixture
|
||
def db(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> str:
|
||
"""把示例任务模块加进导入路径,并返回一个全新的库路径。"""
|
||
import sys
|
||
|
||
examples = Path(__file__).resolve().parents[2] / "examples"
|
||
monkeypatch.syspath_prepend(str(examples))
|
||
sys.modules.pop(_TASKS_MODULE, None)
|
||
return str(tmp_path / "tasks.db")
|
||
|
||
|
||
def _run_cli(db: str, *argv: str) -> int:
|
||
return main(["--db", db, *argv])
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 参数解析
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_parser_requires_a_subcommand() -> None:
|
||
with pytest.raises(SystemExit):
|
||
build_parser().parse_args([])
|
||
|
||
|
||
def test_parser_help_mentions_every_command() -> None:
|
||
text = build_parser().format_help()
|
||
for command in (
|
||
"tasks",
|
||
"run",
|
||
"ps",
|
||
"status",
|
||
"history",
|
||
"stats",
|
||
"buckets",
|
||
"stop",
|
||
"pause",
|
||
"resume",
|
||
):
|
||
assert command in text
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 定义
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_tasks_lists_the_registered_definitions(
|
||
db: str, capsys: pytest.CaptureFixture[str]
|
||
) -> None:
|
||
assert main(["tasks", "--tasks", _TASKS_MODULE]) == EXIT_OK
|
||
out = capsys.readouterr().out
|
||
assert "sync" in out
|
||
assert "add" in out
|
||
assert "两数相加。" in out, "注册项的 docstring 应当作为描述显示"
|
||
|
||
|
||
def test_tasks_on_an_unimportable_module_fails_clearly(
|
||
capsys: pytest.CaptureFixture[str],
|
||
) -> None:
|
||
assert main(["tasks", "--tasks", "no.such.module"]) == EXIT_ERROR
|
||
assert "无法导入任务模块" in capsys.readouterr().err
|
||
|
||
|
||
def test_a_module_not_found_error_explains_sys_path(
|
||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||
) -> None:
|
||
"""模块自身没找到时,必须说清"为什么找不到"。
|
||
|
||
最常踩的坑:``chongming-task`` 是**入口脚本**,不会把当前目录放进 ``sys.path``——
|
||
把模块放在当前目录、然后直接 ``--tasks mymodule`` 会失败,而 "No module named"
|
||
本身完全不解释这件事。
|
||
"""
|
||
monkeypatch.delenv("PYTHONPATH", raising=False)
|
||
assert main(["tasks", "--tasks", "definitely_not_installed"]) == EXIT_ERROR
|
||
err = capsys.readouterr().err
|
||
assert "PYTHONPATH" in err
|
||
assert "不会**把当前目录放进 sys.path" in err
|
||
assert "python -m chongming_task.cli.main" in err
|
||
|
||
|
||
def test_a_module_found_but_failing_to_import_says_so(
|
||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||
) -> None:
|
||
"""模块存在、但内部导入失败:不能把矛头指向 sys.path,那是它自己的依赖问题。"""
|
||
module = tmp_path / "broken_tasks.py"
|
||
module.write_text("import no_such_dependency_here\n", encoding="utf-8")
|
||
monkeypatch.syspath_prepend(str(tmp_path))
|
||
|
||
assert main(["tasks", "--tasks", "broken_tasks"]) == EXIT_ERROR
|
||
err = capsys.readouterr().err
|
||
assert "找到了,但它导入失败" in err
|
||
assert "PYTHONPATH" not in err
|
||
|
||
|
||
def test_the_cli_module_can_be_run_with_python_m() -> None:
|
||
"""备选入口:``python -m`` 会把当前目录放进 ``sys.path``,因此根目录的模块也能用。
|
||
|
||
这条用例钉住"备选入口存在"这个事实——它已经写进了错误提示里,不能悄悄消失。
|
||
"""
|
||
source = Path(__file__).resolve().parents[2] / "src" / "chongming_task" / "cli" / "main.py"
|
||
assert source.is_file()
|
||
assert 'if __name__ == "__main__"' in source.read_text(encoding="utf-8")
|
||
|
||
|
||
def test_tasks_on_a_module_without_register_fails_clearly(
|
||
capsys: pytest.CaptureFixture[str],
|
||
) -> None:
|
||
"""约定必须说清楚——否则用户只会看到一句 AttributeError。"""
|
||
assert main(["tasks", "--tasks", "json"]) == EXIT_ERROR
|
||
assert "register(controller)" in capsys.readouterr().err
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 提交与执行
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_run_can_execute_in_a_child_process(db: str, capsys: pytest.CaptureFixture[str]) -> None:
|
||
"""``--executor process``:任务在子进程里跑,快照因此带上 pid。
|
||
|
||
这是"能被真的杀掉"那条能力在命令行上的入口——前台 ``run`` 遇到不配合的任务时,
|
||
另一个终端发 ``stop`` 就能把它连根拔掉(线程只能放弃等待)。
|
||
"""
|
||
code = _run_cli(
|
||
db,
|
||
"run",
|
||
"--tasks",
|
||
_TASKS_MODULE,
|
||
"add",
|
||
"--arg",
|
||
"left=2",
|
||
"--arg",
|
||
"right=3",
|
||
"--executor",
|
||
"process",
|
||
)
|
||
assert code == EXIT_OK
|
||
assert "succeeded" in capsys.readouterr().out
|
||
|
||
store = SqliteTaskStore(db)
|
||
try:
|
||
snapshot = store.query()[0]
|
||
assert snapshot.executor == "process"
|
||
assert snapshot.pid is not None
|
||
assert snapshot.pid != os.getpid(), "任务确实跑在另一个进程里"
|
||
finally:
|
||
store.close()
|
||
|
||
|
||
def test_run_executes_and_exits_zero(db: str, capsys: pytest.CaptureFixture[str]) -> None:
|
||
code = _run_cli(
|
||
db, "run", "--tasks", _TASKS_MODULE, "add", "--arg", "left=2", "--arg", "right=3"
|
||
)
|
||
assert code == EXIT_OK
|
||
out = capsys.readouterr().out
|
||
assert "succeeded" in out
|
||
|
||
store = SqliteTaskStore(db)
|
||
try:
|
||
snapshots = store.query()
|
||
assert len(snapshots) == 1
|
||
assert snapshots[0].outcome == {"value": 5}
|
||
finally:
|
||
store.close()
|
||
|
||
|
||
def test_run_parses_json_typed_arguments(db: str) -> None:
|
||
"""``--arg n=3`` 应当得到整数 3,而不是字符串 "3"。"""
|
||
assert (
|
||
_run_cli(
|
||
db, "run", "--tasks", _TASKS_MODULE, "add", "--arg", "left=20", "--arg", "right=22"
|
||
)
|
||
== EXIT_OK
|
||
)
|
||
|
||
store = SqliteTaskStore(db)
|
||
try:
|
||
assert store.query()[0].outcome == {"value": 42}
|
||
finally:
|
||
store.close()
|
||
|
||
|
||
def test_run_on_a_failing_task_exits_three(db: str) -> None:
|
||
"""命令成功、任务失败——两者用不同退出码区分,脚本才好判分支。"""
|
||
code = _run_cli(db, "run", "--tasks", _TASKS_MODULE, "flaky", "--arg", "fail_times=9")
|
||
assert code == EXIT_TASK_NOT_SUCCESSFUL
|
||
|
||
|
||
def test_run_rejects_a_malformed_arg(db: str, capsys: pytest.CaptureFixture[str]) -> None:
|
||
code = _run_cli(db, "run", "--tasks", _TASKS_MODULE, "add", "--arg", "noequals")
|
||
assert code == EXIT_ERROR
|
||
assert "K=V" in capsys.readouterr().err
|
||
|
||
|
||
def test_run_records_the_bucket(db: str) -> None:
|
||
code = _run_cli(
|
||
db,
|
||
"run",
|
||
"--tasks",
|
||
_TASKS_MODULE,
|
||
"add",
|
||
"--arg",
|
||
"left=1",
|
||
"--arg",
|
||
"right=1",
|
||
"--bucket-key",
|
||
"tenant-9",
|
||
)
|
||
assert code == EXIT_OK
|
||
|
||
store = SqliteTaskStore(db)
|
||
try:
|
||
snapshot = store.query()[0]
|
||
assert snapshot.bucket_key == "tenant-9"
|
||
assert snapshot.bucket is not None
|
||
finally:
|
||
store.close()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 查询
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_ps_defaults_to_unfinished_and_all_shows_everything(
|
||
db: str, capsys: pytest.CaptureFixture[str]
|
||
) -> None:
|
||
assert (
|
||
_run_cli(db, "run", "--tasks", _TASKS_MODULE, "add", "--arg", "left=1", "--arg", "right=1")
|
||
== EXIT_OK
|
||
)
|
||
capsys.readouterr()
|
||
|
||
assert _run_cli(db, "ps") == EXIT_OK
|
||
assert "没有匹配的任务" in capsys.readouterr().out, "默认只显示未落终态的"
|
||
|
||
assert _run_cli(db, "ps", "--all") == EXIT_OK
|
||
assert "succeeded" in capsys.readouterr().out
|
||
|
||
|
||
def test_status_shows_detail_and_history(db: str, capsys: pytest.CaptureFixture[str]) -> None:
|
||
_run_cli(db, "run", "--tasks", _TASKS_MODULE, "add", "--arg", "left=1", "--arg", "right=1")
|
||
capsys.readouterr()
|
||
|
||
assert _run_cli(db, "status", "x") == EXIT_ERROR # 不存在
|
||
|
||
store = SqliteTaskStore(db)
|
||
try:
|
||
task_id = store.query()[0].task_id
|
||
finally:
|
||
store.close()
|
||
|
||
assert _run_cli(db, "status", task_id) == EXIT_OK
|
||
out = capsys.readouterr().out
|
||
assert "status" in out
|
||
assert "迁移历史" in out
|
||
assert "executor:completed" in out
|
||
|
||
|
||
def test_stats_and_buckets(db: str, capsys: pytest.CaptureFixture[str]) -> None:
|
||
_run_cli(db, "run", "--tasks", _TASKS_MODULE, "add", "--arg", "left=1", "--arg", "right=1")
|
||
capsys.readouterr()
|
||
|
||
assert _run_cli(db, "stats") == EXIT_OK
|
||
out = capsys.readouterr().out
|
||
assert "succeeded" in out
|
||
assert "total" in out
|
||
|
||
assert _run_cli(db, "buckets") == EXIT_OK
|
||
assert "BUCKET" in capsys.readouterr().out
|
||
|
||
|
||
def test_ps_rejects_an_unknown_status(db: str, capsys: pytest.CaptureFixture[str]) -> None:
|
||
assert _run_cli(db, "ps", "--status", "no-such-status") == EXIT_ERROR
|
||
assert "未知状态" in capsys.readouterr().err
|
||
|
||
|
||
def test_ps_filters_by_pipeline_run(db: str, capsys: pytest.CaptureFixture[str]) -> None:
|
||
"""按管道查看是看板最常问的问题,过滤下推到存储层。"""
|
||
from datetime import UTC, datetime
|
||
|
||
from chongming_task.models import TaskSnapshot
|
||
|
||
store = SqliteTaskStore(db)
|
||
try:
|
||
for task_id, run_id in (("a1", "run-a"), ("a2", "run-a"), ("b1", "run-b")):
|
||
store.create(
|
||
TaskSnapshot(
|
||
task_id=task_id,
|
||
name="节点",
|
||
status=TaskStatus.SUCCEEDED,
|
||
status_version=1,
|
||
attempt=1,
|
||
max_attempts=1,
|
||
created_at=datetime(2026, 1, 1, tzinfo=UTC),
|
||
ended_at=datetime(2026, 1, 1, 1, tzinfo=UTC),
|
||
pipeline_run_id=run_id,
|
||
)
|
||
)
|
||
finally:
|
||
store.close()
|
||
|
||
assert _run_cli(db, "ps", "--all", "--pipeline", "run-a") == EXIT_OK
|
||
out = capsys.readouterr().out
|
||
assert "a1" in out
|
||
assert "a2" in out
|
||
assert "b1" not in out
|
||
|
||
assert _run_cli(db, "ps", "--all", "--pipeline", "no-such-run") == EXIT_OK
|
||
assert "没有匹配的任务" in capsys.readouterr().out
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 输出契约
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_json_output_is_primitive_only(db: str, capsys: pytest.CaptureFixture[str]) -> None:
|
||
"""``--json`` 的输出必须全是原语,脚本才不必了解 datetime / 枚举的序列化规则。"""
|
||
_run_cli(db, "run", "--tasks", _TASKS_MODULE, "add", "--arg", "left=2", "--arg", "right=3")
|
||
capsys.readouterr()
|
||
|
||
assert main(["--db", db, "--json", "ps", "--all"]) == EXIT_OK
|
||
payload = json.loads(capsys.readouterr().out)
|
||
assert isinstance(payload, list)
|
||
entry = payload[0]
|
||
assert entry["status"] == "succeeded"
|
||
assert entry["outcome"] == '{"value": 5}'
|
||
assert all(
|
||
value is None or isinstance(value, (str, int, float, bool)) for value in entry.values()
|
||
), "出现了非原语字段"
|
||
|
||
|
||
def test_json_error_path_uses_stderr_not_stdout(capsys: pytest.CaptureFixture[str]) -> None:
|
||
assert main(["--json", "ps"]) == EXIT_ERROR
|
||
captured = capsys.readouterr()
|
||
assert captured.out == ""
|
||
assert "未指定任务存储" in captured.err
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 错误路径
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_missing_db_is_reported_clearly(
|
||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||
) -> None:
|
||
monkeypatch.delenv("CHONGMING_TASK_DB", raising=False)
|
||
assert main(["ps"]) == EXIT_ERROR
|
||
assert "CHONGMING_TASK_DB" in capsys.readouterr().err
|
||
|
||
|
||
def test_db_can_come_from_the_environment(db: str, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
monkeypatch.setenv("CHONGMING_TASK_DB", db)
|
||
_run_cli(db, "run", "--tasks", _TASKS_MODULE, "add", "--arg", "left=1", "--arg", "right=1")
|
||
assert main(["ps", "--all"]) == EXIT_OK
|
||
|
||
|
||
def test_stop_on_an_unknown_task_fails_instead_of_writing_a_dead_command(
|
||
db: str, capsys: pytest.CaptureFixture[str]
|
||
) -> None:
|
||
"""曾经这里的 bug:给不存在的任务下指令会成功,留下一条永远不会被执行的指令,
|
||
而调用方还以为"停止已下发"。
|
||
"""
|
||
_run_cli(db, "run", "--tasks", _TASKS_MODULE, "add", "--arg", "left=1", "--arg", "right=1")
|
||
capsys.readouterr()
|
||
|
||
assert _run_cli(db, "stop", "typo-task-id") == EXIT_ERROR
|
||
assert "没有找到任务" in capsys.readouterr().err
|
||
|
||
store = SqliteTaskStore(db)
|
||
try:
|
||
assert store.commands() == [], "不该留下任何指令"
|
||
finally:
|
||
store.close()
|
||
|
||
|
||
def test_control_on_an_existing_task_is_accepted(
|
||
db: str, capsys: pytest.CaptureFixture[str]
|
||
) -> None:
|
||
_run_cli(db, "run", "--tasks", _TASKS_MODULE, "add", "--arg", "left=1", "--arg", "right=1")
|
||
capsys.readouterr()
|
||
|
||
store = SqliteTaskStore(db)
|
||
try:
|
||
task_id = store.query()[0].task_id
|
||
finally:
|
||
store.close()
|
||
|
||
# 任务已落终态,但"找到并受理"仍然成立。
|
||
assert _run_cli(db, "stop", task_id, "--reason", "收尾") == EXIT_OK
|
||
assert "已请求停止" in capsys.readouterr().out
|
||
|
||
|
||
def test_store_directory_missing_is_reported_clearly(
|
||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||
) -> None:
|
||
"""SQLite 只会报 "unable to open database file",完全看不出是目录不存在。"""
|
||
missing = tmp_path / "not-created" / "tasks.db"
|
||
assert main(["--db", str(missing), "ps"]) == EXIT_ERROR
|
||
assert "目录不存在" in capsys.readouterr().err
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 重启恢复
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _plant_zombie(db: str, task_id: str = "zombie") -> None:
|
||
"""造一个"上次崩溃留下的"任务行:running 且租约早已过期。"""
|
||
from datetime import UTC, datetime, timedelta
|
||
|
||
from chongming_task.models import TaskSnapshot
|
||
|
||
store = SqliteTaskStore(db)
|
||
try:
|
||
old = datetime.now(UTC) - timedelta(minutes=10)
|
||
store.create(
|
||
TaskSnapshot(
|
||
task_id=task_id,
|
||
name="遗留任务",
|
||
status=TaskStatus.RUNNING,
|
||
status_version=3,
|
||
attempt=1,
|
||
max_attempts=1,
|
||
created_at=old,
|
||
started_at=old,
|
||
lease_owner="worker-old",
|
||
lease_until=datetime.now(UTC) - timedelta(minutes=9),
|
||
)
|
||
)
|
||
finally:
|
||
store.close()
|
||
|
||
|
||
def test_reconcile_dry_run_writes_nothing(db: str, capsys: pytest.CaptureFixture[str]) -> None:
|
||
_plant_zombie(db)
|
||
assert _run_cli(db, "reconcile", "--dry-run") == EXIT_OK
|
||
out = capsys.readouterr().out
|
||
assert "marked-lost" in out
|
||
assert "预演" in out
|
||
|
||
store = SqliteTaskStore(db)
|
||
try:
|
||
snapshot = store.get("zombie")
|
||
assert snapshot is not None
|
||
assert snapshot.status is TaskStatus.RUNNING, "预演不该写状态"
|
||
assert store.history("zombie") == []
|
||
finally:
|
||
store.close()
|
||
|
||
|
||
def test_reconcile_marks_zombies(db: str, capsys: pytest.CaptureFixture[str]) -> None:
|
||
_plant_zombie(db)
|
||
assert _run_cli(db, "reconcile", "--reason", "worker-old 崩溃") == EXIT_OK
|
||
assert "已把 1 个僵尸任务标记为 lost" in capsys.readouterr().out
|
||
|
||
store = SqliteTaskStore(db)
|
||
try:
|
||
snapshot = store.get("zombie")
|
||
assert snapshot is not None
|
||
assert snapshot.status is TaskStatus.LOST
|
||
assert store.history("zombie")[0].reason == "worker-old 崩溃"
|
||
finally:
|
||
store.close()
|
||
|
||
|
||
def test_reconcile_with_nothing_to_do(db: str, capsys: pytest.CaptureFixture[str]) -> None:
|
||
assert _run_cli(db, "reconcile") == EXIT_OK
|
||
assert "没有需要处理的任务" in capsys.readouterr().out
|
||
|
||
|
||
def test_reconcile_json_report(db: str, capsys: pytest.CaptureFixture[str]) -> None:
|
||
_plant_zombie(db)
|
||
assert main(["--db", db, "--json", "reconcile", "--dry-run"]) == EXIT_OK
|
||
payload = json.loads(capsys.readouterr().out)
|
||
assert payload["dry_run"] is True
|
||
assert payload["entries"][0]["task_id"] == "zombie"
|
||
assert payload["entries"][0]["previous_status"] == "running"
|
||
assert payload["entries"][0]["outcome"] == "marked-lost"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 节点与桶归属(自治节点 + 统一平面)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_bucket_spec_supports_ranges_and_lists() -> None:
|
||
from chongming_task.cli.main import _parse_bucket_spec
|
||
|
||
assert _parse_bucket_spec("0-3") == frozenset({0, 1, 2, 3})
|
||
assert _parse_bucket_spec("0-1,5,7-8") == frozenset({0, 1, 5, 7, 8})
|
||
assert _parse_bucket_spec(" 2 , 4 ") == frozenset({2, 4})
|
||
assert _parse_bucket_spec("3-3") == frozenset({3})
|
||
|
||
|
||
def test_bucket_spec_rejects_bad_input() -> None:
|
||
from chongming_task.cli.main import _parse_bucket_spec
|
||
|
||
for bad in ("3-1", "a", "0-", "1,x"):
|
||
with pytest.raises(ConfigurationError):
|
||
_parse_bucket_spec(bad)
|
||
with pytest.raises(ConfigurationError, match="没有解析出任何桶"):
|
||
_parse_bucket_spec(" , ")
|
||
|
||
|
||
def test_ps_mine_filters_to_owned_buckets(db: str, capsys: pytest.CaptureFixture[str]) -> None:
|
||
"""自治架构里最常看的视图:"我这一份现在怎样"。"""
|
||
_plant_zombie(db, "zombie") # 无桶任务
|
||
store = SqliteTaskStore(db)
|
||
try:
|
||
for task_id, bucket in (("in-a", 1), ("in-b", 40)):
|
||
store.create(
|
||
TaskSnapshot(
|
||
task_id=task_id,
|
||
name="节点任务",
|
||
bucket=bucket,
|
||
bucket_key=f"key-{bucket}",
|
||
status=TaskStatus.SUCCEEDED,
|
||
status_version=1,
|
||
attempt=1,
|
||
max_attempts=1,
|
||
created_at=datetime(2026, 1, 1, tzinfo=UTC),
|
||
ended_at=datetime(2026, 1, 1, 1, tzinfo=UTC),
|
||
)
|
||
)
|
||
finally:
|
||
store.close()
|
||
|
||
assert main(["--db", db, "--owned-buckets", "0-31", "ps", "--all", "--mine"]) == EXIT_OK
|
||
out = capsys.readouterr().out
|
||
assert "in-a" in out
|
||
assert "in-b" not in out, "别把别的桶的任务算进来"
|
||
assert "zombie" not in out, "无桶任务不属于任何限定了归属的节点"
|
||
|
||
|
||
def test_ps_mine_requires_owned_buckets(db: str, capsys: pytest.CaptureFixture[str]) -> None:
|
||
assert main(["--db", db, "ps", "--mine"]) == EXIT_ERROR
|
||
assert "--owned-buckets" in capsys.readouterr().err
|
||
|
||
|
||
def test_reconcile_is_scoped_to_owned_buckets(db: str, capsys: pytest.CaptureFixture[str]) -> None:
|
||
"""节点只清理自己桶里的僵尸——这是自治架构的核心不变量。"""
|
||
store = SqliteTaskStore(db)
|
||
try:
|
||
for task_id, bucket in (("mine", 1), ("theirs", 40)):
|
||
old = datetime(2026, 1, 1, tzinfo=UTC)
|
||
store.create(
|
||
TaskSnapshot(
|
||
task_id=task_id,
|
||
name="遗留任务",
|
||
bucket=bucket,
|
||
bucket_key=f"key-{bucket}",
|
||
status=TaskStatus.RUNNING,
|
||
status_version=3,
|
||
attempt=1,
|
||
max_attempts=1,
|
||
created_at=old,
|
||
started_at=old,
|
||
lease_owner="dead",
|
||
lease_until=old,
|
||
)
|
||
)
|
||
finally:
|
||
store.close()
|
||
|
||
assert main(["--db", db, "--owned-buckets", "0-31", "reconcile"]) == EXIT_OK
|
||
out = capsys.readouterr().out
|
||
assert "mine" in out
|
||
assert "theirs" not in out
|
||
|
||
store = SqliteTaskStore(db)
|
||
try:
|
||
assert store.get("mine").status is TaskStatus.LOST # type: ignore[union-attr]
|
||
assert store.get("theirs").status is TaskStatus.RUNNING, ( # type: ignore[union-attr]
|
||
"动了别的节点的任务"
|
||
)
|
||
finally:
|
||
store.close()
|
||
|
||
|
||
def test_buckets_view_marks_owned_buckets(db: str, capsys: pytest.CaptureFixture[str]) -> None:
|
||
assert main(["--db", db, "--num-buckets", "4", "--owned-buckets", "0-1", "buckets"]) == EXIT_OK
|
||
out = capsys.readouterr().out
|
||
marked = [line for line in out.splitlines() if line.startswith("*")]
|
||
assert len(marked) == 2, f"应当恰好标出两个桶,实际:{marked}"
|
||
|
||
|
||
def test_an_out_of_range_owned_bucket_is_reported(
|
||
db: str, capsys: pytest.CaptureFixture[str]
|
||
) -> None:
|
||
"""桶号越界必须在启动时报错——否则症状是"任务永远排队且没有任何报错"。"""
|
||
assert main(["--db", db, "--owned-buckets", "0-999", "ps"]) == EXIT_ERROR
|
||
assert "桶号越界" in capsys.readouterr().err
|
||
|
||
|
||
def test_run_respects_owned_buckets(db: str, capsys: pytest.CaptureFixture[str]) -> None:
|
||
"""``run`` 必须**真的**受归属约束。
|
||
|
||
曾经 ``run`` 自己构造 ``Controller(...)``,把 ``--owned-buckets`` 静默忽略了——
|
||
参数被接受却不起作用,比报错更难查。
|
||
"""
|
||
from chongming_task import BucketPolicy
|
||
|
||
policy = BucketPolicy(num_buckets=64)
|
||
mine = next(f"tenant-{i}" for i in range(999) if policy.bucket_for(f"tenant-{i}") < 16)
|
||
theirs = next(f"tenant-{i}" for i in range(999) if policy.bucket_for(f"tenant-{i}") >= 48)
|
||
|
||
# 限定了归属却没给 bucket_key → 必须报错(否则会产出没人认领的任务)
|
||
assert (
|
||
main(
|
||
[
|
||
"--db",
|
||
db,
|
||
"--num-buckets",
|
||
"64",
|
||
"--owned-buckets",
|
||
"0-15",
|
||
"run",
|
||
"--tasks",
|
||
_TASKS_MODULE,
|
||
"add",
|
||
"--arg",
|
||
"left=1",
|
||
"--arg",
|
||
"right=1",
|
||
"--task-id",
|
||
"r1",
|
||
]
|
||
)
|
||
== EXIT_ERROR
|
||
)
|
||
assert "必须给出 bucket_key" in capsys.readouterr().err
|
||
|
||
# 落在别人的桶 → 必须报错
|
||
assert (
|
||
main(
|
||
[
|
||
"--db",
|
||
db,
|
||
"--num-buckets",
|
||
"64",
|
||
"--owned-buckets",
|
||
"0-15",
|
||
"run",
|
||
"--tasks",
|
||
_TASKS_MODULE,
|
||
"add",
|
||
"--arg",
|
||
"left=1",
|
||
"--arg",
|
||
"right=1",
|
||
"--task-id",
|
||
"r2",
|
||
"--bucket-key",
|
||
theirs,
|
||
]
|
||
)
|
||
== EXIT_ERROR
|
||
)
|
||
assert "不属于本节点" in capsys.readouterr().err
|
||
|
||
# 落在自己的桶 → 正常执行
|
||
assert (
|
||
main(
|
||
[
|
||
"--db",
|
||
db,
|
||
"--num-buckets",
|
||
"64",
|
||
"--owned-buckets",
|
||
"0-15",
|
||
"run",
|
||
"--tasks",
|
||
_TASKS_MODULE,
|
||
"add",
|
||
"--arg",
|
||
"left=20",
|
||
"--arg",
|
||
"right=22",
|
||
"--task-id",
|
||
"r3",
|
||
"--bucket-key",
|
||
mine,
|
||
]
|
||
)
|
||
== EXIT_OK
|
||
)
|
||
assert "succeeded" in capsys.readouterr().out
|
||
|
||
|
||
def test_a_module_with_only_task_decorators_works(
|
||
db: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||
) -> None:
|
||
"""只用 ``@task`` 装饰、**没有** ``register()`` 的模块也必须能用。
|
||
|
||
``@task`` 是后来加的写法;CLI 若只认 ``register()``,那个 API 在命令行下就废了。
|
||
"""
|
||
module = tmp_path / "decorated_only.py"
|
||
module.write_text(
|
||
"from chongming_task import task\n"
|
||
"\n"
|
||
"@task(tags={'io'})\n"
|
||
"def doubled(ctx, value: int) -> int:\n"
|
||
" '''把数字翻倍。'''\n"
|
||
" del ctx\n"
|
||
" return value * 2\n",
|
||
encoding="utf-8",
|
||
)
|
||
monkeypatch.syspath_prepend(str(tmp_path))
|
||
|
||
assert main(["tasks", "--tasks", "decorated_only"]) == EXIT_OK
|
||
out = capsys.readouterr().out
|
||
assert "doubled" in out
|
||
assert "把数字翻倍。" in out
|
||
|
||
assert (
|
||
main(["--db", db, "run", "--tasks", "decorated_only", "doubled", "--arg", "value=21"])
|
||
== EXIT_OK
|
||
)
|
||
assert "succeeded" in capsys.readouterr().out
|
||
|
||
|
||
def test_a_module_with_neither_register_nor_task_is_reported(
|
||
capsys: pytest.CaptureFixture[str],
|
||
) -> None:
|
||
assert main(["tasks", "--tasks", "json"]) == EXIT_ERROR
|
||
assert "既没有 register" in capsys.readouterr().err
|