diff --git a/pbl_runtime_ext/__init__.py b/pbl_runtime_ext/__init__.py index 16ac1c3..63c806a 100644 --- a/pbl_runtime_ext/__init__.py +++ b/pbl_runtime_ext/__init__.py @@ -6,7 +6,46 @@ 包出口: from pbl_runtime_ext.init import load_pbl_runtime_ext + +M11b 契约(必须与 init.py 的 env 注册、m11b_api.py 的实现三处同步,缺一即 NameError): + apply_runtime_event / poll_events / read_states / assert_append_only + pbl_runtime_broadcast_pull / pbl_runtime_broadcast_stats """ -__all__ = ["init", "tables", "tx_event", "api"] -__version__ = "1.0.0" +from .init import load_pbl_runtime_ext # noqa: F401 包出口(挂载唯一入口) + +# ---- M11a ---- +from .tables import TABLES, ensure_tables, self_check as tables_self_check # noqa: F401 +from .tx_event import (TxError, append_event, find_by_idem, list_events, # noqa: F401 + next_seq) + +# ---- M11b:实现定义 + 包导出 + init.py env 注册(三处同步)---- +from .m11b_config import (EVENT_TABLE, SLA_ADJUDICATE_MS, SLA_BROADCAST_MS, # noqa: F401 + SLA_END_TO_END_MS, POLL_FALLBACK_MS, STATE_TABLE, + SNAPSHOT_TABLE, contracts, sla_ms) +from .rtx_db import (RtxError, dbname, q_all, q_one, transaction) # noqa: F401 +from .partitions import ensure_forward_partitions # noqa: F401 +from .broadcast import BroadcastHub, channel_of, get_hub # noqa: F401 +from .latency import LatencyTracker, Timer, summary as latency_summary # noqa: F401 +from .tx_write import (apply_runtime_event, assert_append_only, # noqa: F401 + idem_key_of, poll_events, read_states) +from .m11b_api import (pbl_runtime_broadcast_pull, # noqa: F401 + pbl_runtime_broadcast_stats) + +__all__ = [ + # 挂载 + "load_pbl_runtime_ext", "TABLES", "ensure_tables", "tables_self_check", + # M11a + "TxError", "append_event", "find_by_idem", "list_events", "next_seq", + # M11b 契约 + "apply_runtime_event", "poll_events", "read_states", "assert_append_only", + "pbl_runtime_broadcast_pull", "pbl_runtime_broadcast_stats", + "idem_key_of", "contracts", + # M11b 基础设施 + "RtxError", "dbname", "q_all", "q_one", "transaction", + "ensure_forward_partitions", "BroadcastHub", "channel_of", "get_hub", + "LatencyTracker", "Timer", "latency_summary", "sla_ms", + "EVENT_TABLE", "STATE_TABLE", "SNAPSHOT_TABLE", + "SLA_ADJUDICATE_MS", "SLA_BROADCAST_MS", "SLA_END_TO_END_MS", "POLL_FALLBACK_MS", +] +__version__ = "1.1.0" diff --git a/pbl_runtime_ext/api.py b/pbl_runtime_ext/api.py index a4096b5..9728c2d 100644 --- a/pbl_runtime_ext/api.py +++ b/pbl_runtime_ext/api.py @@ -241,30 +241,47 @@ async def pbl_session_member_add(**kw): return {'ok': True, 'members': cnt + 1, 'max_members': MAX_MEMBERS, 'deduped': False} -# ================================================================== M11b 覆盖 -# M11b(单事务事件+状态写入、按月分区、提交后广播、3s 轮询兜底)是**权威实现**: -# 同名契约在此覆盖上方 M11a 版本,避免两套逻辑漂移;返回结构向后兼容(只增字段)。 -# 主链路:m11b_api → tx_write.apply_runtime_event(一个事务写 事件+实体状态+快照 -# → 提交后才广播 → 分段计时裁决/广播/端到端并与 SLA 预算比对)。 -from .m11b_api import ( # noqa: E402 契约层覆盖(放最后,导入即生效) - contracts as m11b_contracts, - pbl_entity_state_apply, - pbl_entity_state_get, - pbl_runtime_broadcast_pull, - pbl_runtime_event_append, +# ================================================================== M11b 契约汇总 +# M11b(单事务事件+状态写入、按月 RANGE 分区、提交后广播、3s 轮询兜底)的**权威实现**在: +# tx_write.py apply_runtime_event / poll_events / read_states / assert_append_only +# m11b_api.py pbl_runtime_broadcast_pull / pbl_runtime_broadcast_stats(协程包装) +# broadcast.py 提交后扇出 + 每通道环形缓冲;partitions.py 月分区预建; +# rtx_db.py 规范 sqlor 入口 + 单事务(探测不到 begin/commit/rollback 即 fail-closed) +# 本文件上方 6 个函数是 M11a 的 dspy 契约(走 pbl_common 适配的 sql_exec/sql_rows), +# 保留以向后兼容;本段**只再导出** M11b 新契约并汇总 CONTRACTS 清单,不重复实现, +# 避免两套逻辑漂移(QC #5:本段改动要点 = 修正原先从 m11b_api 导入 5 个不存在的 +# 名字 pbl_entity_state_apply/pbl_entity_state_get/pbl_runtime_event_append/ +# pbl_runtime_event_poll/pbl_world_broadcast 导致的 ImportError,并去掉 +# pbl_runtime_broadcast_pull 的重复导入行)。 +# +# 铁律(QC #1):新增契约必须三处同步 —— ① 实现(tx_write/m11b_api) +# ② 包导出(__init__.py)③ env 注册(init.py._registrations / M11B_ENV_REGISTRATIONS)。 +# init.py 挂载时按清单核对,漏项直接 RuntimeError 拒绝启动,不留到线上 NameError。 +from .m11b_api import ( # noqa: E402 仅再导出 m11b_api 中真实存在的名字 + apply_runtime_event, + assert_append_only, + poll_events, pbl_runtime_broadcast_pull, pbl_runtime_broadcast_stats, - pbl_runtime_event_poll, - pbl_world_broadcast, + read_states, ) +from .m11b_config import sla_ms # noqa: E402 CONTRACTS = { + # M11a(dspy 端点,向后兼容) "pbl_runtime_event_append": pbl_runtime_event_append, "pbl_runtime_event_poll": pbl_runtime_event_poll, - "pbl_runtime_broadcast_pull": pbl_runtime_broadcast_pull, - "pbl_runtime_broadcast_stats": pbl_runtime_broadcast_stats, "pbl_entity_state_get": pbl_entity_state_get, "pbl_entity_state_apply": pbl_entity_state_apply, "pbl_world_broadcast": pbl_world_broadcast, "pbl_session_member_add": pbl_session_member_add, + # M11b(新增 dspy 端点 + 内部契约) + "pbl_runtime_broadcast_pull": pbl_runtime_broadcast_pull, + "pbl_runtime_broadcast_stats": pbl_runtime_broadcast_stats, + "apply_runtime_event": apply_runtime_event, + "poll_events": poll_events, + "read_states": read_states, + "assert_append_only": assert_append_only, } + +M11B_SLA = sla_ms() diff --git a/pbl_runtime_ext/broadcast.py b/pbl_runtime_ext/broadcast.py index b22dd8b..4b68ade 100644 --- a/pbl_runtime_ext/broadcast.py +++ b/pbl_runtime_ext/broadcast.py @@ -1,245 +1,150 @@ -"""M11b 提交后广播(post-commit broadcast)+ 3s 轮询兜底。 +# -*- coding: utf-8 -*- +"""broadcast —— 进程内广播枢纽(**提交后**才发布,M11b)。 -铁律与语义 ----------- -1. **只在事务提交成功后广播**:广播内容必须是已落库的事实。提交失败绝不广播, - 否则客户端会收到数据库里不存在的状态(幽灵更新,无法收敛)。 -2. **广播是尽力而为(best-effort),不是投递保证**:推送通道(websocket/SSE) - 抖动、进程重启都可能丢事件。丢广播**不撤销已提交事实**,客户端用 - `since_seq` 轮询补齐(兜底周期 3s),保证最终一致、不丢数据。 -3. **环形缓冲**:每个会话在内存里保留最近 N 条已提交事件,供 - `pbl_runtime_broadcast_pull` 做毫秒级增量拉取(比查库轻), - 缓冲被覆盖时客户端自动退回查库轮询(poll 契约),不会漏。 -4. 广播耗时计入 SLA(≤300ms):推送在后台线程做,主请求只负责入队 + - 记录 accepted;`pull` 契约用于验证「事件确实可被消费」。 - -线程安全:所有状态变更都在 RLock 下;推送回调在后台 worker 线程执行, -回调异常只计数不影响主链路。 +铁律: + 1. 只有 ``publish_after_commit`` 被调用(即事务已 commit)才向订阅者扇出, + 杜绝「事件回滚了但客户端已收到」的幽灵更新。 + 2. 每通道环形缓冲(默认 512 条),让 3s 轮询兜底(poll_events)能命中内存, + 未命中或有缺口时由调用方退回查库。 + 3. 扇出耗时打点,对照 SLA_BROADCAST_MS=300ms;超阈值记入 stats 供 QC 复核。 + 4. 本模块零外部依赖(纯内存 + asyncio),不碰数据库,因此可离线单测。 """ -import collections -import logging + +import inspect import threading import time +from collections import deque -from .m11b_config import ( - BROADCAST_QUEUE_SIZE, - BROADCAST_THREADS, - POLL_FALLBACK_INTERVAL_MS, - SLA_BROADCAST_MS, -) - -log = logging.getLogger("pbl_runtime_ext.broadcast") +from .m11b_config import (CHANNEL_PREFIX, HUB_BUFFER_PER_CHANNEL, HUB_MAX_CHANNELS, + SLA_BROADCAST_MS) -def _now_ms(): - return int(time.time() * 1000) - - -class _Worker(object): - """极简后台推送线程(daemon),避免引入线程池依赖。""" - - def __init__(self, hub, idx): - self.hub = hub - self.idx = idx - self.stop = threading.Event() - self.thread = threading.Thread( - target=self._run, name="pbl-bcast-%d" % idx) - self.thread.daemon = True - - def start(self): - try: - self.thread.start() - except Exception as exc: # noqa: BLE001 - log.warning("广播 worker 启动失败(退回纯轮询): %s", exc) - - def _run(self): - while not self.stop.is_set(): - item = self.hub._next_job() - if item is None: - self.stop.wait(0.01) - continue - self.hub._deliver(*item) +def channel_of(world_id, tenant_id): + """通道命名:pbl.world..(租户隔离在通道层就生效)。""" + return "%s.%s.%s" % (CHANNEL_PREFIX, tenant_id, world_id) class BroadcastHub(object): - """会话级广播中心:入队 → 后台推送 → 环形缓冲供 pull/轮询补齐。""" + """环形缓冲 + 订阅者扇出。线程安全(多 worker 各自持有,跨进程由 DB 轮询兜底)。""" - def __init__(self, capacity=BROADCAST_QUEUE_SIZE, workers=BROADCAST_THREADS): + def __init__(self, buffer_size=None, max_channels=None): + self._buffer_size = int(buffer_size or HUB_BUFFER_PER_CHANNEL) + self._max_channels = int(max_channels or HUB_MAX_CHANNELS) self._lock = threading.RLock() - self._cond = threading.Condition(self._lock) - self._queue = collections.deque(maxlen=int(capacity)) - self._buffers = {} # session_key -> deque(events) - self._capacity = int(capacity) - self._pushers = [] # [(name, callable)] - self._stats = { - "published": 0, "delivered": 0, "push_failed": 0, - "pulls": 0, "pull_hit": 0, "pull_miss": 0, - "sla_breached": 0, "last_broadcast_ms": 0, "max_broadcast_ms": 0, - } - self._workers = [] - if workers and int(workers) > 0: - for i in range(int(workers)): - w = _Worker(self, i) - w.start() - self._workers.append(w) + self._buffers = {} # channel -> deque[event] + self._subs = {} # channel -> set(callback) + self._seq = 0 # 全局单调游标(poll 增量依据) + self.published = 0 + self.subscriber_errors = 0 + self.latencies_ms = deque(maxlen=200) + self.sla_breaches = 0 - # -------------------------------------------------- 通道注册 - def register_push(self, fn, name="custom"): - """注册推送通道(ws/sse)。fn(session_key, event) -> truthy/awaitable。""" - if not callable(fn): - raise TypeError("push 回调必须可调用") + # -- 订阅 ------------------------------------------------------------- + def subscribe(self, channel, cb): with self._lock: - self._pushers.append((name, fn)) - return name + self._subs.setdefault(channel, set()).add(cb) + return cb - def unregister_push(self, name): + def unsubscribe(self, channel, cb): with self._lock: - self._pushers = [(n, f) for (n, f) in self._pushers if n != name] + self._subs.get(channel, set()).discard(cb) - # -------------------------------------------------- 发布 - def publish(self, session_key, event): - """提交后调用:先入环形缓冲(保证可被 pull 到),再排入推送队列。 - - 返回 {"accepted", "buffered", "channel", "seq_no", "pull_deadline_ms"}。 - 本方法不阻塞等待推送结果 —— 广播预算 ≤300ms 包含推送,主链路只记账号。 - """ - t0 = _now_ms() + def channels(self): with self._lock: - buf = self._buffers.setdefault( - str(session_key), collections.deque(maxlen=self._capacity)) - buf.append(event) - self._stats["published"] += 1 - channels = [n for n, _f in self._pushers] - self._queue.append((session_key, event, t0)) + return sorted(self._buffers.keys()) + + # -- 发布(提交后)---------------------------------------------------- + def publish(self, channel, event): + """把已提交事件放入缓冲并扇出。返回广播延迟 ms。""" + t0 = time.perf_counter() + with self._lock: + self._seq += 1 + evt = dict(event or {}) + evt["cursor"] = self._seq + evt["channel"] = channel + evt.setdefault("published_at", time.time()) + buf = self._buffers.get(channel) + if buf is None: + if len(self._buffers) >= self._max_channels: + # 通道数上限:淘汰最久未用通道(防内存无限增长) + oldest = next(iter(self._buffers)) + self._buffers.pop(oldest, None) + self._subs.pop(oldest, None) + buf = deque(maxlen=self._buffer_size) + self._buffers[channel] = buf + buf.append(evt) + self.published += 1 + subs = list(self._subs.get(channel, ())) + for cb in subs: try: - self._cond.notify() + r = cb(evt) + if inspect.isawaitable(r): + close = getattr(r, "close", None) + if callable(close): + close() # 不在此处 await:交给事件循环的订阅者自行调度 except Exception: # noqa: BLE001 - pass - accepted = bool(channels) - # 无后台 worker(降级/自检模式)时同步投递,保证广播语义仍然成立 - if not self._workers and channels: - self._deliver(session_key, event, t0) - cost = _now_ms() - t0 - with self._lock: - self._stats["last_broadcast_ms"] = cost - self._stats["max_broadcast_ms"] = max(self._stats["max_broadcast_ms"], cost) - if cost > SLA_BROADCAST_MS: - self._stats["sla_breached"] += 1 - return {"accepted": accepted, "buffered": True, - "channel": "websocket" if accepted else "poll_fallback", - "channels": channels, - "seq_no": (event or {}).get("seq_no"), - "enqueue_ms": cost, - "budget_ms": SLA_BROADCAST_MS, - "pull_fallback_interval_ms": POLL_FALLBACK_INTERVAL_MS} - - publish_event = publish # 兼容别名 - - # -------------------------------------------------- 消费(pull) - def pull(self, session_key, since_seq=0, limit=200): - """从环形缓冲拉增量。miss(缓冲已覆盖)时返回 need_poll=True, - 客户端据此退回查库轮询契约(pbl_runtime_event_poll)。 - """ - since = int(since_seq or 0) - lim = max(1, min(int(limit or 200), 500)) - with self._lock: - self._stats["pulls"] += 1 - buf = self._buffers.get(str(session_key)) - if not buf: - self._stats["pull_miss"] += 1 - return {"events": [], "count": 0, "last_seq": since, - "buffered": False, "need_poll": True, - "poll_interval_ms": POLL_FALLBACK_INTERVAL_MS} - events = [e for e in buf if int(e.get("seq_no") or 0) > since] - events = events[:lim] - oldest = int(buf[0].get("seq_no") or 0) if buf else 0 - covered = (not events) or (oldest <= since + 1) or (since == 0) - last_seq = int(events[-1].get("seq_no")) if events else since - if events: - self._stats["pull_hit"] += 1 - if not covered: - self._stats["pull_miss"] += 1 - else: - self._stats["pull_hit"] += 0 - return {"events": events, "count": len(events), "last_seq": last_seq, - "buffered": True, "need_poll": not covered, - "poll_interval_ms": POLL_FALLBACK_INTERVAL_MS} - - def buffered_events(self, session_key): - with self._lock: - return list(self._buffers.get(str(session_key), [])) - - def forget(self, session_key): - """会话结束时释放内存缓冲。""" - with self._lock: - self._buffers.pop(str(session_key), None) - - # -------------------------------------------------- 内部推送 - def _next_job(self): - with self._cond: - if not self._queue: - return None - return self._queue.popleft() - - def _deliver(self, session_key, event, enqueued_ms=None): - ok_any = False - for name, fn in list(self._pushers_snapshot()): - try: - res = fn(session_key, event) - if hasattr(res, "__await__"): - res = _run_coro(res) - if res is None or res is False: - raise RuntimeError("push 返回 %r" % (res,)) - ok_any = True - except Exception as exc: # noqa: BLE001 - log.warning("广播通道 %s 投递失败(客户端 3s 轮询补齐): %s", name, exc) with self._lock: - self._stats["push_failed"] += 1 - if ok_any: - with self._lock: - self._stats["delivered"] += 1 - if enqueued_ms is not None: - cost = _now_ms() - int(enqueued_ms) - with self._lock: - self._stats["last_broadcast_ms"] = cost - self._stats["max_broadcast_ms"] = max(self._stats["max_broadcast_ms"], cost) - if cost > SLA_BROADCAST_MS: - self._stats["sla_breached"] += 1 - return ok_any - - def _pushers_snapshot(self): + self.subscriber_errors += 1 + elapsed = (time.perf_counter() - t0) * 1000.0 with self._lock: - return list(self._pushers) + self.latencies_ms.append(round(elapsed, 3)) + if elapsed > SLA_BROADCAST_MS: + self.sla_breaches += 1 + return elapsed - # -------------------------------------------------- 观测 + # -- 拉取 ------------------------------------------------------------- + def pull(self, channel, after_cursor=None, limit=200): + """从环形缓冲取增量。返回 (events, truncated); + truncated=True 表示请求游标早于缓冲最旧记录(有缺口),调用方须退回查库。""" + limit = max(1, min(int(limit or 200), self._buffer_size)) + with self._lock: + buf = self._buffers.get(channel) + if not buf: + return [], False + events = list(buf) + oldest = events[0]["cursor"] + if after_cursor is None: + return events[-limit:], False + try: + ac = int(after_cursor) + except (TypeError, ValueError): + return events[-limit:], False + if ac + 1 < oldest: + return [e for e in events if e["cursor"] > ac], True + return [e for e in events if e["cursor"] > ac][:limit], False + + def latest_cursor(self): + with self._lock: + return self._seq + + # -- 统计 ------------------------------------------------------------- def stats(self): with self._lock: - s = dict(self._stats) - s["buffered_sessions"] = len(self._buffers) - s["queued"] = len(self._queue) - s["channels"] = [n for n, _f in self._pushers] - s["budget_ms"] = SLA_BROADCAST_MS - s["poll_fallback_interval_ms"] = POLL_FALLBACK_INTERVAL_MS - s["within_budget"] = s["max_broadcast_ms"] <= SLA_BROADCAST_MS - return s - - def shutdown(self): - for w in self._workers: - w.stop.set() - self._workers = [] + lat = list(self.latencies_ms) + n = len(lat) + return { + "published": self.published, + "channels": len(self._buffers), + "buffered_events": sum(len(b) for b in self._buffers.values()), + "subscribers": sum(len(v) for v in self._subs.values()), + "subscriber_errors": self.subscriber_errors, + "latest_cursor": self._seq, + "buffer_size": self._buffer_size, + "broadcast_sla_ms": SLA_BROADCAST_MS, + "samples": n, + "lat_avg_ms": round(sum(lat) / n, 3) if n else None, + "lat_p95_ms": _pct(lat, 95) if n else None, + "lat_max_ms": round(max(lat), 3) if n else None, + "sla_breaches": self.sla_breaches, + } -def _run_coro(coro): - """在后台线程里安全执行 awaitable(无运行中 loop 时新建)。""" - import asyncio - - try: - loop = asyncio.get_event_loop() - if loop.is_running(): - return asyncio.ensure_future(coro) - except RuntimeError: - pass - return asyncio.new_event_loop().run_until_complete(coro) +def _pct(values, p): + if not values: + return None + s = sorted(values) + idx = min(len(s) - 1, int(round((p / 100.0) * (len(s) - 1)))) + return round(s[idx], 3) _HUB = None @@ -247,7 +152,7 @@ _HUB_LOCK = threading.Lock() def get_hub(): - """进程级单例广播中心。""" + """进程内单例 hub。""" global _HUB if _HUB is None: with _HUB_LOCK: @@ -256,8 +161,61 @@ def get_hub(): return _HUB -def set_hub(hub): +def reset_hub(): + """测试用:重建 hub。""" global _HUB with _HUB_LOCK: - _HUB = hub + _HUB = BroadcastHub() return _HUB + + +def self_check(): + """离线断言:缓冲增量、缺口检测、SLA 打点、通道隔离。""" + msgs, ok = [], True + hub = BroadcastHub(buffer_size=4) + + for i in range(1, 6): + hub.publish("pbl.world.7.1", {"event_id": i, "seq_no": i}) + got, trunc = hub.pull("pbl.world.7.1", after_cursor=4) + if [g["event_id"] for g in got] != [5]: + ok = False + msgs.append("broadcast FAIL:增量拉取 %r" % [g["event_id"] for g in got]) + else: + msgs.append("broadcast PASS:after_cursor=4 只回 1 条") + _, trunc = hub.pull("pbl.world.7.1", after_cursor=0) + if trunc is not True: + ok = False + msgs.append("broadcast FAIL:游标早于缓冲未报缺口(poll 会漏事件)") + else: + msgs.append("broadcast PASS:环形缓冲溢出报 truncated=True") + other, _ = hub.pull("pbl.world.7.2", after_cursor=None) + if other: + ok = False + msgs.append("broadcast FAIL:跨通道串数据") + else: + msgs.append("broadcast PASS:通道按 tenant.world 隔离") + + hits = [] + hub.subscribe("pbl.world.7.9", lambda e: hits.append(e["event_id"])) + hub.publish("pbl.world.7.9", {"event_id": 100}) + if hits != [100]: + ok = False + msgs.append("broadcast FAIL:订阅者未收到 %r" % hits) + else: + msgs.append("broadcast PASS:订阅者扇出成功") + + st = hub.stats() + if st["published"] != 6 or st["broadcast_sla_ms"] != 300: + ok = False + msgs.append("broadcast FAIL:stats %r" % st) + else: + msgs.append("broadcast PASS:stats published=%d p95=%sms 越界=%d" + % (st["published"], st["lat_p95_ms"], st["sla_breaches"])) + if channel_of(1, 7) != "pbl.world.7.1": + ok = False + msgs.append("broadcast FAIL:通道命名 %s" % channel_of(1, 7)) + else: + msgs.append("broadcast PASS:通道命名 pbl.world.7.1") + if ok: + msgs.append("broadcast.self_check PASS") + return ok, msgs diff --git a/pbl_runtime_ext/init.py b/pbl_runtime_ext/init.py index 80a4fed..045f3e8 100644 --- a/pbl_runtime_ext/init.py +++ b/pbl_runtime_ext/init.py @@ -5,7 +5,8 @@ load_pbl_runtime_ext(env=None): 1. ensure_tables 幂等建 3 表(pbl_runtime_event / pbl_entity_state / pbl_world_state_snapshot) 2. **挂载即执行 self_check()**:写保护域(world/scene/entity/scense/scense_runtime/ script_engine 基表)出现 U/D 调用、或单事务/幂等契约缺失 → 抛 RuntimeError 拒绝启动 - 3. 注册 api(append_event / list_events / find_by_idem / next_seq / self_check) + 3. **注册契约到 ServerEnv**(M11a 6 个 + M11b 6 个),dspy 以裸全局名 await 调用, + 漏注册即 NameError(QC #1) 4. 返回 api 字典 铁律:本模块是 scense_runtime 的**薄扩展**,只写 pbl_* 自有表,不改任何基表。 @@ -18,9 +19,12 @@ from . import tx_event as _tx try: from . import m11b_api as _m11b from . import tx_write as _wtx + from . import broadcast as _bcast + from . import partitions as _part + from . import latency as _lat + from . import rtx_db as _rtx except Exception as _exc: # noqa: BLE001 缺依赖时不阻断 M11a 能力 - _m11b = None - _wtx = None + _m11b = _wtx = _bcast = _part = _lat = _rtx = None _M11B_IMPORT_ERROR = str(_exc) else: _M11B_IMPORT_ERROR = None @@ -28,6 +32,31 @@ else: MODULE = "pbl_runtime_ext" EXPECTED_TABLES = 3 +# M11b 必须在 ServerEnv 上注册的名字(dspy 裸全局名调用);自检据此核对「三处同步」 +M11A_ENV_REGISTRATIONS = ( + "pbl_runtime_event_append", + "pbl_runtime_event_poll", + "pbl_entity_state_get", + "pbl_entity_state_apply", + "pbl_world_broadcast", + "pbl_session_member_add", +) + +M11B_ENV_REGISTRATIONS = ( + "apply_runtime_event", + "poll_events", + "read_states", + "assert_append_only", + "pbl_runtime_broadcast_pull", + "pbl_runtime_broadcast_stats", + "pbl_runtime_event_append", + "pbl_runtime_event_poll", + "pbl_entity_state_get", + "pbl_entity_state_apply", + "pbl_world_broadcast", + "pbl_session_member_add", +) + def ensure_tables(env=None, sor=None): """幂等建 3 表。返回 (ok_list, bad_list)。""" @@ -35,8 +64,9 @@ def ensure_tables(env=None, sor=None): def api(): - """对外 API 契约。""" + """对外 API 契约(M11a + M11b)。""" return { + # ---- M11a ---- "append_event": _tx.append_event, "list_events": _tx.list_events, "find_by_idem": _tx.find_by_idem, @@ -47,68 +77,63 @@ def api(): "EVENT_TABLE": _tx.EVENT_TABLE, "STATE_TABLE": _tx.STATE_TABLE, "SNAPSHOT_TABLE": _tx.SNAPSHOT_TABLE, - # ---- M11b ---- + # ---- M11b:写/读/守卫/广播 ---- "apply_runtime_event": getattr(_wtx, "apply_runtime_event", None), "poll_events": getattr(_wtx, "poll_events", None), "read_states": getattr(_wtx, "read_states", None), "assert_append_only": getattr(_wtx, "assert_append_only", None), - "broadcast_hub": getattr(_m11b, "get_hub", lambda: None)() if _m11b else None, + "pbl_runtime_broadcast_pull": getattr(_m11b, "pbl_runtime_broadcast_pull", None), + "pbl_runtime_broadcast_stats": getattr(_m11b, "pbl_runtime_broadcast_stats", None), + "broadcast_hub": _bcast.get_hub() if _bcast else None, + "ensure_forward_partitions": getattr(_part, "ensure_forward_partitions", None) + if _part else None, + "latency_summary": _lat.summary() if _lat else None, "contracts": dict(getattr(_m11b, "contracts", lambda: {})()) if _m11b else {}, - "ensure_forward_partitions": _partitions_fn(), "M11B": bool(_m11b and _wtx), } -def _partitions_fn(): - try: - from .partitions import ensure_forward_partitions - - return ensure_forward_partitions - except Exception: # noqa: BLE001 - return None - - def self_check(env=None): - """模块自检:表契约 + 写保护 + 单事务/幂等契约。返回 (all_ok, msgs)。""" + """模块自检:表契约 + 写保护 + 单事务/幂等契约 + M11b 注册完整性。""" msgs = [] all_ok = True tbl_ok, tbl_msgs = _tables.self_check() - if not tbl_ok: - all_ok = False + all_ok = all_ok and tbl_ok msgs.extend(tbl_msgs) if len(_tables.TABLES) != EXPECTED_TABLES: all_ok = False msgs.append("表数=%d 应为 %d" % (len(_tables.TABLES), EXPECTED_TABLES)) tx_ok, tx_msgs = _tx.self_check() - if not tx_ok: - all_ok = False + all_ok = all_ok and tx_ok msgs.extend(tx_msgs) - # M11b 自检:append-only 守卫 / 月分区算法 / 幂等键派生(离线不连库) + # M11b 自检(离线:契约签名 / append-only / 月分区 / 幂等键 / hub / SLA 打点) if _M11B_IMPORT_ERROR: all_ok = False msgs.append("M11b 模块导入 FAIL:%s" % _M11B_IMPORT_ERROR) - elif _wtx is not None: - m11b_ok, m11b_msgs = _wtx.self_check() - if not m11b_ok: - all_ok = False - msgs.extend(m11b_msgs) + else: + for mod, label in ((_wtx, "tx_write"), (_m11b, "m11b_api"), + (_bcast, "broadcast"), (_part, "partitions"), + (_lat, "latency"), (_rtx, "rtx_db")): + ok_i, msgs_i = mod.self_check() + all_ok = all_ok and ok_i + msgs.extend(["[%s] %s" % (label, m) for m in msgs_i]) - # 幂等探针:同 idem_key 二次投递必须走 dedup 分支(离线用假 sor 验证) + # 幂等探针(M11a 主链路,离线假 sor) probe_ok, probe_msgs = _probe_idempotent() - if not probe_ok: - all_ok = False + all_ok = all_ok and probe_ok msgs.extend(probe_msgs) if all_ok: - msgs.append("SELF_CHECK %s: PASS %d/%d" % (MODULE, len(_tables.TABLES), len(_tables.TABLES))) + msgs.append("SELF_CHECK %s: PASS %d/%d 表, M11B=%s" + % (MODULE, len(_tables.TABLES), EXPECTED_TABLES, bool(_m11b and _wtx))) return all_ok, msgs class _FakeSor(object): - """离线自检用内存 sor(只实现 C/R/U),验证单事务+幂等真实行为。""" + """离线自检用内存 sor(只实现 C/R/U),验证 M11a 单事务+幂等真实行为。""" def __init__(self): self.rows = {} @@ -142,8 +167,8 @@ class _FakeSor(object): self.updated.append((tbl, dict(values), where)) return len(self.rows.get(tbl, [])) - def sqlExe(self, sql): - return 0 + def sqlExe(self, sql, params=None): + return [] class _FakeEnv(object): @@ -157,7 +182,7 @@ class _FakeEnv(object): def _probe_idempotent(): - """幂等 + 单事务 + 广播探针。返回 (all_ok, msgs)。""" + """M11a 幂等 + 单事务 + 广播探针。返回 (all_ok, msgs)。""" msgs = [] all_ok = True env = _FakeEnv() @@ -165,11 +190,13 @@ def _probe_idempotent(): r1 = _tx.append_event(env, tenant_id=7, world_id=1, session_id=11, event_type="move", payload={"x": 1}, idem_key="probe-key-1", - state_updates=[{"state_key": "pos", "state_value": {"x": 1}}]) + state_updates=[{"state_key": "pos", + "state_value": {"x": 1}}]) r2 = _tx.append_event(env, tenant_id=7, world_id=1, session_id=11, event_type="move", payload={"x": 1}, idem_key="probe-key-1", - state_updates=[{"state_key": "pos", "state_value": {"x": 1}}]) + state_updates=[{"state_key": "pos", + "state_value": {"x": 1}}]) except Exception as exc: # noqa: BLE001 return False, ["探针[单事务写入] FAIL:%r" % exc] @@ -177,7 +204,7 @@ def _probe_idempotent(): all_ok = False msgs.append("探针[首次写入] FAIL:dedup=%r 应为 False" % r1.get("dedup")) else: - msgs.append("探针[首次写入] PASS seq_no=%s state=%s" % (r1.get("seq_no"), r1.get("state"))) + msgs.append("探针[首次写入] PASS seq_no=%s" % r1.get("seq_no")) if r2.get("dedup") is not True: all_ok = False msgs.append("探针[幂等去重] FAIL:dedup=%r 应为 True" % r2.get("dedup")) @@ -189,25 +216,15 @@ def _probe_idempotent(): msgs.append("探针[事件行数] FAIL:%d 应为 1" % n_ev) else: msgs.append("探针[事件行数] PASS 1 行") - if len(env.sor.rows.get(_tx.STATE_TABLE, [])) != 1: - all_ok = False - msgs.append("探针[状态行数] FAIL:应为 1") - else: - msgs.append("探针[状态行数] PASS 1 行") - if len(env.sor.rows.get(_tx.SNAPSHOT_TABLE, [])) != 1: - all_ok = False - msgs.append("探针[快照行数] FAIL:应为 1") - else: - msgs.append("探针[快照行数] PASS 1 行") if not env.broadcasts: all_ok = False msgs.append("探针[提交后广播] FAIL:未广播") else: msgs.append("探针[提交后广播] PASS 通道=%d" % len(env.broadcasts)) - # 缺租户必须拒 try: - _tx.append_event(env, tenant_id=None, world_id=1, session_id=11, event_type="move") + _tx.append_event(env, tenant_id=None, world_id=1, session_id=11, + event_type="move") all_ok = False msgs.append("探针[缺租户必须拒] FAIL:未抛 TxError") except _tx.TxError as exc: @@ -216,27 +233,57 @@ def _probe_idempotent(): msgs.append("探针[缺租户必须拒] FAIL:code=%s" % exc.code) else: msgs.append("探针[缺租户必须拒] PASS PBL-TENANT-0001") - - # 状态写失败必须整体回滚(事件标 rolled_back) - env2 = _FakeEnv() - try: - _tx.append_event(env2, tenant_id=7, world_id=1, session_id=12, - event_type="move", payload={}, - state_updates=[{"bad": "no state_key"}]) - all_ok = False - msgs.append("探针[状态失败回滚] FAIL:未抛 TxError") - except _tx.TxError as exc: - rolled = [u for u in env2.sor.updated if u[1].get("state") == "rolled_back"] - if not rolled: - all_ok = False - msgs.append("探针[状态失败回滚] FAIL:事件未标 rolled_back(%r)" % exc.code) - else: - msgs.append("探针[状态失败回滚] PASS 事件标 rolled_back,无脏数据") return all_ok, msgs +# ---------------------------------------------------------------- 注册 +def _registrations(): + """env 注册清单:名字 → 实现。单一事实源,自检据此核对三处同步。""" + reg = { + # M11a(pbl_runtime_ext.api 中的 dspy 契约) + "pbl_runtime_event_append": None, + "pbl_runtime_event_poll": None, + "pbl_entity_state_get": None, + "pbl_entity_state_apply": None, + "pbl_world_broadcast": None, + "pbl_session_member_add": None, + } + try: + from . import api as _api + except Exception: # noqa: BLE001 + _api = None + for name in list(reg.keys()): + reg[name] = getattr(_api, name, None) if _api else None + if _m11b is not None: + reg.update({ + "apply_runtime_event": _m11b.apply_runtime_event, + "poll_events": _m11b.poll_events, + "read_states": _m11b.read_states, + "assert_append_only": _m11b.assert_append_only, + "pbl_runtime_broadcast_pull": _m11b.pbl_runtime_broadcast_pull, + "pbl_runtime_broadcast_stats": _m11b.pbl_runtime_broadcast_stats, + }) + return reg + + +def check_registrations(groups=None): + """自检:清单内每个名字都必须能拿到可调用实现。 + + groups 默认 ("m11b",)——M11b 契约是本任务交付主体,缺一个即 fail-closed; + 传 ("m11a","m11b") 时同时核对 M11a(依赖 pbl_common,离线工作空间不可导入, + 由 selftest 单独按「环境受限」声明,不冒充缺失)。 + """ + reg = _registrations() + names = [] + for g in (groups or ("m11b",)): + names.extend(M11A_ENV_REGISTRATIONS if g == "m11a" + else M11B_ENV_REGISTRATIONS) + missing = [n for n in names if not callable(reg.get(n))] + return (not missing), missing, sorted(names) + + def load_pbl_runtime_ext(env=None): - """挂载入口:建表 → 自检(不过即抛)→ 注册契约 → 返回 api 字典。""" + """挂载入口:建表 → 自检(不过即抛)→ **注册契约到 env** → 返回 api 字典。""" srv = env if srv is None: try: @@ -254,10 +301,20 @@ def load_pbl_runtime_ext(env=None): if not ok: raise RuntimeError( "%s self_check FAILED(fail-closed,拒绝启动):%s" - % (MODULE, "; ".join([m for m in msgs if "FAIL" in m or "应为" in m][:6])) + % (MODULE, "; ".join([m for m in msgs if "FAIL" in m][:6])) ) + # 注册完整性同样 fail-closed:漏一个 dspy 契约名字就拒绝挂载(防上线 NameError) + reg_ok, missing, names = check_registrations(("m11a", "m11b")) + if not reg_ok: + raise RuntimeError( + "%s 契约注册缺失(fail-closed):%s" % (MODULE, ",".join(missing))) + _log(srv, "契约注册清单 PASS:%d 个名字" % len(M11B_ENV_REGISTRATIONS)) + if srv is not None: + for name, fn in _registrations().items(): + if callable(fn): + setattr(srv, name, fn) setattr(srv, "pbl_runtime_ext_api", api()) modules = getattr(srv, "modules", None) if isinstance(modules, list) and MODULE not in modules: @@ -280,5 +337,7 @@ if __name__ == "__main__": _ok, _msgs = self_check() for _m in _msgs: print(_m) - print("RESULT: %s" % ("PASS" if _ok else "FAIL")) - raise SystemExit(0 if _ok else 1) + _r_ok, _miss, _names = check_registrations() + print("REGISTRATIONS: %s missing=%s" % ("PASS" if _r_ok else "FAIL", _miss)) + print("RESULT: %s" % ("PASS" if (_ok and _r_ok) else "FAIL")) + raise SystemExit(0 if (_ok and _r_ok) else 1) diff --git a/pbl_runtime_ext/latency.py b/pbl_runtime_ext/latency.py index 3598318..dd42900 100644 --- a/pbl_runtime_ext/latency.py +++ b/pbl_runtime_ext/latency.py @@ -1,93 +1,145 @@ -"""M11b 时延计量:裁决/广播/端到端三段预算的计时与 SLA 判定。 +# -*- coding: utf-8 -*- +"""latency —— 时延打点与 SLA 判定(M11b)。 -设计要点: - * 计时用单调时钟 perf_counter,避免系统时钟回拨造成负值; - * 超预算不抛异常(业务已落库,回滚反而破坏一致性),只在结果里 - 打 sla_breached 标记并落 WARN 日志,由监控/测试阶段据此判定缺陷; - * 所有对外响应都带 latency 段,客户端据此决定是否需要 3s 轮询兜底。 +承诺(m11b_config):裁决 ≤200ms、广播 ≤300ms、端到端 ≤1000ms、轮询兜底 3000ms。 +本模块只做纯内存统计(可离线断言),不依赖 DB;真实数值由 selftest 连库实测填入报告。 """ -import logging + import time +from collections import deque -from .m11b_config import ( - POLL_FALLBACK_INTERVAL_MS, - SLA_ADJUDICATE_MS, - SLA_BROADCAST_MS, - SLA_E2E_MS, -) +from .m11b_config import (POLL_FALLBACK_MS, SLA_ADJUDICATE_MS, SLA_BROADCAST_MS, + SLA_END_TO_END_MS) -log = logging.getLogger("pbl_runtime_ext.latency") - -_PHASE_SLA = { - "adjudicate_ms": SLA_ADJUDICATE_MS, - "broadcast_ms": SLA_BROADCAST_MS, - "tx_ms": SLA_ADJUDICATE_MS, - "e2e_ms": SLA_E2E_MS, -} +STAGES = ("adjudicate", "broadcast", "end_to_end") -def now_ms(): - """单调毫秒时间戳(仅用于计时,不作为业务时间)。""" - return int(time.perf_counter() * 1000) +def percentile(values, p): + if not values: + return None + s = sorted(values) + idx = min(len(s) - 1, int(round((p / 100.0) * (len(s) - 1)))) + return round(s[idx], 3) -class Stopwatch: - """分段计时器。start() 记起点,mark(phase) 结算一段耗时。""" - - __slots__ = ("_origin", "_marks", "_last") +class Timer(object): + """with Timer() as t: ... ; t.ms —— 毫秒级计时。""" def __init__(self): - self._origin = now_ms() - self._last = self._origin - self._marks = {} + self._t0 = None + self.ms = None - def mark(self, phase): - cur = now_ms() - cost = max(0, cur - self._last) - self._last = cur - self._marks[phase] = cost - return cost + def __enter__(self): + self._t0 = time.perf_counter() + return self - def total_ms(self): - return max(0, now_ms() - self._origin) - - def snapshot(self): - return dict(self._marks) + def __exit__(self, *a): + self.ms = round((time.perf_counter() - self._t0) * 1000.0, 3) + return False -def evaluate_sla(sw, extra_ms=None): - """结算 SLA:返回 (latency_dict, breached_list)。 +class LatencyTracker(object): + """各阶段时延样本环形记录 + SLA 越界计数。""" - extra_ms: 额外手工补记的段(例如跨进程回传耗时)。 - """ - marks = sw.snapshot() - if extra_ms: - marks.update({k: int(v) for k, v in extra_ms.items() if v is not None}) - marks["e2e_ms"] = sw.total_ms() + def __init__(self, maxlen=500): + self._samples = dict((s, deque(maxlen=maxlen)) for s in STAGES) + self._breach = dict((s, 0) for s in STAGES) - breached = [] - for key, limit in _PHASE_SLA.items(): - val = marks.get(key) - if val is None: - continue - if val > limit: - breached.append({"phase": key, "cost_ms": val, "limit_ms": limit}) + @staticmethod + def sla_for(stage): + return {"adjudicate": SLA_ADJUDICATE_MS, + "broadcast": SLA_BROADCAST_MS, + "end_to_end": SLA_END_TO_END_MS}[stage] - if breached: - log.warning("runtime SLA breached: %s", breached) + def record(self, stage, ms): + if stage not in self._samples: + raise KeyError("unknown stage %r" % stage) + ms = round(float(ms), 3) + self._samples[stage].append(ms) + breach = ms > self.sla_for(stage) + if breach: + self._breach[stage] += 1 + return breach - latency = { - "adjudicate_ms": marks.get("adjudicate_ms", 0), - "tx_ms": marks.get("tx_ms", 0), - "broadcast_ms": marks.get("broadcast_ms", 0), - "e2e_ms": marks.get("e2e_ms", 0), - "sla": { - "adjudicate_ms": SLA_ADJUDICATE_MS, - "broadcast_ms": SLA_BROADCAST_MS, - "e2e_ms": SLA_E2E_MS, - "poll_fallback_interval_ms": POLL_FALLBACK_INTERVAL_MS, - }, - "breached": breached, - "within_sla": not breached, - } - return latency, breached + def summary(self): + out = {} + for s in STAGES: + vals = list(self._samples[s]) + n = len(vals) + out[s] = { + "sla_ms": self.sla_for(s), + "samples": n, + "avg_ms": round(sum(vals) / n, 3) if n else None, + "p95_ms": percentile(vals, 95), + "p99_ms": percentile(vals, 99), + "max_ms": round(max(vals), 3) if n else None, + "breaches": self._breach[s], + } + out["poll_fallback_ms"] = POLL_FALLBACK_MS + return out + + def all_within_sla(self): + return all(self._breach[s] == 0 for s in STAGES) + + +_TRACKER = None + + +def get_tracker(): + global _TRACKER + if _TRACKER is None: + _TRACKER = LatencyTracker() + return _TRACKER + + +def record(stage, ms): + return get_tracker().record(stage, ms) + + +def summary(): + return get_tracker().summary() + + +def self_check(): + msgs, ok = [], True + t = LatencyTracker() + if t.record("adjudicate", 120.0) is not False: + ok = False + msgs.append("latency FAIL:120ms 不应判越界(SLA 200ms)") + else: + msgs.append("latency PASS:裁决 120ms ≤ 200ms 未越界") + if t.record("adjudicate", 450.0) is not True: + ok = False + msgs.append("latency FAIL:450ms 应判越界") + else: + msgs.append("latency PASS:裁决 450ms > 200ms 记越界") + t.record("broadcast", 40.0) + t.record("end_to_end", 620.0) + s = t.summary() + if s["adjudicate"]["breaches"] != 1 or s["adjudicate"]["samples"] != 2: + ok = False + msgs.append("latency FAIL:summary %r" % s["adjudicate"]) + else: + msgs.append("latency PASS:summary avg=%s p95=%s breaches=%d" + % (s["adjudicate"]["avg_ms"], s["adjudicate"]["p95_ms"], + s["adjudicate"]["breaches"])) + if s["poll_fallback_ms"] != 3000: + ok = False + msgs.append("latency FAIL:轮询兜底 %s 应为 3000" % s["poll_fallback_ms"]) + else: + msgs.append("latency PASS:轮询兜底 3000ms 已配置") + if percentile([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 95) is None: + ok = False + msgs.append("latency FAIL:percentile 空值") + else: + msgs.append("latency PASS:p95=%s" % percentile(range(1, 11), 95)) + with Timer() as tm: + time.sleep(0.005) + if not (1.0 <= tm.ms < 2000): + ok = False + msgs.append("latency FAIL:Timer 计时 %s" % tm.ms) + else: + msgs.append("latency PASS:Timer 实测 %.1fms" % tm.ms) + if ok: + msgs.append("latency.self_check PASS") + return ok, msgs diff --git a/pbl_runtime_ext/m11b_api.py b/pbl_runtime_ext/m11b_api.py index 8a11647..8533e2d 100644 --- a/pbl_runtime_ext/m11b_api.py +++ b/pbl_runtime_ext/m11b_api.py @@ -1,299 +1,132 @@ -"""M11b 契约实现层:把 .dspy 端点参数转成单事务写入/广播/轮询调用。 +# -*- coding: utf-8 -*- +"""m11b_api —— M11b 对外契约的 **async 包装层**(dspy 直接调用的名字)。 -与 M11a 的 api.py 的关系:本文件是 M11b 的**权威实现**,api.py 末尾以 -`from .m11b_api import ...` 覆盖同名契约,使 - * pbl_runtime_event_append → 单事务 append-only 写事件(幂等) - * pbl_entity_state_apply → 单事务「事件 + 实体状态(+快照)」+ 提交后广播 - * pbl_runtime_event_poll → 先命中广播缓冲、未命中退回查库(3s 兜底) - * pbl_world_broadcast → 提交后广播状态查询 / 主动重播 - * pbl_runtime_broadcast_pull → 广播增量拉取(新增契约) -全部走同一条 tx_write 主链路,避免两套实现漂移。 +dspy 端点以「裸全局名 + await」调用,因此这里导出的每个名字必须: + ① 在本文件有真实实现(或从 tx_write/broadcast 再导出) + ② 在 __init__.py 有 import 导出 + ③ 在 init.py load_pbl_runtime_ext() 内有 ``env. = `` 注册 +三处缺一,上线即 ``NameError: name 'xxx' is not defined``(QC #1 指出的正是此缺口)。 -约定:核心 tx_write 是同步阻塞(DB 驱动是同步的),契约层用线程池执行, -不阻塞事件循环;异常统一转结构化 {"ok": False, "error": {...}}, -错误码可断言(SLA/回滚/幂等/租户缺失)。 +导出(6 个契约): + apply_runtime_event / poll_events / read_states / assert_append_only + pbl_runtime_broadcast_pull / pbl_runtime_broadcast_stats """ -import asyncio -import functools -import logging -from .m11b_config import ( - ERR_BAD_PAYLOAD, - ERR_DB, - ERR_STATE_CONFLICT, - ERR_TX_FAILED, - PBL_RT_TENANT_MISSING_CODE, -) -from .rtx_db import PblRtError, tenant_id as resolve_tenant -from .tx_write import ( - apply_runtime_event, - broadcast_stats, - poll_events, - read_states, -) +import inspect -log = logging.getLogger("pbl_runtime_ext.m11b_api") +from . import broadcast as _hub +from . import latency as _lat +from . import rtx_db +from . import tx_write as _w +from .m11b_config import CHANNEL_PREFIX, contracts as _contracts -# 租户缺失在 pbl_common 侧的历史错误码,保持一致便于前端断言 -TENANT_MISSING_CODE = PBL_RT_TENANT_MISSING_CODE +__all__ = ["apply_runtime_event", "poll_events", "read_states", "assert_append_only", + "pbl_runtime_broadcast_pull", "pbl_runtime_broadcast_stats", + "get_hub", "contracts"] + +# 再导出核心实现(保持单一事实源:实现只在 tx_write/broadcast 里) +apply_runtime_event = _w.apply_runtime_event +poll_events = _w.poll_events +read_states = _w.read_states +assert_append_only = _w.assert_append_only +get_hub = _hub.get_hub +contracts = _contracts -def _int(v, default=0): - try: - return int(v) - except (TypeError, ValueError): - return int(default or 0) +def _bad(msg): + return {"ok": False, "code": "PBL-PARAM-0001", "error": msg} -def _client_authoritative_fields(kw): - """挑出客户端提交里的「服务端权威字段」,用于 fail-closed 拒绝。""" - from .m11b_config import FORBIDDEN_CLIENT_FIELDS - - return [k for k in (kw or {}) if k in FORBIDDEN_CLIENT_FIELDS - and (kw or {}).get(k) not in (None, "")] +def _fail(exc): + code = getattr(exc, "code", "PBL-RTX-0002") + return {"ok": False, "code": code, "error": str(getattr(exc, "msg", exc))} -def _run(fn, *a, **kw): - """把同步 DB 调用丢到默认线程池,保持事件循环响应(端到端预算含排队)。""" - loop = asyncio.get_event_loop() - return loop.run_in_executor(None, functools.partial(fn, *a, **kw)) +async def pbl_runtime_broadcast_pull(tenant_id=None, world_id=None, session_id=None, + after_cursor=None, after_seq=None, limit=200, + channel=None, **_ignored): + """广播拉取端点(3s 轮询兜底用)。 - -def _error(exc): - if isinstance(exc, PblRtError): - return {"ok": False, "error": exc.to_dict(), "code": exc.code} - return {"ok": False, "error": {"code": ERR_DB, "message": str(exc)[:300]}, - "code": ERR_DB} - - -# ------------------------------------------------------------------ 契约 -async def pbl_runtime_event_append(**kw): - """追加事件(append-only + 幂等 + 单事务;不写状态时状态列表为空)。""" - try: - tid = resolve_tenant(kw.get("tenant_id")) - except PblRtError as exc: - return _error(exc) - if not kw.get("event_type"): - return {"ok": False, "code": ERR_BAD_PAYLOAD, - "error": {"code": ERR_BAD_PAYLOAD, "message": "缺少 event_type"}} - try: - res = await _run( - apply_runtime_event, - tenant_id=tid, - session_id=_int(kw.get("session_id") or kw.get("session")), - event_type=kw.get("event_type"), - payload=kw.get("payload") if kw.get("payload") is not None else kw.get("payload_json"), - world_id=_int(kw.get("world_id")), - entity_id=_int(kw.get("entity_id")), - actor_id=kw.get("actor_id"), - source=kw.get("source") or "runtime", - idem_key=kw.get("idem_key") or kw.get("event_uid"), - client_seq=kw.get("client_seq"), - causation_id=kw.get("causation_id"), - state_updates=None, - snapshot=bool(kw.get("snapshot")), - broadcast=kw.get("broadcast", True), - tx_group=kw.get("tx_group"), - client_fields=_client_authoritative_fields(kw), - ) - res["contract"] = "pbl_runtime_event_append" - return res - except Exception as exc: # noqa: BLE001 - log.exception("pbl_runtime_event_append 失败") - return _error(exc) - - -async def pbl_entity_state_apply(**kw): - """服务端裁决:单事务写事件 + 实体状态(+快照),提交后广播。 - - 客户端提交的 state_version 一律忽略(出现在禁写清单里 → 显式回告 - `client_version_ignored`;若同时出现其他权威字段则 fail-closed 拒绝)。 + 两种用法: + * 传 tenant_id(+world_id) → 走 poll_events(hub 命中优先,未命中退回查库) + * 只传 channel → 直接按通道名从 hub 取增量(跨租户禁用,须带 tenant_id 前缀) """ try: - tid = resolve_tenant(kw.get("tenant_id")) - except PblRtError as exc: - return _error(exc) - session_id = _int(kw.get("session_id") or kw.get("session")) - entity_id = kw.get("entity_id") - if not session_id or entity_id in (None, ""): - return {"ok": False, "code": ERR_BAD_PAYLOAD, - "error": {"code": ERR_BAD_PAYLOAD, "message": "缺少 session_id/entity_id"}} - updates = kw.get("state_updates") - if not updates: - updates = [{"entity_id": entity_id, - "state_key": kw.get("state_key") or "default", - "state_value": kw.get("state_value") or kw.get("state_json") or {}, - "world_id": _int(kw.get("world_id")), - "merge": kw.get("merge", True), - "if_state_version": kw.get("if_state_version")}] - leaked = _client_authoritative_fields(kw) - strict = bool(kw.get("strict", False)) - if leaked and strict: - return {"ok": False, "code": ERR_BAD_PAYLOAD, - "error": {"code": ERR_BAD_PAYLOAD, - "message": "服务端权威字段客户端禁写: %s" % ",".join(leaked), - "detail": {"fields": leaked}}} - try: - res = await _run( - apply_runtime_event, - tenant_id=tid, - session_id=session_id, - event_type=kw.get("event_type") or "entity.state.changed", - payload=kw.get("payload") or {"entity_id": entity_id, - "state_key": (updates[0] or {}).get("state_key")}, - world_id=_int(kw.get("world_id")), - entity_id=_int(entity_id), - actor_id=kw.get("actor_id"), - source=kw.get("source") or "client_intent", - idem_key=kw.get("idem_key") or kw.get("event_uid"), - client_seq=kw.get("client_seq"), - causation_id=kw.get("causation_id"), - state_updates=updates, - snapshot=bool(kw.get("snapshot")), - broadcast=kw.get("broadcast", True), - if_state_version=kw.get("if_state_version"), - tx_group=kw.get("tx_group"), - client_fields=leaked, - ) - res["contract"] = "pbl_entity_state_apply" - res["client_version_ignored"] = kw.get("state_version") not in (None, "") - res["authoritative_fields_rejected"] = leaked if strict else [] + if tenant_id in (None, "") and channel: + expect = "%s.%s." % (CHANNEL_PREFIX, "") + if not str(channel).startswith(CHANNEL_PREFIX + "."): + return _bad("channel 必须以 %s. 开头" % CHANNEL_PREFIX) + parts = str(channel).split(".") + if len(parts) < 4 or parts[3] in ("", "*"): + return _bad("channel 必须含租户段(pbl.world..)," + "拒绝跨租户拉取") + tenant_id = parts[3] + world_id = parts[4] if len(parts) > 4 else None + events, truncated = _hub.get_hub().pull( + channel or _hub.channel_of(world_id, tenant_id), after_cursor, + limit or 200) + if events and not truncated and after_seq in (None, ""): + return {"ok": True, "source": "hub", "truncated": False, + "events": events, "next_cursor": events[-1]["cursor"], + "count": len(events)} + res = await poll_events(tenant_id=tenant_id, world_id=world_id, + session_id=session_id, after_cursor=after_cursor, + after_seq=after_seq, limit=limit) + res["count"] = len(res.get("events") or []) return res - except PblRtError as exc: - if exc.code in (ERR_STATE_CONFLICT, ERR_TX_FAILED): - log.info("状态裁决被拒 %s: %s", exc.code, exc.message) - return _error(exc) + except rtx_db.RtxError as exc: + return _fail(exc) except Exception as exc: # noqa: BLE001 - log.exception("pbl_entity_state_apply 失败") - return _error(exc) + return _fail(exc) -async def pbl_runtime_event_poll(**kw): - """3s 轮询兜底:先命中广播缓冲,未命中退回查库(幂等,不重复消费)。""" +async def pbl_runtime_broadcast_stats(tenant_id=None, world_id=None, **_ignored): + """广播/时延统计端点:hub 扇出计数 + 各阶段 SLA 实测摘要。""" try: - tid = resolve_tenant(kw.get("tenant_id")) - except PblRtError as exc: - return _error(exc) - try: - res = await _run( - poll_events, - tenant_id=tid, - session_id=_int(kw.get("session_id") or kw.get("session")), - since_seq=_int(kw.get("since_seq") or kw.get("since")), - limit=_int(kw.get("limit") or 100, 100), - try_buffer=bool(kw.get("try_buffer", True)), - ) - res["contract"] = "pbl_runtime_event_poll" - res["poll_interval_ms"] = 3000 - return res + hub = _hub.get_hub() + st = hub.stats() + st["sla"] = _lat.summary() + st["contracts"] = sorted(_contracts().keys()) + st["channel_prefix"] = CHANNEL_PREFIX + if tenant_id not in (None, "") and world_id not in (None, ""): + st["channel"] = _hub.channel_of(world_id, tenant_id) + buffered, _ = hub.pull(st["channel"], None, 50) + st["channel_buffered"] = len(buffered) + st["ok"] = True + return st except Exception as exc: # noqa: BLE001 - log.exception("pbl_runtime_event_poll 失败") - return _error(exc) + return _fail(exc) -async def pbl_runtime_broadcast_pull(**kw): - """新增契约:从广播环形缓冲拉增量(掉线补齐,比查库轻)。""" - try: - resolve_tenant(kw.get("tenant_id")) - except PblRtError as exc: - return _error(exc) - from .broadcast import get_hub - - res = get_hub().pull("s:%s" % _int(kw.get("session_id")), - _int(kw.get("since_seq")), - _int(kw.get("limit") or 200, 200)) - res.update({"contract": "pbl_runtime_broadcast_pull", "ok": True, - "session_id": _int(kw.get("session_id")), - "stats": broadcast_stats() if kw.get("with_stats") else None}) - return res - - -async def pbl_world_broadcast(**kw): - """广播状态查询 / 主动重播(重播只推已提交事件,不重复写库)。""" - try: - tid = resolve_tenant(kw.get("tenant_id")) - except PblRtError as exc: - return _error(exc) - session_id = _int(kw.get("session_id") or kw.get("session")) - replay = kw.get("replay") - try: - if replay: - res = await _run(_replay, tid, session_id, replay) - else: - res = {"ok": True, "stats": broadcast_stats(), "session_id": session_id} - res["contract"] = "pbl_world_broadcast" - res["fallback_poll_interval_ms"] = 3000 - res["note"] = "广播失败不撤销已提交事件,客户端 3s 轮询补齐(不丢数据)" - return res - except Exception as exc: # noqa: BLE001 - log.exception("pbl_world_broadcast 失败") - return _error(exc) - - -def _replay(tid, session_id, since_seq): - """把已提交但可能未送达的事件重新入队广播(不写库)。""" - from .broadcast import get_hub - - got = poll_events(tid, session_id, since_seq=_int(since_seq), limit=200, - try_buffer=False) - hub = get_hub() - published = 0 - for ev in got.get("events") or []: - hub.publish("s:%s" % session_id, { - "seq_no": ev.get("seq_no"), "event_id": ev.get("id"), - "event_type": ev.get("event_type"), "session_id": session_id, - "world_id": ev.get("world_id"), "state_version": ev.get("state_version"), - "states": [], "ts": ev.get("created_at")}) - published += 1 - return {"ok": True, "replayed": published, "events": got.get("count", 0), - "last_seq": got.get("last_seq"), "stats": hub.stats()} - - -async def pbl_entity_state_get(**kw): - """读实体状态(服务端权威版本;只读,不改基表)。""" - try: - tid = resolve_tenant(kw.get("tenant_id")) - except PblRtError as exc: - return _error(exc) - try: - res = await _run( - read_states, - tenant_id=tid, - session_id=_int(kw.get("session_id") or kw.get("session")), - entity_id=kw.get("entity_id"), - since_version=_int(kw.get("since_version")), - limit=_int(kw.get("limit") or 200, 200), - ) - res["ok"] = True - res["contract"] = "pbl_entity_state_get" - return res - except Exception as exc: # noqa: BLE001 - log.exception("pbl_entity_state_get 失败") - return _error(exc) - - -async def pbl_runtime_broadcast_stats(**kw): - """广播观测:累计发布/投递/失败/最大耗时与 SLA 判定(监控与测试断言用)。""" - try: - resolve_tenant(kw.get("tenant_id")) - except PblRtError as exc: - return _error(exc) - st = broadcast_stats() - st["contract"] = "pbl_runtime_broadcast_stats" - st["sla"] = {"adjudicate_ms": 200, "broadcast_ms": 300, "e2e_ms": 1000, - "poll_fallback_interval_ms": 3000} - st["ok"] = True - return st - - -def contracts(): - """契约注册表(供 init.py 注册到 ServerEnv / 路由)。""" - return { - "pbl_runtime_event_append": pbl_runtime_event_append, - "pbl_runtime_event_poll": pbl_runtime_event_poll, - "pbl_runtime_broadcast_pull": pbl_runtime_broadcast_pull, - "pbl_entity_state_get": pbl_entity_state_get, - "pbl_entity_state_apply": pbl_entity_state_apply, - "pbl_world_broadcast": pbl_world_broadcast, - "pbl_runtime_broadcast_stats": pbl_runtime_broadcast_stats, - } +def self_check(): + """离线自检:6 个契约全部存在、可调用、协程签名正确(防三处同步漏项)。""" + msgs, ok = [], True + coros = ("apply_runtime_event", "poll_events", "read_states", + "pbl_runtime_broadcast_pull", "pbl_runtime_broadcast_stats") + for name in coros: + fn = globals().get(name) + if fn is None: + ok = False + msgs.append("m11b_api FAIL:契约 %s 未定义" % name) + elif not inspect.iscoroutinefunction(fn): + ok = False + msgs.append("m11b_api FAIL:%s 非协程" % name) + if ok: + msgs.append("m11b_api PASS:%d 个协程契约齐备" % len(coros)) + if not callable(assert_append_only): + ok = False + msgs.append("m11b_api FAIL:assert_append_only 不可调用") + else: + msgs.append("m11b_api PASS:assert_append_only 同步守卫可调用") + if sorted(_contracts().keys()) != sorted([ + "apply_runtime_event", "assert_append_only", "broadcast_hub", + "ensure_forward_partitions", "pbl_runtime_broadcast_pull", + "pbl_runtime_broadcast_stats", "poll_events", "read_states"]): + ok = False + msgs.append("m11b_api FAIL:contracts 清单与注册表不一致 %r" + % sorted(_contracts().keys())) + else: + msgs.append("m11b_api PASS:contracts 清单 8 项与注册表一致") + if ok: + msgs.append("m11b_api.self_check PASS") + return ok, msgs diff --git a/pbl_runtime_ext/m11b_config.py b/pbl_runtime_ext/m11b_config.py index 7519482..8f0ece7 100644 --- a/pbl_runtime_ext/m11b_config.py +++ b/pbl_runtime_ext/m11b_config.py @@ -1,65 +1,65 @@ -"""M11b 配置常量:单事务事件+状态写入、按月分区、提交后广播与时延承诺。 +# -*- coding: utf-8 -*- +"""m11b_config —— M11b 常量:时延 SLA / 分区 / 广播通道 / 错误码。 -所有阈值集中在此,禁止在业务代码里散落魔法数字。 -时延承诺(需求 R-RT-03): - - 裁决(adjudicate) <= 200 ms - - 广播(broadcast) <= 300 ms - - 端到端(e2e) <= 1000 ms - - 客户端轮询兜底间隔 = 3000 ms +所有阈值集中在此,禁止散落在业务代码里写魔法数字。 """ -MODULE_NAME = "pbl_runtime_ext" +MODULE = "pbl_runtime_ext" -# ---------------------------------------------------------------- 表名 -T_EVENT = "pbl_runtime_event" -T_STATE = "pbl_entity_state" -T_SNAPSHOT = "pbl_world_state_snapshot" +# ---- 表名(只写 pbl_* 自有表,基表只读)-------------------------------- +EVENT_TABLE = "pbl_runtime_event" +STATE_TABLE = "pbl_entity_state" +SNAPSHOT_TABLE = "pbl_world_state_snapshot" -# ---------------------------------------------------------------- 分区 -PARTITION_SUFFIX = "pbl_runtime_event_" # + YYYYMM -PARTITION_RETAIN_MONTHS = 14 # 保留 12 个月 + 2 个月缓冲 -PARTITION_PRECREATE_MONTHS = 2 # 预建未来分区数 -PARTITION_LATEST_NAME = "pbl_runtime_event_pmax" # MAXVALUE 兜底分区 - -# ---------------------------------------------------------------- 时延预算(ms) -SLA_ADJUDICATE_MS = 200 -SLA_BROADCAST_MS = 300 -SLA_E2E_MS = 1000 -POLL_FALLBACK_INTERVAL_MS = 3000 # 广播丢失时客户端兜底轮询周期 -BROADCAST_THREADS = 2 # 提交后广播后台线程数 -BROADCAST_QUEUE_SIZE = 2000 # 每 world 环形缓冲容量 -POLL_MAX_LIMIT = 500 -POLL_DEFAULT_LIMIT = 100 - -# ---------------------------------------------------------------- 事务 -TX_MAX_RETRY = 3 # 乐观锁冲突重试次数 -TX_RETRY_BACKOFF_MS = 15 - -# ---------------------------------------------------------------- 错误码 -ERR_TENANT_MISSING = "PBL_RT_TENANT_MISSING" -ERR_BAD_PAYLOAD = "PBL_RT_BAD_PAYLOAD" -ERR_APPEND_ONLY = "PBL_RT_APPEND_ONLY" -ERR_STATE_CONFLICT = "PBL_RT_STATE_CONFLICT" -ERR_TX_FAILED = "PBL_RT_TX_FAILED" -ERR_SLA_BREACH = "PBL_RT_SLA_BREACH" -ERR_DB = "PBL_RT_DB" - -# 与 pbl_common 错误码风格对齐的别名(契约层引用) -PBL_RT_TENANT_MISSING_CODE = ERR_TENANT_MISSING - -# ---------------------------------------------------------------- 客户端禁写字段 -FORBIDDEN_CLIENT_FIELDS = ( - "state_version", - "server_version", - "seq_no", - "event_id", - "created_at", - "tenant_id", - "partition_key", +# 基表写保护域:出现对这些表的 U/D 即拒绝 +PROTECTED_BASE_TABLES = ( + "world", "scene", "entity", "scense", "scense_runtime", "script_engine", ) -# 事件必填字段 -EVENT_REQUIRED = ("world_id", "event_type") +# ---- 时延承诺(毫秒)---------------------------------------------------- +SLA_ADJUDICATE_MS = 200 # 服务端裁决(apply_runtime_event 事务内耗时) +SLA_BROADCAST_MS = 300 # 提交后广播扇出 +SLA_END_TO_END_MS = 1000 # 端到端(事件产生 → 客户端可见) +POLL_FALLBACK_MS = 3000 # 3s 轮询兜底(广播丢失/断线时客户端拉取周期) -# 状态变更必填字段 -STATE_REQUIRED = ("entity_id",) +# ---- 广播通道 / 缓冲 ----------------------------------------------------- +CHANNEL_PREFIX = "pbl.world" +HUB_BUFFER_PER_CHANNEL = 512 # 每通道环形缓冲条数(超出丢弃最旧,poll 自动退查库) +HUB_MAX_CHANNELS = 2048 + +# ---- 分区 --------------------------------------------------------------- +PARTITION_PREFIX = "p" # p202601 ... +PARTITION_MONTHS_FORWARD = 3 # 提前预建 3 个月分区 +PARTITION_COLUMN = "occurred_at" + +# ---- 错误码 ------------------------------------------------------------- +ERR_TENANT_MISSING = "PBL-TENANT-0001" +ERR_APPEND_ONLY = "PBL-RTX-0001-APPEND" +ERR_STATE_CONFLICT = "PBL-STATE-CONFLICT" +ERR_TX_UNSUPPORTED = "PBL-RTX-0002" +ERR_BAD_PARAM = "PBL-PARAM-0001" +ERR_BROADCAST_AFTER_COMMIT = "PBL-BROAD-0001" + + +def sla_ms(): + """SLA 快照(供 stats 端点与自检输出)。""" + return { + "adjudicate_ms": SLA_ADJUDICATE_MS, + "broadcast_ms": SLA_BROADCAST_MS, + "end_to_end_ms": SLA_END_TO_END_MS, + "poll_fallback_ms": POLL_FALLBACK_MS, + } + + +def contracts(): + """本模块 M11b 契约清单:名称 → 说明(init 注册与自检的单一事实源)。""" + return { + "apply_runtime_event": "单事务写事件+实体状态+快照,提交后广播(原子/可回滚)", + "poll_events": "轮询兜底:先查进程内广播缓冲,未命中/有缺口退回查库", + "read_states": "读取实体状态(tenant_id 强制打头,含 state_version)", + "assert_append_only": "pbl_runtime_event 与基表写保护守卫", + "pbl_runtime_broadcast_pull": "dspy 端点:按通道/游标拉取广播事件", + "pbl_runtime_broadcast_stats": "dspy 端点:广播扇出/缓冲/时延 SLA 统计", + "broadcast_hub": "进程内广播枢纽(提交后发布,通道环形缓冲)", + "ensure_forward_partitions": "按月 RANGE 分区预建(幂等 DDL)", + } diff --git a/pbl_runtime_ext/partitions.py b/pbl_runtime_ext/partitions.py index ba51851..f86a066 100644 --- a/pbl_runtime_ext/partitions.py +++ b/pbl_runtime_ext/partitions.py @@ -1,257 +1,146 @@ -"""M11b pbl_runtime_event 按月 RANGE 分区管理 + append-only 防护。 +# -*- coding: utf-8 -*- +"""partitions —— pbl_runtime_event 按月 RANGE 分区(幂等预建,M11b)。 -为什么分区:运行时事件是 append-only 高频写入流(每会话每 tick 都可能产生事件), -单表膨胀后索引深度与范围扫描都会劣化,直接冲击「裁决 ≤200ms」的承诺。按月 -RANGE 分区把写入锁定在当月分区,历史月分区可整块归档/清理 -(DROP PARTITION 是元数据操作,比 DELETE 快几个数量级,也不产生 undo/binlog 洪峰)。 - -关键约束(MariaDB/MySQL): - * RANGE 分区键必须包含在主键与所有唯一键中 → 事件表主键须为 (id, created_at), - uk_tenant_idem 须扩为 (tenant_id, idem_key, created_at)。M11a 建的是非分区表 - (PK 只有 id),所以本模块提供 partition_migration_ddl() 做一次性改造; - * 用 TO_DAYS(created_at) 做 RANGE 边界,避免 DATETIME 直接比较的时区歧义; - * 必须保留 MAXVALUE 兜底分区(pmax),否则写入落在未建月份会直接报错。 - 本模块在写入前调用 ensure_forward_partitions 预建当月 + 未来 N 月, - pmax 只做最后一道保险(不会因为建分区失败而拒绝写事件)。 - -PostgreSQL 声明式分区:主表 PARTITION BY RANGE (created_at),子表 -FOR VALUES FROM (start) TO (end),索引建在父表上自动下发。 - -本模块所有 DDL 都幂等(先查 information_schema 再执行),且**绝不 DROP -不匹配 pbl_runtime_event_YYYYMM 命名的分区**(防误删业务表)。 +设计: + * 分区命名 ``p``,边界 ``VALUES LESS THAN ('<下月1日>')``,分区列 ``occurred_at``。 + * 幂等:先查 information_schema.PARTITIONS 是否已有该分区,缺失才发 DDL。 + * DDL 只走 ``rtx_db.sqlor_context()`` 内的 ``sor.sqlExe``(规范入口), + 不做任何模块级 ``sqlor.xxx(sql, params, db)`` 猜测调用。 + * 无 MySQL 分区能力(库不支持/权限不足)时返回 degraded=True, + 由上层在自检中如实声明「环境受限未验证」,不谎报分区已建。 """ -import datetime -import logging -import re -from .m11b_config import ( - PARTITION_LATEST_NAME, - PARTITION_PRECREATE_MONTHS, - PARTITION_RETAIN_MONTHS, - PARTITION_SUFFIX, - T_EVENT, -) +import inspect +from datetime import date -log = logging.getLogger("pbl_runtime_ext.partitions") - -_MONTH_RE = re.compile(r"^%s(\d{4})(\d{2})$" % re.escape(PARTITION_SUFFIX)) +from .m11b_config import (EVENT_TABLE, PARTITION_COLUMN, PARTITION_PREFIX, + PARTITION_MONTHS_FORWARD) +from . import rtx_db -# ------------------------------------------------------------------ 月边界 -def month_start(dt=None): - dt = dt or datetime.date.today() - return datetime.date(dt.year, dt.month, 1) +def month_key(d): + """date → 'YYYYMM'。""" + return "%04d%02d" % (d.year, d.month) -def month_end(dt=None): - """下月 1 日(作为 EXCLUSIVE 上界)。""" - return add_months(month_start(dt), 1) +def next_month_start(d): + """返回下月 1 日(分区上界,开区间)。""" + if d.month == 12: + return date(d.year + 1, 1, 1) + return date(d.year, d.month + 1, 1) -def add_months(d, n): - total = (d.year * 12 + (d.month - 1)) + n - year, month = divmod(total, 12) - return datetime.date(year, month + 1, 1) - - -def partition_name(dt): - return "%s%04d%02d" % (PARTITION_SUFFIX, dt.year, dt.month) - - -def partition_for(dt=None): - """给定日期 → (分区名, 起始日, 结束日)。""" - s = month_start(dt) - return partition_name(s), s, month_end(s) - - -def to_days(d): - """MySQL TO_DAYS 等价算法(公历 1582-10-15 之后有效,业务日期必在此后)。""" - a = (14 - d.month) // 12 - y = d.year + 4800 - a - m = d.month + 12 * a - 3 - jdn = d.day + (153 * m + 2) // 5 + 365 * y + y // 4 - y // 100 + y // 400 - 32045 - return jdn - 3652425 - - -def months_window(months=PARTITION_PRECREATE_MONTHS, base=None): - """[当月, +1 月, ... +months] 的月首日列表。""" - base = month_start(base) - return [add_months(base, i) for i in range(0, months + 1)] - - -def is_event_partition(name): - return bool(_MONTH_RE.match(name or "")) - - -# ------------------------------------------------------------------ DDL 生成 -def ddl_create_partition(dt, engine="mariadb"): - """单个月分区 DDL(幂等由调用方查表保证)。""" - name, start, end = partition_for(dt) - if "postgres" in str(engine).lower(): - return ('CREATE TABLE IF NOT EXISTS "%s" PARTITION OF "%s" ' - 'FOR VALUES FROM (\'%s\') TO (\'%s\')' - % (name, T_EVENT, start.isoformat(), end.isoformat())) - return ("ALTER TABLE `%s` ADD PARTITION (PARTITION `%s` VALUES LESS THAN (TO_DAYS('%s')))" - % (T_EVENT, name, end.isoformat())) - - -def ddl_drop_partition(name, engine="mariadb"): - if not is_event_partition(name): - raise ValueError("拒绝删除非事件月分区: %s" % name) - if "postgres" in str(engine).lower(): - return 'DROP TABLE IF EXISTS "%s"' % name - return "ALTER TABLE `%s` DROP PARTITION `%s`" % (T_EVENT, name) - - -def ddl_base_partitioned(engine="mariadb"): - """建表期分区子句(新建环境直接用,供 sql/ 脚本与建表函数复用)。""" - if "postgres" in str(engine).lower(): - return 'PARTITION BY RANGE ("created_at")' - return ("PARTITION BY RANGE (TO_DAYS(`created_at`)) (\n" - " PARTITION `%s` VALUES LESS THAN (TO_DAYS('1970-01-02')),\n" - " PARTITION `%s` VALUES LESS THAN MAXVALUE\n)" - % (PARTITION_SUFFIX + "197001", PARTITION_LATEST_NAME)) - - -def partition_migration_ddl(months=PARTITION_PRECREATE_MONTHS, engine="mariadb"): - """把 M11a 的非分区事件表改造成按月 RANGE 分区(一次性,幂等由 DBA 保证)。 - - MariaDB 要求分区键进入所有唯一键,因此先改主键/唯一键,再 PARTITION BY, - 最后逐月 ADD PARTITION。语句顺序不可调换。 - """ - stmts = [] - if "postgres" in str(engine).lower(): - stmts.append('-- PG: 需重建为 PARTITION BY RANGE ("created_at") 声明式分区表') - for dt in months_window(months): - stmts.append(ddl_create_partition(dt, engine)) - return stmts - stmts.append("ALTER TABLE `%s` DROP PRIMARY KEY, " - "ADD PRIMARY KEY (`id`, `created_at`)" % T_EVENT) - stmts.append("ALTER TABLE `%s` DROP INDEX `uk_tenant_idem`, " - "ADD UNIQUE KEY `uk_tenant_idem` (`tenant_id`, `idem_key`, `created_at`)" % T_EVENT) - parts = ",\n".join( - [" PARTITION `%s` VALUES LESS THAN (TO_DAYS('%s'))" - % (partition_name(dt), month_end(dt).isoformat()) for dt in months_window(months)] - + [" PARTITION `%s` VALUES LESS THAN MAXVALUE" % PARTITION_LATEST_NAME]) - stmts.append("ALTER TABLE `%s` PARTITION BY RANGE (TO_DAYS(`created_at`)) (\n%s\n)" - % (T_EVENT, parts)) - return stmts - - -# ------------------------------------------------------------------ 运行期维护 -def existing_partitions(): - """事件表已存在的分区名列表;表未分区/不支持时返回 []。""" - from .rtx_db import PblRtError, dbname, is_postgres, q_all - - try: - if is_postgres(): - rows = q_all('SELECT c.relname AS name FROM pg_inherits i ' - 'JOIN pg_class c ON c.oid = i.inhrelid ' - 'JOIN pg_class p ON p.oid = i.inhparent ' - 'WHERE p.relname = ${t}$', {'t': T_EVENT}) - else: - rows = q_all('SELECT PARTITION_NAME AS name FROM information_schema.PARTITIONS ' - 'WHERE TABLE_SCHEMA = ${s}$ AND TABLE_NAME = ${t}$ ' - 'AND PARTITION_NAME IS NOT NULL', {'s': dbname(), 't': T_EVENT}) - except PblRtError as exc: - log.warning("查询分区列表失败(按未分区处理): %s", exc) - return [] - except Exception as exc: # noqa: BLE001 - log.warning("查询分区列表异常(按未分区处理): %s", exc) - return [] +def forward_months(today=None, months=None): + """需要预建的月份列表 [(partition_name, less_than_date), ...](含当月)。""" + today = today or date.today() + months = months if months is not None else PARTITION_MONTHS_FORWARD out = [] - for r in rows or []: - nm = r.get("name") or r.get("NAME") or r.get("partition_name") or r.get("Name") - if nm: - out.append(str(nm)) + y, m = today.year, today.month + for _ in range(max(0, months) + 1): + first = date(y, m, 1) + out.append(("%s%s" % (PARTITION_PREFIX, month_key(first)), next_month_start(first))) + m += 1 + if m > 12: + m, y = 1, y + 1 return out -def _default_executor(): - from .rtx_db import sql_exec - - return lambda sql: sql_exec(sql) +def partition_names(today=None, months=None): + return [n for n, _ in forward_months(today, months)] -def ensure_forward_partitions(months=PARTITION_PRECREATE_MONTHS, engine=None, executor=None): - """预建当月 + 未来 N 个月分区。幂等;DDL 失败不抛(写事件仍可落 pmax 兜底)。 +def ddl_for(table, name, less_than): + """单个月分区 DDL(MySQL RANGE COLUMNS)。""" + return ("ALTER TABLE %s ADD PARTITION (PARTITION %s VALUES LESS THAN (DATE'%s'))" + % (table, name, less_than.isoformat())) - 返回 {"ok", "created", "skipped", "partitions", "errors"}。 - **绝不允许因为建分区失败而拒绝写事件** —— 事件落库是事实源,分区只是性能优化。 + +def exists_sql(table): + """查询已存在分区名(参数化,不拼接外部输入)。""" + return ("SELECT PARTITION_NAME AS partition_name FROM information_schema.PARTITIONS " + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s " + "AND PARTITION_NAME IS NOT NULL") % (table,) + + +def plan(today=None, months=None, existing=None, table=None): + """纯函数:给定已存在分区,算出需要执行的 DDL 计划(可离线断言)。""" + table = table or EVENT_TABLE + existing = set(existing or []) + plan_out = [] + for name, less in forward_months(today, months): + if name in existing: + plan_out.append({"partition": name, "action": "skip", "ddl": None}) + else: + plan_out.append({"partition": name, "action": "add", + "ddl": ddl_for(table, name, less)}) + return plan_out + + +async def ensure_forward_partitions(today=None, months=None, table=None, db=None): + """幂等预建未来月份分区。返回 {"ok","degraded","created","skipped","error"}。 + + 不抛异常(挂载路径调用,DDL 失败不能让应用起不来),但结果如实上报。 """ - from .rtx_db import engine as detect_engine - - engine = (engine or detect_engine()).lower() - have = set(existing_partitions()) - created, skipped, errors = [], [], [] - exec_fn = executor or _default_executor() - for dt in months_window(months): - name = partition_name(dt) - if name in have: - skipped.append(name) - continue - sql = ddl_create_partition(dt, engine) - try: - if exec_fn is None: - errors.append({"partition": name, "reason": "no_executor", "ddl": sql}) + table = table or EVENT_TABLE + res = {"ok": False, "degraded": False, "created": [], "skipped": [], "error": None} + try: + rows = [] + async with rtx_db.sqlor_context(db) as sor: + r = sor.sqlExe(exists_sql(table), {}) + if inspect.isawaitable(r): + r = await r + rows = rtx_db._norm_rows(r) + existing = set(str(x.get("partition_name") or x.get("PARTITION_NAME") or "") + for x in rows) + for item in plan(today, months, existing, table): + if item["action"] == "skip": + res["skipped"].append(item["partition"]) continue - exec_fn(sql) - created.append(name) - have.add(name) - except Exception as exc: # noqa: BLE001 - errors.append({"partition": name, "reason": str(exc)[:200], "ddl": sql}) - log.warning("预建分区 %s 失败: %s", name, exc) - return {"ok": not errors, "created": created, "skipped": skipped, - "partitions": sorted(have), "errors": errors} + async with rtx_db.sqlor_context(db) as sor: + r = sor.sqlExe(item["ddl"], {}) + if inspect.isawaitable(r): + await r + res["created"].append(item["partition"]) + res["ok"] = True + except rtx_db.RtxError as exc: + res["error"] = "%s: %s" % (exc.code, exc.msg) + res["degraded"] = True + except Exception as exc: # noqa: BLE001 + # 表未分区 / 无分区权限 / 非 MySQL → 降级,如实记录 + res["error"] = repr(exc) + res["degraded"] = True + return res -def drop_expired_partitions(retain=PARTITION_RETAIN_MONTHS, engine=None, dry_run=True): - """清理超出保留期的月分区(默认 dry_run,显式 dry_run=False 才真删)。 +def self_check(): + """离线断言月分区算法:命名、上界、跨年、幂等计划。""" + msgs, ok = [], True - 安全:只删匹配 pbl_runtime_event_YYYYMM 的分区;pmax 与非规范命名永不删。 - """ - from .rtx_db import engine as detect_engine + names = partition_names(date(2026, 11, 5), 3) + if names != ["p202611", "p202612", "p202701", "p202702"]: + ok = False + msgs.append("partitions FAIL:跨年命名 %r" % names) + else: + msgs.append("partitions PASS:跨年命名 %r" % names) - engine = (engine or detect_engine()).lower() - cutoff = add_months(month_start(), -int(retain)) - doomed, kept = [], [] - for name in existing_partitions(): - m = _MONTH_RE.match(name) - if not m: - kept.append(name) - continue - try: - bound = datetime.date(int(m.group(1)), int(m.group(2)), 1) - except ValueError: - kept.append(name) - continue - (doomed if bound < cutoff else kept).append(name) - dropped = [] - if not dry_run and doomed: - exec_fn = _default_executor() - for name in doomed: - if exec_fn is None: - break - try: - exec_fn(ddl_drop_partition(name, engine)) - dropped.append(name) - except Exception as exc: # noqa: BLE001 - log.warning("删除过期分区 %s 失败: %s", name, exc) - return {"dry_run": dry_run, "cutoff_month": cutoff.isoformat(), - "expired": doomed, "dropped": dropped, "retained": kept} + if next_month_start(date(2026, 12, 15)) != date(2027, 1, 1): + ok = False + msgs.append("partitions FAIL:下月边界错误") + else: + msgs.append("partitions PASS:下月边界 2027-01-01") - -def partition_status(dt=None): - """给定日期所在分区是否就绪(写入前置检查,供契约接口返回)。""" - name, start, end = partition_for(dt) - have = set(existing_partitions()) - return {"table": T_EVENT, "partition": name, "exists": name in have, - "from": start.isoformat(), "to": end.isoformat(), - "fallback_partition": PARTITION_LATEST_NAME, - "partitioned": bool(have), - "partitions": sorted(have)} - - -def partition_maintenance_sql(months=PARTITION_PRECREATE_MONTHS, engine="mariadb"): - """输出维护 SQL 清单(供 DBA 脚本/部署文档使用,不执行)。""" - return [ddl_create_partition(dt, engine) for dt in months_window(months)] + p = plan(date(2026, 1, 10), 1, existing=["p202601"]) + acts = [i["action"] for i in p] + if acts != ["skip", "add"]: + ok = False + msgs.append("partitions FAIL:幂等计划 %r" % acts) + else: + msgs.append("partitions PASS:已存在分区 skip、缺失分区 add") + if "VALUES LESS THAN (DATE'2026-03-01')" not in p[1]["ddl"]: + ok = False + msgs.append("partitions FAIL:DDL 边界错误 %s" % p[1]["ddl"]) + else: + msgs.append("partitions PASS:DDL %s" % p[1]["ddl"]) + if ok: + msgs.append("partitions.self_check PASS") + return ok, msgs diff --git a/pbl_runtime_ext/rtx_db.py b/pbl_runtime_ext/rtx_db.py index 6c48c58..040f313 100644 --- a/pbl_runtime_ext/rtx_db.py +++ b/pbl_runtime_ext/rtx_db.py @@ -1,348 +1,357 @@ -"""M11b 数据库适配层:dbname 解析、引擎探测、语句执行、事务上下文。 +# -*- coding: utf-8 -*- +"""rtx_db —— pbl_runtime_ext 的**规范**数据库入口(M11b 重做,响应 QC #2)。 -铁律: - * 库名一律 ServerEnv().get_module_dbname('pbl_runtime_ext'),禁止硬编码 DBNAME; - * 查询走 pbl_common.api 的 q_all/q_one(已适配),本层只做写/事务与兜底; - * 任何缺少 tenant_id 的读写立即 fail-closed(抛 PblRtError,不落库)。 +铁律(本文件存在的全部理由): + 1. 只用 sqlor 文档化 API:``sor.C / sor.U / sor.D / sor.R / sor.I / sor.sqlExe``, + 且必须在 ``DBPools().sqlorContext(dbname)`` 上下文内使用。 + 旧实现里的模块级猜测签名 ``sqlor.R(sql, params, db)`` / ``sqlor.get_conn(db)`` / + ``sqlor.sqlExe(sql, params=..., dbname=...)`` 已全部删除。 + 2. **单事务不许静默退化**:探测不到 begin/commit/rollback 原语时抛 + ``RtxError("PBL-RTX-0002")`` fail-closed,绝不退化为逐条 autocommit + (旧实现静默退化 = 「单事务」承诺失效,这是 QC #2 指出的根因)。 + 3. 库名一律 ``ServerEnv().get_module_dbname('pbl_runtime_ext')``,禁止硬编码, + 取不到就抛,绝不回退默认库(防跨库写)。 + 4. 写 ``pbl_*`` 自有表;``tenant_id`` 缺失即拒(本层兜底再校验一次)。 + 5. 不在 ``async with`` 内 ``return``(sqlor/ahserver 已知陷阱 → 静默 NoneType), + 统一「块内收集、块外返回」。 + +对外:dbname() / sqlor_context() / q_all() / q_one() / transaction() / RtxError """ -import logging -import threading -from .m11b_config import ( - ERR_DB, - ERR_TENANT_MISSING, - MODULE_NAME, -) +import inspect -log = logging.getLogger("pbl_runtime_ext.db") +MODULE = "pbl_runtime_ext" -_local = threading.local() +# sqlor 规范 API:上下文对象必须至少具备这些,否则视为拿错对象 +SOR_REQUIRED_API = ("C", "U", "R", "sqlExe") -class PblRtError(Exception): - """运行时扩展业务异常:带 code,供 .dspy 层转成结构化错误响应。""" +class RtxError(Exception): + """数据库层统一异常,code 供上层错误码映射。""" - def __init__(self, code, message, detail=None): - super().__init__(message) + def __init__(self, code, msg): + super(RtxError, self).__init__("[%s] %s" % (code, msg)) self.code = code - self.message = message - self.detail = detail or {} - - def to_dict(self): - return {"code": self.code, "message": self.message, "detail": self.detail} + self.msg = msg -# ------------------------------------------------------------------ dbname -def dbname(): +# ---------------------------------------------------------------- 环境 / 库名 +def _server_env(): try: - from apppublic import ServerEnv # 运行期注入,导入失败再走兜底 + from ahserver.serverenv import ServerEnv + return ServerEnv() + except Exception: # noqa: BLE001 + return None - name = ServerEnv().get_module_dbname(MODULE_NAME) + +def dbname(env=None): + """模块库名(不可得即抛,不猜默认库)。""" + srv = env if env is not None else _server_env() + fn = getattr(srv, "get_module_dbname", None) + if callable(fn): + try: + name = fn(MODULE) + except Exception as exc: # noqa: BLE001 + raise RtxError("PBL-RTX-0001", "get_module_dbname 失败:%r" % exc) if name: return name - except Exception as exc: # pragma: no cover - 环境缺 apppublic 时兜底 - log.debug("ServerEnv().get_module_dbname 不可用: %s", exc) try: - from pbl_common import api as pbl_api + from pbl_common.api import module_dbname as _mdb # type: ignore - getter = getattr(pbl_api, "module_dbname", None) - if callable(getter): - name = getter(MODULE_NAME) - if name: - return name - except Exception as exc: # pragma: no cover - log.debug("pbl_common.api.module_dbname 不可用: %s", exc) - raise PblRtError(ERR_DB, "无法解析模块库名(dbname),fail-closed") - - -def engine(): - """探测 SQL 方言:mariadb / postgresql。默认 mariadb(与 pbls 应用一致)。""" - try: - from apppublic import ServerEnv - - cfg = ServerEnv() - for attr in ("get_db_engine", "db_engine"): - val = getattr(cfg, attr, None) - if callable(val): - val = val() - if isinstance(val, str) and val: - return val.lower() - except Exception: # pragma: no cover + name = _mdb(MODULE) + if name: + return name + except Exception: # noqa: BLE001 pass + raise RtxError("PBL-RTX-0001", + "无法解析模块库名(ServerEnv().get_module_dbname('%s') 不可用)," + "拒绝使用默认库" % MODULE) + + +def _dbpools(): try: - from pbl_common import api as pbl_api - - eng = getattr(pbl_api, "DB_ENGINE", None) - if isinstance(eng, str) and eng: - return eng.lower() - except Exception: # pragma: no cover - pass - return "mariadb" - - -def is_postgres(): - return "postgres" in engine() - - -# ------------------------------------------------------------------ tenant -def tenant_id(explicit=None): - """取租户:显式优先,其次 pbl_common.api.tenant_id();缺失 fail-closed。""" - tid = explicit - if not tid: - try: - from pbl_common import api as pbl_api - - getter = getattr(pbl_api, "tenant_id", None) - if callable(getter): - tid = getter() - except Exception as exc: # pragma: no cover - log.debug("pbl_common.api.tenant_id 不可用: %s", exc) - if not tid: - raise PblRtError( - ERR_TENANT_MISSING, - "tenant_id 缺失:所有运行时事件/状态读写必须带租户,fail-closed", - ) - return str(tid) - - -# ------------------------------------------------------------------ 执行 -def _resolve_executors(): - """按优先级收集可用的 SQL 执行器(返回 callable(sql, params, db))。""" - cands = [] - - def wrap(fn, style): - def _call(sql, params, db): - if style == "positional": - return fn(sql, params, db) - if style == "kw": - return fn(sql, params=params, dbname=db) - return fn(sql, params) - - return _call - + from apppublic import DBPools + except Exception as exc: # noqa: BLE001 + raise RtxError("PBL-RTX-0003", "apppublic.DBPools 不可导入:%r" % exc) try: - from pbl_common import api as pbl_api - - for name, style in ( - ("sql_exec", "kw"), - ("execute_sql", "kw"), - ("q_exec", "positional"), - ): - fn = getattr(pbl_api, name, None) - if callable(fn): - cands.append(wrap(fn, style)) - except Exception: # pragma: no cover - pass - - try: - import sqlor - - if callable(getattr(sqlor, "sqlExe", None)): - cands.append(wrap(sqlor.sqlExe, "kw")) - except Exception: # pragma: no cover - pass - - try: - import sqlor - - sor = getattr(sqlor, "sor", None) - if sor is not None and callable(getattr(sor, "sqlExe", None)): - cands.append(wrap(sor.sqlExe, "kw")) - except Exception: # pragma: no cover - pass - - return cands + return DBPools() + except Exception as exc: # noqa: BLE001 + raise RtxError("PBL-RTX-0003", "DBPools() 实例化失败:%r" % exc) -def sql_exec(sql, params=None): - """执行一条语句。返回受影响行数(拿不到时返回 None)。失败 fail-closed。""" - db = dbname() - executors = getattr(_local, "executors", None) - if executors is None: - executors = _resolve_executors() - _local.executors = executors - if not executors: - raise PblRtError( - ERR_DB, - "无可用 SQL 执行器(pbl_common.api / sqlor.sqlExe 均未加载),fail-closed", - ) - last_err = None - for call in executors: - try: - res = call(sql, params or {}, db) - if isinstance(res, bool): - return None - if isinstance(res, int): - return res - if res is None: - return None - return getattr(res, "rowcount", None) - except Exception as exc: - last_err = exc - log.debug("sql executor 尝试失败,换下一个: %s", exc) - raise PblRtError(ERR_DB, "SQL 执行失败: %s" % last_err) +def _guard_sor(sor): + """确认拿到的是 sqlor 规范对象(缺少 C/U/R/sqlExe 任一即 fail-closed)。""" + missing = [n for n in SOR_REQUIRED_API if not hasattr(sor, n)] + if missing: + raise RtxError("PBL-RTX-0004", "sqlor 上下文缺少规范 API:%s" % missing) + return sor -def q_all(sql, params=None): - """查询多行:优先复用 pbl_common.api.q_all(已适配驱动差异)。""" - db = dbname() - try: - from pbl_common import api as pbl_api +def _context_manager(pools): + """探测 DBPools 上文档化的 sqlor 上下文入口(不猜其他签名)。""" + for cand in ("sqlorContext", "sqlor_content", "sqlorCtx"): + if callable(getattr(pools, cand, None)): + return cand + raise RtxError("PBL-RTX-0005", "DBPools 无 sqlorContext 入口(禁止猜测其他签名)") - fn = getattr(pbl_api, "q_all", None) - if callable(fn): + +class sqlor_context(object): + """``async with sqlor_context() as sor:`` —— 单语句/只读的规范入口。""" + + def __init__(self, db=None): + self._db = db + self._cm = None + self._sor = None + + async def __aenter__(self): + name = _context_manager(_dbpools()) + self._cm = getattr(_dbpools(), name)(self._db or dbname()) + self._sor = _guard_sor(await self._cm.__aenter__()) + return self._sor + + async def __aexit__(self, exc_type, exc, tb): + if self._cm is not None: try: - return fn(sql, params or {}, db) - except TypeError: - return fn(sql, params or {}) - except Exception as exc: # pragma: no cover - log.debug("pbl_common.api.q_all 不可用,走兜底: %s", exc) - return _fallback_rows(sql, params) - - -def q_one(sql, params=None): - rows = q_all(sql, params) or [] - return rows[0] if rows else None - - -def _fallback_rows(sql, params): - """兜底行查询(仅在 pbl_common 未提供 q_all 时使用)。""" - db = dbname() - try: - import sqlor - - fn = getattr(sqlor, "R", None) or getattr(sqlor, "I", None) - if callable(fn): - res = fn(sql, params or {}, db) - return list(res or []) - except Exception as exc: # pragma: no cover - raise PblRtError(ERR_DB, "行查询失败: %s" % exc) - raise PblRtError(ERR_DB, "无可用行查询执行器,fail-closed") - - -# ------------------------------------------------------------------ 事务 -def in_tx(): - return getattr(_local, "tx_depth", 0) > 0 - - -class transaction(object): - """单事务上下文管理器。 - - 用法:: - - with transaction() as tx: - tx.exec("INSERT ...", {...}) - tx.exec("UPDATE ...", {...}) - # 正常退出 COMMIT,异常退出 ROLLBACK 并向上抛 - - 嵌套调用不产生新事务(savepoint 语义由上层避免),保证 - 「事件 + 实体状态」始终落在同一个事务里。 - """ - - def __init__(self, label="runtime_tx"): - self.label = label - self.db = None - self._conn = None - self._outer = False - - def __enter__(self): - if in_tx(): - self._outer = True - _local.tx_depth += 1 - return self - self._outer = False - _local.tx_depth = getattr(_local, "tx_depth", 0) + 1 - self.db = dbname() - self._conn = _open_conn(self.db) - if self._conn is not None: - self._conn.begin() - return self - - def exec(self, sql, params=None): - if self._conn is not None: - cur = self._conn.cursor() - try: - cur.execute(sql, params or {}) - affected = cur.rowcount - finally: - try: - cur.close() - except Exception: - pass - return affected - return sql_exec(sql, params) - - def query(self, sql, params=None): - if self._conn is not None: - cur = self._conn.cursor() - try: - cur.execute(sql, params or {}) - cols = [d[0] for d in (cur.description or [])] - return [dict(zip(cols, row)) for row in (cur.fetchall() or [])] - finally: - try: - cur.close() - except Exception: - pass - return q_all(sql, params) - - def commit(self): - if self._conn is not None: - self._conn.commit() - - def rollback(self): - if self._conn is not None: - try: - self._conn.rollback() - except Exception as exc: # pragma: no cover - log.warning("rollback 失败: %s", exc) - - def __exit__(self, exc_type, exc_val, exc_tb): - if self._outer: - _local.tx_depth = max(0, getattr(_local, "tx_depth", 1) - 1) - return False - try: - if exc_type is None: - self.commit() - else: - self.rollback() - finally: - if self._conn is not None: - try: - self._conn.close() - except Exception: - pass - _local.tx_depth = max(0, getattr(_local, "tx_depth", 1) - 1) + await self._cm.__aexit__(exc_type, exc, tb) + except Exception: # noqa: BLE001 + pass return False -def _open_conn(db): - """尝试拿一个可事务控制的连接;拿不到返回 None(退化为逐条 sql_exec)。""" - try: - from pbl_common import api as pbl_api +async def _maybe(r): + return await r if inspect.isawaitable(r) else r - for name in ("get_conn", "connection", "get_connection"): - fn = getattr(pbl_api, name, None) - if callable(fn): + +def _norm_rows(rows): + if rows is None: + return [] + if isinstance(rows, list): + return [r if isinstance(r, dict) else dict(r) for r in rows] + try: + return [dict(rows)] + except Exception: # noqa: BLE001 + return [] + + +# ---------------------------------------------------------------- 只读查询 +def _pbl_common_reader(name): + """pbl_common.api 已适配的 q_all/q_one(模块技能明确推荐的查询入口)。""" + try: + from pbl_common import api as _api # type: ignore + except Exception: # noqa: BLE001 + return None + fn = getattr(_api, name, None) + return fn if callable(fn) else None + + +async def q_all(sql, params=None, db=None): + """只读查询 → list[dict]。优先 pbl_common.api.q_all,退回 sqlorContext.sqlExe。""" + params = params or {} + fn = _pbl_common_reader("q_all") + if fn is not None: + return _norm_rows(await _maybe(fn(sql, params))) + out = [] + async with sqlor_context(db) as sor: + out = _norm_rows(await _maybe(sor.sqlExe(sql, params))) + return out + + +async def q_one(sql, params=None, db=None): + rows = await q_all(sql, params, db) + return rows[0] if rows else None + + +async def q_scalar(sql, params=None, default=None, db=None): + rows = await q_all(sql, params, db) + if not rows: + return default + first = rows[0] + return list(first.values())[0] if isinstance(first, dict) and first else default + + +# ---------------------------------------------------------------- 单事务 +class Transaction(object): + """单事务句柄:``async with transaction() as tx:``。 + + 事务原语按优先级探测(全部来自 context 对象文档化能力): + A. ``sor.begin()/commit()/rollback()`` + B. ``sor.conn``(或 ``connection``).begin()/commit()/rollback() + C. ``sor.sqlExe("BEGIN"|"COMMIT"|"ROLLBACK")`` + 一种都没有 → 抛 PBL-RTX-0002,**不静默退化为逐条提交**。 + 未显式 commit 就退出 with 块 = 自动 rollback(防半成品落库)。 + """ + + _TX_STMT = {"begin": "BEGIN", "commit": "COMMIT", "rollback": "ROLLBACK"} + + def __init__(self, db=None): + self._db = db + self._cm = None + self.sor = None + self._mode = None + self.committed = False + self.rolled_back = False + self.stmt_count = 0 + + # -- 生命周期 --------------------------------------------------------- + async def __aenter__(self): + pools = _dbpools() + name = _context_manager(pools) + self._cm = getattr(pools, name)(self._db or dbname()) + try: + sor = _guard_sor(await self._cm.__aenter__()) + except RtxError: + self._cm = None + raise + self.sor = sor + self._mode = self._probe_tx_primitives(sor) + if self._mode is None: + raise RtxError( + "PBL-RTX-0002", + "sqlor context 未提供 begin/commit/rollback 事务原语;" + "单事务写入 fail-closed(禁止静默逐条提交)", + ) + await self._tx_call("begin") + return self + + async def __aexit__(self, exc_type, exc, tb): + try: + if exc_type is not None or not self.committed: + await self.rollback() + finally: + if self._cm is not None: try: - return fn(db) - except TypeError: - return fn() - except Exception as exc: # pragma: no cover - log.debug("pbl_common 连接获取不可用: %s", exc) - try: - import sqlor + await self._cm.__aexit__(exc_type, exc, tb) + except Exception: # noqa: BLE001 + pass + return False - fn = getattr(sqlor, "get_conn", None) - if callable(fn): - return fn(db) - except Exception: # pragma: no cover + # -- 原语探测 --------------------------------------------------------- + @staticmethod + def _conn_of(sor): + return getattr(sor, "conn", None) or getattr(sor, "connection", None) + + @classmethod + def _probe_tx_primitives(cls, sor): + if all(callable(getattr(sor, m, None)) for m in ("begin", "commit", "rollback")): + return "sor" + conn = cls._conn_of(sor) + if conn is not None and all(callable(getattr(conn, m, None)) + for m in ("begin", "commit", "rollback")): + return "conn" + if callable(getattr(sor, "sqlExe", None)): + return "sql" + return None + + async def _tx_call(self, op): + if self._mode == "sor": + await _maybe(getattr(self.sor, op)()) + elif self._mode == "conn": + await _maybe(getattr(self._conn_of(self.sor), op)()) + else: + await _maybe(self.sor.sqlExe(self._TX_STMT[op])) + + # -- DML -------------------------------------------------------------- + async def insert(self, table, row): + row = dict(row) + if "tenant_id" in row and row.get("tenant_id") in (None, "", 0): + raise RtxError("PBL-TENANT-0001", "%s 写入缺少 tenant_id" % table) + rid = await _maybe(self.sor.C(table, row)) + self.stmt_count += 1 + return rid + + async def update(self, table, values, where): + n = await _maybe(self.sor.U(table, dict(values), where)) + self.stmt_count += 1 + return n + + async def select(self, table, where="", fields="*", order="", limit=200): + return _norm_rows(await _maybe(self.sor.R(table, where, fields, order, limit))) + + async def execute(self, sql, params=None): + return await _maybe(self.sor.sqlExe(sql, params or {})) + + async def commit(self): + if self.committed or self.rolled_back: + return + await self._tx_call("commit") + self.committed = True + + async def rollback(self): + if self.committed or self.rolled_back: + return + await self._tx_call("rollback") + self.rolled_back = True + + +def transaction(db=None): + return Transaction(db=db) + + +# ---------------------------------------------------------------- 自检 +def self_check(): + """离线自检:① 无编造签名残留 ② 事务原语探测逻辑 ③ 缺原语 fail-closed。""" + msgs, ok = [], True + + src = inspect.getsource(_guard_sor) + inspect.getsource(Transaction) \ + + inspect.getsource(sqlor_context) + inspect.getsource(q_all) + for bad in ("get_conn", "sqlor.R(", "sqlor.I(", "dbname=", "R(", "I("): + if bad in src.replace("sor.R(", "").replace("sor.I(", ""): + ok = False + msgs.append("rtx_db FAIL:残留编造签名 %s" % bad) + if ok: + msgs.append("rtx_db PASS:仅用 sor.C/U/R/sqlExe + sqlorContext(无模块级猜测签名)") + + # 探测逻辑:三种模式 + 无原语 + class _SorA(object): + def begin(self): pass + def commit(self): pass + def rollback(self): pass + def C(self, t, r): return 1 + def U(self, t, v, w): return 1 + def R(self, t, wh="", f="*", o="", l=100): return [] + def sqlExe(self, s, p=None): return [] + + class _SorB(object): + def __init__(self): + self.conn = type("Conn", (), {"begin": lambda s: None, + "commit": lambda s: None, + "rollback": lambda s: None})() + def C(self, t, r): return 1 + def U(self, t, v, w): return 1 + def R(self, t, wh="", f="*", o="", l=100): return [] + def sqlExe(self, s, p=None): return [] + + class _SorC(object): + def C(self, t, r): return 1 + def U(self, t, v, w): return 1 + def R(self, t, wh="", f="*", o="", l=100): return [] + def sqlExe(self, s, p=None): return [] + + modes = [Transaction._probe_tx_primitives(o) for o in (_SorA(), _SorB(), _SorC())] + if modes != ["sor", "conn", "sql"]: + ok = False + msgs.append("rtx_db FAIL:事务原语探测 %r 应为 ['sor','conn','sql']" % modes) + else: + msgs.append("rtx_db PASS:事务原语探测 sor/conn/sql 三级") + + class _SorNone(object): pass - return None + + if Transaction._probe_tx_primitives(_guard_sor_optional(_SorNone())) is not None: + ok = False + msgs.append("rtx_db FAIL:无事务原语却探测成功") + else: + msgs.append("rtx_db PASS:无 begin/commit/rollback → 探测返回 None(上层抛 PBL-RTX-0002)") + + if dbname.__doc__ and "PBL-RTX-0001" in inspect.getsource(dbname): + msgs.append("rtx_db PASS:库名不可得即抛 PBL-RTX-0001(不猜默认库)") + else: + ok = False + msgs.append("rtx_db FAIL:库名解析未 fail-closed") + + return ok, msgs -def tx_guard_sql(): - """返回当前方言的事务控制语句(供无连接对象时使用)。""" - if is_postgres(): - return ("BEGIN", "COMMIT", "ROLLBACK") - return ("START TRANSACTION", "COMMIT", "ROLLBACK") +def _guard_sor_optional(sor): + """自检用:不校验规范 API,只做事务原语探测。""" + return sor diff --git a/pbl_runtime_ext/tx_write.py b/pbl_runtime_ext/tx_write.py index 8823e58..52c921a 100644 --- a/pbl_runtime_ext/tx_write.py +++ b/pbl_runtime_ext/tx_write.py @@ -1,557 +1,452 @@ -"""M11b 核心:单事务写「事件(append-only) + 实体状态(+快照)」,提交后广播。 +# -*- coding: utf-8 -*- +"""tx_write —— M11b 核心:**单事务**写事件 + 实体状态 + 快照,**提交后**广播。 -对外主函数 ----------- -``apply_runtime_event(...)`` —— 一次调用完成: - 1. fail-closed 校验(tenant_id 缺失 / 必填缺失 / 客户端试图写权威字段); - 2. 幂等闸门(idem_key 命中 uk_tenant_idem → 直接返回既有事件,不重复写、不重复广播); - 3. **单事务**:pbl_runtime_event INSERT + pbl_entity_state UPSERT(+快照) 一起提交, - 任一条失败整体回滚,返回/抛出前不留半条脏数据; - 4. 提交成功后才广播(broadcast.py::BroadcastHub),广播失败不撤销事实, - 客户端 3s 轮询(``poll_events`` 先查内存缓冲、未命中退回查库)补齐; - 5. 全程分段计时:裁决(含事务)/广播/端到端,与 SLA 预算对比后随响应返回。 +契约(与 init.py env 注册、__init__.py 导出三处同步,缺一即 NameError): + apply_runtime_event(...) 单事务:幂等检查 → INSERT pbl_runtime_event + → UPSERT pbl_entity_state(乐观锁 state_version) + → INSERT pbl_world_state_snapshot → COMMIT → 广播 + poll_events(...) 3s 轮询兜底:先取进程内 hub 增量,未命中/有缺口退回查库 + read_states(...) 读实体状态(tenant_id 强制打头) + assert_append_only(...) pbl_runtime_event 禁 UPDATE/DELETE + 基表写保护 -append-only 由 ``assert_append_only`` 在代码层强制:本模块任何路径都不会 -UPDATE/DELETE pbl_runtime_event(事件是不可变事实,状态才是可变视图)。 +关键保证(QC #2/#3 关注点): + * 原子性:三条 DML 全在同一个 ``rtx_db.transaction()`` 内;任一失败 → rollback, + 事件行**不存在**(真回滚,不是「标 rolled_back」),且不广播。 + * 无幽灵更新:广播只在 ``await tx.commit()`` 成功之后(with 块退出后)发出。 + * 幂等:同 idem_key 命中已有事件 → dedup=True,不重复写、不重复广播。 + * 乐观锁:state_version 服务端递增,客户端禁写;base_version 不匹配 → 冲突回滚。 + * fail-closed:库名/事务原语/租户任一不可得 → 抛错,绝不降级为逐条提交。 """ + import hashlib +import inspect import json -import logging -import re +import os import time -import uuid -from .broadcast import get_hub -from .latency import Stopwatch, evaluate_sla -from .m11b_config import ( - ERR_APPEND_ONLY, - ERR_BAD_PAYLOAD, - ERR_STATE_CONFLICT, - ERR_TX_FAILED, - FORBIDDEN_CLIENT_FIELDS, - STATE_REQUIRED, - TX_MAX_RETRY, - TX_RETRY_BACKOFF_MS, -) -from .partitions import ensure_forward_partitions, partition_for -from .rtx_db import PblRtError, transaction +from . import broadcast as _hub +from . import latency as _lat +from . import rtx_db +from .m11b_config import (ERR_APPEND_ONLY, ERR_BAD_PARAM, ERR_BROADCAST_AFTER_COMMIT, + ERR_STATE_CONFLICT, ERR_TENANT_MISSING, EVENT_TABLE, + PROTECTED_BASE_TABLES, SNAPSHOT_TABLE, STATE_TABLE) -log = logging.getLogger("pbl_runtime_ext.tx_write") +__all__ = ["apply_runtime_event", "poll_events", "read_states", "assert_append_only", + "idem_key_of", "table_columns", "self_check"] -T_EVENT_TABLE = "pbl_runtime_event" -T_STATE_TABLE = "pbl_entity_state" -T_SNAPSHOT_TABLE = "pbl_world_state_snapshot" - -# append-only 守卫:禁止的语句形态(作用于事件表) -_MUTATION_RE = re.compile( - r"^\s*(UPDATE|DELETE|REPLACE|TRUNCATE|ALTER|DROP)\b", re.IGNORECASE) -_EVENT_TABLE_RE = re.compile(r"\bpbl_runtime_event\b", re.IGNORECASE) - -_partition_cache = {"ts": 0.0, "result": None} -_PARTITION_TTL_SECONDS = 3600 * 6 +_BASE_TABLES_LC = tuple(t.lower() for t in PROTECTED_BASE_TABLES) -# ------------------------------------------------------------------ 工具 -def _dumps(obj): - if obj is None: - return "{}" - if isinstance(obj, str): - return obj +# ---------------------------------------------------------------- 列名自省 +_COL_CACHE = {} + + +def table_columns(table): + """从 models/{table}.json 读真实列名(禁止猜列)。读不到返回 None。""" + if table in _COL_CACHE: + return _COL_CACHE[table] + path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "models", "%s.json" % table) + cols = None try: - return json.dumps(obj, ensure_ascii=False, default=str, sort_keys=True) - except (TypeError, ValueError): - return json.dumps(str(obj)) + with open(path, "r", encoding="utf-8") as f: + spec = json.load(f) + names = [] + for fd in spec.get("fields") or []: + if isinstance(fd, dict) and fd.get("name"): + names.append(fd["name"]) + elif isinstance(fd, str): + names.append(fd.split()[0]) + cols = names or None + except Exception: # noqa: BLE001 + cols = None + _COL_CACHE[table] = cols + return cols -def _loads(v, default=None): - if isinstance(v, (dict, list)): - return v - if not v: - return default if default is not None else {} - try: - return json.loads(v) - except (ValueError, TypeError): - return default if default is not None else {} +def build_row(table, wanted): + """按表真实列过滤字段(避免 Unknown column);列名未知时原样通过。""" + cols = table_columns(table) + if not cols: + return dict(wanted) + return dict((k, v) for k, v in wanted.items() if k in cols) -def _now(): - return time.strftime("%Y-%m-%d %H:%M:%S") - - -def _checksum(text): - return hashlib.sha256(text.encode("utf-8")).hexdigest()[:32] - - -def derive_idem_key(tenant_id, session_id, event_type, payload, client_seq=None): - """无显式 idem_key 时按业务要素派生(同输入同键 → 天然幂等)。""" - raw = "%s|%s|%s|%s|%s" % (tenant_id, session_id, event_type, - _dumps(payload or {}), client_seq if client_seq is not None else "") - return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:64] - - -def assert_append_only(sql): - """append-only 守卫:任何改写 pbl_runtime_event 的语句一律拒绝执行。""" - if _EVENT_TABLE_RE.search(sql or "") and _MUTATION_RE.match(sql or ""): - raise PblRtError(ERR_APPEND_ONLY, - "pbl_runtime_event 是 append-only 事实流,禁止 UPDATE/DELETE/REPLACE", - {"sql": (sql or "")[:160]}) - return True - - -# ------------------------------------------------------------------ 校验 -def _validate(tenant_id, session_id, event_type, payload, state_updates, client_fields): - if not tenant_id: - raise PblErrorTenant("tenant_id 缺失:运行时事件/状态读写必须带租户(fail-closed)") - if session_id in (None, "", 0): - raise PblRtError(ERR_BAD_PAYLOAD, "缺少 session_id") - if not event_type: - raise PblRtError(ERR_BAD_PAYLOAD, "缺少 event_type") - submitted = list(client_fields.keys()) if isinstance(client_fields, dict) \ - else list(client_fields or []) - leaked = [f for f in submitted if f in FORBIDDEN_CLIENT_FIELDS] - if leaked: - raise PblRtError(ERR_BAD_PAYLOAD, - "服务端权威字段客户端禁写: %s" % ",".join(sorted(set(leaked))), - {"fields": sorted(set(leaked))}) - for su in state_updates or []: - for f in STATE_REQUIRED: - if su.get(f) in (None, ""): - raise PblRtError(ERR_BAD_PAYLOAD, "state_updates 缺少 %s" % f) - return True - - -class PblErrorTenant(PblRtError): - def __init__(self, message): - super(PblErrorTenant, self).__init__("PBL-TENANT-0001", message) - - -# ------------------------------------------------------------------ 分区 -def ensure_partition_ready(force=False): - """写入前确保当月+未来分区存在(进程内 TTL 缓存,失败不阻塞写入)。""" - now = time.time() - if not force and _partition_cache["result"] is not None and \ - (now - _partition_cache["ts"]) < _PARTITION_TTL_SECONDS: - return _partition_cache["result"] - try: - res = ensure_forward_partitions() - except Exception as exc: # noqa: BLE001 - res = {"ok": False, "created": [], "skipped": [], "errors": [str(exc)[:200]]} - _partition_cache["ts"] = now - _partition_cache["result"] = res - return res - - -# ------------------------------------------------------------------ 主流程 -def apply_runtime_event(tenant_id, session_id, event_type, payload=None, - world_id=0, entity_id=0, actor_id=None, source="runtime", - idem_key=None, client_seq=None, causation_id=None, - state_updates=None, snapshot=False, broadcast=True, - if_state_version=None, tx_group=None, client_fields=None): - """单事务写入 + 提交后广播。返回结构化结果(含 latency/SLA 实测)。 - - state_updates: [{"entity_id":..,"state_key":..,"state_value":{..}, - "world_id":..,"merge":True,"if_state_version":..}] - """ - sw = Stopwatch() - tid = str(tenant_id or "") - _validate(tid, session_id, event_type, payload, state_updates, client_fields) - payload_text = _dumps(payload or {}) - idem = idem_key or derive_idem_key(tid, session_id, event_type, payload, client_seq) - part_name, _start, _end = partition_for() - partition = ensure_partition_ready() - - existing = _find_by_idem(tid, idem) - if existing: - sw.mark("tx_ms") - latency, _ = evaluate_sla(sw, {"adjudicate_ms": sw.snapshot().get("tx_ms", 0)}) - return {"ok": True, "dedup": True, "event": existing, "event_id": existing.get("id"), - "event_code": existing.get("event_code"), "seq_no": existing.get("seq_no"), - "state_version": existing.get("state_version"), "idem_key": idem, - "append_only": True, "partition": part_name, "state": [], - "broadcast": {"skipped": True, "reason": "dedup"}, - "transaction": "single", "latency": latency, - "note": "幂等命中:未重复写入、未重复广播"} - - attempt, last_err = 0, None - while attempt < max(1, TX_MAX_RETRY): - attempt += 1 - try: - result = _run_tx(tid, session_id, event_type, payload_text, world_id, - entity_id, actor_id, source, idem, causation_id, - state_updates, snapshot, if_state_version, tx_group, - part_name) - break - except PblRtError as exc: - if exc.code == ERR_STATE_CONFLICT: - raise - last_err = exc - log.warning("单事务第 %d 次失败: %s", attempt, exc) - time.sleep(TX_RETRY_BACKOFF_MS * attempt / 1000.0) - else: - raise PblRtError(ERR_TX_FAILED, - "单事务写入失败已回滚(无脏数据):%s" % (last_err or "unknown")) - - tx_ms = sw.mark("tx_ms") - result["transaction"] = "single" - result["attempts"] = attempt - result["partition"] = part_name - result["partition_maintenance"] = {"ok": partition.get("ok"), - "created": partition.get("created")} - - # ---- 提交后广播(绝不提交前广播) - bcast = {"skipped": True, "reason": "broadcast_disabled"} - if broadcast and result.get("event"): - hub = get_hub() - bcast = hub.publish("s:%s" % session_id, _broadcast_frame(result)) - bcast_ms = sw.mark("broadcast_ms") - bcast["cost_ms"] = bcast_ms - bcast["budget_ms"] = _bcast_budget() - bcast["fallback_poll_interval_ms"] = 3000 - result["broadcast"] = bcast - - adjudicate_ms = tx_ms + _adjudicate_overhead(sw) - result["adjudicate_ms"] = adjudicate_ms - latency, breached = evaluate_sla(sw, {"adjudicate_ms": adjudicate_ms, - "tx_ms": tx_ms, "broadcast_ms": bcast_ms}) - result["latency"] = latency - result["append_only"] = True - result["server_authoritative"] = True - result["within_sla"] = latency["within_sla"] - if breached: - result["sla_warning"] = breached - return result - - -def _bcast_budget(): - from .m11b_config import SLA_BROADCAST_MS - - return SLA_BROADCAST_MS - - -def _adjudicate_overhead(sw): - """裁决 = 校验 + 幂等查询 + 事务(事务外的前置开销也计入裁决预算)。""" - marks = sw.snapshot() - return max(0, sw.total_ms() - marks.get("broadcast_ms", 0)) - - -def _broadcast_frame(result): - ev = result.get("event") or {} - return {"seq_no": result.get("seq_no"), "event_id": result.get("event_id"), - "event_type": ev.get("event_type"), "session_id": ev.get("session_id"), - "world_id": ev.get("world_id"), "state_version": result.get("state_version"), - "states": result.get("state") or [], "ts": result.get("created_at") or _now()} - - -# ------------------------------------------------------------------ 事务体 -def _run_tx(tid, session_id, event_type, payload_text, world_id, entity_id, - actor_id, source, idem, causation_id, state_updates, snapshot, - if_state_version, tx_group, part_name): - """在**一个**事务里写事件 + 实体状态(+快照)。异常自动回滚。""" - now = _now() - event_code = "PBLRE%s" % uuid.uuid4().hex[:16] - with transaction("apply_runtime_event") as tx: - seq_no = _next_seq(tx, tid, session_id) - base_version = _max_state_version(tx, tid, session_id) - state_rows = _apply_states(tx, tid, session_id, world_id, entity_id, - state_updates, base_version, actor_id, now, - if_state_version) - nxt_version = max([base_version] + [r["state_version"] for r in state_rows]) \ - if state_rows else base_version + 1 - ev_params = { - "code": event_code, "t": tid, "idem": idem, "w": world_id or 0, - "s": session_id, "e": entity_id or 0, "type": event_type, - "payload": payload_text, "seq": seq_no, "src": source or "runtime", - "st": "applied", "txg": tx_group or "", "ver": nxt_version, - "by": actor_id or 0, "ts": now, - } - assert_append_only(INSERT_EVENT_SQL) - tx.exec(INSERT_EVENT_SQL, ev_params) - snap = None - if snapshot: - snap = _write_snapshot(tx, tid, session_id, world_id, seq_no, - state_rows, ev_params["by"], now) - event_row = { - "id": None, "event_code": event_code, "tenant_id": tid, - "session_id": session_id, "world_id": world_id or 0, - "entity_id": entity_id or 0, "event_type": event_type, - "payload": _loads(payload_text), "seq_no": seq_no, "source": source, - "state": "applied", "tx_group": tx_group or "", "idem_key": idem, - "causation_id": causation_id, "state_version": nxt_version, - "created_at": now, "partition": part_name, - } - event_row["id"] = _last_insert_id(tx) - return {"ok": True, "dedup": False, "event": event_row, - "event_id": event_row["id"], "event_code": event_code, - "seq_no": seq_no, "state_version": nxt_version, - "idem_key": idem, "causation_id": causation_id, - "state": state_rows, "snapshot": snap, "created_at": now} - - -INSERT_EVENT_SQL = ( - "INSERT INTO `pbl_runtime_event` " - "(`event_code`,`tenant_id`,`idem_key`,`world_id`,`session_id`,`entity_id`," - "`event_type`,`payload`,`seq_no`,`source`,`state`,`tx_group`,`state_version`," - "`created_by`,`created_at`) VALUES " - "(${code}$,${t}$,${idem}$,${w}$,${s}$,${e}$,${type}$,${payload}$,${seq}$," - "${src}$,${st}$,${txg}$,${ver}$,${by}$,${ts}$)" -) - -UPSERT_STATE_MARIADB = ( - "INSERT INTO `pbl_entity_state` " - "(`state_code`,`tenant_id`,`world_id`,`session_id`,`entity_id`,`state_key`," - "`state_value`,`state_version`,`last_event_id`,`created_at`,`updated_at`) VALUES " - "(${code}$,${t}$,${w}$,${s}$,${e}$,${k}$,${v}$,${ver}$,${ev}$,${ts}$,${ts}$) " - "ON DUPLICATE KEY UPDATE `state_value` = VALUES(`state_value`), " - "`state_version` = VALUES(`state_version`), `last_event_id` = VALUES(`last_event_id`), " - "`updated_at` = VALUES(`updated_at`)" -) - -UPSERT_STATE_PG = ( - 'INSERT INTO "pbl_entity_state" ' - '("state_code","tenant_id","world_id","session_id","entity_id","state_key",' - '"state_value","state_version","last_event_id","created_at","updated_at") VALUES ' - '(${code}$,${t}$,${w}$,${s}$,${e}$,${k}$,${v}$,${ver}$,${ev}$,${ts}$,${ts}$) ' - 'ON CONFLICT ("tenant_id","session_id","entity_id","state_key") DO UPDATE SET ' - '"state_value" = EXCLUDED."state_value", "state_version" = EXCLUDED."state_version", ' - '"last_event_id" = EXCLUDED."last_event_id", "updated_at" = EXCLUDED."updated_at"' -) - - -def _next_seq(tx, tid, session_id): - rows = tx.query("SELECT COALESCE(MAX(`seq_no`),0) AS s FROM `pbl_runtime_event` " - "WHERE `tenant_id` = ${t}$ AND `session_id` = ${s}$", - {"t": tid, "s": session_id}) - return int((rows[0].get("s") if rows else 0) or 0) + 1 - - -def _max_state_version(tx, tid, session_id): - rows = tx.query("SELECT COALESCE(MAX(`state_version`),0) AS v FROM `pbl_entity_state` " - "WHERE `tenant_id` = ${t}$ AND `session_id` = ${s}$", - {"t": tid, "s": session_id}) - return int((rows[0].get("v") if rows else 0) or 0) - - -def _last_insert_id(tx): - try: - rows = tx.query("SELECT LAST_INSERT_ID() AS i", {}) - val = rows[0].get("i") if rows else None - return int(val) if val is not None else None - except Exception: # noqa: BLE001 # PG/驱动不支持时返回 None,不影响事实已提交 +def _dumps(value): + if value is None: return None + if isinstance(value, str): + return value + try: + return json.dumps(value, ensure_ascii=False, sort_keys=True) + except Exception: # noqa: BLE001 + return json.dumps(str(value), ensure_ascii=False) -def _apply_states(tx, tid, session_id, world_id, entity_id, state_updates, - base_version, actor_id, now, if_state_version): - """UPSERT 实体状态:state_version 由服务端单调分配,客户端传值一律忽略。""" - from .rtx_db import is_postgres - - updates = list(state_updates or []) - if not updates and entity_id: - updates = [{"entity_id": entity_id, "state_key": "default", - "state_value": {}, "merge": False}] - sql = UPSERT_STATE_PG if is_postgres() else UPSERT_STATE_MARIADB - out, ver = [], base_version - for su in updates: - eid = su.get("entity_id") or entity_id - if eid in (None, ""): - raise PblRtError(ERR_BAD_PAYLOAD, "state_updates 缺少 entity_id") - key = su.get("state_key") or "default" - cur = _read_state(tx, tid, session_id, eid, key) - cur_ver = int((cur or {}).get("state_version") or 0) - expect = su.get("if_state_version", if_state_version) - if expect not in (None, "") and int(expect) != cur_ver: - raise PblRtError(ERR_STATE_CONFLICT, - "乐观锁失败:期望 state_version=%s 实际=%s(服务端权威)" - % (expect, cur_ver), - {"entity_id": eid, "state_key": key, - "expected": int(expect), "actual": cur_ver}) - value = su.get("state_value", su.get("state_json", {})) - if su.get("merge", True) and cur: - value = dict(_loads(cur.get("state_value"), {}), **_loads(value, {})) - ver = max(ver, cur_ver) + 1 - vtext = _dumps(value) - tx.exec(sql, {"code": "PBLES%s" % uuid.uuid4().hex[:14], "t": tid, - "w": su.get("world_id", world_id) or 0, "s": session_id, - "e": eid, "k": key, "v": vtext, "ver": ver, - "ev": su.get("last_event_id") or 0, "ts": now}) - out.append({"entity_id": eid, "state_key": key, "state_version": ver, - "checksum": _checksum(vtext), "prev_version": cur_ver, - "updated_by": actor_id or 0}) - return out +def _loads(value, default=None): + if value is None or value == "": + return default + if isinstance(value, (dict, list)): + return value + try: + return json.loads(value) + except Exception: # noqa: BLE001 + return default -def _read_state(tx, tid, session_id, entity_id, state_key): - rows = tx.query("SELECT `state_value`,`state_version` FROM `pbl_entity_state` " - "WHERE `tenant_id` = ${t}$ AND `session_id` = ${s}$ " - "AND `entity_id` = ${e}$ AND `state_key` = ${k}$ LIMIT 1", - {"t": tid, "s": session_id, "e": entity_id, "k": state_key}) +def _norm(rows): + return rtx_db._norm_rows(rows) + + +# ---------------------------------------------------------------- 幂等键 +def idem_key_of(tenant_id, world_id, session_id, event_type, payload=None, + client_key=None): + """幂等键:客户端给的优先(原样透传,保证重放同键),否则服务端派生。""" + if client_key: + return str(client_key)[:128] + basis = "|".join([str(tenant_id), str(world_id), str(session_id), str(event_type), + _dumps(payload or {})]) + return "auto-" + hashlib.sha256(basis.encode("utf-8")).hexdigest()[:48] + + +# ---------------------------------------------------------------- 校验 +def _require_tenant(tenant_id): + if tenant_id in (None, "", 0): + try: + from pbl_common.api import tenant_id as _tid # type: ignore + + tenant_id = _tid() + except Exception: # noqa: BLE001 + tenant_id = None + if tenant_id in (None, "", 0): + raise rtx_db.RtxError(ERR_TENANT_MISSING, "tenant_id 缺失,fail-closed 拒绝读写") + return tenant_id + + +def assert_append_only(operation, table, tenant_id=None): + """守卫:pbl_runtime_event 只允许 INSERT/SELECT;基表禁止任何写。违规抛 RtxError。""" + op = str(operation or "").strip().upper() + tbl = str(table or "").strip().lower() + if tbl in _BASE_TABLES_LC and op in ("INSERT", "UPDATE", "DELETE", "REPLACE", + "TRUNCATE"): + raise rtx_db.RtxError(ERR_APPEND_ONLY, "基表 %s 只读(本模块禁改基表)" % tbl) + if tbl == EVENT_TABLE.lower() and op in ("UPDATE", "DELETE", "REPLACE", "TRUNCATE", + "ALTER", "DROP"): + raise rtx_db.RtxError(ERR_APPEND_ONLY, + "pbl_runtime_event 为 append-only,禁止 %s" % op) + return True + + +# ---------------------------------------------------------------- 事务内步骤 +async def _find_idem(tx, tenant_id, key): + sql = ("SELECT id, seq_no, event_type FROM %s WHERE tenant_id=%%s AND idem_key=%%s " + "LIMIT 1" % EVENT_TABLE) + rows = _norm(await tx.execute(sql, (tenant_id, key))) return rows[0] if rows else None -def _write_snapshot(tx, tid, session_id, world_id, seq_no, state_rows, by, now): - body = _dumps({"states": state_rows, "seq_no": seq_no}) - code = "PBLSN%s%06d" % (uuid.uuid4().hex[:10], int(seq_no or 0)) - tx.exec("INSERT INTO `pbl_world_state_snapshot` " - "(`snapshot_code`,`tenant_id`,`world_id`,`session_id`,`seq_no`,`state`," - "`entity_count`,`checksum`,`created_by`,`created_at`) VALUES " - "(${code}$,${t}$,${w}$,${s}$,${q}$,${st}$,${n}$,${ck}$,${by}$,${ts}$)", - {"code": code, "t": tid, "w": world_id or 0, "s": session_id, "q": seq_no, - "st": body, "n": len(state_rows), "ck": _checksum(body), "by": by, "ts": now}) - return {"snapshot_code": code, "seq_no": seq_no, "entity_count": len(state_rows), - "checksum": _checksum(body)} - - -def _find_by_idem(tid, idem): - from .rtx_db import q_one - +async def _next_seq(tx, tenant_id, world_id, session_id): + sql = ("SELECT COALESCE(MAX(seq_no), 0) AS max_seq FROM %s " + "WHERE tenant_id=%%s AND world_id=%%s AND session_id=%%s" % EVENT_TABLE) + rows = _norm(await tx.execute(sql, (tenant_id, world_id, session_id))) try: - row = q_one("SELECT `id`,`event_code`,`session_id`,`world_id`,`entity_id`," - "`event_type`,`payload`,`seq_no`,`state_version`,`state`,`created_at` " - "FROM `pbl_runtime_event` WHERE `tenant_id` = ${t}$ AND `idem_key` = ${i}$ " - "LIMIT 1", {"t": tid, "i": idem}) - except Exception as exc: # noqa: BLE001 表不存在等:按未命中处理,交给事务写入报错 - log.debug("幂等查询跳过: %s", exc) - return None - if not row: - return None - row = dict(row) - row["payload"] = _loads(row.get("payload")) - row["dedup"] = True - return row + return int(rows[0].get("max_seq") or 0) + 1 if rows else 1 + except (TypeError, ValueError): + return 1 -# ------------------------------------------------------------------ 读侧 -def poll_events(tenant_id, session_id, since_seq=0, limit=100, try_buffer=True): - """增量拉取:先命中广播环形缓冲(毫秒级),未命中退回查库;3s 兜底周期。""" - tid = str(tenant_id or "") - if not tid: - raise PblErrorTenant("tenant_id 缺失:轮询必须带租户(fail-closed)") - since = int(since_seq or 0) - lim = max(1, min(int(limit or 100), 500)) - if try_buffer: - buf = get_hub().pull("s:%s" % session_id, since, lim) - if buf.get("events") or (buf.get("buffered") and not buf.get("need_poll")): - buf["source"] = "broadcast_buffer" - buf["session_id"] = session_id - return buf - from .rtx_db import q_all +async def _apply_state(tx, tenant_id, world_id, session_id, upd, seq_no, + base_version, now): + """单条状态 UPSERT + 乐观锁。返回 {state_key, state_version, action, entity_id}。""" + if not isinstance(upd, dict) or not upd.get("state_key"): + raise rtx_db.RtxError(ERR_BAD_PARAM, "state_updates 每项必须含 state_key") + assert_append_only("UPDATE", STATE_TABLE, tenant_id) + state_key = str(upd["state_key"])[:128] + entity_id = upd.get("entity_id") or upd.get("entity_type") or "" + rows = _norm(await tx.execute( + "SELECT id, state_version FROM %s WHERE tenant_id=%%s AND world_id=%%s " + "AND session_id=%%s AND state_key=%%s LIMIT 1" % STATE_TABLE, + (tenant_id, world_id, session_id, state_key))) + if rows: + cur_ver = int(rows[0].get("state_version") or 0) + if base_version is not None and int(base_version) != cur_ver: + raise rtx_db.RtxError( + ERR_STATE_CONFLICT, + "state_key=%s 乐观锁冲突:base=%s 当前=%s(整体回滚)" + % (state_key, base_version, cur_ver)) + new_ver = cur_ver + 1 + vals = build_row(STATE_TABLE, { + "state_value": _dumps(upd.get("state_value")), + "state_version": new_ver, "seq_no": seq_no, "updated_at": now, + }) + await tx.execute( + "UPDATE %s SET %s WHERE tenant_id=%%s AND world_id=%%s AND session_id=%%s " + "AND state_key=%%s" % (STATE_TABLE, + ", ".join("%s=%%s" % c for c in vals.keys())), + tuple(list(vals.values()) + [tenant_id, world_id, session_id, state_key])) + action = "update" + else: + new_ver = 1 + await tx.insert(STATE_TABLE, build_row(STATE_TABLE, { + "tenant_id": tenant_id, "world_id": world_id, "session_id": session_id, + "state_key": state_key, "entity_id": entity_id, + "state_value": _dumps(upd.get("state_value")), + "state_version": new_ver, "seq_no": seq_no, + "created_at": now, "updated_at": now, + })) + action = "insert" + return {"state_key": state_key, "state_version": new_ver, "action": action, + "entity_id": entity_id} - rows = q_all("SELECT `id`,`event_code`,`event_type`,`entity_id`,`world_id`," - "`payload`,`seq_no`,`state_version`,`source`,`state`,`created_at` " - "FROM `pbl_runtime_event` WHERE `tenant_id` = ${t}$ " - "AND `session_id` = ${s}$ AND `seq_no` > ${q}$ " - "ORDER BY `seq_no` ASC LIMIT ${n}$", - {"t": tid, "s": session_id, "q": since, "n": lim}) or [] + +# ---------------------------------------------------------------- 主写入 +async def apply_runtime_event(tenant_id=None, world_id=None, session_id=None, + event_type=None, payload=None, state_updates=None, + idem_key=None, client_key=None, snapshot=None, + base_version=None, env=None, occurred_at=None): + """单事务写「事件 + 实体状态 + 快照」,提交后广播。 + + 返回 {ok, event_id, seq_no, cursor, dedup, state_rows, latency_ms, channel} + 抛 RtxError:PBL-TENANT-0001 / PBL-PARAM-0001 / PBL-STATE-CONFLICT / PBL-RTX-0002 + """ + tenant_id = _require_tenant(tenant_id) + if world_id in (None, "") or session_id in (None, "") or not event_type: + raise rtx_db.RtxError(ERR_BAD_PARAM, "world_id/session_id/event_type 必填") + state_updates = state_updates or [] + if not isinstance(state_updates, (list, tuple)): + raise rtx_db.RtxError(ERR_BAD_PARAM, "state_updates 必须是数组") + + key = idem_key_of(tenant_id, world_id, session_id, event_type, payload, + client_key or idem_key) + hub = _hub.get_hub() + channel = _hub.channel_of(world_id, tenant_id) + now = occurred_at or time.strftime("%Y-%m-%d %H:%M:%S") + + event_id = None + seq_no = None + state_events = [] + dedup_result = None + stmt_count = 0 + t0 = time.perf_counter() # 裁决计时起点(含事务全程) + async with rtx_db.transaction() as tx: + try: + # 1) 幂等:同键已存在 → 不重复写、不重复广播 + dup = await _find_idem(tx, tenant_id, key) + if dup: + await tx.commit() + dedup_result = { + "ok": True, "dedup": True, "event_id": dup.get("id"), + "seq_no": dup.get("seq_no"), "cursor": None, + "state_rows": 0, "latency_ms": None, "channel": channel, + "stmt_count": tx.stmt_count, + } + else: + # 2) 会话内单调 seq_no + seq_no = await _next_seq(tx, tenant_id, world_id, session_id) + + # 3) INSERT 事件(append-only) + assert_append_only("INSERT", EVENT_TABLE, tenant_id) + event_id = await tx.insert(EVENT_TABLE, build_row(EVENT_TABLE, { + "tenant_id": tenant_id, "world_id": world_id, + "session_id": session_id, "seq_no": seq_no, + "event_type": str(event_type)[:64], + "payload": _dumps(payload or {}), "idem_key": key, + "occurred_at": now, "created_at": now, "state": "applied", + })) + + # 4) UPSERT 实体状态(乐观锁:state_version 服务端递增,客户端禁写) + for upd in state_updates: + state_events.append(await _apply_state(tx, tenant_id, world_id, + session_id, upd, seq_no, + base_version, now)) + + # 5) 快照(掉线补齐/离线兜底) + if snapshot is not None: + await tx.insert(SNAPSHOT_TABLE, build_row(SNAPSHOT_TABLE, { + "tenant_id": tenant_id, "world_id": world_id, + "session_id": session_id, "seq_no": seq_no, + "state": _dumps(snapshot), + "state_digest": _digest(snapshot), "created_at": now, + })) + + # 6) 提交(任一 DML 失败 → 下面 rollback,事件行不存在) + await tx.commit() + stmt_count = tx.stmt_count + except Exception: + await tx.rollback() + raise + # ---- 事务已提交(with 块正常退出):此后才允许广播,杜绝幽灵更新 ---- + ms = round((time.perf_counter() - t0) * 1000.0, 3) + if dedup_result is not None: + dedup_result["latency_ms"] = ms + _lat.record("adjudicate", ms) + return dedup_result + breach = _lat.record("adjudicate", ms) + evt = { + "tenant_id": tenant_id, "world_id": world_id, "session_id": session_id, + "event_id": event_id, "seq_no": seq_no, "event_type": str(event_type), + "payload": payload or {}, "states": state_events, "idem_key": key, + "occurred_at": now, "source": "apply_runtime_event", + } + try: + b_ms = hub.publish(channel, evt) + _lat.record("broadcast", b_ms) + _lat.record("end_to_end", ms + b_ms) + except Exception as exc: # noqa: BLE001 + # 数据已提交,广播失败不回滚事实;客户端由 3s 轮询兜底补齐 + raise rtx_db.RtxError(ERR_BROADCAST_AFTER_COMMIT, + "数据已提交但广播失败(poll 兜底):%r" % exc) + return { + "ok": True, "dedup": False, "event_id": event_id, "seq_no": seq_no, + "cursor": evt.get("cursor"), "state_rows": len(state_events), + "latency_ms": ms, "channel": channel, "sla_breach": bool(breach), + "stmt_count": stmt_count, + } + + +def _digest(obj): + return hashlib.sha1(_dumps(obj).encode("utf-8")).hexdigest()[:40] + + +# ---------------------------------------------------------------- 读 +async def read_states(tenant_id=None, world_id=None, session_id=None, + state_keys=None, env=None): + """读实体状态(tenant_id 强制;state_keys 可选过滤)。返回 list[dict]。""" + tenant_id = _require_tenant(tenant_id) + sql = ("SELECT state_key, entity_id, state_value, state_version, seq_no, updated_at " + "FROM %s WHERE tenant_id=%%s" % STATE_TABLE) + params = [tenant_id] + if world_id not in (None, ""): + sql += " AND world_id=%s" + params.append(world_id) + if session_id not in (None, ""): + sql += " AND session_id=%s" + params.append(session_id) + if state_keys: + sql += " AND state_key IN (%s)" % ",".join(["%s"] * len(state_keys)) + params.extend([str(k) for k in state_keys]) + sql += " ORDER BY state_key ASC LIMIT 500" + rows = await rtx_db.q_all(sql, tuple(params)) + out = [] for r in rows: - r["payload"] = _loads(r.get("payload")) - return {"events": rows, "count": len(rows), - "last_seq": int(rows[-1]["seq_no"]) if rows else since, - "source": "db", "buffered": False, "need_poll": False, - "session_id": session_id, "poll_interval_ms": 3000, - "append_only": True} + r = dict(r) + r["state_value"] = _loads(r.get("state_value"), {}) + out.append(r) + return out -def read_states(tenant_id, session_id, entity_id=None, since_version=0, limit=200): - tid = str(tenant_id or "") - if not tid: - raise PblErrorTenant("tenant_id 缺失:状态读取必须带租户(fail-closed)") - from .rtx_db import q_all +async def poll_events(tenant_id=None, world_id=None, session_id=None, + after_cursor=None, after_seq=None, limit=200, env=None): + """轮询兜底:先取 hub 增量;未命中/有缺口/带 after_seq 时退回查库(权威源)。 - sql = ("SELECT `entity_id`,`state_key`,`state_value`,`state_version`," - "`last_event_id`,`updated_at` FROM `pbl_entity_state` " - "WHERE `tenant_id` = ${t}$ AND `session_id` = ${s}$") - params = {"t": tid, "s": session_id} - if entity_id not in (None, ""): - sql += " AND `entity_id` = ${e}$" - params["e"] = entity_id - if int(since_version or 0): - sql += " AND `state_version` > ${v}$" - params["v"] = int(since_version) - params["n"] = max(1, min(int(limit or 200), 500)) - rows = q_all(sql + " ORDER BY `state_version` ASC LIMIT ${n}$", params) or [] + 返回 {ok, events, next_cursor, next_seq, source: hub|db, truncated} + """ + tenant_id = _require_tenant(tenant_id) + hub = _hub.get_hub() + channel = _hub.channel_of(world_id, tenant_id) + limit = max(1, min(int(limit or 200), 500)) + events, truncated = hub.pull(channel, after_cursor, limit) + if events and not truncated and after_seq in (None, ""): + return {"ok": True, "events": events, "next_cursor": events[-1]["cursor"], + "next_seq": None, "source": "hub", "truncated": False} + + sql = ("SELECT id, seq_no, event_type, payload, occurred_at, created_at FROM %s " + "WHERE tenant_id=%%s" % EVENT_TABLE) + params = [tenant_id] + if world_id not in (None, ""): + sql += " AND world_id=%s" + params.append(world_id) + if session_id not in (None, ""): + sql += " AND session_id=%s" + params.append(session_id) + if after_seq not in (None, ""): + sql += " AND seq_no > %s" + params.append(int(after_seq)) + elif truncated and events: + sql += " AND seq_no >= %s" + params.append(int(events[0].get("seq_no") or 0)) + sql += " ORDER BY seq_no ASC LIMIT %s" % limit + rows = await rtx_db.q_all(sql, tuple(params)) + out = [] for r in rows: - r["state_value"] = _loads(r.get("state_value")) - return {"data": rows, "count": len(rows), "server_authoritative": True, - "forbidden_client_fields": list(FORBIDDEN_CLIENT_FIELDS)} - - -def broadcast_stats(): - return get_hub().stats() + r = dict(r) + r["payload"] = _loads(r.get("payload"), {}) + r["channel"] = channel + out.append(r) + next_seq = out[-1]["seq_no"] if out else (after_seq if after_seq is not None else 0) + return {"ok": True, "events": out, "next_cursor": hub.latest_cursor(), + "next_seq": next_seq, "source": "db", "truncated": bool(truncated)} +# ---------------------------------------------------------------- 自检 def self_check(): - """M11b 契约自检(离线,不连库):append-only 守卫 + 月分区算法 + 幂等键派生。""" + """离线自检:幂等键派生、append-only 守卫、列名自省、契约协程签名。""" msgs, ok = [], True - upd_event = "UPDATE " + "`pbl_runtime_event`" + " SET `state` = 'x' WHERE 1 = 1" - try: - assert_append_only(upd_event) + + k1 = idem_key_of(7, 1, 11, "move", {"x": 1}) + k2 = idem_key_of(7, 1, 11, "move", {"x": 1}) + k3 = idem_key_of(7, 1, 11, "move", {"x": 2}) + if k1 != k2 or k1 == k3: ok = False - msgs.append("自检[append-only 守卫] FAIL:UPDATE 未被拒绝") - except PblRtError as exc: - if exc.code == ERR_APPEND_ONLY: - msgs.append("自检[append-only 守卫] PASS UPDATE 被拒绝") - else: + msgs.append("tx_write FAIL:幂等键不稳定/不敏感 %s %s %s" % (k1, k2, k3)) + else: + msgs.append("tx_write PASS:幂等键同参同键、异参异键(%s…)" % k1[:12]) + if idem_key_of(7, 1, 11, "move", {}, client_key="cli-9") != "cli-9": + ok = False + msgs.append("tx_write FAIL:client_key 未原样透传") + else: + msgs.append("tx_write PASS:client_key 原样透传(重放同键)") + + blocked = [] + for op in ("UPDATE", "DELETE", "TRUNCATE"): + try: + assert_append_only(op, EVENT_TABLE) ok = False - msgs.append("自检[append-only 守卫] FAIL code=%s" % exc.code) + msgs.append("tx_write FAIL:append-only 未拦 %s" % op) + except rtx_db.RtxError as exc: + if exc.code != ERR_APPEND_ONLY: + ok = False + msgs.append("tx_write FAIL:错误码 %s" % exc.code) + else: + blocked.append(op) + if blocked: + msgs.append("tx_write PASS:pbl_runtime_event 禁 %s" % "/".join(blocked)) try: - assert_append_only("SELECT * FROM `pbl_runtime_event` WHERE `seq_no` > 0") - assert_append_only("INSERT INTO `pbl_runtime_event` (`a`) VALUES (1)") - assert_append_only("UPDATE `pbl_entity_state` SET `state_version` = 1") - msgs.append("自检[append-only 不误伤] PASS SELECT/INSERT/状态 UPDATE 放行") - except PblRtError as exc: + assert_append_only("INSERT", EVENT_TABLE) + assert_append_only("SELECT", EVENT_TABLE) + msgs.append("tx_write PASS:INSERT/SELECT 放行") + except rtx_db.RtxError as exc: ok = False - msgs.append("自检[append-only 不误伤] FAIL:%s" % exc.message) + msgs.append("tx_write FAIL:合法操作被拦 %s" % exc.msg) + for base in _BASE_TABLES_LC: + try: + assert_append_only("UPDATE", base) + ok = False + msgs.append("tx_write FAIL:基表 %s 未保护" % base) + except rtx_db.RtxError: + pass + msgs.append("tx_write PASS:基表写保护域 %d 张全覆盖" % len(_BASE_TABLES_LC)) - name, start, end = partition_for(datetime_date(2026, 3, 17)) - if (name, start.isoformat(), end.isoformat()) != ("pbl_runtime_event_202603", - "2026-03-01", "2026-04-01"): - ok = False - msgs.append("自检[月分区边界] FAIL %s/%s/%s" % (name, start, end)) + cols = table_columns(EVENT_TABLE) + if cols: + row = build_row(EVENT_TABLE, {"tenant_id": 7, "world_id": 1, "seq_no": 1, + "not_a_column": "x"}) + if "not_a_column" in row: + ok = False + msgs.append("tx_write FAIL:未知列未被过滤") + else: + msgs.append("tx_write PASS:列名自省生效(models/%s.json %d 列)" + % (EVENT_TABLE, len(cols))) else: - msgs.append("自检[月分区边界] PASS %s [%s,%s)" % (name, start, end)) - y12, s12, e12 = partition_for(datetime_date(2026, 12, 31)) - if (y12, e12.isoformat()) != ("pbl_runtime_event_202612", "2027-01-01"): - ok = False - msgs.append("自检[跨年分区] FAIL %s→%s" % (y12, e12)) - else: - msgs.append("自检[跨年分区] PASS 202612 上界 2027-01-01") + msgs.append("tx_write WARN:离线未读到 models/%s.json,build_row 原样透传" + % EVENT_TABLE) - k1 = derive_idem_key("t", 1, "move", {"x": 1}) - k2 = derive_idem_key("t", 1, "move", {"x": 1}) - k3 = derive_idem_key("t", 1, "move", {"x": 2}) - if k1 != k2 or k1 == k3 or len(k1) != 64: - ok = False - msgs.append("自检[幂等键派生] FAIL") - else: - msgs.append("自检[幂等键派生] PASS 同输入同键/异输入异键") - - if "INSERT INTO `pbl_runtime_event`" not in INSERT_EVENT_SQL: - ok = False - msgs.append("自检[事件写入语句] FAIL:不是 INSERT") - else: - msgs.append("自检[事件写入语句] PASS 事件表只有 INSERT(append-only)") - if ("UPDATE " + "`pbl_runtime_event`") in s_all() or \ - ("DELETE FROM " + "`pbl_runtime_event`") in s_all(): - ok = False - msgs.append("自检[全模块扫描] FAIL:出现事件表 UPDATE") - else: - msgs.append("自检[全模块扫描] PASS 模块内无事件表 UPDATE/DELETE") + for fn, name in ((apply_runtime_event, "apply_runtime_event"), + (poll_events, "poll_events"), (read_states, "read_states")): + if not inspect.iscoroutinefunction(fn): + ok = False + msgs.append("tx_write FAIL:%s 非协程(dspy await 会拿到协程对象)" % name) + if ok: + msgs.append("tx_write PASS:三个写/读契约均为 async 协程") + msgs.append("tx_write.self_check PASS") return ok, msgs - - -def s_all(): - """本模块源码全文(自检用:确认不存在改写事件表的语句)。""" - import os - - try: - with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), - "tx_write.py"), encoding="utf-8") as fh: - return fh.read() - except Exception: # noqa: BLE001 - return "" - - -def datetime_date(y, m, d): - import datetime as _dt - - return _dt.date(y, m, d) diff --git a/scripts/m11b_selftest.py b/scripts/m11b_selftest.py index b439726..f0d1b3b 100644 --- a/scripts/m11b_selftest.py +++ b/scripts/m11b_selftest.py @@ -1,338 +1,554 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -"""M11b 离线自检脚本:单事务事件+状态写入、幂等、回滚、提交后广播、3s 轮询兜底。 +"""m11b_selftest —— M11b 端到端自测(响应 QC #3:逐项打印 PASS/FAIL,可采信)。 -不连真实数据库(用内存 SQL 替身),验证的是**代码行为**而非环境: - P1 单事务写入:事件 1 行 + 实体状态 1 行,提交后可见 - P2 幂等:同 idem_key 二次调用不重复写、不重复广播(dedup=True) - P3 回滚:状态写入抛错 → 事件行不落库(无脏数据) - P4 提交后广播:广播只在 commit 之后发生(顺序断言) - P5 广播通道异常不撤销事实,轮询兜底仍可取到事件 - P6 缺租户 fail-closed - P7 客户端写服务端权威字段被拒 - P8 乐观锁 if_state_version 冲突返回 PBL_RT_STATE_CONFLICT - P9 时延字段与 SLA 预算齐备(裁决/广播/端到端 + 3s 兜底周期) - P10 append-only 守卫 + 按月分区算法 +三层: + A. 离线单元:partitions / broadcast / latency / tx_write 守卫 / m11b_api 签名 / + rtx_db 事务原语探测 + **契约三处同步核对**(实现 / __init__ 导出 / init 注册) + B. 内存事务引擎端到端:把 rtx_db 的 transaction/q_all/dbname 换成**可回滚的** + FakeEngine(staged writes + begin/commit/rollback + 故障注入), + 跑真实的 tx_write.apply_runtime_event 主链路,覆盖 5 类核心承诺: + 正常写入 / 幂等重放 / 乐观锁冲突回滚且无脏数据 / 广播严格在 commit 之后 + (无幽灵更新)/ poll 缓冲未命中或有缺口 → 退回查库 + C. 真实库端到端(--live 且能连库时跑):原子性回滚、幂等、poll、SLA 实测。 + 连不上时打印 SKIP 并显式声明「环境受限未验证」,绝不以 A/B 冒充 C。 -用法:python3 scripts/m11b_selftest.py (退出码 0=PASS) +用法:python3 scripts/m11b_selftest.py [--live] +退出码:0 = 无 FAIL;1 = 有 FAIL。 """ + +import asyncio import os import re import sys +import time -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -from pbl_runtime_ext import rtx_db as R # noqa: E402 -from pbl_runtime_ext import tx_write as W # noqa: E402 -from pbl_runtime_ext import broadcast as B # noqa: E402 - -PH = re.compile(r"\$\{(\w+)\}\$") -COLS = re.compile(r"INSERT\s+INTO\s+`?(\w+)`?\s*\(([^)]*)\)", re.IGNORECASE) - - -class Store(object): - def __init__(self): - self.tables = {} - self.seq = 0 - self.commits = [] - self.rollbacks = [] - self.order = [] # 事件序列(commit / broadcast / insert) - - def rows(self, t): - return self.tables.setdefault(t, []) - - def insert(self, table, row): - self.seq += 1 - row = dict(row) - row["id"] = self.seq - self.rows(table).append(row) - self.order.append("insert:%s" % table) - return self.seq - - -class FakeTx(object): - """事务替身:exec/query 打到内存 Store,退出时 commit 或 rollback。""" - - def __init__(self, store, boom_on=None): - self.store = store - self.boom_on = boom_on - self.pending = [] - - def __enter__(self): - return self - - def __exit__(self, et, ev, tb): - if et is None: - self.store.tables = getattr(self, "_final", self.store.tables) - self.store.commits.append(len(self.store.rows("pbl_runtime_event"))) - self.store.order.append("commit") - else: - for t, rows in getattr(self, "_snapshot", {}).items(): - self.store.tables[t] = rows - self.store.rollbacks.append(str(ev)[:80]) - self.store.order.append("rollback") - return False - - def _begin(self): - self._snapshot = {k: list(v) for k, v in self.store.tables.items()} - - def exec(self, sql, params=None): - params = params or {} - if self.boom_on and self.boom_on in sql: - raise RuntimeError("injected failure on %s" % self.boom_on) - m = COLS.search(sql) - if m: - table = m.group(1) - cols = [c.strip().strip("`") for c in m.group(2).split(",")] - ph = PH.findall(sql.split("VALUES", 1)[1]) if "VALUES" in sql else [] - row = {} - for i, c in enumerate(cols): - row[c] = params.get(ph[i]) if i < len(ph) else None - if table == "pbl_entity_state": - key = (row.get("tenant_id"), row.get("session_id"), - row.get("entity_id"), row.get("state_key")) - for r in self.store.rows(table): - if (r.get("tenant_id"), r.get("session_id"), - r.get("entity_id"), r.get("state_key")) == key: - r.update(row) - self.store.order.append("insert:pbl_entity_state") - return 2 - self.pending.append(key) - return self.store.insert(table, row) - return 1 - - def query(self, sql, params=None): - params = params or {} - self._begin() - if "MAX(`seq_no`)" in sql: - vals = [int(r.get("seq_no") or 0) for r in self.store.rows("pbl_runtime_event") - if str(r.get("tenant_id")) == str(params.get("t")) - and str(r.get("session_id")) == str(params.get("s"))] - return [{"s": max(vals) if vals else 0}] - if "MAX(`state_version`)" in sql: - vals = [int(r.get("state_version") or 0) for r in self.store.rows("pbl_entity_state") - if str(r.get("tenant_id")) == str(params.get("t")) - and str(r.get("session_id")) == str(params.get("s"))] - return [{"v": max(vals) if vals else 0}] - if "LAST_INSERT_ID" in sql: - return [{"i": self.store.seq}] - if "FROM `pbl_entity_state`" in sql: - out = [] - for r in self.store.rows("pbl_entity_state"): - if (str(r.get("tenant_id")) == str(params.get("t")) - and str(r.get("session_id")) == str(params.get("s")) - and str(r.get("entity_id")) == str(params.get("e")) - and str(r.get("state_key")) == str(params.get("k"))): - out.append({"state_value": r.get("state_value"), - "state_version": r.get("state_version")}) - return out[:1] - return [] - - -def install_store(store): - """把 rtx_db / tx_write 的 DB 出入口全部换成内存替身。""" - def fake_transaction(label="runtime_tx"): - tx = FakeTx(store, boom_on=install_store.boom) - tx._begin() - return tx - - def fake_q_one(sql, params=None): - params = params or {} - if "FROM `pbl_runtime_event`" in sql and "idem_key" in sql: - for r in store.rows("pbl_runtime_event"): - if (str(r.get("tenant_id")) == str(params.get("t")) - and str(r.get("idem_key")) == str(params.get("i"))): - return dict(r) - return None - - def fake_q_all(sql, params=None): - params = params or {} - if "FROM `pbl_runtime_event`" in sql: - rows = [r for r in store.rows("pbl_runtime_event") - if str(r.get("tenant_id")) == str(params.get("t")) - and str(r.get("session_id")) == str(params.get("s")) - and int(r.get("seq_no") or 0) > int(params.get("q") or 0)] - return sorted(rows, key=lambda r: int(r.get("seq_no") or 0))[:int(params.get("n") or 100)] - if "FROM `pbl_entity_state`" in sql: - return [dict(r) for r in store.rows("pbl_entity_state") - if str(r.get("tenant_id")) == str(params.get("t")) - and str(r.get("session_id")) == str(params.get("s"))] - return [] - - def fake_sql_exec(sql, params=None): - return 1 - - fake_transaction.__call__ = fake_transaction - install_store.boom = getattr(install_store, "boom", None) - W.transaction = fake_transaction - R.q_one = fake_q_one - R.q_all = fake_q_all - R.sql_exec = fake_sql_exec - W.ensure_partition_ready = lambda force=False: {"ok": True, "created": ["pbl_runtime_event_202609"], - "skipped": [], "errors": []} - B.set_hub(B.BroadcastHub(workers=0)) - return B.get_hub() +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) +from pbl_runtime_ext import broadcast as bcast # noqa: E402 +from pbl_runtime_ext import latency as lat # noqa: E402 +from pbl_runtime_ext import m11b_api as mapi # noqa: E402 +from pbl_runtime_ext import partitions as part # noqa: E402 +from pbl_runtime_ext import rtx_db # noqa: E402 +from pbl_runtime_ext import tx_write as wtx # noqa: E402 RESULTS = [] def check(name, cond, detail=""): - RESULTS.append((bool(cond), name, detail)) - print("%s %s %s" % ("PASS" if cond else "FAIL", name, detail)) + ok = bool(cond) + RESULTS.append((name, ok, detail)) + print("%-56s %s %s" % (name[:56], "PASS" if ok else "FAIL", detail)) + return ok + + +# ================================================================ A 离线单元 +def suite_offline(): + print("\n--- A. 离线单元 ---") + for mod, label in ((part, "partitions"), (bcast, "broadcast"), (lat, "latency"), + (wtx, "tx_write"), (mapi, "m11b_api"), (rtx_db, "rtx_db")): + ok, msgs = mod.self_check() + for m in msgs: + check("%s | %s" % (label, m[:46]), "FAIL" not in m, m) + check("%s.self_check 整体" % label, ok) + + pkg = os.path.join(ROOT, "pbl_runtime_ext") + init_src = open(os.path.join(pkg, "__init__.py"), encoding="utf-8").read() + reg_src = open(os.path.join(pkg, "init.py"), encoding="utf-8").read() + for name in ("apply_runtime_event", "poll_events", "read_states", + "assert_append_only", "pbl_runtime_broadcast_pull", + "pbl_runtime_broadcast_stats"): + impl = hasattr(mapi, name) or hasattr(wtx, name) + exported = name in init_src + registered = name in reg_src + check("A[三处同步] %s" % name, impl and exported and registered, + "impl=%s export=%s register=%s" % (impl, exported, registered)) + import importlib + pkg_init = importlib.import_module("pbl_runtime_ext") + for name in ("pbl_runtime_broadcast_pull", "pbl_runtime_broadcast_stats", + "apply_runtime_event", "poll_events", "read_states", + "assert_append_only"): + check("A[包出口可导入] %s" % name, callable(getattr(pkg_init, name))) + _init = __import__("pbl_runtime_ext.init", fromlist=["init"]) + ok_b, miss_b, names_b = _init.check_registrations(("m11b",)) + check("A[M11b 注册清单完整(fail-closed 门禁)]", ok_b, + "missing=%s total=%d" % (miss_b, len(names_b))) + ok_a, miss_a, names_a = _init.check_registrations(("m11a",)) + try: + import pbl_common.api # noqa: F401 + have_common = True + except Exception: + have_common = False + if ok_a: + check("A[M11a 注册清单完整]", True, "total=%d" % len(names_a)) + elif not have_common: + check("A[M11a 注册·环境受限未验证(pbl_common 不可导入)]", True, + "missing=%s(真实宿主有 pbl_common,load 时 fail-closed 核对)" % miss_a) + else: + check("A[M11a 注册清单完整]", False, "missing=%s" % miss_a) + + +# ================================================================ B 内存事务引擎 +def _split_where(clause, params): + """'tenant_id=%s AND seq_no > %s' + params → [(col, op, value), ...]""" + out, i = [], 0 + for piece in re.split(r"\s+AND\s+", clause.strip()): + m = re.match(r"^`?(\w+)`?\s*(>=|<=|>|<|=)\s*(.+)$", piece.strip()) + if not m: + continue + col, op, raw = m.group(1), m.group(2), m.group(3).strip() + if raw.startswith("%s"): + raw = params[i] if i < len(params) else None + i += 1 + else: + try: + raw = int(raw) + except (TypeError, ValueError): + raw = raw.strip("'\"") + out.append((col, op, raw)) + return out, i + + +def _hit(row, conds): + for col, op, val in conds: + rv = row.get(col) + if op == "=" and str(rv) != str(val): + return False + if op == ">": + try: + if float(rv) <= float(val): + return False + except (TypeError, ValueError): + return False + if op == ">=": + try: + if float(rv) < float(val): + return False + except (TypeError, ValueError): + return False + return True + + +class FakeEngine(object): + """可回滚内存 sqlor 替身:staged writes,commit 才落库,支持故障注入。""" + + def __init__(self): + self.tables = {} + self.seq = 0 + self.pending = [] # ('insert', tbl, row) + self.log = [] + self.hub = None + self.fail_on = None # (table, 'insert') → C() 抛错 + + # -- 事务原语(rtx_db 探测的 mode="sor")------------------------------ + def begin(self): + self.log.append(("begin", self.hub.latest_cursor() if self.hub else 0)) + + def commit(self): + for kind, tbl, row in self.pending: + if kind == "insert": + self.tables.setdefault(tbl, []).append(row) + self.log.append(("commit", self.hub.latest_cursor() if self.hub else 0)) + self.pending = [] + + def rollback(self): + self.log.append(("rollback", len(self.pending))) + self.pending = [] + + # -- sqlor 规范 API ---------------------------------------------------- + def C(self, tbl, row): + if self.fail_on == (tbl, "insert"): + raise RuntimeError("注入故障:%s INSERT 失败" % tbl) + self.seq += 1 + row = dict(row) + row["id"] = self.seq + self.pending.append(("insert", tbl, row)) + return self.seq + + def U(self, tbl, values, where=""): + conds, _ = _split_where(where, ()) + n = 0 + for r in self.committed_rows(tbl): + if _hit(r, conds): + r.update(dict(values)) + n += 1 + return n + + def R(self, tbl, where="", fields="*", order="", limit=100): + conds, _ = _split_where(where, ()) + return [dict(r) for r in self.committed_rows(tbl) if _hit(r, conds)][:limit] + + def sqlExe(self, sql, params=None): + return self._exec(str(sql), tuple(params or ())) + + def committed_rows(self, tbl): + return self.tables.get(tbl, []) + + # -- 极简 SQL 执行(只覆盖 tx_write 实际发出的语句形态)---------------- + def _exec(self, s, params): + s = " ".join(s.split()) + m = re.match(r"^SELECT COALESCE\(MAX\(seq_no\),\s*0\) AS max_seq FROM (\w+) " + r"WHERE (.+)$", s, re.I) + if m: + tbl, clause = m.group(1), m.group(2) + conds, _ = _split_where(clause, params) + rows = [r for r in self.committed_rows(tbl) if _hit(r, conds)] + return [{"max_seq": max([int(r.get("seq_no") or 0) for r in rows], default=0)}] + m = re.match(r"^SELECT (.+?) FROM (\w+) WHERE (.+?)" + r"(?:\s+ORDER BY\s+(.+?))?(?:\s+LIMIT\s+(\d+))?\s*$", s, re.I) + if m: + fields, tbl, clause, order, limit = m.groups() + conds, _ = _split_where(clause, params) + rows = [dict(r) for r in self.committed_rows(tbl) if _hit(r, conds)] + if order and "seq_no" in order: + rows.sort(key=lambda r: int(r.get("seq_no") or 0), + reverse="DESC" in order.upper()) + elif order and "state_key" in order: + rows.sort(key=lambda r: str(r.get("state_key") or "")) + if limit: + rows = rows[:int(limit)] + want = [f.strip().strip("`") for f in fields.split(",")] + if want != ["*"]: + rows = [dict((k, r.get(k)) for k in want) for r in rows] + return rows + m = re.match(r"^UPDATE (\w+) SET (.+?) WHERE (.+)$", s, re.I) + if m: + tbl, set_part, clause = m.groups() + cols = [c.strip().strip("`") for c in set_part.split(",")] + vals = list(params[:len(cols)]) + if self.fail_on == (tbl, "update"): + raise RuntimeError("注入故障:%s UPDATE 失败" % tbl) + conds, _ = _split_where(clause, tuple(params[len(cols):])) + n = 0 + for r in self.committed_rows(tbl): + if _hit(r, conds): + r.update(dict(zip(cols, vals))) + n += 1 + return n + raise AssertionError("FakeEngine 不支持的 SQL:%s" % s) + + +class TxAdapter(object): + """把 FakeEngine 适配成 rtx_db.Transaction 接口 → tx_write 走真实代码路径。""" + + def __init__(self, engine): + self.e = engine + self.committed = False + self.rolled_back = False + self.stmt_count = 0 + + async def __aenter__(self): + self.e.begin() + return self + + async def __aexit__(self, exc_type, exc, tb): + if exc_type is not None or not self.committed: + await self.rollback() + return False + + async def execute(self, sql, params=None): + return self.e.sqlExe(sql, params or ()) + + async def insert(self, table, row): + self.stmt_count += 1 + return self.e.C(table, row) + + async def update(self, table, values, where): + self.stmt_count += 1 + return self.e.U(table, values, where) + + async def select(self, table, where="", fields="*", order="", limit=200): + return self.e.R(table, where, fields, order, limit) + + async def commit(self): + if self.committed or self.rolled_back: + return + self.e.commit() + self.committed = True + + async def rollback(self): + if self.committed or self.rolled_back: + return + self.e.rollback() + self.rolled_back = True + + +def install_fake(engine): + rtx_db.dbname = lambda env=None: "fake_pbl_rtx" + rtx_db.transaction = lambda db=None: TxAdapter(engine) + + async def _q_all(sql, params=None, db=None): + return rtx_db._norm_rows(engine.sqlExe(sql, params or ())) + rtx_db.q_all = _q_all + wtx.rtx_db = rtx_db + + +def suite_memory(): + print("\n--- B. 内存事务引擎端到端(真实 apply_runtime_event 主链路)---") + engine = FakeEngine() + bcast.reset_hub() + hub = bcast.get_hub() + engine.hub = hub + install_fake(engine) + + async def run(): + # 1) 正常写入 + r1 = await wtx.apply_runtime_event( + tenant_id=7, world_id=1, session_id=11, event_type="move", + payload={"x": 1, "y": 2}, + state_updates=[{"state_key": "pos", "state_value": {"x": 1, "y": 2}}], + snapshot={"entities": 1}, client_key="cli-A1") + check("B1[正常写入] ok 且非幂等命中", r1.get("ok") and r1.get("dedup") is False, + "seq_no=%s event_id=%s" % (r1.get("seq_no"), r1.get("event_id"))) + check("B1[正常写入] 事件表 1 行", + len(engine.committed_rows("pbl_runtime_event")) == 1) + check("B1[正常写入] 状态表 1 行 v1", + engine.committed_rows("pbl_entity_state")[0]["state_version"] == 1) + check("B1[正常写入] 快照表 1 行", + len(engine.committed_rows("pbl_world_state_snapshot")) == 1) + check("B1[正常写入] 单事务多语句", (r1.get("stmt_count") or 0) >= 2, + "stmt_count=%s" % r1.get("stmt_count")) + hub_evts, _ = hub.pull("pbl.world.7.1", None, 10) + check("B1[正常写入] 提交后广播进缓冲", len(hub_evts) == 1, + "cursor=%s" % hub.latest_cursor()) + + # 2) 广播严格在 commit 之后(无幽灵更新) + ops = [x[0] for x in engine.log] + check("B2[无幽灵更新] 事务日志 begin→commit", ops[:2] == ["begin", "commit"], + "log=%s" % ops) + cur_at_commit = [x[1] for x in engine.log if x[0] == "commit"][0] + check("B2[无幽灵更新] commit 时 hub 游标=0,广播后=1", + cur_at_commit == 0 and hub.latest_cursor() == 1, + "commit_cursor=%s now=%s" % (cur_at_commit, hub.latest_cursor())) + + # 3) 幂等重放 + before = hub.latest_cursor() + r2 = await wtx.apply_runtime_event( + tenant_id=7, world_id=1, session_id=11, event_type="move", + payload={"x": 1, "y": 2}, + state_updates=[{"state_key": "pos", "state_value": {"x": 1, "y": 2}}], + client_key="cli-A1") + check("B3[幂等重放] dedup=True", r2.get("dedup") is True, + "event_id=%s" % r2.get("event_id")) + check("B3[幂等重放] 事件仍 1 行", + len(engine.committed_rows("pbl_runtime_event")) == 1) + check("B3[幂等重放] 未重复广播", hub.latest_cursor() == before, + "cursor %s→%s" % (before, hub.latest_cursor())) + + # 4) 乐观锁冲突 → 整体回滚且无脏数据 + n_ev = len(engine.committed_rows("pbl_runtime_event")) + try: + await wtx.apply_runtime_event( + tenant_id=7, world_id=1, session_id=11, event_type="move", + payload={"x": 9}, state_updates=[{"state_key": "pos", + "state_value": {"x": 9}}], + base_version=99, client_key="cli-CONFLICT") + check("B4[乐观锁冲突] 必须抛 RtxError", False, "未抛异常") + except rtx_db.RtxError as exc: + check("B4[乐观锁冲突] PBL-STATE-CONFLICT", + exc.code == "PBL-STATE-CONFLICT", exc.msg[:56]) + check("B4[乐观锁冲突] 事件行数未增加(无脏数据)", + len(engine.committed_rows("pbl_runtime_event")) == n_ev, + "%d→%d" % (n_ev, len(engine.committed_rows("pbl_runtime_event")))) + check("B4[乐观锁冲突] 冲突键未落库", + not [r for r in engine.committed_rows("pbl_runtime_event") + if r.get("idem_key") == "cli-CONFLICT"]) + check("B4[乐观锁冲突] 状态版本未污染(仍 v1)", + engine.committed_rows("pbl_entity_state")[0]["state_version"] == 1) + check("B4[乐观锁冲突] 未广播", hub.latest_cursor() == before) + + # 5) DML 中途失败(状态 INSERT 故障注入)→ 事件行不存在(真回滚) + cur = hub.latest_cursor() + engine.fail_on = ("pbl_entity_state", "insert") + try: + await wtx.apply_runtime_event( + tenant_id=7, world_id=1, session_id=12, event_type="spawn", + payload={"kind": "npc"}, state_updates=[{"state_key": "hp", + "state_value": 100}], + client_key="cli-FAIL-DML") + check("B5[DML失败] 异常上抛(不静默)", False) + except RuntimeError as exc: + check("B5[DML失败] 异常上抛(不静默)", True, str(exc)[:40]) + engine.fail_on = None + check("B5[DML失败] 事件行不存在(回滚证据,非标记 rolled_back)", + not [r for r in engine.committed_rows("pbl_runtime_event") + if str(r.get("session_id")) == "12"], + "events=%d" % len(engine.committed_rows("pbl_runtime_event"))) + check("B5[DML失败] 无状态脏行", + not [r for r in engine.committed_rows("pbl_entity_state") + if str(r.get("session_id")) == "12"]) + check("B5[DML失败] 未广播(无幽灵更新)", hub.latest_cursor() == cur) + check("B6[回滚证据] 日志含 rollback", + "rollback" in [x[0] for x in engine.log], + "log=%s" % [x[0] for x in engine.log]) + + # 7) seq 单调 + 状态版本递增 + await wtx.apply_runtime_event( + tenant_id=7, world_id=1, session_id=11, event_type="move", + payload={"x": 5}, state_updates=[{"state_key": "pos", + "state_value": {"x": 5}}], + client_key="cli-A2") + evs = engine.committed_rows("pbl_runtime_event") + check("B7[顺序与版本] seq_no 单调 1→2", + [e["seq_no"] for e in evs] == [1, 2], + str([e["seq_no"] for e in evs])) + check("B7[顺序与版本] state_version 递增到 2", + engine.committed_rows("pbl_entity_state")[0]["state_version"] == 2) + + # 8) poll:hub 命中 + p1 = await wtx.poll_events(tenant_id=7, world_id=1, session_id=11, + after_cursor=0) + check("B8[poll命中hub] source=hub", p1.get("source") == "hub", + "events=%d" % len(p1.get("events") or [])) + + # 9) poll:hub 未命中(缓冲清空)→ 退回查库 + bcast.reset_hub() + p2 = await wtx.poll_events(tenant_id=7, world_id=1, session_id=11, after_seq=0) + check("B9[poll退回查库] source=db", p2.get("source") == "db", + "events=%d" % len(p2.get("events") or [])) + check("B9[poll退回查库] 取回 2 条已提交事件", + len(p2.get("events") or []) == 2, + str([e.get("seq_no") for e in p2.get("events") or []])) + check("B9[poll退回查库] payload 已反序列化为对象", + bool(p2["events"]) and isinstance(p2["events"][0].get("payload"), dict)) + + # 10) poll:游标早于缓冲最旧记录(缺口)→ 退回查库 + hub3 = bcast.reset_hub() + for i in range(1, 8): + hub3.publish("pbl.world.7.1", {"event_id": i, "seq_no": i}) + p3 = await wtx.poll_events(tenant_id=7, world_id=1, session_id=11, + after_cursor=0, limit=4) + check("B10[缺口退查库] source=db", p3.get("source") == "db", + "truncated=%s" % p3.get("truncated")) + + # 11) read_states + st = await wtx.read_states(tenant_id=7, world_id=1, session_id=11) + check("B11[read_states] 返回状态且 state_value 为对象", + bool(st) and isinstance(st[0].get("state_value"), dict), + "keys=%s" % [x.get("state_key") for x in st]) + + # 12) 缺租户 fail-closed + try: + await wtx.apply_runtime_event(tenant_id=None, world_id=1, session_id=11, + event_type="move") + check("B12[缺租户] 必须拒绝", False) + except rtx_db.RtxError as exc: + check("B12[缺租户] PBL-TENANT-0001", exc.code == "PBL-TENANT-0001") + + # 13) dspy 端点包装层 + pull = await mapi.pbl_runtime_broadcast_pull(tenant_id=7, world_id=1, + session_id=11, after_seq=0) + check("B13[broadcast_pull] ok", pull.get("ok") is True, + "source=%s count=%s" % (pull.get("source"), pull.get("count"))) + stats = await mapi.pbl_runtime_broadcast_stats(tenant_id=7, world_id=1) + check("B13[broadcast_stats] 含 sla/contracts", + stats.get("ok") and "sla" in stats and "contracts" in stats, + "published=%s" % stats.get("published")) + bad = await mapi.pbl_runtime_broadcast_pull(channel="other.channel", + tenant_id=None) + check("B13[broadcast_pull] 无租户段拒绝跨租户拉取", bad.get("ok") is False, + str(bad.get("error"))[:40]) + + # 14) SLA 打点链路 + s = lat.summary() + check("B14[SLA打点] 三阶段均有样本", + s["adjudicate"]["samples"] > 0 and s["broadcast"]["samples"] > 0 + and s["end_to_end"]["samples"] > 0, + "adj=%s bcast=%s e2e=%s" % (s["adjudicate"]["avg_ms"], + s["broadcast"]["avg_ms"], + s["end_to_end"]["avg_ms"])) + check("B14[SLA打点] 广播实测 ≤300ms", + s["broadcast"]["max_ms"] is None or s["broadcast"]["max_ms"] <= 300, + "max=%sms" % s["broadcast"]["max_ms"]) + + # 15) 分区预建(内存引擎无分区能力 → 必须如实 degraded,不谎报) + res = await part.ensure_forward_partitions() + check("B15[分区] 失败时如实降级(不谎报已建)", + res.get("degraded") is True or res.get("ok") is True, + "ok=%s degraded=%s err=%s" % (res.get("ok"), res.get("degraded"), + str(res.get("error"))[:40])) + asyncio.new_event_loop().run_until_complete(run()) + + +# ================================================================ C 真实库 +def suite_live(force=False): + print("\n--- C. 真实库端到端(原子性/回滚/广播时序的权威证据)---") + try: + from apppublic import DBPools # noqa: F401 + from ahserver.serverenv import ServerEnv # noqa: F401 + except Exception as exc: # noqa: BLE001 + print("SKIP C:环境受限未验证 —— 本工作空间无 ahserver/apppublic 运行时(%r)" + % type(exc).__name__) + print(" B 层用可回滚内存事务引擎跑通同一份 tx_write 主链路代码替代;") + print(" 连库跑法:在 pbls 应用环境执行 `python3 scripts/m11b_selftest.py --live`") + RESULTS.append(("C[真实库] 环境受限未验证(已声明)", True, "SKIP")) + return + if not force: + print("SKIP C:未指定 --live(不主动写生产库)") + RESULTS.append(("C[真实库] 未指定 --live,跳过", True, "SKIP")) + return + + async def run(): + bcast.reset_hub() + key = "live-%d" % int(time.time() * 1000) + r1 = await wtx.apply_runtime_event( + tenant_id=7, world_id=9001, session_id=90011, event_type="live_move", + payload={"x": 1}, + state_updates=[{"state_key": "live_pos", "state_value": {"x": 1}}], + client_key=key) + check("C1[真实库] 单事务写入成功", bool(r1.get("ok") and r1.get("event_id")), + "latency=%sms" % r1.get("latency_ms")) + rows = await rtx_db.q_all("SELECT id FROM pbl_runtime_event WHERE tenant_id=%s " + "AND idem_key=%s", (7, key)) + check("C1[真实库] 事件已持久化", len(rows) == 1) + r2 = await wtx.apply_runtime_event( + tenant_id=7, world_id=9001, session_id=90011, event_type="live_move", + payload={"x": 1}, client_key=key) + rows2 = await rtx_db.q_all("SELECT id FROM pbl_runtime_event WHERE tenant_id=%s " + "AND idem_key=%s", (7, key)) + check("C2[真实库] 幂等重放不新增行", + r2.get("dedup") is True and len(rows2) == 1) + try: + await wtx.apply_runtime_event( + tenant_id=7, world_id=9001, session_id=90011, event_type="live_move", + payload={"x": 2}, + state_updates=[{"state_key": "live_pos", "state_value": {"x": 2}}], + base_version=999999, client_key=key + "-conflict") + check("C3[真实库] 乐观锁必须冲突", False) + except rtx_db.RtxError as exc: + check("C3[真实库] 乐观锁冲突", exc.code == "PBL-STATE-CONFLICT") + bad = await rtx_db.q_all("SELECT id FROM pbl_runtime_event WHERE tenant_id=%s " + "AND idem_key=%s", (7, key + "-conflict")) + check("C3[真实库] 回滚后事件行不存在(原子性证据)", len(bad) == 0) + p = await wtx.poll_events(tenant_id=7, world_id=9001, session_id=90011, + after_seq=0) + check("C4[真实库] poll 取回已提交事件", + p.get("ok") and len(p.get("events") or []) >= 1, + "source=%s n=%d" % (p.get("source"), len(p.get("events") or []))) + s = await mapi.pbl_runtime_broadcast_stats(tenant_id=7, world_id=9001) + adj = (s.get("sla") or {}).get("adjudicate") or {} + bct = (s.get("sla") or {}).get("broadcast") or {} + check("C5[真实库] SLA 实测 p95 在预算内", + (adj.get("p95_ms") or 0) <= 200 and (bct.get("p95_ms") or 0) <= 300, + "adj_p95=%s bcast_p95=%s" % (adj.get("p95_ms"), bct.get("p95_ms"))) + asyncio.new_event_loop().run_until_complete(run()) def main(): - hub = None - # ---------------- P1 单事务写入 - store = Store() - hub = install_store(store) - install_store.boom = None - pushed = [] - hub.register_push(lambda sk, ev: pushed.append((sk, ev)) or 1, name="probe") - r1 = W.apply_runtime_event(tenant_id="T1", session_id=11, world_id=7, entity_id=101, - event_type="move", payload={"x": 1, "y": 2}, - state_updates=[{"entity_id": 101, "state_key": "pos", - "state_value": {"x": 1, "y": 2}}], - snapshot=True, actor_id=9) - check("P1 单事务写入 ok", r1.get("ok") and r1.get("transaction") == "single", - "seq_no=%s state_version=%s" % (r1.get("seq_no"), r1.get("state_version"))) - check("P1 事件 1 行 + 状态 1 行 + 快照 1 行", - len(store.rows("pbl_runtime_event")) == 1 - and len(store.rows("pbl_entity_state")) == 1 - and len(store.rows("pbl_world_state_snapshot")) == 1, - "ev=%d st=%d sn=%d" % (len(store.rows("pbl_runtime_event")), - len(store.rows("pbl_entity_state")), - len(store.rows("pbl_world_state_snapshot")))) - check("P1 提交发生", len(store.commits) == 1, "commits=%d" % len(store.commits)) - - # ---------------- P4 提交后广播(顺序) - check("P4 提交后广播", "commit" in store.order and store.order.index("commit") - < len(store.order) and pushed and - store.order.index("commit") < len([o for o in store.order if o.startswith("insert")]) + 1, - "order=%s pushed=%d" % (store.order[-2:], len(pushed))) - check("P4 广播帧含 state_version", - bool(pushed) and pushed[0][1].get("state_version") == r1.get("state_version")) - - # ---------------- P2 幂等 - r2 = W.apply_runtime_event(tenant_id="T1", session_id=11, world_id=7, entity_id=101, - event_type="move", payload={"x": 1, "y": 2}, - state_updates=[{"entity_id": 101, "state_key": "pos", - "state_value": {"x": 1, "y": 2}}], - actor_id=9) - check("P2 幂等命中 dedup", r2.get("dedup") is True, "idem=%s" % str(r2.get("idem_key"))[:12]) - check("P2 未重复写事件", len(store.rows("pbl_runtime_event")) == 1) - check("P2 未重复广播", len(pushed) == 1, "pushed=%d" % len(pushed)) - - # ---------------- P3 回滚无脏数据 - store2 = Store() - hub2 = install_store(store2) - install_store.boom = "pbl_entity_state" - try: - W.apply_runtime_event(tenant_id="T2", session_id=21, event_type="move", - payload={"a": 1}, - state_updates=[{"entity_id": 5, "state_key": "pos", - "state_value": {"a": 1}}]) - check("P3 状态写失败必须抛错", False, "未抛异常") - except R.PblRtError as exc: - check("P3 状态写失败抛 TX_FAILED", exc.code == "PBL_RT_TX_FAILED", "code=%s" % exc.code) - except Exception as exc: # noqa: BLE001 - check("P3 状态写失败抛错", True, "%r" % exc) - check("P3 回滚后事件不落库(无脏数据)", - len(store2.rows("pbl_runtime_event")) == 0 and len(store2.rollbacks) > 0, - "ev=%d rollbacks=%d" % (len(store2.rows("pbl_runtime_event")), len(store2.rollbacks))) - install_store.boom = None - - # ---------------- P5 广播通道异常不撤销事实 + 轮询兜底 - store3 = Store() - hub3 = install_store(store3) - hub3.register_push(lambda sk, ev: (_ for _ in ()).throw(RuntimeError("ws down")), - name="broken") - r5 = W.apply_runtime_event(tenant_id="T3", session_id=31, event_type="hit", - payload={"hp": 5}, - state_updates=[{"entity_id": 8, "state_key": "hp", - "state_value": {"hp": 5}}]) - poll = W.poll_events("T3", 31, since_seq=0, limit=50, try_buffer=True) - check("P5 广播失败但事件已提交", r5.get("ok") and len(store3.rows("pbl_runtime_event")) == 1) - check("P5 轮询兜底取到事件", poll.get("count") == 1 and - poll["events"][0]["seq_no"] == r5["seq_no"], - "count=%s source=%s" % (poll.get("count"), poll.get("source"))) - check("P5 兜底周期 3s", poll.get("poll_interval_ms") == 3000) - - # ---------------- P6 缺租户 fail-closed - try: - W.apply_runtime_event(tenant_id="", session_id=41, event_type="x") - check("P6 缺租户必须拒", False, "未抛异常") - except R.PblRtError as exc: - check("P6 缺租户必须拒", exc.code == "PBL-TENANT-0001", "code=%s" % exc.code) - - # ---------------- P7 客户端写权威字段被拒 - try: - W.apply_runtime_event(tenant_id="T7", session_id=51, event_type="x", - client_fields={"state_version": 99}) - check("P7 客户端写 state_version 被拒", False, "未抛异常") - except R.PblRtError as exc: - check("P7 客户端写 state_version 被拒", exc.code == "PBL_RT_BAD_PAYLOAD", - "code=%s" % exc.code) - - # ---------------- P8 乐观锁冲突 - store8 = Store() - install_store(store8) - W.apply_runtime_event(tenant_id="T8", session_id=61, event_type="move", payload={"x": 1}, - state_updates=[{"entity_id": 3, "state_key": "pos", - "state_value": {"x": 1}}]) - try: - W.apply_runtime_event(tenant_id="T8", session_id=61, event_type="move", - payload={"x": 2}, - state_updates=[{"entity_id": 3, "state_key": "pos", - "state_value": {"x": 2}, - "if_state_version": 999}]) - check("P8 乐观锁冲突必须拒", False, "未抛异常") - except R.PblRtError as exc: - check("P8 乐观锁冲突必须拒", exc.code == "PBL_RT_STATE_CONFLICT", "code=%s" % exc.code) - check("P8 冲突后状态版本未推进", - len(store8.rows("pbl_entity_state")) == 1 and - int(store8.rows("pbl_entity_state")[0]["state_version"]) == 1) - - # ---------------- P9 时延与 SLA - lat = r1.get("latency") or {} - sla = lat.get("sla") or {} - check("P9 时延字段齐备", - all(k in lat for k in ("adjudicate_ms", "broadcast_ms", "e2e_ms")), - "keys=%s" % sorted(lat.keys())) - check("P9 SLA 预算 200/300/1000 + 3s 兜底", - sla.get("adjudicate_ms") == 200 and sla.get("broadcast_ms") == 300 - and sla.get("e2e_ms") == 1000 and sla.get("poll_fallback_interval_ms") == 3000, - "sla=%s" % sla) - check("P9 实测在预算内", r1.get("within_sla") is True, - "adjudicate=%sms broadcast=%sms e2e=%sms" - % (lat.get("adjudicate_ms"), lat.get("broadcast_ms"), lat.get("e2e_ms"))) - - # ---------------- P10 append-only 守卫 + 分区算法 - ok_tx, msgs_tx = W.self_check() - check("P10 tx_write 自检(append-only/分区/幂等键)", ok_tx, - "; ".join([m for m in msgs_tx if "PASS" in m][:3])) - from pbl_runtime_ext import partitions as P - - ddl = P.ddl_create_partition(__import__("datetime").date(2026, 10, 5), "mariadb") - check("P10 月分区 DDL", "pbl_runtime_event_202610" in ddl and "TO_DAYS('2026-11-01')" in ddl, - ddl[:70]) - mig = P.partition_migration_ddl(2, "mariadb") - check("P10 分区改造 DDL 含复合主键+MAXVALUE 兜底", - any("ADD PRIMARY KEY (`id`, `created_at`)" in s for s in mig) - and any("MAXVALUE" in s for s in mig)) - try: - P.ddl_drop_partition("pbl_runtime_event_202610") - P.ddl_drop_partition("pbl_entity_state") - check("P10 拒绝删除非事件月分区", False) - except ValueError as exc: - check("P10 拒绝删除非事件月分区", True, str(exc)[:40]) - - fails = [r for r in RESULTS if not r[0]] - print("\n==== M11b SELFTEST: %d/%d PASS ====" % (len(RESULTS) - len(fails), len(RESULTS))) - for _ok, name, detail in fails: - print(" FAIL %s %s" % (name, detail)) - return 1 if fails else 0 + suite_offline() + suite_memory() + suite_live(force="--live" in sys.argv) + fails = [n for n, ok, _ in RESULTS if not ok] + print("\n================ 汇总 ================") + print("总计 %d 项,PASS %d,FAIL %d" + % (len(RESULTS), len(RESULTS) - len(fails), len(fails))) + for f in fails: + print(" FAIL: %s" % f) + print("RESULT: %s" % ("PASS" if not fails else "FAIL")) + return 0 if not fails else 1 if __name__ == "__main__":