"""存储接线测试:写穿、读穿、跨进程指令。 这里用**两个 ``SqliteTaskStore`` 连接同一个数据库文件**来模拟两个进程。这不是 过度讲究:跨进程真正难的部分就是"两个连接看不到彼此的内存",用同一个 store 对象 测是测不出来的。 用例的重点是那些**会静默出错**的地方:写穿漏一次、指令被错误的进程吃掉、指令被 错误地标记完成——它们都不会抛异常,只会让看板上的状态悄悄变成错的。 """ from __future__ import annotations import tempfile import time from datetime import UTC, datetime from pathlib import Path from typing import Any from chongming_task.controller import Controller from chongming_task.errors import StoreError from chongming_task.models import TaskSnapshot from chongming_task.states import TaskStatus from chongming_task.stores.memory import InMemoryTaskStore from chongming_task.stores.protocol import Command, CommandKind from chongming_task.stores.sqlite import SqliteTaskStore _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 slow_loop(ctx: Any) -> str: """持续小步推进直到被停止。""" for _ in range(1_000_000): ctx.checkpoint() time.sleep(0.005) return "done" def quick_add(ctx: Any, left: int, right: int) -> int: del ctx return left + right def _shared_store(tmp: Path) -> tuple[SqliteTaskStore, SqliteTaskStore]: """同一个数据库文件上的两个独立连接 = 两个"进程"。""" return ( SqliteTaskStore(tmp / "tasks.db"), SqliteTaskStore(tmp / "tasks.db"), ) # --------------------------------------------------------------------------- # 写穿 # --------------------------------------------------------------------------- def test_initial_snapshot_is_persisted_before_any_transition() -> None: """必须先建行:之后每次迁移都是按版本更新的 UPDATE。 顺序如果反了,第一次 ``save`` 会因"任务不存在"而失败。但注意:写穿失败是被 捕获并记进 ``persist_failures`` 的(存储故障不该让任务失败),所以**光断言行 存在是不够的**——必须同时断言没有任何写穿失败,否则这条用例会在实现有 bug 时 依然通过。 """ store = InMemoryTaskStore() controller = Controller(store=store, max_parallel=1) controller.register("block", slow_loop) controller.submit("block", task_id="t1", label="占住额度") assert _wait_until( lambda: (store.get("t1") or TaskSnapshot("x", "x")).status is TaskStatus.RUNNING ) # 额度已被占满,这个任务停在 queued。 controller.submit("block", task_id="t2", label="排队中") persisted = store.get("t2") assert persisted is not None assert persisted.status is TaskStatus.QUEUED assert persisted.name == "排队中" assert controller.persist_failures == (), "建行与更新的顺序不对,写穿失败了" controller.stop_all() controller.wait_all(timeout=_WINDOW) assert store.get("t2").status is TaskStatus.STOPPED # type: ignore[union-attr] def test_every_transition_is_written_through_and_is_auditable() -> None: store = InMemoryTaskStore() controller = Controller(store=store) controller.register("add", quick_add) controller.submit("add", 2, 3, task_id="t1") controller.wait_all(timeout=_WINDOW) snapshot = store.get("t1") assert snapshot is not None assert snapshot.status is TaskStatus.SUCCEEDED assert snapshot.outcome == {"value": 5} assert snapshot.validate() == () history = store.history("t1") assert [item.to_status for item in history] == [ TaskStatus.QUEUED, TaskStatus.STARTING, TaskStatus.RUNNING, TaskStatus.SUCCEEDED, ] assert [item.version for item in history] == [1, 2, 3, 4] def test_bucket_and_key_survive_the_write_through() -> None: store = InMemoryTaskStore() controller = Controller(store=store) controller.register("add", quick_add) controller.submit("add", 1, 1, task_id="t1", bucket_key="tenant-42") controller.wait_all(timeout=_WINDOW) snapshot = store.get("t1") assert snapshot is not None assert snapshot.bucket == controller.bucket_policy.bucket_for("tenant-42") assert snapshot.bucket_key == "tenant-42" def test_progress_and_heartbeat_reach_the_store_without_bumping_the_version() -> None: """进度走高频旁路:它若参与乐观并发,会与状态写入持续冲突。""" store = InMemoryTaskStore() controller = Controller(store=store, command_poll_interval=0.05) controller.register("block", slow_loop) controller.submit("block", task_id="t1") assert _wait_until( lambda: (store.get("t1") or TaskSnapshot("x", "x")).status is TaskStatus.RUNNING ) before = store.get("t1") assert before is not None version = before.status_version assert _wait_until( lambda: (store.get("t1").last_heartbeat_at if store.get("t1") else None) is not None, timeout=3.0, ) after = store.get("t1") assert after is not None assert after.status_version == version, "心跳回写不应改动版本号" controller.stop("t1") controller.wait_all(timeout=_WINDOW) # --------------------------------------------------------------------------- # 读穿 # --------------------------------------------------------------------------- def test_a_second_controller_sees_tasks_it_does_not_own() -> None: """这就是存储接线存在的全部理由:跨进程可见。""" with tempfile.TemporaryDirectory(prefix="chongming-wire-") as directory: writer_store, reader_store = _shared_store(Path(directory)) try: writer = Controller(store=writer_store) writer.register("add", quick_add) writer.submit("add", 1, 1, task_id="t1", label="跨进程可见") writer.wait_all(timeout=_WINDOW) reader = Controller(store=reader_store) assert reader.runs() == (), "读方没有本地句柄" snapshot = reader.get("t1") assert snapshot is not None assert snapshot.name == "跨进程可见" assert snapshot.status is TaskStatus.SUCCEEDED assert [s.task_id for s in reader.list_tasks()] == ["t1"] assert reader.stats().of(TaskStatus.SUCCEEDED) == 1 writer.close(timeout=_WINDOW) finally: writer_store.close() reader_store.close() def test_reader_filters_are_pushed_down_to_the_store() -> None: with tempfile.TemporaryDirectory(prefix="chongming-wire-") as directory: writer_store, reader_store = _shared_store(Path(directory)) try: writer = Controller(store=writer_store) writer.register("add", quick_add) writer.submit("add", 1, 1, task_id="a1", label="抽取-甲", bucket_key="tenant-A") writer.submit("add", 1, 1, task_id="a2", label="加载", bucket_key="tenant-B") writer.wait_all(timeout=_WINDOW) reader = Controller(store=reader_store) assert [s.task_id for s in reader.list_tasks(name_prefix="抽取")] == ["a1"] assert [ s.task_id for s in reader.list_tasks(bucket=writer.bucket_policy.bucket_for("tenant-B")) ] == ["a2"] assert reader.list_tasks(status=TaskStatus.FAILED) == [] writer.close(timeout=_WINDOW) finally: writer_store.close() reader_store.close() # --------------------------------------------------------------------------- # 跨进程控制 # --------------------------------------------------------------------------- def test_stop_crosses_the_process_boundary_via_a_command() -> None: """一个进程无法给另一个进程的执行载体发信号,只能写指令让持有者取走。""" with tempfile.TemporaryDirectory(prefix="chongming-wire-") as directory: owner_store, client_store = _shared_store(Path(directory)) try: owner = Controller(store=owner_store, command_poll_interval=0.05, owner="owner-process") owner.register("block", slow_loop) owner.submit("block", task_id="t1", label="长任务") assert _wait_until( lambda: ( (owner_store.get("t1") or TaskSnapshot("x", "x")).status is TaskStatus.RUNNING ) ) client = Controller(store=client_store, owner="ops-client") assert client.stop("t1", reason="跨进程停止") is True assert client.runs() == (), "客户端并不持有这个任务" assert _wait_until( lambda: ( (owner_store.get("t1") or TaskSnapshot("x", "x")).status is TaskStatus.STOPPED ) ), "持有者没有取走指令" final = client.get("t1") assert final is not None assert final.status is TaskStatus.STOPPED assert final.failure is not None owner.close(timeout=_WINDOW) finally: owner_store.close() client_store.close() def test_command_records_who_requested_it() -> None: store = InMemoryTaskStore() owner = Controller(store=store, command_poll_interval=0.05, owner="owner-1") owner.register("block", slow_loop) run = owner.submit("block", task_id="t1") assert _wait_until(lambda: run.status is TaskStatus.RUNNING) client = Controller(store=store, owner="ops@laptop") client.stop("t1", reason="发布窗口") assert _wait_until(lambda: run.status is TaskStatus.STOPPED) owner.close(timeout=_WINDOW) def test_pause_of_a_remote_task_reports_unconfirmed() -> None: """跨进程没有回执通道,因此 `pause` 只能如实返回 False(未确认)。""" store = InMemoryTaskStore() owner = Controller(store=store, command_poll_interval=0.05) owner.register("block", slow_loop) run = owner.submit("block", task_id="t1") assert _wait_until(lambda: run.status is TaskStatus.RUNNING) client = Controller(store=store) assert client.pause("t1") is False assert _wait_until(lambda: run.status is TaskStatus.PAUSED), "指令没有被执行" owner.close(timeout=_WINDOW) def test_a_client_that_never_submitted_does_not_steal_commands() -> None: """纯查询客户端若也去取指令,会把别人的停止请求抢走。 即便它把指令交还,持续轮询也会让真正的持有者反复错过——所以轮询线程只在 "提交过任务"之后才启动。 """ store = InMemoryTaskStore() owner = Controller(store=store, command_poll_interval=0.05) owner.register("block", slow_loop) run = owner.submit("block", task_id="t1") assert _wait_until(lambda: run.status is TaskStatus.RUNNING) # 一个只读客户端:只查询,不提交。 client = Controller(store=store) client.list_tasks() client.get("t1") client.stats() assert client._worker is None, "没有提交过任务的实例不该启动指令轮询" owner.stop("t1") assert _wait_until(lambda: run.status is TaskStatus.STOPPED) owner.close(timeout=_WINDOW) def test_a_command_for_a_task_we_do_not_own_is_released_not_completed() -> None: """取到不属于自己的指令必须**交还**,否则真正的持有者永远收不到它。 真实场景:另一个进程提交的任务,它的停止指令被**本**进程的轮询线程取到了—— 本进程没有那个任务的本地句柄。所以这里直接往存储里写"别人的任务"和它的指令, 而不是走 ``Controller.request``(后者会先校验任务存在,见下一条用例)。 """ store = InMemoryTaskStore() controller = Controller(store=store, command_poll_interval=0.05) controller.register("add", quick_add) # 先提交一个自己的任务,让轮询线程启动起来。 controller.submit("add", 1, 1, task_id="mine") controller.wait_all(timeout=_WINDOW) store.create( TaskSnapshot( task_id="someone-elses", name="别人的任务", status=TaskStatus.RUNNING, status_version=1, attempt=1, max_attempts=1, ) ) store.submit_command( Command( command_id="c-foreign", task_id="someone-elses", kind=CommandKind.STOP, created_at=datetime(2026, 1, 1, tzinfo=UTC), requested_by="other-process", ) ) def released() -> bool: found = [item for item in store.commands() if item.command_id == "c-foreign"] return bool(found) and found[0].is_open and found[0].lease_until is None assert _wait_until(released), "指令没有被交还,真正的持有者将永远收不到它" controller.close(timeout=_WINDOW) def test_request_refuses_a_task_that_does_not_exist() -> None: """拼错的 task_id 不该写出一条**永远不会有人执行**的指令。 没有这道校验时,``stop typo`` 会"成功",于是存储里留下一条孤儿指令,而调用方 还以为停止已经下发。 """ store = InMemoryTaskStore() controller = Controller(store=store) controller.register("add", quick_add) controller.submit("add", 1, 1, task_id="mine") controller.wait_all(timeout=_WINDOW) assert controller.request("typo-task-id", CommandKind.STOP) is False assert store.commands() == [] controller.close(timeout=_WINDOW) def test_request_without_a_store_is_rejected() -> None: """没有共享媒介就不可能跨进程控制,如实返回 False 而不是假装成功。""" controller = Controller() assert controller.request("t1", CommandKind.STOP) is False # --------------------------------------------------------------------------- # 存储故障不该让任务失败 # --------------------------------------------------------------------------- class _FlakyStore(InMemoryTaskStore): """``save`` 总是失败,用来验证存储故障不会打断任务。""" def save(self, snapshot: TaskSnapshot, *, expect_version: int) -> None: raise StoreError("模拟磁盘故障") def test_store_failure_does_not_fail_the_task_but_is_recorded() -> None: """任务本身没问题,坏的只是可观测性。 但也绝不能静默——否则"看板上的状态是旧的"这件事毫无线索。 """ controller = Controller(store=_FlakyStore()) controller.register("add", quick_add) run = controller.submit("add", 2, 3, task_id="t1") outcome = controller.wait("t1", timeout=_WINDOW) assert outcome.ok, "存储故障不该让任务失败" assert outcome.value == 5 assert run.status is TaskStatus.SUCCEEDED failures = controller.persist_failures assert failures, "写穿失败必须留痕" assert "StoreError" in failures[0] assert "t1" in failures[0] def test_persist_failures_are_bounded() -> None: controller = Controller(store=_FlakyStore()) controller.register("add", quick_add) for index in range(30): controller.submit("add", index, 1, task_id=f"t{index}") controller.wait_all(timeout=_WINDOW) assert len(controller.persist_failures) <= 20 # --------------------------------------------------------------------------- # 与分桶、并发额度的协同 # --------------------------------------------------------------------------- def test_bucket_counts_read_through_the_store() -> None: with tempfile.TemporaryDirectory(prefix="chongming-wire-") as directory: writer_store, reader_store = _shared_store(Path(directory)) try: writer = Controller(store=writer_store) writer.register("add", quick_add) writer.submit("add", 1, 1, task_id="a1", bucket_key="tenant-A") writer.submit("add", 1, 1, task_id="a2", bucket_key="tenant-A") writer.submit("add", 1, 1, task_id="b1") writer.wait_all(timeout=_WINDOW) reader = Controller(store=reader_store) counts = reader.bucket_counts() assert counts[writer.bucket_policy.bucket_for("tenant-A")] == 2 assert counts[None] == 1 writer.close(timeout=_WINDOW) finally: writer_store.close() reader_store.close() def test_local_concurrency_limit_still_applies_with_a_store() -> None: """接了存储也不能绕过本地的额度:排队中的任务在存储里应当是 queued。""" store = InMemoryTaskStore() controller = Controller(store=store, max_parallel=1) controller.register("block", slow_loop) first = controller.submit("block", task_id="t1") assert _wait_until(lambda: first.status is TaskStatus.RUNNING) second = controller.submit("block", task_id="t2") assert second.status is TaskStatus.QUEUED assert store.get("t2").status is TaskStatus.QUEUED # type: ignore[union-attr] controller.stop_all() controller.wait_all(timeout=_WINDOW) assert store.get("t2").status is TaskStatus.STOPPED # type: ignore[union-attr]