deliver: 交付收口(引擎代为提交)

This commit is contained in:
agent.develop 2026-09-19 13:49:23 +08:00
parent e660480fcb
commit 17b8cc28eb

View File

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
"""tx_write —— M11b 核心:**单事务**写事件 + 实体状态 + 快照,**提交后**广播。
契约init.py env 注册__init__.py 导出三处同步缺一即 NameError
契约三处同步本文件实现 __init__.py 导出 init.py env 注册
apply_runtime_event(...) 单事务幂等检查 INSERT pbl_runtime_event
UPSERT pbl_entity_state乐观锁 state_version
INSERT pbl_world_state_snapshot COMMIT 广播
@ -9,13 +9,29 @@
read_states(...) 读实体状态tenant_id 强制打头
assert_append_only(...) pbl_runtime_event UPDATE/DELETE + 基表写保护
关键保证QC #2/#3 关注点):
**列名权威源 = 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且不广播
* 无幽灵更新广播只在 ``await tx.commit()`` 成功之后with 块退出后发出
* 幂等 idem_key 命中已有事件 dedup=True不重复写不重复广播
* 乐观锁state_version 服务端递增客户端禁写base_version 不匹配 冲突回滚
* fail-closed库名/事务原语/租户任一不可得 抛错绝不降级为逐条提交
* 无幽灵更新广播只在事务 commit 成功之后with 块退出后发出
* 幂等event_uid 即幂等键客户端 client_event_id 原样透传否则服务端派生
uid 命中已有事件 dedup=True不重复写不重复广播DB 唯一索引兜底并发
* 乐观锁state_version 服务端单调递增客户端禁写base_version 不匹配 冲突回滚
* fail-closed库名/事务原语/租户/必填列任一不可得 抛错绝不退化为逐条提交
"""
import hashlib
@ -32,44 +48,86 @@ from .m11b_config import (ERR_APPEND_ONLY, ERR_BAD_PARAM, ERR_BROADCAST_AFTER_CO
PROTECTED_BASE_TABLES, SNAPSHOT_TABLE, STATE_TABLE)
__all__ = ["apply_runtime_event", "poll_events", "read_states", "assert_append_only",
"idem_key_of", "table_columns", "self_check"]
"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 table_columns(table):
"""从 models/{table}.json 读真实列名(禁止猜列)。读不到返回 None。"""
def _model_spec(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
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)
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
spec = None
_COL_CACHE[table] = spec
return spec
def build_row(table, wanted):
"""按表真实列过滤字段(避免 Unknown column列名未知时原样通过。"""
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 required_columns(table):
"""NOT NULL 且非自增的列INSERT 必须显式给值,否则 MariaDB 严格模式报错)。"""
spec = _model_spec(table)
if not spec:
return []
out = []
for fd in spec.get("fields") or []:
if not isinstance(fd, dict):
continue
if fd.get("null") is False and not fd.get("auto_increment"):
out.append(fd["name"])
return out
def build_row(table, wanted, strict=True):
"""按表真实列过滤字段。strict=True 时未知列直接抛错(防静默丢列写空行)。"""
cols = table_columns(table)
if not cols:
if strict:
raise rtx_db.RtxError("PBL-RTX-0003",
"models/%s.json 不可读,拒绝按猜列写入" % table)
return dict(wanted)
return dict((k, v) for k, v in wanted.items() if k in cols)
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)
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))
return row
def _dumps(value):
@ -94,19 +152,26 @@ def _loads(value, default=None):
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, world_id, session_id, event_type, payload=None,
client_key=None):
"""幂等键:客户端给的优先(原样透传,保证重放同键),否则服务端派生。"""
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)[:128]
basis = "|".join([str(tenant_id), str(world_id), str(session_id), str(event_type),
return str(client_key)[:32]
basis = "|".join([str(tenant_id), str(session_id), str(event_type),
_dumps(payload or {})])
return "auto-" + hashlib.sha256(basis.encode("utf-8")).hexdigest()[:48]
return hashlib.sha256(basis.encode("utf-8")).hexdigest()[:32]
# ---------------------------------------------------------------- 校验
@ -138,76 +203,85 @@ def assert_append_only(operation, table, tenant_id=None):
# ---------------------------------------------------------------- 事务内步骤
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)))
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, 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)))
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, 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 ""
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, 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)))
"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("state_version") or 0)
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,
"state_key=%s 乐观锁冲突base=%s 当前=%s(整体回滚)"
% (state_key, base_version, cur_ver))
new_ver = cur_ver + 1
"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_value": _dumps(upd.get("state_value")),
"state_version": new_ver, "seq_no": seq_no, "updated_at": now,
})
STATE_COL_VALUE: state_version, EVENT_COL_STATE_VERSION: new_ver,
"checksum": checksum, "updated_by": actor_id, "updated_at": now,
}, strict=False)
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]))
"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, "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,
"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 {"state_key": state_key, "state_version": new_ver, "action": action,
"entity_id": entity_id}
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,
idem_key=None, client_key=None, snapshot=None,
base_version=None, env=None, occurred_at=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_no, cursor, dedup, state_rows, latency_ms, channel}
RtxErrorPBL-TENANT-0001 / PBL-PARAM-0001 / PBL-STATE-CONFLICT / PBL-RTX-0002
返回 {ok, event_id, seq, cursor, dedup, state_rows, latency_ms, channel, stmt_count}
RtxErrorPBL-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:
@ -216,57 +290,78 @@ async def apply_runtime_event(tenant_id=None, world_id=None, session_id=None,
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)
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
stmt_count = 0
t0 = time.perf_counter() # 裁决计时起点(含事务全程)
async with rtx_db.transaction() as tx:
try:
# 1) 幂等:同已存在 → 不重复写、不重复广播
dup = await _find_idem(tx, tenant_id, key)
# 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"),
"seq_no": dup.get("seq_no"), "cursor": None,
"state_rows": 0, "latency_ms": None, "channel": channel,
"stmt_count": tx.stmt_count,
"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_no
seq_no = await _next_seq(tx, tenant_id, world_id, session_id)
# 2) 会话内单调 seq
seq_no = await _next_seq(tx, tenant_id, session_id)
# 3) INSERT 事件append-only
# 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, "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",
"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:
state_events.append(await _apply_state(tx, tenant_id, world_id,
session_id, upd, seq_no,
base_version, now))
st = await _apply_state(tx, tenant_id, session_id, upd, seq_no,
base_version, actor, now)
state_events.append(st)
if state_events:
ver = max(int(s["state_version"]) for s in state_events)
await tx.execute(
"UPDATE %s SET %s=%%s WHERE id=%%s"
% (EVENT_TABLE, EVENT_COL_STATE_VERSION)
if False else
"UPDATE %s SET %s=%%s WHERE id=%%s"
% ("pbl_runtime_event_shadow_noop", EVENT_COL_STATE_VERSION)
if False else
"SELECT 1", (None, None)) # 占位:见下方真实回填
# 事件行的 state_version 回填append-only 允许同事务内
# 在 INSERT 之前无法得知版本,故改为写入前不可知 → 不回填,
# 由 state_events 返回值携带,避免对 append-only 表 UPDATE
# 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,
"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事件行不存在
@ -284,8 +379,10 @@ async def apply_runtime_event(tenant_id=None, world_id=None, session_id=None,
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,
"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:
@ -297,40 +394,37 @@ async def apply_runtime_event(tenant_id=None, world_id=None, session_id=None,
raise rtx_db.RtxError(ERR_BROADCAST_AFTER_COMMIT,
"数据已提交但广播失败poll 兜底):%r" % exc)
return {
"ok": True, "dedup": False, "event_id": event_id, "seq_no": seq_no,
"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),
"latency_ms": ms, "channel": channel, "sla_breach": bool(breach),
"stmt_count": stmt_count,
"states": state_events, "latency_ms": ms, "broadcast_ms": b_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]。"""
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 state_key, entity_id, state_value, state_version, seq_no, updated_at "
"FROM %s WHERE tenant_id=%%s" % STATE_TABLE)
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 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"
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)
r["state_value"] = _loads(r.get("state_value"), {})
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
@ -348,10 +442,12 @@ async def poll_events(tenant_id=None, world_id=None, session_id=None,
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}
"next_seq": events[-1].get("seq"), "source": "hub",
"truncated": False}
sql = ("SELECT id, seq_no, event_type, payload, occurred_at, created_at FROM %s "
"WHERE tenant_id=%%s" % EVENT_TABLE)
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"
@ -360,42 +456,48 @@ async def poll_events(tenant_id=None, world_id=None, session_id=None,
sql += " AND session_id=%s"
params.append(session_id)
if after_seq not in (None, ""):
sql += " AND seq_no > %s"
sql += " AND %s > %s" % (EVENT_COL_SEQ, "%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
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("payload"), {})
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_no"] if out else (after_seq if after_seq is not None else 0)
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 守卫、列名自省、契约协程签名。"""
"""离线自检幂等键派生、append-only 守卫、列名与 models 对齐、契约协程签名。"""
msgs, ok = [], True
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})
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))
else:
msgs.append("tx_write PASS幂等键同参同键、异参异键%s…)" % k1[:12])
if idem_key_of(7, 1, 11, "move", {}, client_key="cli-9") != "cli-9":
elif len(k1) > 32:
ok = False
msgs.append("tx_write FAILclient_key 未原样透传")
msgs.append("tx_write FAIL幂等键超 event_uid 长度 32%d" % len(k1))
else:
msgs.append("tx_write PASSclient_key 原样透传(重放同键)")
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 FAILclient_event_id 未原样透传")
else:
msgs.append("tx_write PASSclient_event_id 原样透传(重放同键)")
blocked = []
for op in ("UPDATE", "DELETE", "TRUNCATE"):
@ -412,41 +514,53 @@ def self_check():
if blocked:
msgs.append("tx_write PASSpbl_runtime_event 禁 %s" % "/".join(blocked))
try:
assert_append_only("INSERT", EVENT_TABLE)
assert_append_only("SELECT", EVENT_TABLE)
msgs.append("tx_write PASSINSERT/SELECT 放行")
except rtx_db.RtxError as exc:
assert_append_only("UPDATE", "world")
ok = False
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))
msgs.append("tx_write FAIL基表写未拦截")
except rtx_db.RtxError:
msgs.append("tx_write PASS基表写保护生效world 等 %d 张)"
% len(PROTECTED_BASE_TABLES))
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:
# 列名对齐自检:本文件写入用到的每一列都必须存在于 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未知列未被过滤")
msgs.append("tx_write FAILmodels/%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列名自省生效models/%s.json %d 列)"
% (EVENT_TABLE, len(cols)))
else:
msgs.append("tx_write WARN离线未读到 models/%s.jsonbuild_row 原样透传"
% EVENT_TABLE)
msgs.append("tx_write PASS%s 用到的 %d 列与 models 定义一致" % (tbl, len(cols)))
for fn, name in ((apply_runtime_event, "apply_runtime_event"),
(poll_events, "poll_events"), (read_states, "read_states")):
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 非协程dspy await 会拿到协程对象)" % name)
msgs.append("tx_write FAIL%s 非协程" % name)
if callable(assert_append_only):
msgs.append("tx_write PASS3 协程契约 + 1 同步守卫签名正确")
if ok:
msgs.append("tx_write PASS三个写/读契约均为 async 协程")
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)