deliver: 交付收口(引擎代为提交)
This commit is contained in:
parent
e660480fcb
commit
17b8cc28eb
@ -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}
|
||||
抛 RtxError:PBL-TENANT-0001 / PBL-PARAM-0001 / PBL-STATE-CONFLICT / PBL-RTX-0002
|
||||
返回 {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:
|
||||
@ -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 FAIL:client_key 未原样透传")
|
||||
msgs.append("tx_write FAIL:幂等键超 event_uid 长度 32(%d)" % len(k1))
|
||||
else:
|
||||
msgs.append("tx_write PASS:client_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 FAIL:client_event_id 未原样透传")
|
||||
else:
|
||||
msgs.append("tx_write PASS:client_event_id 原样透传(重放同键)")
|
||||
|
||||
blocked = []
|
||||
for op in ("UPDATE", "DELETE", "TRUNCATE"):
|
||||
@ -412,41 +514,53 @@ def self_check():
|
||||
if blocked:
|
||||
msgs.append("tx_write PASS:pbl_runtime_event 禁 %s" % "/".join(blocked))
|
||||
try:
|
||||
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:
|
||||
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 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:列名自省生效(models/%s.json %d 列)"
|
||||
% (EVENT_TABLE, len(cols)))
|
||||
else:
|
||||
msgs.append("tx_write WARN:离线未读到 models/%s.json,build_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 PASS:3 协程契约 + 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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user