Files

456 lines
17 KiB
Python
Raw Permalink Normal View History

"""存储接线测试:写穿、读穿、跨进程指令。
这里用**两个 ``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]