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
|