945 lines
41 KiB
Python
945 lines
41 KiB
Python
|
|
# ============================================================
|
|||
|
|
# Saga/补偿编排框架(moduleId: domain-saga, 可重用 ✔)
|
|||
|
|
# round-39 方向 P:跨内部世界状态与外部系统的 Saga/补偿
|
|||
|
|
# ① 编排单元:step(name/action/幂等键 idem_key/超时 timeout_sec/重试 max_retries/补偿 compensation_action)
|
|||
|
|
# ② 恢复队列:saga 记录随世界状态持久化(宽容加载),resume/replay 未完成 saga
|
|||
|
|
# ③ 补偿注册表:动作→补偿动作映射;失败自动执行补偿链;补偿也失败 → MANUAL_TAKEOVER
|
|||
|
|
# ④ 超时/重试:每步超时上限 + 重试次数(指数退避简化),超限走补偿
|
|||
|
|
# ⑤ 审计:每次 step 状态迁移写 saga.step.*(含 idemKey/status/compensation)
|
|||
|
|
# ⑥ 第一个 Saga 实例:WMS 缺料→重排→MES 下发→回执 闭环(server/aps_domain/wms_events.py)
|
|||
|
|
# ============================================================
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import copy
|
|||
|
|
import time
|
|||
|
|
import uuid
|
|||
|
|
from dataclasses import dataclass
|
|||
|
|
from datetime import datetime
|
|||
|
|
from typing import Any, Callable
|
|||
|
|
|
|||
|
|
from server.timeutil import fmt_dt
|
|||
|
|
|
|||
|
|
World = dict[str, Any]
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------
|
|||
|
|
# 状态机
|
|||
|
|
# ------------------------------------------------------------
|
|||
|
|
SAGA_STATUSES = ("PENDING", "RUNNING", "WAITING_HUMAN", "SUCCEEDED", "FAILED",
|
|||
|
|
"COMPENSATING", "COMPENSATED", "MANUAL_TAKEOVER")
|
|||
|
|
STEP_STATUSES = ("PENDING", "RUNNING", "WAITING_HUMAN", "SUCCEEDED", "FAILED",
|
|||
|
|
"COMPENSATED", "SKIPPED")
|
|||
|
|
|
|||
|
|
_TERMINAL = ("SUCCEEDED", "COMPENSATED", "MANUAL_TAKEOVER")
|
|||
|
|
_RECOVERABLE = ("PENDING", "RUNNING", "WAITING_HUMAN", "FAILED")
|
|||
|
|
|
|||
|
|
|
|||
|
|
class SagaError(Exception):
|
|||
|
|
"""Saga 编排基础异常。"""
|
|||
|
|
|
|||
|
|
|
|||
|
|
class SagaStepTimeout(SagaError):
|
|||
|
|
"""步骤超时(超过 timeout_sec 或动作主动抛出)。"""
|
|||
|
|
|
|||
|
|
|
|||
|
|
class SagaStepFailed(SagaError):
|
|||
|
|
"""步骤业务失败(动作可主动抛出,等价于抛普通异常)。"""
|
|||
|
|
|
|||
|
|
|
|||
|
|
class SagaCompensationError(SagaError):
|
|||
|
|
"""补偿动作失败(触发 MANUAL_TAKEOVER)。"""
|
|||
|
|
|
|||
|
|
|
|||
|
|
class SagaNotFound(KeyError):
|
|||
|
|
"""saga 记录不存在。"""
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _now() -> str:
|
|||
|
|
return fmt_dt(datetime.now())
|
|||
|
|
|
|||
|
|
def _jsonable(value: Any) -> Any:
|
|||
|
|
"""把步骤结果递归转成可 JSON 持久化形态(pydantic BaseModel → dict,其余兜底 str)。"""
|
|||
|
|
if hasattr(value, "model_dump"):
|
|||
|
|
return _jsonable(value.model_dump())
|
|||
|
|
if isinstance(value, dict):
|
|||
|
|
return {str(k): _jsonable(v) for k, v in value.items()}
|
|||
|
|
if isinstance(value, (list, tuple)):
|
|||
|
|
return [_jsonable(v) for v in value]
|
|||
|
|
if value is None or isinstance(value, (str, int, float, bool)):
|
|||
|
|
return value
|
|||
|
|
return str(value)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------
|
|||
|
|
# ① 编排单元
|
|||
|
|
# ------------------------------------------------------------
|
|||
|
|
@dataclass
|
|||
|
|
class SagaStep:
|
|||
|
|
"""单个 saga 步骤:动作 + 幂等键 + 超时 + 重试 + 补偿动作。"""
|
|||
|
|
|
|||
|
|
name: str
|
|||
|
|
action: str
|
|||
|
|
idem_key: str = ""
|
|||
|
|
timeout_sec: float = 30.0
|
|||
|
|
max_retries: int = 2
|
|||
|
|
compensation_action: str | None = None
|
|||
|
|
gate: bool = False
|
|||
|
|
|
|||
|
|
@classmethod
|
|||
|
|
def from_mapping(cls, raw: dict[str, Any]) -> SagaStep:
|
|||
|
|
d = dict(raw or {})
|
|||
|
|
keys = {"name", "action", "idem_key", "timeout_sec", "max_retries",
|
|||
|
|
"compensation_action", "gate", "idemKey", "timeoutSec",
|
|||
|
|
"maxRetries", "compensationAction"}
|
|||
|
|
clean: dict[str, Any] = {}
|
|||
|
|
for k, v in d.items():
|
|||
|
|
if k not in keys:
|
|||
|
|
continue
|
|||
|
|
clean[k] = v
|
|||
|
|
if "idemKey" in clean and "idem_key" not in clean:
|
|||
|
|
clean["idem_key"] = clean.pop("idemKey")
|
|||
|
|
if "timeoutSec" in clean and "timeout_sec" not in clean:
|
|||
|
|
clean["timeout_sec"] = clean.pop("timeoutSec")
|
|||
|
|
if "maxRetries" in clean and "max_retries" not in clean:
|
|||
|
|
clean["max_retries"] = clean.pop("maxRetries")
|
|||
|
|
if "compensationAction" in clean and "compensation_action" not in clean:
|
|||
|
|
clean["compensation_action"] = clean.pop("compensationAction")
|
|||
|
|
return cls(**clean)
|
|||
|
|
|
|||
|
|
def to_dict(self) -> dict[str, Any]:
|
|||
|
|
return {
|
|||
|
|
"name": self.name,
|
|||
|
|
"action": self.action,
|
|||
|
|
"idemKey": self.idem_key,
|
|||
|
|
"timeoutSec": float(self.timeout_sec),
|
|||
|
|
"maxRetries": int(self.max_retries),
|
|||
|
|
"compensationAction": self.compensation_action,
|
|||
|
|
"gate": bool(self.gate),
|
|||
|
|
"status": "PENDING",
|
|||
|
|
"attempts": 0,
|
|||
|
|
"timeouts": 0,
|
|||
|
|
"startedAt": None,
|
|||
|
|
"finishedAt": None,
|
|||
|
|
"result": None,
|
|||
|
|
"error": None,
|
|||
|
|
"compensation": None,
|
|||
|
|
}
|
|||
|
|
# ------------------------------------------------------------
|
|||
|
|
# Saga 编排器
|
|||
|
|
# ------------------------------------------------------------
|
|||
|
|
class SagaCoordinator:
|
|||
|
|
"""Saga 编排器:步骤执行 / 重试 / 超时 / 补偿链 / 人工接管 / 持久化恢复。
|
|||
|
|
|
|||
|
|
- saga 记录持久化在 store.data["sagas"](随世界状态落盘,宽容加载);
|
|||
|
|
- 幂等键登记在 store.data["sagaIdem"](跨 saga 去重 / 恢复时安全重放);
|
|||
|
|
- 补偿注册表 registry: {动作名: {"fn": callable(ctx)->dict, "compensation": 补偿动作名}};
|
|||
|
|
- 补偿动作也是注册表条目:{补偿动作名: {"fn": callable(ctx)->dict}}。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self, store, *, actor: str = "saga",
|
|||
|
|
registry: dict[str, dict[str, Any]] | None = None,
|
|||
|
|
clock: Callable[[], float] | None = None,
|
|||
|
|
sleep_fn: Callable[[float], None] | None = None,
|
|||
|
|
backoff_base: float = 0.0, backoff_cap: float = 60.0,
|
|||
|
|
second_approver: Any = None):
|
|||
|
|
self.store = store
|
|||
|
|
self.actor = actor
|
|||
|
|
self.registry = registry if registry is not None else default_registry()
|
|||
|
|
self.clock = clock or time.monotonic
|
|||
|
|
self.sleep_fn = sleep_fn or time.sleep
|
|||
|
|
self.backoff_base = backoff_base
|
|||
|
|
self.backoff_cap = backoff_cap
|
|||
|
|
self.second_approver = second_approver
|
|||
|
|
self._ensure_tables()
|
|||
|
|
|
|||
|
|
# ---------------- 持久化(随世界状态,宽容加载) ----------------
|
|||
|
|
def _ensure_tables(self) -> None:
|
|||
|
|
world = self.store.data
|
|||
|
|
sagas = world.setdefault("sagas", [])
|
|||
|
|
world["sagas"] = [s for s in sagas if isinstance(s, dict)]
|
|||
|
|
idem = world.setdefault("sagaIdem", {})
|
|||
|
|
if not isinstance(idem, dict):
|
|||
|
|
world["sagaIdem"] = {}
|
|||
|
|
|
|||
|
|
def _save(self) -> None:
|
|||
|
|
self.store.save()
|
|||
|
|
|
|||
|
|
def _find(self, saga_id: str) -> dict[str, Any]:
|
|||
|
|
for s in self.store.data.get("sagas", []):
|
|||
|
|
if s.get("id") == saga_id:
|
|||
|
|
return s
|
|||
|
|
raise SagaNotFound(saga_id)
|
|||
|
|
|
|||
|
|
# ---------------- 创建 ----------------
|
|||
|
|
def create_saga(self, name: str, steps: list[SagaStep | dict[str, Any]],
|
|||
|
|
context: dict[str, Any] | None = None, *,
|
|||
|
|
dedupe_key: str | None = None) -> dict[str, Any]:
|
|||
|
|
"""创建并持久化一条 saga;dedupe_key 相同(如 WMS 事件 eventId)时复用已有记录(幂等)。"""
|
|||
|
|
if dedupe_key:
|
|||
|
|
existing = next((s for s in self.store.data.get("sagas", [])
|
|||
|
|
if s.get("name") == name and s.get("dedupeKey") == dedupe_key), None)
|
|||
|
|
if existing is not None:
|
|||
|
|
return existing
|
|||
|
|
saga_id = f"saga-{uuid.uuid4().hex[:10]}"
|
|||
|
|
step_records: list[dict[str, Any]] = []
|
|||
|
|
for raw in steps:
|
|||
|
|
step_records.append(raw.to_dict() if isinstance(raw, SagaStep)
|
|||
|
|
else SagaStep.from_mapping(dict(raw)).to_dict())
|
|||
|
|
record: dict[str, Any] = {
|
|||
|
|
"id": saga_id,
|
|||
|
|
"name": name,
|
|||
|
|
"status": "PENDING",
|
|||
|
|
"createdAt": _now(),
|
|||
|
|
"updatedAt": _now(),
|
|||
|
|
"context": _jsonable(copy.deepcopy(context or {})),
|
|||
|
|
"dedupeKey": dedupe_key,
|
|||
|
|
"steps": step_records,
|
|||
|
|
"manualTakeover": False,
|
|||
|
|
"takeoverReason": None,
|
|||
|
|
"compensationReason": None,
|
|||
|
|
"auditRefs": [f"saga:{saga_id}"],
|
|||
|
|
}
|
|||
|
|
self.store.data.setdefault("sagas", []).append(record)
|
|||
|
|
self._audit_saga(record, "saga.created", result="PENDING")
|
|||
|
|
self._save()
|
|||
|
|
return record
|
|||
|
|
|
|||
|
|
# ---------------- 执行 ----------------
|
|||
|
|
def run(self, saga_id: str, *, auto_approve: bool = True) -> dict[str, Any]:
|
|||
|
|
"""执行/续跑一条 saga:按序执行未完成步骤;失败自动进入补偿链。
|
|||
|
|
|
|||
|
|
- auto_approve=True:gate 步骤由编排器代行人工确认(测试/自动化模式);
|
|||
|
|
- auto_approve=False:gate 步骤停在 WAITING_HUMAN(中间态可见,人工门禁)。
|
|||
|
|
"""
|
|||
|
|
saga = self._find(saga_id)
|
|||
|
|
if saga["status"] in ("SUCCEEDED", "COMPENSATED"):
|
|||
|
|
return saga
|
|||
|
|
if saga["status"] == "MANUAL_TAKEOVER" or saga.get("manualTakeover"):
|
|||
|
|
return saga # 人工接管中:需显式 retry 恢复自动化
|
|||
|
|
saga["status"] = "RUNNING"
|
|||
|
|
saga["updatedAt"] = _now()
|
|||
|
|
self._audit_saga(saga, "saga.run", result="RUNNING")
|
|||
|
|
for step in saga["steps"]:
|
|||
|
|
if step["status"] in ("SUCCEEDED", "COMPENSATED", "SKIPPED"):
|
|||
|
|
continue
|
|||
|
|
if step.get("gate") and not auto_approve:
|
|||
|
|
step["status"] = "WAITING_HUMAN"
|
|||
|
|
saga["status"] = "WAITING_HUMAN"
|
|||
|
|
saga["updatedAt"] = _now()
|
|||
|
|
self._audit_step(saga, step, "saga.step.waiting_human", result="WAITING_HUMAN")
|
|||
|
|
self._save()
|
|||
|
|
return saga
|
|||
|
|
outcome = self._execute_step(saga, step, auto_approve=auto_approve)
|
|||
|
|
if outcome == "FAILED":
|
|||
|
|
self._compensate(saga, reason=f"step:{step['name']}")
|
|||
|
|
return saga
|
|||
|
|
saga["status"] = "SUCCEEDED"
|
|||
|
|
saga["updatedAt"] = _now()
|
|||
|
|
self._audit_saga(saga, "saga.succeeded", result="SUCCEEDED")
|
|||
|
|
self._save()
|
|||
|
|
return saga
|
|||
|
|
|
|||
|
|
def retry(self, saga_id: str, *, auto_approve: bool = True) -> dict[str, Any]:
|
|||
|
|
"""显式重试:回滚失败步的已发生副作用(写前快照),重置重试预算后重跑。
|
|||
|
|
|
|||
|
|
人工接管(MANUAL_TAKEOVER)后调用 retry 可恢复自动化。
|
|||
|
|
"""
|
|||
|
|
saga = self._find(saga_id)
|
|||
|
|
if saga["status"] in ("SUCCEEDED", "COMPENSATED"):
|
|||
|
|
return saga
|
|||
|
|
ctx = self._ctx(saga, None)
|
|||
|
|
for step in saga["steps"]:
|
|||
|
|
if step["status"] == "FAILED":
|
|||
|
|
if step.get("checkpointId"):
|
|||
|
|
try:
|
|||
|
|
_restore_from_checkpoint({**ctx, "step": step})
|
|||
|
|
except SagaCompensationError:
|
|||
|
|
pass # 快照缺失时保留现场,交由 run 决定
|
|||
|
|
step["status"] = "PENDING"
|
|||
|
|
step["error"] = None
|
|||
|
|
step["attempts"] = 0
|
|||
|
|
step["timeouts"] = 0
|
|||
|
|
step["result"] = None
|
|||
|
|
step["finishedAt"] = None
|
|||
|
|
elif step["status"] == "WAITING_HUMAN":
|
|||
|
|
step["status"] = "PENDING"
|
|||
|
|
saga["manualTakeover"] = False
|
|||
|
|
saga["takeoverReason"] = None
|
|||
|
|
saga["compensationReason"] = None
|
|||
|
|
saga["status"] = "PENDING"
|
|||
|
|
saga["updatedAt"] = _now()
|
|||
|
|
self._audit_saga(saga, "saga.retry", result="RUNNING")
|
|||
|
|
self._save()
|
|||
|
|
return self.run(saga_id, auto_approve=auto_approve)
|
|||
|
|
|
|||
|
|
def compensate(self, saga_id: str, *, reason: str = "manual") -> dict[str, Any]:
|
|||
|
|
"""手动补偿(P2 人工动作):对已成功(或失败但留有写前快照)的步骤逆序补偿。"""
|
|||
|
|
saga = self._find(saga_id)
|
|||
|
|
if saga["status"] == "COMPENSATED":
|
|||
|
|
return saga
|
|||
|
|
self._compensate(saga, reason=reason)
|
|||
|
|
return saga
|
|||
|
|
|
|||
|
|
def takeover(self, saga_id: str, *, reason: str = "manual") -> dict[str, Any]:
|
|||
|
|
"""人工接管:停止自动执行,标记 MANUAL_TAKEOVER(可经 retry 恢复自动化)。"""
|
|||
|
|
saga = self._find(saga_id)
|
|||
|
|
if saga["status"] in _TERMINAL:
|
|||
|
|
return saga
|
|||
|
|
saga["manualTakeover"] = True
|
|||
|
|
saga["takeoverReason"] = reason
|
|||
|
|
saga["status"] = "MANUAL_TAKEOVER"
|
|||
|
|
saga["updatedAt"] = _now()
|
|||
|
|
self._audit_saga(saga, "saga.manual_takeover", result="MANUAL_TAKEOVER",
|
|||
|
|
extra={"reason": reason})
|
|||
|
|
self._save()
|
|||
|
|
return saga
|
|||
|
|
|
|||
|
|
def resume_all(self, *, auto_approve: bool = True) -> list[dict[str, Any]]:
|
|||
|
|
"""恢复队列:重放所有未完成(非终态、非人工接管)的 saga。"""
|
|||
|
|
out: list[dict[str, Any]] = []
|
|||
|
|
for saga in list(self.store.data.get("sagas", [])):
|
|||
|
|
if saga["status"] in _RECOVERABLE and not saga.get("manualTakeover"):
|
|||
|
|
out.append(self.run(saga["id"], auto_approve=auto_approve))
|
|||
|
|
return out
|
|||
|
|
|
|||
|
|
def list(self, limit: int | None = None) -> list[dict[str, Any]]:
|
|||
|
|
sagas = list(reversed(self.store.data.get("sagas", [])))
|
|||
|
|
return sagas if not limit else sagas[: int(limit)]
|
|||
|
|
|
|||
|
|
def get(self, saga_id: str) -> dict[str, Any] | None:
|
|||
|
|
try:
|
|||
|
|
return self._find(saga_id)
|
|||
|
|
except SagaNotFound:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
# ---------------- 步骤执行 ----------------
|
|||
|
|
def _execute_step(self, saga: dict[str, Any], step: dict[str, Any], *,
|
|||
|
|
auto_approve: bool) -> str:
|
|||
|
|
act = self.registry.get(step["action"])
|
|||
|
|
if act is None:
|
|||
|
|
step["status"] = "FAILED"
|
|||
|
|
step["error"] = f"未注册动作 {step['action']}"
|
|||
|
|
step["finishedAt"] = _now()
|
|||
|
|
self._audit_step(saga, step, "saga.step.failed", result="FAILED")
|
|||
|
|
self._save()
|
|||
|
|
return "FAILED"
|
|||
|
|
# 幂等重放:同 idem_key 已被(其他/先前)saga 成功执行 → 复用记录,不重复外部效果
|
|||
|
|
idem_key = step.get("idemKey") or ""
|
|||
|
|
if idem_key and step["status"] != "FAILED":
|
|||
|
|
prior = self.store.data.setdefault("sagaIdem", {}).get(idem_key)
|
|||
|
|
if prior and prior.get("step") == step["name"]:
|
|||
|
|
step["result"] = _jsonable(copy.deepcopy(prior.get("result")))
|
|||
|
|
step["status"] = "SUCCEEDED"
|
|||
|
|
step["finishedAt"] = _now()
|
|||
|
|
self._audit_step(saga, step, "saga.step.idempotent_replayed", result="SUCCEEDED")
|
|||
|
|
self._save()
|
|||
|
|
return "OK"
|
|||
|
|
# 补偿性步骤执行前:自动建写前快照(回滚锚点)
|
|||
|
|
if step.get("compensationAction") and not step.get("checkpointId"):
|
|||
|
|
step["checkpointId"] = self._snapshot(saga, step)
|
|||
|
|
max_attempts = int(step.get("maxRetries") or 0) + 1
|
|||
|
|
for attempt in range(max_attempts):
|
|||
|
|
step["status"] = "RUNNING"
|
|||
|
|
step["attempts"] = int(step.get("attempts") or 0) + 1
|
|||
|
|
step["startedAt"] = _now()
|
|||
|
|
step["error"] = None
|
|||
|
|
started = self.clock()
|
|||
|
|
try:
|
|||
|
|
result = act["fn"](self._ctx(saga, step))
|
|||
|
|
elapsed = self.clock() - started
|
|||
|
|
if float(step.get("timeoutSec") or 0) > 0 and elapsed > float(step["timeoutSec"]):
|
|||
|
|
raise SagaStepTimeout(
|
|||
|
|
f"步骤超时 {elapsed:.1f}s > {step['timeoutSec']}s")
|
|||
|
|
step["result"] = _jsonable(result if isinstance(result, dict) else {"value": result})
|
|||
|
|
step["status"] = "SUCCEEDED"
|
|||
|
|
step["finishedAt"] = _now()
|
|||
|
|
if idem_key:
|
|||
|
|
self.store.data.setdefault("sagaIdem", {})[idem_key] = {
|
|||
|
|
"sagaId": saga["id"], "step": step["name"],
|
|||
|
|
"result": step["result"], "at": _now()}
|
|||
|
|
self._audit_step(saga, step, "saga.step.succeeded", result="SUCCEEDED")
|
|||
|
|
self._save()
|
|||
|
|
return "OK"
|
|||
|
|
except SagaStepTimeout as exc:
|
|||
|
|
step["timeouts"] = int(step.get("timeouts") or 0) + 1
|
|||
|
|
step["error"] = f"timeout: {exc}"
|
|||
|
|
self._audit_step(saga, step, "saga.step.timeout", result="TIMEOUT")
|
|||
|
|
except Exception as exc:
|
|||
|
|
step["error"] = f"{type(exc).__name__}: {exc}"
|
|||
|
|
self._audit_step(saga, step, "saga.step.failed", result="FAILED")
|
|||
|
|
step["finishedAt"] = _now()
|
|||
|
|
if attempt < max_attempts - 1:
|
|||
|
|
delay = min(float(self.backoff_base) * (2 ** attempt), float(self.backoff_cap))
|
|||
|
|
self._audit_step(saga, step, "saga.step.retry", result="RETRY",
|
|||
|
|
extra={"delaySec": delay, "attempt": step["attempts"]})
|
|||
|
|
self._save()
|
|||
|
|
if delay > 0:
|
|||
|
|
self.sleep_fn(delay)
|
|||
|
|
continue
|
|||
|
|
break
|
|||
|
|
step["status"] = "FAILED"
|
|||
|
|
step["finishedAt"] = _now()
|
|||
|
|
self._audit_step(saga, step, "saga.step.failed", result="FAILED")
|
|||
|
|
self._save()
|
|||
|
|
return "FAILED"
|
|||
|
|
|
|||
|
|
# ---------------- 补偿链 ----------------
|
|||
|
|
def _compensate(self, saga: dict[str, Any], *, reason: str) -> None:
|
|||
|
|
saga["status"] = "COMPENSATING"
|
|||
|
|
saga["compensationReason"] = reason
|
|||
|
|
saga["updatedAt"] = _now()
|
|||
|
|
self._audit_saga(saga, "saga.compensating", result="COMPENSATING",
|
|||
|
|
extra={"reason": reason})
|
|||
|
|
failed = False
|
|||
|
|
for step in reversed(saga["steps"]):
|
|||
|
|
if not self._compensatable(step):
|
|||
|
|
continue
|
|||
|
|
comp_action = step["compensationAction"]
|
|||
|
|
comp = self.registry.get(comp_action)
|
|||
|
|
if comp is None:
|
|||
|
|
step["compensation"] = {"status": "FAILED",
|
|||
|
|
"error": f"未注册补偿动作 {comp_action}", "at": _now()}
|
|||
|
|
failed = True
|
|||
|
|
self._audit_step(saga, step, "saga.step.compensation_failed", result="FAILED",
|
|||
|
|
extra={"compensation": comp_action})
|
|||
|
|
self._save()
|
|||
|
|
break
|
|||
|
|
try:
|
|||
|
|
res = comp["fn"](self._ctx(saga, step))
|
|||
|
|
step["compensation"] = {
|
|||
|
|
"status": "SUCCEEDED",
|
|||
|
|
"action": comp_action,
|
|||
|
|
"result": _jsonable(res if isinstance(res, dict) else {"value": res}),
|
|||
|
|
"at": _now(),
|
|||
|
|
}
|
|||
|
|
if step.get("idemKey"):
|
|||
|
|
self.store.data.setdefault("sagaIdem", {}).pop(step["idemKey"], None)
|
|||
|
|
self._audit_step(saga, step, "saga.step.compensated", result="SUCCEEDED",
|
|||
|
|
extra={"compensation": comp_action})
|
|||
|
|
self._save()
|
|||
|
|
except Exception as exc:
|
|||
|
|
step["compensation"] = {
|
|||
|
|
"status": "FAILED", "action": comp_action,
|
|||
|
|
"error": f"{type(exc).__name__}: {exc}", "at": _now(),
|
|||
|
|
}
|
|||
|
|
failed = True
|
|||
|
|
self._audit_step(saga, step, "saga.step.compensation_failed", result="FAILED",
|
|||
|
|
extra={"compensation": comp_action})
|
|||
|
|
self._save()
|
|||
|
|
break
|
|||
|
|
if failed:
|
|||
|
|
saga["status"] = "MANUAL_TAKEOVER"
|
|||
|
|
saga["manualTakeover"] = True
|
|||
|
|
saga["takeoverReason"] = f"compensation-failed: {reason}"
|
|||
|
|
else:
|
|||
|
|
saga["status"] = "COMPENSATED"
|
|||
|
|
saga["updatedAt"] = _now()
|
|||
|
|
self._audit_saga(saga, "saga.manual_takeover" if failed else "saga.compensated",
|
|||
|
|
result=saga["status"],
|
|||
|
|
extra={"reason": reason, "compensationReason": saga["compensationReason"]})
|
|||
|
|
self._save()
|
|||
|
|
|
|||
|
|
@staticmethod
|
|||
|
|
def _compensatable(step: dict[str, Any]) -> bool:
|
|||
|
|
if not step.get("compensationAction"):
|
|||
|
|
return False
|
|||
|
|
if (step.get("compensation") or {}).get("status") == "SUCCEEDED":
|
|||
|
|
return False # 已补偿
|
|||
|
|
return step["status"] == "SUCCEEDED" or bool(step.get("checkpointId"))
|
|||
|
|
|
|||
|
|
# ---------------- 基础设施 ----------------
|
|||
|
|
def _ctx(self, saga: dict[str, Any], step: dict[str, Any] | None) -> dict[str, Any]:
|
|||
|
|
return {
|
|||
|
|
"store": self.store,
|
|||
|
|
"saga": saga,
|
|||
|
|
"step": step,
|
|||
|
|
"steps": {s["name"]: s for s in saga["steps"]},
|
|||
|
|
"actor": self.actor,
|
|||
|
|
"context": saga.get("context") or {},
|
|||
|
|
"coordinator": self,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def _snapshot(self, saga: dict[str, Any], step: dict[str, Any]) -> str:
|
|||
|
|
cps = self._checkpoint_store()
|
|||
|
|
pair = cps.create(self.store.data,
|
|||
|
|
label=f"saga:{saga['id']}:{step['name']} 前",
|
|||
|
|
reason="auto:saga.pre-step",
|
|||
|
|
conversation_note=f"Saga {saga['name']} 步骤 {step['name']} 执行前基线")
|
|||
|
|
return str(pair["pairId"])
|
|||
|
|
|
|||
|
|
def _checkpoint_store(self):
|
|||
|
|
cps = getattr(self.store, "checkpoints", None)
|
|||
|
|
if cps is None:
|
|||
|
|
from server.state.checkpoints import get_checkpoints
|
|||
|
|
cps = get_checkpoints()
|
|||
|
|
return cps
|
|||
|
|
|
|||
|
|
def _audit_saga(self, saga: dict[str, Any], action: str, *, result: str = "SUCCESS",
|
|||
|
|
extra: dict[str, Any] | None = None) -> None:
|
|||
|
|
from server.agent_core.audit import write_audit
|
|||
|
|
write_audit(self.store.data, self.store.next_id, actor=self.actor, category="SAGA",
|
|||
|
|
action=action, target={"type": "SAGA", "id": saga["id"], "name": saga["name"]},
|
|||
|
|
power="P1",
|
|||
|
|
rationale={"sagaId": saga["id"], "name": saga["name"],
|
|||
|
|
"status": saga["status"], **(extra or {})},
|
|||
|
|
result=result,
|
|||
|
|
evidence_refs=list(saga.get("auditRefs") or [f"saga:{saga['id']}"]))
|
|||
|
|
|
|||
|
|
def _audit_step(self, saga: dict[str, Any], step: dict[str, Any], action: str, *,
|
|||
|
|
result: str = "SUCCESS", extra: dict[str, Any] | None = None) -> None:
|
|||
|
|
from server.agent_core.audit import write_audit
|
|||
|
|
refs = list(saga.get("auditRefs") or [f"saga:{saga['id']}"])
|
|||
|
|
step_ref = f"saga.step:{saga['id']}:{step['name']}"
|
|||
|
|
if step_ref not in refs:
|
|||
|
|
refs.append(step_ref)
|
|||
|
|
saga["auditRefs"] = refs
|
|||
|
|
write_audit(self.store.data, self.store.next_id, actor=self.actor, category="SAGA",
|
|||
|
|
action=action,
|
|||
|
|
target={"type": "SAGA_STEP", "sagaId": saga["id"], "step": step["name"]},
|
|||
|
|
power="P1",
|
|||
|
|
rationale={"sagaId": saga["id"], "step": step["name"], "action": step["action"],
|
|||
|
|
"idemKey": step.get("idemKey"), "status": step["status"],
|
|||
|
|
"attempts": step.get("attempts"),
|
|||
|
|
"compensation": step.get("compensationAction"),
|
|||
|
|
**(extra or {})},
|
|||
|
|
result=result, evidence_refs=refs)
|
|||
|
|
# ------------------------------------------------------------
|
|||
|
|
# ⑥ WMS 缺料闭环 Saga 动作(动作注册表默认值)
|
|||
|
|
# ------------------------------------------------------------
|
|||
|
|
def _act_wms_consume_event(ctx: dict[str, Any]) -> dict[str, Any]:
|
|||
|
|
"""动作:WMS 事件消费(eventId 幂等)。"""
|
|||
|
|
from server.aps_domain import wms_events
|
|||
|
|
event = ctx["context"].get("event") or {}
|
|||
|
|
r = wms_events.consume_event(ctx["store"], event, actor=ctx["actor"])
|
|||
|
|
if r.get("duplicate"):
|
|||
|
|
return {**r, "replayed": True}
|
|||
|
|
return r
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _act_wms_evaluate_impact(ctx: dict[str, Any]) -> dict[str, Any]:
|
|||
|
|
"""动作:缺料影响半径(沙盒只读差分)。"""
|
|||
|
|
from server.aps_domain import wms_events
|
|||
|
|
event = ctx["context"].get("event") or {}
|
|||
|
|
return wms_events.evaluate_shortage_impact(ctx["store"].data, event)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _act_wms_stage_solution(ctx: dict[str, Any]) -> dict[str, Any]:
|
|||
|
|
"""动作:生成 Explore 方案卡(P2 门禁卡;确认点在下一 gate 步骤)。"""
|
|||
|
|
from server.aps_domain import wms_events
|
|||
|
|
store = ctx["store"]
|
|||
|
|
event = ctx["context"].get("event") or {}
|
|||
|
|
session_id = (ctx["context"].get("sessionId")) or "wms"
|
|||
|
|
return wms_events.stage_shortage_solution(store, event, session_id=session_id,
|
|||
|
|
actor=ctx["actor"])
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _act_flex_reschedule(ctx: dict[str, Any]) -> dict[str, Any]:
|
|||
|
|
"""执行一次经确认的 L4 重排,并只返回本次确认实际创建的精确版本。"""
|
|||
|
|
from server.aps_domain.workflow import execute_confirmed
|
|||
|
|
|
|||
|
|
store = ctx["store"]
|
|||
|
|
stage_res = (ctx["steps"].get("stage_solution") or {}).get("result") or {}
|
|||
|
|
confirm_id = str(stage_res.get("confirmId") or "")
|
|||
|
|
if not confirm_id:
|
|||
|
|
raise SagaStepFailed("缺少方案卡 confirmId(stage_solution 未成功)")
|
|||
|
|
|
|||
|
|
before_versions = list(store.data.get("flexScheduleVersions") or [])
|
|||
|
|
before_ids = {str(row.get("id")) for row in before_versions}
|
|||
|
|
msg = execute_confirmed(store, confirm_id, approve=True, actor=ctx["actor"])
|
|||
|
|
after_versions = list(store.data.get("flexScheduleVersions") or [])
|
|||
|
|
new_versions = [row for row in after_versions if str(row.get("id")) not in before_ids]
|
|||
|
|
if len(after_versions) <= len(before_versions) or len(new_versions) != 1:
|
|||
|
|
raise SagaStepFailed(
|
|||
|
|
"本次重排确认未创建唯一的新排产版本,拒绝沿用历史 DRAFT 版本:"
|
|||
|
|
f"confirmId={confirm_id}; message={msg}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
version = new_versions[0]
|
|||
|
|
version_id = version.get("id")
|
|||
|
|
event_id = str((ctx.get("context", {}).get("event") or {}).get("eventId") or "")
|
|||
|
|
approval = next(
|
|||
|
|
(
|
|||
|
|
event
|
|||
|
|
for event in reversed(store.data.get("auditEvents") or [])
|
|||
|
|
if event.get("action") == "flex.reschedule.approve"
|
|||
|
|
and str((event.get("rationale") or {}).get("confirmId") or "") == confirm_id
|
|||
|
|
and str((event.get("target") or {}).get("id")) == str(version_id)
|
|||
|
|
),
|
|||
|
|
None,
|
|||
|
|
)
|
|||
|
|
expected_event_ref = f"wms-event:{event_id}" if event_id else ""
|
|||
|
|
if approval is None or (
|
|||
|
|
expected_event_ref
|
|||
|
|
and expected_event_ref not in (approval.get("evidenceRefs") or [])
|
|||
|
|
):
|
|||
|
|
raise SagaStepFailed(
|
|||
|
|
"新排产版本缺少与当前确认卡/WMS 事件一致的审计证据,拒绝进入发布门禁:"
|
|||
|
|
f"confirmId={confirm_id}; versionId={version_id}; eventId={event_id or '-'}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"message": msg,
|
|||
|
|
"versionId": version_id,
|
|||
|
|
"versionNo": version.get("versionNo"),
|
|||
|
|
"confirmId": confirm_id,
|
|||
|
|
"eventId": event_id or None,
|
|||
|
|
"approvalAuditId": approval.get("id"),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def _act_schedule_publish_stage(ctx: dict[str, Any]) -> dict[str, Any]:
|
|||
|
|
"""Stage an exact flex-version P2 publication card after rescheduling."""
|
|||
|
|
|
|||
|
|
from server.aps_domain.workflow import stage_schedule_publish
|
|||
|
|
|
|||
|
|
store = ctx["store"]
|
|||
|
|
reschedule_result = (ctx["steps"].get("reschedule") or {}).get("result") or {}
|
|||
|
|
version_id = reschedule_result.get("versionId")
|
|||
|
|
if not version_id:
|
|||
|
|
raise SagaStepFailed("缺少重排版本 versionId(reschedule 未成功)")
|
|||
|
|
session_id = ctx["context"].get("sessionId") or "wms"
|
|||
|
|
reply = stage_schedule_publish(
|
|||
|
|
store,
|
|||
|
|
session_id=session_id,
|
|||
|
|
actor=ctx["actor"],
|
|||
|
|
track="flex",
|
|||
|
|
version_id=int(version_id),
|
|||
|
|
)
|
|||
|
|
block = next(
|
|||
|
|
(
|
|||
|
|
item
|
|||
|
|
for item in reply.blocks or []
|
|||
|
|
if getattr(item, "type", None) == "confirm-card"
|
|||
|
|
),
|
|||
|
|
None,
|
|||
|
|
)
|
|||
|
|
confirm_id = str((getattr(block, "props", None) or {}).get("confirmId") or "")
|
|||
|
|
if not confirm_id:
|
|||
|
|
raise SagaStepFailed(f"柔性版本发布暂存失败: {reply.text}")
|
|||
|
|
return {
|
|||
|
|
"staged": True,
|
|||
|
|
"confirmId": confirm_id,
|
|||
|
|
"versionId": int(version_id),
|
|||
|
|
"message": reply.text,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _act_schedule_publish(ctx: dict[str, Any]) -> dict[str, Any]:
|
|||
|
|
"""Approve the independent P2 publication gate without touching MES."""
|
|||
|
|
|
|||
|
|
from server.aps_domain.workflow import execute_confirmed
|
|||
|
|
|
|||
|
|
store = ctx["store"]
|
|||
|
|
stage_result = (ctx["steps"].get("stage_publish") or {}).get("result") or {}
|
|||
|
|
confirm_id = str(stage_result.get("confirmId") or "")
|
|||
|
|
version_id = stage_result.get("versionId")
|
|||
|
|
if not confirm_id or not version_id:
|
|||
|
|
raise SagaStepFailed("缺少发布确认卡或目标版本(stage_publish 未成功)")
|
|||
|
|
message = execute_confirmed(
|
|||
|
|
store,
|
|||
|
|
confirm_id,
|
|||
|
|
approve=True,
|
|||
|
|
actor=ctx["actor"],
|
|||
|
|
)
|
|||
|
|
version = next(
|
|||
|
|
(
|
|||
|
|
row
|
|||
|
|
for row in store.data.get("flexScheduleVersions") or []
|
|||
|
|
if row.get("id") == int(version_id)
|
|||
|
|
),
|
|||
|
|
None,
|
|||
|
|
)
|
|||
|
|
if version is None or str(version.get("status") or "").upper() != "PUBLISHED":
|
|||
|
|
raise SagaStepFailed(f"柔性版本发布失败: {message}")
|
|||
|
|
return {
|
|||
|
|
"published": True,
|
|||
|
|
"confirmId": confirm_id,
|
|||
|
|
"versionId": int(version_id),
|
|||
|
|
"message": message,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _act_mes_dispatch_stage(ctx: dict[str, Any]) -> dict[str, Any]:
|
|||
|
|
"""Stage P3 MES dispatch only for the exact published reschedule version."""
|
|||
|
|
|
|||
|
|
from server.aps_domain import wms_events
|
|||
|
|
|
|||
|
|
store = ctx["store"]
|
|||
|
|
event = ctx["context"].get("event") or {}
|
|||
|
|
event_id = str(event.get("eventId") or "")
|
|||
|
|
session_id = ctx["context"].get("sessionId") or "wms"
|
|||
|
|
reschedule_result = (ctx["steps"].get("reschedule") or {}).get("result") or {}
|
|||
|
|
expected_version_id = reschedule_result.get("versionId")
|
|||
|
|
versions = store.data.get("flexScheduleVersions") or []
|
|||
|
|
latest = versions[-1] if versions else None
|
|||
|
|
if (
|
|||
|
|
latest is None
|
|||
|
|
or latest.get("id") != expected_version_id
|
|||
|
|
or str(latest.get("status") or "").upper() != "PUBLISHED"
|
|||
|
|
):
|
|||
|
|
raise SagaStepFailed("MES 下发暂存失败: 重排目标版本不是当前已发布执行基准")
|
|||
|
|
result = wms_events.stage_dispatch(
|
|||
|
|
store,
|
|||
|
|
event_id,
|
|||
|
|
session_id=session_id,
|
|||
|
|
actor=ctx["actor"],
|
|||
|
|
)
|
|||
|
|
if not result.get("staged"):
|
|||
|
|
raise SagaStepFailed(f"MES 下发暂存失败: {result.get('message')}")
|
|||
|
|
staged_version_id = (result.get("validation") or {}).get("versionId")
|
|||
|
|
if int(staged_version_id or 0) != int(expected_version_id or 0):
|
|||
|
|
raise SagaStepFailed("MES dispatch staging failed: confirmation card is not bound to the rescheduled version")
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _act_mes_dispatch(ctx: dict[str, Any]) -> dict[str, Any]:
|
|||
|
|
"""动作(gate):P3 双人确认 → MES 下发 → 回执镜像写回审计。
|
|||
|
|
|
|||
|
|
失败时保留部分结果(已创建外部工单/执行凭据)供补偿链撤销。
|
|||
|
|
"""
|
|||
|
|
from server.aps_domain import wms_events
|
|||
|
|
store = ctx["store"]
|
|||
|
|
event = ctx["context"].get("event") or {}
|
|||
|
|
event_id = str(event.get("eventId") or "")
|
|||
|
|
stage_res = (ctx["steps"].get("stage_dispatch") or {}).get("result") or {}
|
|||
|
|
block = stage_res.get("block") or {}
|
|||
|
|
confirm_id = str((block.get("props") or {}).get("confirmId")
|
|||
|
|
or stage_res.get("confirmId") or "")
|
|||
|
|
if not confirm_id:
|
|||
|
|
raise SagaStepFailed("缺少 MES 下发确认卡 confirmId(stage_dispatch 未成功)")
|
|||
|
|
step = ctx["step"]
|
|||
|
|
prior = (step.get("result") or {}).get("executionGrant") if isinstance(step.get("result"), dict) else None
|
|||
|
|
grant = prior or _p3_double_approve(ctx["coordinator"], confirm_id)
|
|||
|
|
reschedule_res = (ctx["steps"].get("reschedule") or {}).get("result") or {}
|
|||
|
|
version_id = reschedule_res.get("versionId")
|
|||
|
|
validation = stage_res.get("validation") or {}
|
|||
|
|
dispatch_evidence = [f"schedule-version:{int(version_id or 0)}"]
|
|||
|
|
if validation.get("evidenceRef"):
|
|||
|
|
dispatch_evidence.append(str(validation["evidenceRef"]))
|
|||
|
|
cps = ctx["coordinator"]._checkpoint_store()
|
|||
|
|
try:
|
|||
|
|
res = wms_events.execute_dispatch_receipt(
|
|||
|
|
store, event_id=event_id, confirm_id=confirm_id, execution_grant=grant,
|
|||
|
|
version_id=int(version_id or 0),
|
|||
|
|
before_snapshot=str(step.get("checkpointId") or ""),
|
|||
|
|
checkpoint_store=cps, evidence_refs=dispatch_evidence,
|
|||
|
|
actor=ctx["actor"])
|
|||
|
|
except Exception as exc:
|
|||
|
|
# 保留部分执行信息(已创建的外部工单/凭据)供补偿链撤销
|
|||
|
|
step["result"] = _jsonable({
|
|||
|
|
"partial": True, "error": str(exc), "executionGrant": grant,
|
|||
|
|
"created": _external_wo_ids(store, version_id),
|
|||
|
|
})
|
|||
|
|
ctx["coordinator"]._save()
|
|||
|
|
raise
|
|||
|
|
return {**res, "executionGrant": grant}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _p3_double_approve(coordinator: SagaCoordinator, confirm_id: str) -> str:
|
|||
|
|
"""P3 双人职责分离确认:当前身份第一重 + approver-2 第二重 → executionGrant。"""
|
|||
|
|
from server.agent_core import harness
|
|||
|
|
from server.auth.context import IdentityContext, bind_identity, reset_identity
|
|||
|
|
first = harness.take_confirmation(confirm_id, approve=True)
|
|||
|
|
if first is None:
|
|||
|
|
raise SagaStepFailed("MES P3 确认卡不可用(可能已处理)")
|
|||
|
|
if first.get("needsSecondConfirm"):
|
|||
|
|
approver = coordinator.second_approver or IdentityContext(
|
|||
|
|
2002, "approver-2", "Approver 2", "platform", roles=("planner",))
|
|||
|
|
token = bind_identity(approver)
|
|||
|
|
try:
|
|||
|
|
second = harness.take_confirmation(confirm_id, approve=True)
|
|||
|
|
finally:
|
|||
|
|
reset_identity(token)
|
|||
|
|
if second is None:
|
|||
|
|
raise SagaStepFailed("MES P3 二次确认失败")
|
|||
|
|
grant = second.get("executionGrant")
|
|||
|
|
else:
|
|||
|
|
grant = first.get("executionGrant")
|
|||
|
|
if not grant:
|
|||
|
|
raise SagaStepFailed("MES P3 未获得执行凭据")
|
|||
|
|
return str(grant)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _external_wo_ids(store, version_id) -> list[str]:
|
|||
|
|
"""从 mesLinks 反查某版本已下发的外部工单 ID(补偿撤销用)。"""
|
|||
|
|
out: list[str] = []
|
|||
|
|
for link in store.data.get("mesLinks", []):
|
|||
|
|
if link.get("kind") != "dispatch":
|
|||
|
|
continue
|
|||
|
|
if version_id is not None and link.get("versionId") != version_id:
|
|||
|
|
continue
|
|||
|
|
if link.get("externalWoId") and link["externalWoId"] not in out:
|
|||
|
|
out.append(link["externalWoId"])
|
|||
|
|
return out
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------- 补偿动作 ----------------
|
|||
|
|
def _restore_from_checkpoint(ctx: dict[str, Any]) -> dict[str, Any]:
|
|||
|
|
"""按步骤写前快照恢复世界(保留审计链与 saga 日志,回滚业务表到步骤前)。"""
|
|||
|
|
store = ctx["store"]
|
|||
|
|
step = ctx["step"]
|
|||
|
|
pair_id = step.get("checkpointId")
|
|||
|
|
if not pair_id:
|
|||
|
|
raise SagaCompensationError(f"步骤 {step['name']} 缺少写前快照,无法回滚")
|
|||
|
|
cps = ctx["coordinator"]._checkpoint_store()
|
|||
|
|
pair = cps.get(pair_id)
|
|||
|
|
if pair is None:
|
|||
|
|
raise SagaCompensationError(f"写前快照 {pair_id} 不存在,无法回滚")
|
|||
|
|
world = copy.deepcopy(pair["world"])
|
|||
|
|
current = store.data
|
|||
|
|
for key in ("auditEvents", "sagas", "sagaIdem", "mesCancellations", "wmsReceiptVoids"):
|
|||
|
|
if key in current:
|
|||
|
|
world[key] = current[key]
|
|||
|
|
_replace_world(store, world)
|
|||
|
|
return {"pairId": pair_id, "label": pair.get("label")}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _replace_world(store, world: dict[str, Any]) -> None:
|
|||
|
|
if hasattr(store, "restore"):
|
|||
|
|
store.restore(world)
|
|||
|
|
else:
|
|||
|
|
store.data.clear()
|
|||
|
|
store.data.update(world)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _comp_flex_reschedule_rollback(ctx: dict[str, Any]) -> dict[str, Any]:
|
|||
|
|
"""补偿:重排失败 → 回滚到步骤执行前版本(写前快照恢复)。"""
|
|||
|
|
restored = _restore_from_checkpoint(ctx)
|
|||
|
|
return {"worldRestore": restored, "message": "重排已回滚到步骤执行前版本"}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _comp_mes_cancel_dispatch(ctx: dict[str, Any]) -> dict[str, Any]:
|
|||
|
|
"""补偿:MES 下发失败 → 撤销外部工单 + 回执 VOIDED + 世界回滚到下发前。"""
|
|||
|
|
from server.aps_domain import wms_events
|
|||
|
|
from server.aps_domain.mes import cancel_dispatch
|
|||
|
|
store = ctx["store"]
|
|||
|
|
step = ctx["step"]
|
|||
|
|
result = step.get("result") or {}
|
|||
|
|
external_ids = list(result.get("created") or [])
|
|||
|
|
version_id = (ctx["steps"].get("reschedule") or {}).get("result", {}).get("versionId")
|
|||
|
|
if not external_ids:
|
|||
|
|
external_ids = _external_wo_ids(store, version_id)
|
|||
|
|
cancel_res = cancel_dispatch(store, external_wo_ids=external_ids,
|
|||
|
|
idem_key=step.get("idemKey"), actor=ctx["actor"],
|
|||
|
|
reason="saga-compensation")
|
|||
|
|
rollback: dict[str, Any] = {}
|
|||
|
|
try:
|
|||
|
|
rollback = wms_events.rollback_receipt(
|
|||
|
|
store, event_id=(ctx["context"].get("event") or {}).get("eventId"),
|
|||
|
|
actor=ctx["actor"], reason="saga-compensation")
|
|||
|
|
except Exception as exc:
|
|||
|
|
rollback = {"error": f"{type(exc).__name__}: {exc}"}
|
|||
|
|
restored = _restore_from_checkpoint(ctx)
|
|||
|
|
return {"cancel": cancel_res, "receiptRollback": rollback, "worldRestore": restored}
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------
|
|||
|
|
# 默认动作注册表(动作 → 补偿动作)
|
|||
|
|
# ------------------------------------------------------------
|
|||
|
|
def default_registry() -> dict[str, dict[str, Any]]:
|
|||
|
|
return {
|
|||
|
|
"wms.consume_event": {"fn": _act_wms_consume_event, "compensation": None},
|
|||
|
|
"wms.evaluate_impact": {"fn": _act_wms_evaluate_impact, "compensation": None},
|
|||
|
|
"wms.stage_solution": {"fn": _act_wms_stage_solution, "compensation": None},
|
|||
|
|
"flex.reschedule": {
|
|||
|
|
"fn": _act_flex_reschedule,
|
|||
|
|
"compensation": "flex.reschedule.rollback",
|
|||
|
|
},
|
|||
|
|
"flex.reschedule.rollback": {"fn": _comp_flex_reschedule_rollback},
|
|||
|
|
"schedule.publish.stage": {"fn": _act_schedule_publish_stage, "compensation": None},
|
|||
|
|
"schedule.publish": {"fn": _act_schedule_publish, "compensation": None},
|
|||
|
|
"mes.dispatch.stage": {"fn": _act_mes_dispatch_stage, "compensation": None},
|
|||
|
|
"mes.dispatch": {"fn": _act_mes_dispatch, "compensation": "mes.cancel_dispatch"},
|
|||
|
|
"mes.cancel_dispatch": {"fn": _comp_mes_cancel_dispatch},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------
|
|||
|
|
# 便捷入口
|
|||
|
|
# ------------------------------------------------------------
|
|||
|
|
def get_coordinator(store, *, actor: str = "saga",
|
|||
|
|
registry: dict[str, dict[str, Any]] | None = None,
|
|||
|
|
**kwargs) -> SagaCoordinator:
|
|||
|
|
"""按世界状态构造(或复用)Saga 编排器。"""
|
|||
|
|
return SagaCoordinator(store, actor=actor, registry=registry, **kwargs)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def build_wms_shortage_saga(coordinator: SagaCoordinator, event: dict[str, Any], *,
|
|||
|
|
session_id: str = "wms", actor: str = "saga",
|
|||
|
|
timeout_sec: float = 30.0, max_retries: int = 2) -> dict[str, Any]:
|
|||
|
|
"""Build the governed WMS shortage closure Saga.
|
|||
|
|
|
|||
|
|
consume -> impact -> solution card -> P2 reschedule -> P2 publish ->
|
|||
|
|
P3 MES dispatch -> receipt. Publication and dispatch remain separate gates.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
event_id = str(event.get("eventId") or "")
|
|||
|
|
if not event_id:
|
|||
|
|
raise ValueError("WMS 缺料 Saga 需要事件 eventId")
|
|||
|
|
steps = [
|
|||
|
|
SagaStep(
|
|||
|
|
"consume_event",
|
|||
|
|
"wms.consume_event",
|
|||
|
|
idem_key=f"wms-event:{event_id}",
|
|||
|
|
timeout_sec=timeout_sec,
|
|||
|
|
max_retries=max_retries,
|
|||
|
|
),
|
|||
|
|
SagaStep(
|
|||
|
|
"evaluate_impact",
|
|||
|
|
"wms.evaluate_impact",
|
|||
|
|
idem_key=f"impact:{event_id}",
|
|||
|
|
timeout_sec=timeout_sec,
|
|||
|
|
max_retries=max_retries,
|
|||
|
|
),
|
|||
|
|
SagaStep(
|
|||
|
|
"stage_solution",
|
|||
|
|
"wms.stage_solution",
|
|||
|
|
idem_key=f"stage:{event_id}",
|
|||
|
|
timeout_sec=timeout_sec,
|
|||
|
|
max_retries=max_retries,
|
|||
|
|
),
|
|||
|
|
SagaStep(
|
|||
|
|
"reschedule",
|
|||
|
|
"flex.reschedule",
|
|||
|
|
idem_key=f"reschedule:{event_id}",
|
|||
|
|
timeout_sec=timeout_sec,
|
|||
|
|
max_retries=max_retries,
|
|||
|
|
compensation_action="flex.reschedule.rollback",
|
|||
|
|
gate=True,
|
|||
|
|
),
|
|||
|
|
SagaStep(
|
|||
|
|
"stage_publish",
|
|||
|
|
"schedule.publish.stage",
|
|||
|
|
idem_key=f"publish-stage:{event_id}",
|
|||
|
|
timeout_sec=timeout_sec,
|
|||
|
|
max_retries=max_retries,
|
|||
|
|
),
|
|||
|
|
SagaStep(
|
|||
|
|
"publish",
|
|||
|
|
"schedule.publish",
|
|||
|
|
idem_key=f"publish:{event_id}",
|
|||
|
|
timeout_sec=timeout_sec,
|
|||
|
|
max_retries=max_retries,
|
|||
|
|
gate=True,
|
|||
|
|
),
|
|||
|
|
SagaStep(
|
|||
|
|
"stage_dispatch",
|
|||
|
|
"mes.dispatch.stage",
|
|||
|
|
idem_key=f"dispatch-stage:{event_id}",
|
|||
|
|
timeout_sec=timeout_sec,
|
|||
|
|
max_retries=max_retries,
|
|||
|
|
),
|
|||
|
|
SagaStep(
|
|||
|
|
"dispatch",
|
|||
|
|
"mes.dispatch",
|
|||
|
|
idem_key=f"dispatch:{event_id}",
|
|||
|
|
timeout_sec=timeout_sec,
|
|||
|
|
max_retries=max_retries,
|
|||
|
|
compensation_action="mes.cancel_dispatch",
|
|||
|
|
gate=True,
|
|||
|
|
),
|
|||
|
|
]
|
|||
|
|
return coordinator.create_saga(
|
|||
|
|
"wms-shortage-closure",
|
|||
|
|
steps,
|
|||
|
|
context={"event": event, "sessionId": session_id},
|
|||
|
|
dedupe_key=event_id,
|
|||
|
|
)
|