730 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""tx_write —— M11b 核心:**单事务**写事件 + 实体状态 + 快照,**提交后**广播。
契约(三处同步:本文件实现 → __init__.py 导出 → init.py env 注册):
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 + 基表写保护
**列名权威源 = models/*.json**(module-development-spec 铁律:禁止猜列)。
本文件所有 INSERT/UPDATE/SELECT 只使用下列真实列,并由 build_row()/require_columns()
双重校验(未知列直接抛错,不再静默丢列——这正是上一版幂等/乐观锁失效的根因):
pbl_runtime_event : tenant_id, event_uid, session_id, world_id, actor_id,
event_type, payload_json, causation_id, state_version, seq,
occurred_at, created_at, updated_at
(uk_re_uid = tenant_id+event_uid+occurred_at 唯一;
idx_re_session_seq = tenant_id+session_id+seq)
pbl_entity_state : tenant_id, session_id, entity_id, state_json, state_version,
checksum, updated_by, created_at, updated_at
(uk_es = tenant_id+session_id+entity_id 唯一)
pbl_world_state_snapshot: tenant_id, session_id, snapshot_uid, state_fingerprint,
entities_json, events_through_seq, reason, created_at, updated_at
关键保证(QC #2/#3/#7 关注点):
* 原子性:三条 DML 全在同一个 ``rtx_db.transaction()`` 内;任一失败 → rollback,
事件行**不存在**(真回滚,不是「标 rolled_back」),且不广播。
* 无幽灵更新:广播只在事务 commit 成功之后(with 块退出后)发出。
* 幂等:event_uid 即幂等键(客户端 client_event_id 原样透传,否则服务端派生),
同 uid 命中已有事件 → dedup=True,不重复写、不重复广播;DB 唯一索引兜底并发。
* 乐观锁:state_version 服务端单调递增,客户端禁写;base_version 不匹配 → 冲突回滚。
* fail-closed:库名/事务原语/租户/必填列任一不可得 → 抛错,绝不退化为逐条提交。
"""
import hashlib
import inspect
import json
import os
import time
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)
__all__ = ["apply_runtime_event", "poll_events", "read_states", "assert_append_only",
"idem_key_of", "table_columns", "build_row", "self_check"]
_BASE_TABLES_LC = tuple(t.lower() for t in PROTECTED_BASE_TABLES)
_MODULE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 对外契约使用的逻辑字段名 → 表真实列名(单一映射,避免再次漂移)
EVENT_COL_SEQ = "seq" # 会话内单调序号
EVENT_COL_UID = "event_uid" # 幂等键(唯一索引成员)
EVENT_COL_PAYLOAD = "payload_json"
EVENT_COL_STATE_VERSION = "state_version"
STATE_COL_KEY = "entity_id" # 状态行主键维度(tenant+session+entity_id)
STATE_COL_VALUE = "state_json"
# ---------------------------------------------------------------- 列名自省
_COL_CACHE = {}
def _model_spec(table):
"""读取 models/{table}.json(表结构权威源)。读不到返回 None(不猜)。"""
if table in _COL_CACHE:
return _COL_CACHE[table]
path = os.path.join(_MODULE_ROOT, "models", "%s.json" % table)
spec = None
try:
with open(path, "r", encoding="utf-8") as f:
spec = json.load(f)
except Exception: # noqa: BLE001
spec = None
_COL_CACHE[table] = spec
return spec
def table_columns(table):
"""表真实列名 list;模型缺失返回 None。"""
spec = _model_spec(table)
if not spec:
return None
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])
return names or None
def _is_not_null(fd):
"""字段是否声明 NOT NULL —— 兼容 models/*.json 并存的两种写法。
* ``{"null": false}`` :xlsx→json 产物写法(pbl_entity_state / pbl_world_state_snapshot)
* ``{"nullable": "no"}`` :database-table-definition-spec 规范写法(pbl_runtime_event)
M11b-2c-A3 修复(PBL-RTX-0004 **漏报侧**根因):此前只认 ``null: false``,
而 pbl_runtime_event 用的是规范写法 ``nullable: "no"``,使该表 ``required_columns()``
恒为 ``[]`` —— build_row 的 INSERT 完整性门禁对事件表**完全失效**
(该报的缺列不报,静默写出半空行)。
"""
if fd.get("null") is False:
return True
if str(fd.get("nullable") or "").strip().lower() in ("no", "false", "0"):
return True
return False
def _has_db_default(fd):
"""列是否在 DDL 中声明 DEFAULT(含 CURRENT_TIMESTAMP)。
``NOT NULL DEFAULT x`` 的列在 INSERT 省略时由数据库补值,**不构成"缺 NOT NULL 列"**。
M11b-2c-A3 修复(PBL-RTX-0004 **误报侧**根因之二):pbl_runtime_event 的
event_code/idem_key/scene_id/entity_id/actor_id/causation_id/seq/source/state/
tx_group/broadcast/created_by/created_at/updated_at 等列均为 NOT NULL DEFAULT,
写路径按语义不填它们;若计入必填集合,合法调用会被误报缺列。
(与 _recognize nullable 写法配套:只修识别不修默认值排除,事件表主链路会被误挡。)
"""
if "default" not in fd:
return False
dv = fd.get("default")
if dv is None:
return False
if str(dv).strip().upper() in ("NULL", "NONE"):
return False
return True # 含 DEFAULT ''(空串默认值同样是 DDL 默认值,数据库会补值)
def required_columns(table):
"""INSERT 必须**显式给值**的列:NOT NULL + 非自增 + 无数据库 DEFAULT。
自增列由数据库生成、NOT NULL DEFAULT 列由默认值补上,两者都不需要调用方提供;
纳入必填集合只会造成 PBL-RTX-0004 误报。真正的必填列(pbl_entity_state 的
tenant_id/session_id/entity_id/state_version/checksum/updated_by/created_at/
updated_at,pbl_runtime_event 的 event_uid/event_type)缺值时数据库严格模式会
直接报错,故必须在这里 fail-closed 拦住,不得放宽。
"""
spec = _model_spec(table)
if not spec:
return []
out = []
for fd in spec.get("fields") or []:
if not isinstance(fd, dict) or not fd.get("name"):
continue
if _is_not_null(fd) and not fd.get("auto_increment") and not _has_db_default(fd):
out.append(fd["name"])
return out
def build_row(table, wanted, strict=True, full_row=True):
"""按表真实列过滤字段。strict=True 时未知列直接抛错(防静默丢列写空行)。
full_row=True(默认,**INSERT 语义**):额外校验 required_columns() 齐备,
缺列抛 PBL-RTX-0004。
full_row=False(**UPDATE SET 子句语义**):被改动的行在库中已存在,未出现在 SET
列表里的 NOT NULL 列保持原值,**不存在"缺列"**,因此不得套用 INSERT 的完整性
校验;同时禁止 SET 不可变列(tenant_id/id/created_at),违者抛 PBL-RTX-0006。
PBL-RTX-0004 闭环(M11b-2c-A3,A1 判定为业务实现缺陷):误报有两条独立路径——
(1) UPDATE 分支沿用 INSERT 校验(此前用 strict=False 绕,但 strict 只管未知列,
缺列校验照跑 → tenant_id/session_id/entity_id/created_at 必被误报)。
现按 full_row=False 构造:定位维度走 WHERE、created_at 保持建档原值。
(2) 必填集合把「自增列 / NOT NULL DEFAULT 列」也算进来 → required_columns()
已排除(见 _has_db_default)。
漏报侧同修:required_columns() 此前不认 ``nullable: "no"`` 写法,事件表门禁恒真。
修后语义:该报的(真缺 event_uid/event_type 等无默认 NOT NULL 列)一定报,
不该报的(UPDATE SET 子集、数据库能补值的列)一定不报。
"""
cols = table_columns(table)
if not cols:
if strict:
raise rtx_db.RtxError("PBL-RTX-0003",
"models/%s.json 不可读,拒绝按猜列写入" % table)
return dict(wanted)
unknown = [k for k in wanted if k not in cols]
if unknown and strict:
raise rtx_db.RtxError(
"PBL-RTX-0003",
"%s 未知列 %s(权威列=%s)" % (table, unknown, cols))
row = dict((k, v) for k, v in wanted.items() if k in cols)
if full_row:
missing = [c for c in required_columns(table) if c not in row]
if missing:
raise rtx_db.RtxError("PBL-RTX-0004",
"%s 缺少 NOT NULL 列 %s" % (table, missing))
else:
immutable = [c for c in ("tenant_id", "id", "created_at") if c in row]
if immutable:
raise rtx_db.RtxError("PBL-RTX-0006",
"%s UPDATE 禁止 SET 不可变列 %s" % (table, immutable))
return row
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 _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 _digest(obj):
return hashlib.sha256(_dumps(obj).encode("utf-8")).hexdigest()[:40]
def _norm(rows):
return rtx_db._norm_rows(rows)
# ---------------------------------------------------------------- 幂等键
def idem_key_of(tenant_id, session_id, event_type, payload=None, client_key=None):
"""幂等键(落 event_uid 列,长度 ≤32 且被唯一索引覆盖)。
客户端给的 client_event_id 原样透传(截断 32,保证重放同键);
否则由 tenant|session|type|payload 派生 sha256[:32],同参必同键。
"""
if client_key:
return str(client_key)[:32]
basis = "|".join([str(tenant_id), str(session_id), str(event_type),
_dumps(payload or {})])
return hashlib.sha256(basis.encode("utf-8")).hexdigest()[:32]
# ---------------------------------------------------------------- 校验
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, uid):
sql = ("SELECT id, %s, event_type, %s FROM %s "
"WHERE tenant_id=%%s AND %s=%%s LIMIT 1"
% (EVENT_COL_SEQ, EVENT_COL_STATE_VERSION, EVENT_TABLE, EVENT_COL_UID))
rows = _norm(await tx.execute(sql, (tenant_id, uid)))
return rows[0] if rows else None
async def _next_seq(tx, tenant_id, session_id):
"""会话内单调序号:MAX(seq)+1(与 uk/idx_re_session_seq 同维度)。"""
sql = ("SELECT COALESCE(MAX(%s), 0) AS max_seq FROM %s "
"WHERE tenant_id=%%s AND session_id=%%s" % (EVENT_COL_SEQ, EVENT_TABLE))
rows = _norm(await tx.execute(sql, (tenant_id, session_id)))
try:
return int(rows[0].get("max_seq") or 0) + 1 if rows else 1
except (TypeError, ValueError):
return 1
async def _apply_state(tx, tenant_id, session_id, upd, seq_no, base_version,
actor_id, now):
"""单条实体状态 UPSERT + 乐观锁。返回 {entity_id, state_version, action}。"""
if not isinstance(upd, dict):
raise rtx_db.RtxError(ERR_BAD_PARAM, "state_updates 每项必须是对象")
entity_id = upd.get("entity_id") or upd.get("state_key")
if not entity_id:
raise rtx_db.RtxError(ERR_BAD_PARAM,
"state_updates 每项必须含 entity_id(客户端禁写 state_version)")
entity_id = str(entity_id)[:64]
value = upd.get("state") if "state" in upd else upd.get("state_value")
if value is None:
raise rtx_db.RtxError(ERR_BAD_PARAM, "state_updates 每项必须含 state")
rows = _norm(await tx.execute(
"SELECT id, %s FROM %s WHERE tenant_id=%%s AND session_id=%%s AND %s=%%s LIMIT 1"
% (EVENT_COL_STATE_VERSION, STATE_TABLE, STATE_COL_KEY),
(tenant_id, session_id, entity_id)))
state_version = _dumps(value)
checksum = _digest(value)
if rows:
cur_ver = int(rows[0].get(EVENT_COL_STATE_VERSION) or 0)
if base_version is not None and int(base_version) != cur_ver:
raise rtx_db.RtxError(
ERR_STATE_CONFLICT,
"entity_id=%s 乐观锁冲突:base_version=%s 当前 state_version=%s(整体回滚)"
% (entity_id, base_version, cur_ver))
new_ver = cur_ver + 1 # 服务端单调递增
vals = build_row(STATE_TABLE, {
STATE_COL_VALUE: state_version, EVENT_COL_STATE_VERSION: new_ver,
"checksum": checksum, "updated_by": actor_id, "updated_at": now,
}, strict=True, full_row=False)
await tx.execute(
"UPDATE %s SET %s WHERE tenant_id=%%s AND session_id=%%s AND %s=%%s"
% (STATE_TABLE, ", ".join("%s=%%s" % c for c in vals.keys()), STATE_COL_KEY),
tuple(list(vals.values()) + [tenant_id, session_id, entity_id]))
action = "update"
else:
new_ver = 1
await tx.insert(STATE_TABLE, build_row(STATE_TABLE, {
"tenant_id": tenant_id, "session_id": session_id,
STATE_COL_KEY: entity_id, STATE_COL_VALUE: state_version,
EVENT_COL_STATE_VERSION: new_ver, "checksum": checksum,
"updated_by": actor_id, "created_at": now, "updated_at": now,
}))
action = "insert"
return {"entity_id": entity_id, "state_version": new_ver, "action": action,
"state_key": entity_id, "state_value": value, "seq": seq_no}
# ---------------------------------------------------------------- 主写入
async def apply_runtime_event(tenant_id=None, world_id=None, session_id=None,
event_type=None, payload=None, state_updates=None,
client_event_id=None, idem_key=None, snapshot=None,
base_version=None, actor_id=None, causation_id=None,
occurred_at=None, env=None):
"""单事务写「事件 + 实体状态 + 快照」,提交后广播。
返回 {ok, event_id, seq, cursor, dedup, state_rows, latency_ms, channel, stmt_count}
抛 RtxError:PBL-TENANT-0001 / PBL-PARAM-0001 / PBL-STATE-CONFLICT / PBL-RTX-000x
"""
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 必须是数组")
uid = idem_key_of(tenant_id, session_id, event_type, payload,
client_event_id 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")
actor = str(actor_id or "server")[:32]
causation = str(causation_id or "")[:32]
event_id = None
seq_no = None
state_events = []
dedup_result = None
t0 = time.perf_counter() # 裁决计时起点(含事务全程)
async with rtx_db.transaction() as tx:
try:
# 1) 幂等:同 event_uid 已存在 → 不重复写、不重复广播
dup = await _find_idem(tx, tenant_id, uid)
if dup:
await tx.commit()
dedup_result = {
"ok": True, "dedup": True, "event_id": dup.get("id"),
"event_uid": uid, "seq": dup.get(EVENT_COL_SEQ),
"cursor": None, "state_rows": 0, "latency_ms": None,
"channel": channel, "stmt_count": tx.stmt_count,
}
else:
# 2) 会话内单调 seq
seq_no = await _next_seq(tx, tenant_id, session_id)
# 3) INSERT 事件(append-only:本模块对该表只有 INSERT)
assert_append_only("INSERT", EVENT_TABLE, tenant_id)
max_ver = 0
event_id = await tx.insert(EVENT_TABLE, build_row(EVENT_TABLE, {
"tenant_id": tenant_id, EVENT_COL_UID: uid,
"session_id": session_id, "world_id": world_id,
"actor_id": actor, "event_type": str(event_type)[:64],
EVENT_COL_PAYLOAD: _dumps(payload or {}),
"causation_id": causation,
EVENT_COL_STATE_VERSION: max_ver,
EVENT_COL_SEQ: seq_no,
"occurred_at": now, "created_at": now, "updated_at": now,
}))
# 4) UPSERT 实体状态(乐观锁:state_version 服务端递增,客户端禁写)
for upd in state_updates:
st = await _apply_state(tx, tenant_id, session_id, upd, seq_no,
base_version, actor, now)
state_events.append(st)
# 4b) 事件行 state_version:pbl_runtime_event 为 append-only,
# 同事务内 INSERT 之后**不再 UPDATE 回填**(对 append-only 表
# 的任何 UPDATE 都被 assert_append_only 拦截)。本轮 UPSERT 后的
# 最新 state_version 只通过返回值 states[] 下发给调用方,
# 事件行保持写入时的 state_version(=本次事件 seq 对应的水位)。
# 5) 快照(掉线补齐/离线兜底)
if snapshot is not None:
await tx.insert(SNAPSHOT_TABLE, build_row(SNAPSHOT_TABLE, {
"tenant_id": tenant_id, "session_id": session_id,
"snapshot_uid": _digest({"u": uid, "s": snapshot})[:32],
"state_fingerprint": _digest(snapshot),
"entities_json": _dumps(snapshot),
"events_through_seq": seq_no,
"reason": str(snapshot.get("reason") if isinstance(snapshot, dict)
else "apply_runtime_event")[:32],
"created_at": now, "updated_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, "event_uid": uid, "seq": seq_no,
"seq_no": seq_no, # 兼容旧字段名(对外响应别名)
"event_type": str(event_type), "payload": payload or {},
"states": state_events, "idem_key": uid,
"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, "event_uid": uid,
"seq": seq_no, "seq_no": seq_no,
"cursor": evt.get("cursor"), "state_rows": len(state_events),
"states": state_events, "latency_ms": ms, "broadcast_ms": b_ms,
"channel": channel, "sla_breach": bool(breach), "stmt_count": stmt_count,
}
# ---------------------------------------------------------------- 读
async def read_states(tenant_id=None, session_id=None, entity_ids=None,
world_id=None, env=None):
"""读实体状态(tenant_id 强制;entity_ids 可选过滤)。返回 list[dict]。"""
tenant_id = _require_tenant(tenant_id)
sql = ("SELECT %s, state_json, state_version, checksum, updated_by, updated_at "
"FROM %s WHERE tenant_id=%%s" % (STATE_COL_KEY, STATE_TABLE))
params = [tenant_id]
if session_id not in (None, ""):
sql += " AND session_id=%s"
params.append(session_id)
if entity_ids:
sql += " AND %s IN (%s)" % (STATE_COL_KEY, ",".join(["%s"] * len(entity_ids)))
params.extend([str(e) for e in entity_ids])
sql += " ORDER BY %s ASC LIMIT 500" % STATE_COL_KEY
rows = await rtx_db.q_all(sql, tuple(params))
out = []
for r in rows:
r = dict(r)
state = _loads(r.get(STATE_COL_VALUE), {})
r["state"] = state
r["state_value"] = state # 对外别名
r["entity_id"] = r.get(STATE_COL_KEY)
out.append(r)
return out
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 时退回查库(权威源)。
返回 {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": events[-1].get("seq"), "source": "hub",
"truncated": False}
sql = ("SELECT id, %s, event_type, %s, %s, occurred_at, created_at FROM %s "
"WHERE tenant_id=%%s"
% (EVENT_COL_SEQ, EVENT_COL_PAYLOAD, EVENT_COL_STATE_VERSION, 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 %s > %s" % (EVENT_COL_SEQ, "%s")
params.append(int(after_seq))
elif truncated and events:
sql += " AND %s >= %s" % (EVENT_COL_SEQ, "%s")
params.append(int(events[0].get("seq") or 0))
sql += " ORDER BY %s ASC LIMIT %s" % (EVENT_COL_SEQ, int(limit))
rows = await rtx_db.q_all(sql, tuple(params))
out = []
for r in rows:
r = dict(r)
r["payload"] = _loads(r.get(EVENT_COL_PAYLOAD), {})
r["seq"] = int(r.get(EVENT_COL_SEQ) or 0)
r["seq_no"] = r["seq"]
r["channel"] = channel
out.append(r)
next_seq = out[-1]["seq"] if out else (int(after_seq) if after_seq not in (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():
"""离线自检:幂等键派生、append-only 守卫、列名与 models 对齐、契约协程签名。"""
msgs, ok = [], True
k1 = idem_key_of(7, 11, "move", {"x": 1})
k2 = idem_key_of(7, 11, "move", {"x": 1})
k3 = idem_key_of(7, 11, "move", {"x": 2})
if k1 != k2 or k1 == k3:
ok = False
msgs.append("tx_write FAIL:幂等键不稳定/不敏感 %s %s %s" % (k1, k2, k3))
elif len(k1) > 32:
ok = False
msgs.append("tx_write FAIL:幂等键超 event_uid 长度 32(%d)" % len(k1))
else:
msgs.append("tx_write PASS:幂等键同参同键、异参异键、≤32 字符(%s…)" % k1[:12])
if idem_key_of(7, 11, "move", {}, client_key="cli-9") != "cli-9":
ok = False
msgs.append("tx_write FAIL:client_event_id 未原样透传")
else:
msgs.append("tx_write PASS:client_event_id 原样透传(重放同键)")
blocked = []
for op in ("UPDATE", "DELETE", "TRUNCATE"):
try:
assert_append_only(op, EVENT_TABLE)
ok = False
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("UPDATE", "world")
ok = False
msgs.append("tx_write FAIL:基表写未拦截")
except rtx_db.RtxError:
msgs.append("tx_write PASS:基表写保护生效(world 等 %d 张)"
% len(PROTECTED_BASE_TABLES))
# 列名对齐自检:本文件写入用到的每一列都必须存在于 models/*.json
plan = {
EVENT_TABLE: ["tenant_id", EVENT_COL_UID, "session_id", "world_id", "actor_id",
"event_type", EVENT_COL_PAYLOAD, "causation_id",
EVENT_COL_STATE_VERSION, EVENT_COL_SEQ, "occurred_at",
"created_at", "updated_at"],
STATE_TABLE: ["tenant_id", "session_id", STATE_COL_KEY, STATE_COL_VALUE,
EVENT_COL_STATE_VERSION, "checksum", "updated_by",
"created_at", "updated_at"],
SNAPSHOT_TABLE: ["tenant_id", "session_id", "snapshot_uid", "state_fingerprint",
"entities_json", "events_through_seq", "reason", "created_at",
"updated_at"],
}
for tbl, cols in plan.items():
real = table_columns(tbl)
if not real:
ok = False
msgs.append("tx_write FAIL:models/%s.json 不可读" % tbl)
continue
drift = [c for c in cols if c not in real]
if drift:
ok = False
msgs.append("tx_write FAIL:%s 列漂移 %s(真实列=%s)" % (tbl, drift, real))
else:
msgs.append("tx_write PASS:%s 用到的 %d 列与 models 定义一致" % (tbl, len(cols)))
# ---- PBL-RTX-0004 闭环门禁(M11b-2c-A3):误报侧不报 + 漏报侧必报 ----
try:
upd_row = build_row(STATE_TABLE, {
STATE_COL_VALUE: "{}", EVENT_COL_STATE_VERSION: 2, "checksum": "c",
"updated_by": "server", "updated_at": "2026-09-21 12:00:00",
}, strict=True, full_row=False)
expect = [STATE_COL_VALUE, EVENT_COL_STATE_VERSION, "checksum", "updated_by",
"updated_at"]
if sorted(upd_row.keys()) != sorted(expect):
ok = False
msgs.append("tx_write FAIL:UPDATE SET 列被意外裁剪 %s(应为 %s)"
% (sorted(upd_row.keys()), sorted(expect)))
else:
msgs.append("tx_write PASS:UPDATE SET 语义不误报缺 NOT NULL 列"
"(PBL-RTX-0004 误报侧已闭环)")
except rtx_db.RtxError as exc:
ok = False
msgs.append("tx_write FAIL:UPDATE SET 仍被 %s 拒绝(PBL-RTX-0004 未闭环)" % exc.code)
try:
build_row(STATE_TABLE, {"session_id": 11, STATE_COL_KEY: "pos"})
ok = False
msgs.append("tx_write FAIL:INSERT 真缺 NOT NULL 列未被拦(门禁恒真 = 漏报回归)")
except rtx_db.RtxError as exc:
if exc.code == "PBL-RTX-0004":
msgs.append("tx_write PASS:INSERT 真缺列仍被 PBL-RTX-0004 拦下(fail-closed 未弱化)")
else:
ok = False
msgs.append("tx_write FAIL:INSERT 缺列错误码异常 %s(应为 PBL-RTX-0004)" % exc.code)
# 事件表用 nullable:"no" 写法,识别修复后必填集合必须非空(否则门禁又退回恒真)
for tbl in (EVENT_TABLE, STATE_TABLE, SNAPSHOT_TABLE):
req = required_columns(tbl)
if not req:
ok = False
msgs.append("tx_write FAIL:%s required_columns 为空"
"(模型 NOT NULL 写法未被识别,INSERT 门禁失效)" % tbl)
else:
msgs.append("tx_write PASS:%s 必填列 %d 个(已排除自增/DEFAULT 列):%s"
% (tbl, len(req), ",".join(req)))
# 只给「必填列真子集」必须被拦(事件表缺 event_type / 状态表缺 tenant_id)
for tbl, partial in ((EVENT_TABLE, {EVENT_COL_UID: "u1"}),
(STATE_TABLE, {"session_id": 11, STATE_COL_KEY: "pos",
EVENT_COL_STATE_VERSION: 1, "checksum": "c",
"updated_by": "a", "created_at": "2026-09-21",
"updated_at": "2026-09-21"})):
try:
build_row(tbl, partial)
ok = False
msgs.append("tx_write FAIL:%s 只给部分必填列却通过校验" % tbl)
except rtx_db.RtxError as exc:
if exc.code != "PBL-RTX-0004":
ok = False
msgs.append("tx_write FAIL:%s 部分列错误码 %s" % (tbl, exc.code))
else:
msgs.append("tx_write PASS:%s 部分必填列被 PBL-RTX-0004 拦下" % tbl)
# 写路径实际使用的 INSERT 列集合必须通过(防修复过头把主链路挡死)
try:
build_row(EVENT_TABLE, {
"tenant_id": "7", EVENT_COL_UID: "u", "session_id": 11, "world_id": 1,
"actor_id": "server", "event_type": "move", EVENT_COL_PAYLOAD: "{}",
"causation_id": "", EVENT_COL_STATE_VERSION: 0, EVENT_COL_SEQ: 1,
"occurred_at": "2026-09-21 12:00:00", "created_at": "2026-09-21 12:00:00",
"updated_at": "2026-09-21 12:00:00"})
build_row(SNAPSHOT_TABLE, {
"tenant_id": "7", "session_id": 11, "snapshot_uid": "s",
"state_fingerprint": "f", "entities_json": "{}", "events_through_seq": 1,
"reason": "apply_runtime_event", "created_at": "2026-09-21",
"updated_at": "2026-09-21"})
msgs.append("tx_write PASS:事件表/快照表 INSERT 写路径列集合通过校验(无回归)")
except rtx_db.RtxError as exc:
ok = False
msgs.append("tx_write FAIL:写路径 INSERT 列集合被 %s 拒绝(修复过头)" % exc.code)
# 不可变列守卫:SET tenant_id/created_at 必须被 PBL-RTX-0006 拒绝
try:
build_row(STATE_TABLE, {"tenant_id": "7", STATE_COL_VALUE: "{}"},
strict=True, full_row=False)
ok = False
msgs.append("tx_write FAIL:UPDATE SET 不可变列未被拦")
except rtx_db.RtxError as exc:
if exc.code == "PBL-RTX-0006":
msgs.append("tx_write PASS:UPDATE 禁止 SET 不可变列(PBL-RTX-0006)")
else:
ok = False
msgs.append("tx_write FAIL:不可变列错误码 %s(应为 PBL-RTX-0006)" % exc.code)
for name, fn 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 非协程" % name)
if callable(assert_append_only):
msgs.append("tx_write PASS:3 协程契约 + 1 同步守卫签名正确")
# ---- 端到端写入探针(QC #2:把 state_updates 非空路径纳入自检覆盖)----
# 上一版占位死代码 tx.execute("SELECT 1", (None, None)) 只在 state_updates 非空
# 时执行,离线自检不调用 apply_runtime_event 因而漏检(假阴性)。此处强制跑
# m11b_probe 的内存事务引擎探针,任何占位/参数数不匹配/幽灵广播都会 FAIL。
try:
from . import m11b_probe as _probe # 延迟导入:避免模块级循环依赖
p_ok, p_msgs = _probe.run_probes()
ok = ok and p_ok
msgs.extend(["probe| " + m for m in p_msgs])
except Exception as exc: # noqa: BLE001
ok = False
msgs.append("tx_write FAIL:m11b_probe 不可用(写入路径未被自检覆盖)%r" % exc)
if ok:
msgs.append("tx_write.self_check PASS")
return ok, msgs
if __name__ == "__main__":
_ok, _msgs = self_check()
for _m in _msgs:
print(_m)
raise SystemExit(0 if _ok else 1)