383 lines
16 KiB
Python
383 lines
16 KiB
Python
|
|
# ============================================================
|
|||
|
|
# round-39 方向 P:Saga/补偿编排框架黄金测试
|
|||
|
|
# 固化:状态机 / 幂等 / 重试 / 超时 / 补偿链 / 人工接管 / 持久化恢复
|
|||
|
|
# 矩阵 78:任一步失败可重试或补偿;中间态可见;人工接管点和审计记录明确
|
|||
|
|
# 矩阵 119:每个 P3 动作定义幂等键、超时、重试、补偿、人工接管和审计事件
|
|||
|
|
# ============================================================
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import copy
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
from fastapi.testclient import TestClient
|
|||
|
|
|
|||
|
|
from server.aps_domain import saga
|
|||
|
|
from server.state.checkpoints import CheckpointStore
|
|||
|
|
from server.state.seed import seed_world
|
|||
|
|
|
|||
|
|
|
|||
|
|
class _MemStore:
|
|||
|
|
def __init__(self, data=None, checkpoint_path: Path | None = None):
|
|||
|
|
self.data = data if data is not None else seed_world()
|
|||
|
|
self.checkpoints = CheckpointStore(str(checkpoint_path or Path(__file__).parent / "_saga_cp.json"))
|
|||
|
|
|
|||
|
|
def next_id(self, kind: str) -> int:
|
|||
|
|
key = f"_c_{kind}"
|
|||
|
|
self.data[key] = self.data.get(key, 1000) + 1
|
|||
|
|
return self.data[key]
|
|||
|
|
|
|||
|
|
def save(self) -> None:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
|
|||
|
|
def make_action(name: str, log: list[str], *, fails: int = 0, timeout: bool = False):
|
|||
|
|
state = {"calls": 0}
|
|||
|
|
|
|||
|
|
def fn(ctx) -> dict:
|
|||
|
|
state["calls"] += 1
|
|||
|
|
log.append(name)
|
|||
|
|
if timeout:
|
|||
|
|
raise saga.SagaStepTimeout("simulated timeout")
|
|||
|
|
if state["calls"] <= fails:
|
|||
|
|
raise saga.SagaStepFailed("simulated failure")
|
|||
|
|
return {"done": name, "calls": state["calls"]}
|
|||
|
|
|
|||
|
|
return fn
|
|||
|
|
|
|||
|
|
|
|||
|
|
def make_registry(log: list[str], *, s2_fails: int = 0, s3_fails: int = 0,
|
|||
|
|
timeout: bool = False, comp_fails: bool = False) -> dict:
|
|||
|
|
reg = {
|
|||
|
|
"s1": {"fn": make_action("s1", log), "compensation": "c1"},
|
|||
|
|
"c1": {"fn": make_action("c1", log)},
|
|||
|
|
"s2": {"fn": make_action("s2", log, fails=s2_fails), "compensation": "c2"},
|
|||
|
|
"c2": {"fn": make_action("c2", log)},
|
|||
|
|
"s3": {"fn": make_action("s3", log, fails=s3_fails, timeout=timeout), "compensation": "c3"},
|
|||
|
|
"c3": {"fn": make_action("c3", log)},
|
|||
|
|
}
|
|||
|
|
if comp_fails:
|
|||
|
|
def broken_c1(_ctx):
|
|||
|
|
log.append("c1")
|
|||
|
|
raise saga.SagaCompensationError("simulated compensation failure")
|
|||
|
|
reg["c1"]["fn"] = broken_c1
|
|||
|
|
return reg
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _demo_steps() -> list[saga.SagaStep]:
|
|||
|
|
return [
|
|||
|
|
saga.SagaStep("a", "s1", idem_key="k1", compensation_action="c1"),
|
|||
|
|
saga.SagaStep("b", "s2", idem_key="k2", max_retries=2, compensation_action="c2"),
|
|||
|
|
saga.SagaStep("c", "s3", idem_key="k3", compensation_action="c3"),
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------
|
|||
|
|
# ① 创建:记录结构 + 随世界状态持久化
|
|||
|
|
# ------------------------------------------------------------
|
|||
|
|
def test_saga_record_created_with_steps_and_persisted():
|
|||
|
|
store = _MemStore()
|
|||
|
|
coord = saga.SagaCoordinator(store, actor="test")
|
|||
|
|
rec = coord.create_saga("demo", _demo_steps(), context={"x": 1})
|
|||
|
|
assert rec["status"] == "PENDING"
|
|||
|
|
assert rec["steps"][0]["status"] == "PENDING"
|
|||
|
|
assert rec["steps"][0]["idemKey"] == "k1"
|
|||
|
|
assert rec["steps"][0]["timeoutSec"] == 30.0
|
|||
|
|
assert rec["steps"][0]["maxRetries"] == 2
|
|||
|
|
assert rec["steps"][0]["compensationAction"] == "c1"
|
|||
|
|
assert rec["auditRefs"] == [f"saga:{rec['id']}"]
|
|||
|
|
# 随世界状态持久化
|
|||
|
|
assert store.data["sagas"][0]["id"] == rec["id"]
|
|||
|
|
assert any(e["action"] == "saga.created" for e in store.data["auditEvents"])
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_saga_dedupe_key_reuses_existing_record():
|
|||
|
|
store = _MemStore()
|
|||
|
|
coord = saga.SagaCoordinator(store, actor="test")
|
|||
|
|
r1 = coord.create_saga("demo", _demo_steps(), dedupe_key="EV-1")
|
|||
|
|
r2 = coord.create_saga("demo", _demo_steps(), dedupe_key="EV-1")
|
|||
|
|
assert r2["id"] == r1["id"]
|
|||
|
|
assert len(store.data["sagas"]) == 1
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------
|
|||
|
|
# ② 全链成功 + 审计(含 idemKey/status/compensation)
|
|||
|
|
# ------------------------------------------------------------
|
|||
|
|
def test_saga_runs_all_steps_to_succeeded_with_audit():
|
|||
|
|
store = _MemStore()
|
|||
|
|
log: list[str] = []
|
|||
|
|
coord = saga.SagaCoordinator(store, registry=make_registry(log), actor="test")
|
|||
|
|
rec = coord.create_saga("demo", _demo_steps())
|
|||
|
|
out = coord.run(rec["id"])
|
|||
|
|
assert out["status"] == "SUCCEEDED"
|
|||
|
|
assert [s["status"] for s in out["steps"]] == ["SUCCEEDED"] * 3
|
|||
|
|
assert log == ["s1", "s2", "s3"]
|
|||
|
|
# 幂等键登记
|
|||
|
|
assert set(store.data["sagaIdem"]) == {"k1", "k2", "k3"}
|
|||
|
|
actions = [e["action"] for e in store.data["auditEvents"]]
|
|||
|
|
assert actions.count("saga.step.succeeded") == 3
|
|||
|
|
assert "saga.succeeded" in actions
|
|||
|
|
# 每次 step 迁移审计含 idemKey/status/compensation
|
|||
|
|
step_audit = next(e for e in store.data["auditEvents"] if e["action"] == "saga.step.succeeded")
|
|||
|
|
assert step_audit["rationale"]["idemKey"]
|
|||
|
|
assert step_audit["rationale"]["status"] == "SUCCEEDED"
|
|||
|
|
assert "compensation" in step_audit["rationale"]
|
|||
|
|
assert f"saga.step:{rec['id']}:a" in step_audit["evidenceRefs"]
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------
|
|||
|
|
# ③ 重试:瞬时失败重试成功后 saga 完成
|
|||
|
|
# ------------------------------------------------------------
|
|||
|
|
def test_saga_step_retry_after_transient_failure():
|
|||
|
|
store = _MemStore()
|
|||
|
|
log: list[str] = []
|
|||
|
|
coord = saga.SagaCoordinator(store, registry=make_registry(log, s2_fails=2), actor="test")
|
|||
|
|
rec = coord.create_saga("demo", _demo_steps())
|
|||
|
|
out = coord.run(rec["id"])
|
|||
|
|
assert out["status"] == "SUCCEEDED"
|
|||
|
|
b = next(s for s in out["steps"] if s["name"] == "b")
|
|||
|
|
assert b["attempts"] == 3 # 1 次初始 + 2 次重试
|
|||
|
|
assert b["error"] is None
|
|||
|
|
assert "saga.step.retry" in [e["action"] for e in store.data["auditEvents"]]
|
|||
|
|
assert "saga.step.timeout" not in [e["action"] for e in store.data["auditEvents"]]
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------
|
|||
|
|
# ④ 失败超限 → 自动补偿链(逆序)
|
|||
|
|
# ------------------------------------------------------------
|
|||
|
|
def test_saga_exhausts_retries_then_compensates_in_reverse():
|
|||
|
|
store = _MemStore()
|
|||
|
|
log: list[str] = []
|
|||
|
|
coord = saga.SagaCoordinator(store, registry=make_registry(log, s3_fails=99), actor="test")
|
|||
|
|
rec = coord.create_saga("demo", _demo_steps())
|
|||
|
|
out = coord.run(rec["id"])
|
|||
|
|
assert out["status"] == "COMPENSATED"
|
|||
|
|
assert out["compensationReason"].startswith("step:c")
|
|||
|
|
c = next(s for s in out["steps"] if s["name"] == "c")
|
|||
|
|
assert c["status"] == "FAILED"
|
|||
|
|
assert c["compensation"]["status"] == "SUCCEEDED"
|
|||
|
|
# 逆序补偿:c3(失败步自身的部分效果)→ c2 → c1(s3 重试预算=2 → 3 次尝试)
|
|||
|
|
assert log == ["s1", "s2", "s3", "s3", "s3", "c3", "c2", "c1"]
|
|||
|
|
actions = [e["action"] for e in store.data["auditEvents"]]
|
|||
|
|
assert "saga.compensating" in actions and "saga.compensated" in actions
|
|||
|
|
assert "saga.step.compensated" in actions
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------
|
|||
|
|
# ⑤ 超时:动作主动超时 / 真实时钟超时 → 重试 → 补偿
|
|||
|
|
# ------------------------------------------------------------
|
|||
|
|
def test_saga_timeout_triggers_retry_then_compensation():
|
|||
|
|
store = _MemStore()
|
|||
|
|
log: list[str] = []
|
|||
|
|
coord = saga.SagaCoordinator(store, registry=make_registry(log, timeout=True), actor="test")
|
|||
|
|
rec = coord.create_saga("demo", _demo_steps())
|
|||
|
|
out = coord.run(rec["id"])
|
|||
|
|
assert out["status"] == "COMPENSATED"
|
|||
|
|
c = next(s for s in out["steps"] if s["name"] == "c")
|
|||
|
|
assert c["timeouts"] == 3 # 每次尝试都超时
|
|||
|
|
assert c["error"].startswith("timeout:")
|
|||
|
|
assert "saga.step.timeout" in [e["action"] for e in store.data["auditEvents"]]
|
|||
|
|
|
|||
|
|
|
|||
|
|
class _FakeClock:
|
|||
|
|
def __init__(self) -> None:
|
|||
|
|
self.t = 0.0
|
|||
|
|
|
|||
|
|
def __call__(self) -> float:
|
|||
|
|
return self.t
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_saga_clock_timeout_detection():
|
|||
|
|
store = _MemStore()
|
|||
|
|
log: list[str] = []
|
|||
|
|
clock = _FakeClock()
|
|||
|
|
reg = {
|
|||
|
|
"s1": {"fn": make_action("s1", log), "compensation": None},
|
|||
|
|
"slow": {"fn": _slow_action(log, clock), "compensation": "slow-comp"},
|
|||
|
|
"slow-comp": {"fn": make_action("slow-comp", log)},
|
|||
|
|
}
|
|||
|
|
coord = saga.SagaCoordinator(store, registry=reg, clock=clock, actor="test")
|
|||
|
|
rec = coord.create_saga("demo", [
|
|||
|
|
saga.SagaStep("a", "s1", idem_key="k1"),
|
|||
|
|
saga.SagaStep("b", "slow", idem_key="k2", timeout_sec=1.0, max_retries=1,
|
|||
|
|
compensation_action="slow-comp"),
|
|||
|
|
])
|
|||
|
|
out = coord.run(rec["id"])
|
|||
|
|
assert out["status"] == "COMPENSATED"
|
|||
|
|
b = next(s for s in out["steps"] if s["name"] == "b")
|
|||
|
|
assert b["timeouts"] == 2
|
|||
|
|
assert b["error"].startswith("timeout:")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _slow_action(log: list[str], clock: _FakeClock):
|
|||
|
|
def fn(_ctx) -> dict:
|
|||
|
|
log.append("slow")
|
|||
|
|
clock.t += 100.0 # 模拟耗时超过 timeout_sec
|
|||
|
|
return {"done": "slow"}
|
|||
|
|
return fn
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------
|
|||
|
|
# ⑥ 补偿失败 → MANUAL_TAKEOVER(人工接管点)
|
|||
|
|
# ------------------------------------------------------------
|
|||
|
|
def test_saga_compensation_failure_leads_to_manual_takeover():
|
|||
|
|
store = _MemStore()
|
|||
|
|
log: list[str] = []
|
|||
|
|
coord = saga.SagaCoordinator(store, registry=make_registry(log, s2_fails=99, comp_fails=True),
|
|||
|
|
actor="test")
|
|||
|
|
rec = coord.create_saga("demo", [
|
|||
|
|
saga.SagaStep("a", "s1", idem_key="k1", compensation_action="c1"),
|
|||
|
|
saga.SagaStep("b", "s2", idem_key="k2", max_retries=1), # 无补偿定义
|
|||
|
|
])
|
|||
|
|
out = coord.run(rec["id"])
|
|||
|
|
assert out["status"] == "MANUAL_TAKEOVER"
|
|||
|
|
assert out["manualTakeover"] is True
|
|||
|
|
assert "compensation-failed" in out["takeoverReason"]
|
|||
|
|
a = next(s for s in out["steps"] if s["name"] == "a")
|
|||
|
|
assert a["compensation"]["status"] == "FAILED"
|
|||
|
|
actions = [e["action"] for e in store.data["auditEvents"]]
|
|||
|
|
assert "saga.manual_takeover" in actions
|
|||
|
|
assert "saga.step.compensation_failed" in actions
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_saga_takeover_blocks_auto_run_then_retry_resumes():
|
|||
|
|
store = _MemStore()
|
|||
|
|
log: list[str] = []
|
|||
|
|
coord = saga.SagaCoordinator(store, registry=make_registry(log), actor="test")
|
|||
|
|
rec = coord.create_saga("demo", _demo_steps())
|
|||
|
|
out = coord.takeover(rec["id"], reason="operator pause")
|
|||
|
|
assert out["status"] == "MANUAL_TAKEOVER"
|
|||
|
|
assert out["manualTakeover"] is True
|
|||
|
|
assert out["takeoverReason"] == "operator pause"
|
|||
|
|
# 接管后自动 run 被阻断
|
|||
|
|
out2 = coord.run(rec["id"])
|
|||
|
|
assert out2["status"] == "MANUAL_TAKEOVER"
|
|||
|
|
assert log == [] # 没有任何步骤执行
|
|||
|
|
# 人工接管后显式 retry → 恢复自动化
|
|||
|
|
out3 = coord.retry(rec["id"])
|
|||
|
|
assert out3["status"] == "SUCCEEDED"
|
|||
|
|
assert out3["manualTakeover"] is False
|
|||
|
|
assert log == ["s1", "s2", "s3"]
|
|||
|
|
assert "saga.retry" in [e["action"] for e in store.data["auditEvents"]]
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------
|
|||
|
|
# ⑦ 恢复队列:中断的 saga 可 resume/replay
|
|||
|
|
# ------------------------------------------------------------
|
|||
|
|
def test_saga_persistence_resume_after_restart():
|
|||
|
|
store = _MemStore()
|
|||
|
|
log: list[str] = []
|
|||
|
|
coord = saga.SagaCoordinator(store, registry=make_registry(log), actor="test")
|
|||
|
|
rec = coord.create_saga("demo", _demo_steps())
|
|||
|
|
# 模拟进程中断:步骤 b 执行中崩溃(status=RUNNING,无 finishedAt)
|
|||
|
|
rec["status"] = "RUNNING"
|
|||
|
|
rec["steps"][0]["status"] = "SUCCEEDED"
|
|||
|
|
rec["steps"][0]["result"] = {"done": "s1"}
|
|||
|
|
rec["steps"][1]["status"] = "RUNNING"
|
|||
|
|
rec["steps"][1]["startedAt"] = "2026-08-02 10:00:00"
|
|||
|
|
store.save()
|
|||
|
|
# 重启:世界数据(含 sagas/sagaIdem)整体重建
|
|||
|
|
store2 = _MemStore(copy.deepcopy(store.data))
|
|||
|
|
log2: list[str] = []
|
|||
|
|
coord2 = saga.SagaCoordinator(store2, registry=make_registry(log2), actor="test")
|
|||
|
|
out = coord2.resume_all()
|
|||
|
|
assert len(out) == 1
|
|||
|
|
assert out[0]["status"] == "SUCCEEDED"
|
|||
|
|
assert log2 == ["s2", "s3"] # 已成功步骤不重放
|
|||
|
|
assert all(s["status"] == "SUCCEEDED" for s in out[0]["steps"])
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------
|
|||
|
|
# ⑧ 幂等重放:同 idem_key 已成功 → 复用记录,不重复执行外部效果
|
|||
|
|
# ------------------------------------------------------------
|
|||
|
|
def test_saga_idempotent_replay_reuses_recorded_result():
|
|||
|
|
store = _MemStore()
|
|||
|
|
store.data.setdefault("sagaIdem", {})["k1"] = {
|
|||
|
|
"sagaId": "saga-other", "step": "a", "result": {"replayed": True}, "at": "2026-08-02 10:00:00"}
|
|||
|
|
log: list[str] = []
|
|||
|
|
coord = saga.SagaCoordinator(store, registry=make_registry(log), actor="test")
|
|||
|
|
rec = coord.create_saga("demo", _demo_steps())
|
|||
|
|
out = coord.run(rec["id"])
|
|||
|
|
assert out["status"] == "SUCCEEDED"
|
|||
|
|
a = next(s for s in out["steps"] if s["name"] == "a")
|
|||
|
|
assert a["result"]["replayed"] is True
|
|||
|
|
assert log == ["s2", "s3"] # s1 未重新执行
|
|||
|
|
assert "saga.step.idempotent_replayed" in [e["action"] for e in store.data["auditEvents"]]
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------
|
|||
|
|
# ⑨ 中间态可见:gate 步骤停在 WAITING_HUMAN,恢复后继续
|
|||
|
|
# ------------------------------------------------------------
|
|||
|
|
def test_saga_gate_pauses_waits_human_and_resumes():
|
|||
|
|
store = _MemStore()
|
|||
|
|
log: list[str] = []
|
|||
|
|
coord = saga.SagaCoordinator(store, registry=make_registry(log), actor="test")
|
|||
|
|
rec = coord.create_saga("demo", [
|
|||
|
|
saga.SagaStep("a", "s1", idem_key="k1", gate=True),
|
|||
|
|
saga.SagaStep("b", "s2", idem_key="k2"),
|
|||
|
|
])
|
|||
|
|
out = coord.run(rec["id"], auto_approve=False)
|
|||
|
|
assert out["status"] == "WAITING_HUMAN"
|
|||
|
|
assert out["steps"][0]["status"] == "WAITING_HUMAN"
|
|||
|
|
assert out["steps"][1]["status"] == "PENDING"
|
|||
|
|
assert "saga.step.waiting_human" in [e["action"] for e in store.data["auditEvents"]]
|
|||
|
|
# 中间态可见:详情查询可看到门禁点
|
|||
|
|
detail = coord.get(rec["id"])
|
|||
|
|
assert detail["status"] == "WAITING_HUMAN"
|
|||
|
|
# 人工放行后继续
|
|||
|
|
out2 = coord.run(rec["id"], auto_approve=True)
|
|||
|
|
assert out2["status"] == "SUCCEEDED"
|
|||
|
|
assert log == ["s1", "s2"]
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------
|
|||
|
|
# ⑩ 网关 API:GET /api/saga、详情、retry/compensate/takeover
|
|||
|
|
# ------------------------------------------------------------
|
|||
|
|
def test_saga_gateway_api_endpoints(monkeypatch):
|
|||
|
|
import server.aps_domain.saga as saga_module
|
|||
|
|
import server.gateway.app as gateway_module
|
|||
|
|
from tests.auth_provider import install_test_auth
|
|||
|
|
|
|||
|
|
store = _MemStore()
|
|||
|
|
log: list[str] = []
|
|||
|
|
coord = saga.SagaCoordinator(store, registry=make_registry(log), actor="web")
|
|||
|
|
monkeypatch.setattr(gateway_module, "get_store", lambda: store)
|
|||
|
|
monkeypatch.setattr(saga_module, "get_coordinator", lambda _store, **kw: coord)
|
|||
|
|
install_test_auth(monkeypatch, "tenant-saga-test")
|
|||
|
|
|
|||
|
|
rec = coord.create_saga("api-demo", _demo_steps())
|
|||
|
|
client = TestClient(gateway_module.create_app())
|
|||
|
|
login = client.post("/api/auth/login", json={"username": "planner"})
|
|||
|
|
assert login.status_code == 200
|
|||
|
|
|
|||
|
|
# 人工接管
|
|||
|
|
r = client.post(f"/api/saga/{rec['id']}/takeover", json={"reason": "ops"})
|
|||
|
|
assert r.status_code == 200
|
|||
|
|
assert r.json()["status"] == "MANUAL_TAKEOVER"
|
|||
|
|
assert r.json()["manualTakeover"] is True
|
|||
|
|
|
|||
|
|
# 重试恢复自动化
|
|||
|
|
r = client.post(f"/api/saga/{rec['id']}/retry", json={})
|
|||
|
|
assert r.status_code == 200
|
|||
|
|
assert r.json()["status"] == "SUCCEEDED"
|
|||
|
|
|
|||
|
|
# 列表(含中间态 steps 明细)
|
|||
|
|
r = client.get("/api/saga")
|
|||
|
|
assert r.status_code == 200
|
|||
|
|
assert r.json()["count"] >= 1
|
|||
|
|
assert any(s["id"] == rec["id"] for s in r.json()["sagas"])
|
|||
|
|
|
|||
|
|
# 详情
|
|||
|
|
r = client.get(f"/api/saga/{rec['id']}")
|
|||
|
|
assert r.status_code == 200
|
|||
|
|
assert r.json()["id"] == rec["id"]
|
|||
|
|
assert [s["name"] for s in r.json()["steps"]] == ["a", "b", "c"]
|
|||
|
|
|
|||
|
|
# 手动补偿
|
|||
|
|
r = client.post(f"/api/saga/{rec['id']}/compensate", json={"reason": "manual"})
|
|||
|
|
assert r.status_code == 200
|
|||
|
|
assert r.json()["status"] == "COMPENSATED"
|
|||
|
|
|
|||
|
|
# 不存在 → error 语义
|
|||
|
|
r = client.get("/api/saga/saga-nope")
|
|||
|
|
assert r.status_code == 200
|
|||
|
|
assert "error" in r.json()
|