Files
chongming-utils-task/tests/integration/test_postgres_store.py
T

380 lines
14 KiB
Python
Raw Normal View History

"""Postgres 后端的契约与跨连接测试。
需要一台可连的 Postgres,DSN 从环境变量给出::
CHONGMING_TEST_POSTGRES='postgresql://user@127.0.0.1:5432/tasks' \\
uv run pytest tests/integration/test_postgres_store.py
未设置时整组跳过——它依赖外部服务,与内置实现的契约测试(纯本地)刻意分开。
这里覆盖三类只有真数据库才能证明的事:
1. **完整契约**——与内存 / SQLite 跑的是同一套 ``StoreContract``;
2. **跨连接原子性**——设计说明 §15.4 明确要求后端自建并发用例:
N 个连接并发 ``save`` 恰好一个成功、并发 ``claim_commands`` 不重复下发、
持有者被强杀后租约仍按时间过期且连接池自愈;
3. **接线**——``Controller`` 真的能把它当存储用,跨 store 的控制走指令通道。
"""
from __future__ import annotations
import os
import threading
import time
from datetime import UTC, datetime, timedelta
from typing import Any
from uuid import uuid4
import pytest
psycopg = pytest.importorskip("psycopg")
pytest.importorskip("psycopg_pool")
from psycopg import sql # noqa: E402
from psycopg.conninfo import make_conninfo # noqa: E402
from chongming_task.controller import Controller # noqa: E402
from chongming_task.errors import ConcurrencyConflict, StoreError # noqa: E402
from chongming_task.states import TaskStatus # noqa: E402
from chongming_task.stores.postgres import PostgresTaskStore # noqa: E402
from chongming_task.stores.protocol import TaskStore # noqa: E402
from chongming_task.testing.store_contract import ( # noqa: E402
StoreContract,
command,
simple_snapshot,
)
_DSN = os.environ.get("CHONGMING_TEST_POSTGRES")
_T0 = datetime(2026, 1, 1, tzinfo=UTC)
pytestmark = pytest.mark.skipif(
not _DSN,
reason="未设置 CHONGMING_TEST_POSTGRES(DSN):Postgres 集成测试需要一台可连的数据库",
)
# ---------------------------------------------------------------------------
# 隔离:每个存储实例独占一个 schema(与 SQLite 的临时文件等价)
# ---------------------------------------------------------------------------
def _isolated_dsn() -> tuple[str, str]:
"""建一个全新 schema,返回 ``(schema 名, 指向它的 DSN)``。"""
assert _DSN is not None
schema = f"cstore_{uuid4().hex[:12]}"
with psycopg.connect(_DSN, autocommit=True) as conn:
conn.execute(sql.SQL("CREATE SCHEMA {}").format(sql.Identifier(schema)))
return schema, make_conninfo(_DSN, options=f"-csearch_path={schema}")
def _drop_schema(schema: str) -> None:
assert _DSN is not None
with psycopg.connect(_DSN, autocommit=True) as conn:
conn.execute(sql.SQL("DROP SCHEMA IF EXISTS {} CASCADE").format(sql.Identifier(schema)))
# ---------------------------------------------------------------------------
# 1. 完整契约
# ---------------------------------------------------------------------------
class TestPostgresStoreContract(StoreContract):
"""与内存 / SQLite 实现跑**完全相同**的用例。"""
_sandboxes: list[str]
def setup_method(self) -> None:
self._sandboxes = []
super().setup_method()
def make_store(self) -> TaskStore:
schema, dsn = _isolated_dsn()
self._sandboxes.append(schema)
return PostgresTaskStore(dsn)
def teardown_method(self) -> None:
super().teardown_method()
for schema in self._sandboxes:
_drop_schema(schema)
# ---------------------------------------------------------------------------
# 2. 跨连接原子性(StoreContract 覆盖不到,设计说明 §15.4 要求)
# ---------------------------------------------------------------------------
def test_concurrent_saves_have_exactly_one_winner() -> None:
"""N 个连接并发 save 同一任务:恰好一个成功,其余收到冲突。
乐观并发在**跨连接**下真正成立,而不是只在单连接语义里成立。
"""
schema, dsn = _isolated_dsn()
try:
seed = PostgresTaskStore(dsn)
seed.create(simple_snapshot("t1", version=1))
seed.close()
workers = 8
barrier = threading.Barrier(workers)
outcomes: list[str] = []
lock = threading.Lock()
def attempt() -> None:
store = PostgresTaskStore(dsn)
try:
barrier.wait(timeout=10)
try:
store.save(
simple_snapshot("t1", status=TaskStatus.SUCCEEDED, version=2),
expect_version=1,
)
except ConcurrencyConflict:
outcome = "conflict"
else:
outcome = "ok"
finally:
store.close()
with lock:
outcomes.append(outcome)
threads = [threading.Thread(target=attempt) for _ in range(workers)]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=30)
assert sorted(outcomes) == ["conflict"] * (workers - 1) + ["ok"]
finally:
_drop_schema(schema)
def test_concurrent_claims_hand_out_each_command_exactly_once() -> None:
"""N 个连接并发认领:同一条指令绝不会被两个连接取到。
"挑选 + 打租约"是一条 ``UPDATE ... FOR UPDATE SKIP LOCKED``,所以并发认领互不
阻塞、也不会重复。这是 SQLite 用 ``BEGIN IMMEDIATE`` 换来的同一个保证。
"""
schema, dsn = _isolated_dsn()
try:
total = 20
seed = PostgresTaskStore(dsn)
for index in range(total):
seed.submit_command(command(f"c{index}", created_offset=float(index)))
seed.close()
claimed: list[str] = []
lock = threading.Lock()
def drain() -> None:
store = PostgresTaskStore(dsn)
try:
while True:
batch = store.claim_commands(now=_T0, lease_seconds=300, limit=3)
if not batch:
return
with lock:
claimed.extend(item.command_id for item in batch)
finally:
store.close()
threads = [threading.Thread(target=drain) for _ in range(8)]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=30)
assert sorted(claimed) == sorted(f"c{index}" for index in range(total))
assert len(claimed) == len(set(claimed)), "同一条指令被下发了两次"
finally:
_drop_schema(schema)
def test_a_killed_holder_does_not_wedge_the_lease() -> None:
"""持有者被强杀:租约仍按时间过期,别的连接可以接管;连接池自愈。
租约是**存储里的数据**,不是连接上的会话状态——所以杀掉持有者的后端连接既不会
提前释放它,也不会永久锁住它。
"""
schema, dsn = _isolated_dsn()
try:
store_a = PostgresTaskStore(dsn, min_size=1, max_size=1)
store_a.create(simple_snapshot("t1"))
store_a.submit_command(command("c1"))
claimed = store_a.claim_commands(now=_T0, lease_seconds=30)
assert [item.command_id for item in claimed] == ["c1"]
# 模拟持有者进程消失:杀掉它的后端连接(本测试此刻只有 store_a 在用)。
killed: list[bool] = []
try:
with psycopg.connect(_DSN, autocommit=True) as admin:
rows = admin.execute(
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
"WHERE application_name = 'chongming-task' AND pid <> pg_backend_pid()"
).fetchall()
killed = [bool(row[0]) for row in rows]
except psycopg.errors.InsufficientPrivilege: # pragma: no cover - 权限受限的环境
pytest.skip("当前角色无权 pg_terminate_backend,跳过")
assert any(killed), "没有杀掉任何连接,测试前提不成立"
store_b = PostgresTaskStore(dsn)
# 租约未到期:接管者取不到。
assert store_b.claim_commands(now=_T0 + timedelta(seconds=10), lease_seconds=30) == []
# 租约到期:可以接管。
reclaimed = store_b.claim_commands(now=_T0 + timedelta(seconds=31), lease_seconds=30)
assert [item.command_id for item in reclaimed] == ["c1"]
# 被杀掉的持有者:连接池把死连接换掉,操作照常。
assert store_a.get("t1") is not None
store_a.close()
store_b.close()
finally:
_drop_schema(schema)
# ---------------------------------------------------------------------------
# 3. close() 的语义(模块文档承诺过,就要有用例守着)
# ---------------------------------------------------------------------------
def test_close_is_idempotent_and_operations_fail_afterwards() -> None:
schema, dsn = _isolated_dsn()
try:
store = PostgresTaskStore(dsn)
store.close()
store.close() # 幂等:重复关闭是空操作
with pytest.raises(StoreError, match="已关闭"):
store.get("t1")
finally:
_drop_schema(schema)
def test_a_role_without_ddl_privileges_can_use_an_existing_schema() -> None:
"""建表是**条件性**的:表已存在时一个 DDL 都不执行。
因此运维控制台可以用只读账号接进来(只给 SELECT 就够看板了)。若实现无条件跑
``CREATE TABLE IF NOT EXISTS``,这个账号会因为缺 CREATE 权限直接连不上。
"""
schema, dsn = _isolated_dsn()
role = f"cstore_ro_{uuid4().hex[:8]}"
password = uuid4().hex
try:
seed = PostgresTaskStore(dsn)
seed.create(simple_snapshot("t1", name="只读也能看到"))
seed.close()
try:
with psycopg.connect(_DSN, autocommit=True) as admin:
admin.execute(
sql.SQL("CREATE ROLE {} LOGIN PASSWORD {}").format(
sql.Identifier(role), sql.Literal(password)
)
)
except psycopg.errors.InsufficientPrivilege: # pragma: no cover - 权限受限的环境
pytest.skip("当前角色无权创建角色,跳过")
with psycopg.connect(_DSN, autocommit=True) as admin:
admin.execute(
sql.SQL("GRANT USAGE ON SCHEMA {} TO {}").format(
sql.Identifier(schema), sql.Identifier(role)
)
)
admin.execute(
sql.SQL(
"GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA {} TO {}"
).format(sql.Identifier(schema), sql.Identifier(role))
)
info = psycopg.conninfo.conninfo_to_dict(dsn) if dsn else {}
info.update({"user": role, "password": password})
readonly_dsn = psycopg.conninfo.make_conninfo(**info)
store = PostgresTaskStore(readonly_dsn) # 没有 CREATE 权限,也必须能起来
try:
snapshot = store.get("t1")
assert snapshot is not None and snapshot.name == "只读也能看到"
assert len(store.query()) == 1
finally:
store.close()
finally:
with psycopg.connect(_DSN, autocommit=True) as admin:
admin.execute(sql.SQL("DROP OWNED BY {}").format(sql.Identifier(role)))
admin.execute(sql.SQL("DROP ROLE IF EXISTS {}").format(sql.Identifier(role)))
_drop_schema(schema)
# ---------------------------------------------------------------------------
# 4. 接线:Controller 真的能把它当存储用
# ---------------------------------------------------------------------------
def _add(ctx: Any, left: int, right: int) -> int:
del ctx
return left + right
def _block(ctx: Any) -> str:
for _ in range(1_000_000):
ctx.checkpoint()
time.sleep(0.005)
return "done"
def _wait_until(predicate: Any, timeout: float = 10.0) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return True
time.sleep(0.01)
return False
def test_state_transitions_persist_and_are_auditable() -> None:
schema, dsn = _isolated_dsn()
try:
store = PostgresTaskStore(dsn)
controller = Controller(store=store)
controller.register("add", _add)
controller.submit("add", 2, 3, task_id="t1")
controller.wait_all(timeout=10)
snapshot = store.get("t1")
assert snapshot is not None
assert snapshot.status is TaskStatus.SUCCEEDED
assert snapshot.outcome == {"value": 5}
assert [item.to_status for item in store.history("t1")] == [
TaskStatus.QUEUED,
TaskStatus.STARTING,
TaskStatus.RUNNING,
TaskStatus.SUCCEEDED,
]
store.close()
finally:
_drop_schema(schema)
def test_stop_crosses_stores_through_postgres() -> None:
"""两个 store = 两个"进程":worker 在跑,运维从另一套连接停掉它。"""
schema, dsn = _isolated_dsn()
try:
owner_store = PostgresTaskStore(dsn)
owner = Controller(store=owner_store, command_poll_interval=0.05, owner="worker-pg")
owner.register("block", _block)
run = owner.submit("block", task_id="t1")
assert _wait_until(lambda: run.status is TaskStatus.RUNNING)
client_store = PostgresTaskStore(dsn)
client = Controller(store=client_store, owner="ops-pg")
assert client.runs() == (), "客户端并不持有这个任务"
assert client.stop("t1", reason="跨 store 停止") is True
assert _wait_until(lambda: run.status is TaskStatus.STOPPED), "持有者没有取走指令"
final = client.get("t1")
assert final is not None and final.status is TaskStatus.STOPPED
owner.close(timeout=10)
client.close(timeout=5)
finally:
_drop_schema(schema)