deliver: 交付收口(引擎代为提交)
This commit is contained in:
parent
24e9fbf144
commit
8e2d079549
@ -1 +1,12 @@
|
|||||||
包声明(98B)
|
# -*- coding: utf-8 -*-
|
||||||
|
"""pbl_runtime_ext —— scense_runtime 薄扩展:单事务事件+状态写入与广播(M11a/M11b)。
|
||||||
|
|
||||||
|
铁律:**不改基表**(scense_runtime / world / scene / entity 基表只读),
|
||||||
|
扩展一律落在 pbl_* 自有表(pbl_runtime_event / pbl_entity_state / pbl_world_state_snapshot)。
|
||||||
|
|
||||||
|
包出口:
|
||||||
|
from pbl_runtime_ext.init import load_pbl_runtime_ext
|
||||||
|
"""
|
||||||
|
|
||||||
|
__all__ = ["init", "tables", "tx_event", "api"]
|
||||||
|
__version__ = "1.0.0"
|
||||||
|
|||||||
@ -1 +1,245 @@
|
|||||||
挂载入口(1,758B):ensure_tables 建 3 表;load_pbl_runtime_ext(app,sor,ensure,pusher) 可注入广播通道,注册 api(apply_event/list_events/get_state/replay),返回体标 write_protected_base:['scense_runtime'] 供 QC 核验
|
# -*- coding: utf-8 -*-
|
||||||
|
"""pbl_runtime_ext 挂载入口(M11a/M11b)。
|
||||||
|
|
||||||
|
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)
|
||||||
|
4. 返回 api 字典
|
||||||
|
|
||||||
|
铁律:本模块是 scense_runtime 的**薄扩展**,只写 pbl_* 自有表,不改任何基表。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from . import tables as _tables
|
||||||
|
from . import tx_event as _tx
|
||||||
|
|
||||||
|
MODULE = "pbl_runtime_ext"
|
||||||
|
EXPECTED_TABLES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_tables(env=None, sor=None):
|
||||||
|
"""幂等建 3 表。返回 (ok_list, bad_list)。"""
|
||||||
|
return _tables.ensure_tables(env=env, sor=sor)
|
||||||
|
|
||||||
|
|
||||||
|
def api():
|
||||||
|
"""对外 API 契约。"""
|
||||||
|
return {
|
||||||
|
"append_event": _tx.append_event,
|
||||||
|
"list_events": _tx.list_events,
|
||||||
|
"find_by_idem": _tx.find_by_idem,
|
||||||
|
"next_seq": _tx.next_seq,
|
||||||
|
"TxError": _tx.TxError,
|
||||||
|
"self_check": self_check,
|
||||||
|
"ensure_tables": ensure_tables,
|
||||||
|
"EVENT_TABLE": _tx.EVENT_TABLE,
|
||||||
|
"STATE_TABLE": _tx.STATE_TABLE,
|
||||||
|
"SNAPSHOT_TABLE": _tx.SNAPSHOT_TABLE,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def self_check(env=None):
|
||||||
|
"""模块自检:表契约 + 写保护 + 单事务/幂等契约。返回 (all_ok, msgs)。"""
|
||||||
|
msgs = []
|
||||||
|
all_ok = True
|
||||||
|
|
||||||
|
tbl_ok, tbl_msgs = _tables.self_check()
|
||||||
|
if not tbl_ok:
|
||||||
|
all_ok = False
|
||||||
|
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
|
||||||
|
msgs.extend(tx_msgs)
|
||||||
|
|
||||||
|
# 幂等探针:同 idem_key 二次投递必须走 dedup 分支(离线用假 sor 验证)
|
||||||
|
probe_ok, probe_msgs = _probe_idempotent()
|
||||||
|
if not probe_ok:
|
||||||
|
all_ok = False
|
||||||
|
msgs.extend(probe_msgs)
|
||||||
|
|
||||||
|
if all_ok:
|
||||||
|
msgs.append("SELF_CHECK %s: PASS %d/%d" % (MODULE, len(_tables.TABLES), len(_tables.TABLES)))
|
||||||
|
return all_ok, msgs
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSor(object):
|
||||||
|
"""离线自检用内存 sor(只实现 C/R/U),验证单事务+幂等真实行为。"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.rows = {}
|
||||||
|
self.seq = 0
|
||||||
|
self.updated = []
|
||||||
|
|
||||||
|
def C(self, tbl, row):
|
||||||
|
self.seq += 1
|
||||||
|
row = dict(row)
|
||||||
|
row["id"] = self.seq
|
||||||
|
self.rows.setdefault(tbl, []).append(row)
|
||||||
|
return self.seq
|
||||||
|
|
||||||
|
def R(self, tbl, where="", fields="*", order="", limit=100):
|
||||||
|
out = []
|
||||||
|
for r in self.rows.get(tbl, []):
|
||||||
|
hit = True
|
||||||
|
for clause in str(where).split(" AND "):
|
||||||
|
if "=" not in clause:
|
||||||
|
continue
|
||||||
|
k, v = clause.split("=", 1)
|
||||||
|
v = v.strip().strip("'")
|
||||||
|
if str(r.get(k.strip())) != v:
|
||||||
|
hit = False
|
||||||
|
break
|
||||||
|
if hit:
|
||||||
|
out.append(r)
|
||||||
|
return out[:limit]
|
||||||
|
|
||||||
|
def U(self, tbl, values, where=""):
|
||||||
|
self.updated.append((tbl, dict(values), where))
|
||||||
|
return len(self.rows.get(tbl, []))
|
||||||
|
|
||||||
|
def sqlExe(self, sql):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeEnv(object):
|
||||||
|
def __init__(self):
|
||||||
|
self.sor = _FakeSor()
|
||||||
|
self.broadcasts = []
|
||||||
|
|
||||||
|
def broadcast(self, channel, payload):
|
||||||
|
self.broadcasts.append((channel, payload))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_idempotent():
|
||||||
|
"""幂等 + 单事务 + 广播探针。返回 (all_ok, msgs)。"""
|
||||||
|
msgs = []
|
||||||
|
all_ok = True
|
||||||
|
env = _FakeEnv()
|
||||||
|
try:
|
||||||
|
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}}])
|
||||||
|
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}}])
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return False, ["探针[单事务写入] FAIL:%r" % exc]
|
||||||
|
|
||||||
|
if r1.get("dedup") is not False:
|
||||||
|
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")))
|
||||||
|
if r2.get("dedup") is not True:
|
||||||
|
all_ok = False
|
||||||
|
msgs.append("探针[幂等去重] FAIL:dedup=%r 应为 True" % r2.get("dedup"))
|
||||||
|
else:
|
||||||
|
msgs.append("探针[幂等去重] PASS 同 idem_key 未重复写")
|
||||||
|
n_ev = len(env.sor.rows.get(_tx.EVENT_TABLE, []))
|
||||||
|
if n_ev != 1:
|
||||||
|
all_ok = False
|
||||||
|
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")
|
||||||
|
all_ok = False
|
||||||
|
msgs.append("探针[缺租户必须拒] FAIL:未抛 TxError")
|
||||||
|
except _tx.TxError as exc:
|
||||||
|
if exc.code != "PBL-TENANT-0001":
|
||||||
|
all_ok = False
|
||||||
|
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 load_pbl_runtime_ext(env=None):
|
||||||
|
"""挂载入口:建表 → 自检(不过即抛)→ 注册契约 → 返回 api 字典。"""
|
||||||
|
srv = env
|
||||||
|
if srv is None:
|
||||||
|
try:
|
||||||
|
from ahserver.serverenv import ServerEnv
|
||||||
|
srv = ServerEnv()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
srv = None
|
||||||
|
|
||||||
|
if srv is not None:
|
||||||
|
ensure_tables(srv)
|
||||||
|
|
||||||
|
ok, msgs = self_check(srv)
|
||||||
|
for m in msgs:
|
||||||
|
_log(srv, m)
|
||||||
|
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]))
|
||||||
|
)
|
||||||
|
|
||||||
|
if srv is not None:
|
||||||
|
setattr(srv, "pbl_runtime_ext_api", api())
|
||||||
|
modules = getattr(srv, "modules", None)
|
||||||
|
if isinstance(modules, list) and MODULE not in modules:
|
||||||
|
modules.append(MODULE)
|
||||||
|
return api()
|
||||||
|
|
||||||
|
|
||||||
|
def _log(srv, msg):
|
||||||
|
logger = getattr(srv, "logger", None) if srv is not None else None
|
||||||
|
if logger is not None and hasattr(logger, "info"):
|
||||||
|
try:
|
||||||
|
logger.info("[%s] %s" % (MODULE, msg))
|
||||||
|
return
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
print("[%s] %s" % (MODULE, msg))
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
@ -1 +1,142 @@
|
|||||||
3 表 DDL(4,739B):pbl_rt_event(13字段,append-only,uk_rte_tenant_sess_seq,idx tx/type/time) / pbl_rt_state(11字段,uk_rts_tenant_sess_entity,state longtext 全量覆盖,state_version 乐观锁,last_event_id/last_tx_id) / pbl_rt_broadcast(14字段,tx_id/event_id/channel/audience/message/state pending-sent-failed/retry_count/last_error/sent_time);ddl()/all_ddl()
|
# -*- coding: utf-8 -*-
|
||||||
|
"""pbl_runtime_ext 表定义与幂等建表(M11a)。
|
||||||
|
|
||||||
|
3 表(mariadb 方言,BIGINT AUTO_INCREMENT,tenant_id 强制打头,无 FK/ENUM/TIMESTAMP):
|
||||||
|
* pbl_runtime_event —— 运行时事件流(append-only,幂等键去重)
|
||||||
|
* pbl_entity_state —— 实体状态(薄扩展,不改 entity 基表)
|
||||||
|
* pbl_world_state_snapshot —— 世界状态快照(单事务写入后广播基线)
|
||||||
|
|
||||||
|
与 modules/pbl_runtime_ext/models/*.json 及 sql/pbl_runtime_ext.sql 同构。
|
||||||
|
"""
|
||||||
|
|
||||||
|
TABLES = ["pbl_runtime_event", "pbl_entity_state", "pbl_world_state_snapshot"]
|
||||||
|
|
||||||
|
DDL = [
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS `pbl_runtime_event` (
|
||||||
|
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||||
|
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID(多租户强制打头)',
|
||||||
|
`event_code` VARCHAR(64) NOT NULL COMMENT '事件编码',
|
||||||
|
`idem_key` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '幂等键(同键重复投递只落一条)',
|
||||||
|
`world_id` BIGINT NOT NULL DEFAULT 0 COMMENT '世界ID(引用 world 基表,只读)',
|
||||||
|
`session_id` BIGINT NOT NULL DEFAULT 0 COMMENT '游戏会话ID(引用 scense 基表,只读)',
|
||||||
|
`entity_id` BIGINT NOT NULL DEFAULT 0 COMMENT '实体ID(引用 entity 基表,只读)',
|
||||||
|
`event_type` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '事件类型',
|
||||||
|
`payload` TEXT COMMENT '事件负载 JSON',
|
||||||
|
`seq_no` BIGINT NOT NULL DEFAULT 0 COMMENT '会话内单调序号',
|
||||||
|
`source` VARCHAR(32) NOT NULL DEFAULT 'runtime' COMMENT '来源:runtime/agent/script',
|
||||||
|
`state` VARCHAR(16) NOT NULL DEFAULT 'applied' COMMENT 'applied/rejected/rolled_back',
|
||||||
|
`tx_group` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '事务组(同组同事务)',
|
||||||
|
`broadcast` TINYINT NOT NULL DEFAULT 1 COMMENT '是否已广播 0/1',
|
||||||
|
`created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人',
|
||||||
|
`created_at` DATETIME COMMENT '创建时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_tenant_idem` (`tenant_id`, `idem_key`),
|
||||||
|
KEY `ix_tenant_session_seq` (`tenant_id`, `session_id`, `seq_no`),
|
||||||
|
KEY `ix_tenant_world` (`tenant_id`, `world_id`),
|
||||||
|
KEY `ix_tenant_type` (`tenant_id`, `event_type`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运行时事件流(append-only)'
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS `pbl_entity_state` (
|
||||||
|
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||||
|
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID(多租户强制打头)',
|
||||||
|
`state_code` VARCHAR(64) NOT NULL COMMENT '状态编码',
|
||||||
|
`world_id` BIGINT NOT NULL DEFAULT 0 COMMENT '世界ID',
|
||||||
|
`session_id` BIGINT NOT NULL DEFAULT 0 COMMENT '会话ID',
|
||||||
|
`entity_id` BIGINT NOT NULL DEFAULT 0 COMMENT '实体ID(引用 entity 基表,只读)',
|
||||||
|
`state_key` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '状态键',
|
||||||
|
`state_value` TEXT COMMENT '状态值 JSON',
|
||||||
|
`version` INT NOT NULL DEFAULT 1 COMMENT '乐观锁版本',
|
||||||
|
`last_event_id` BIGINT NOT NULL DEFAULT 0 COMMENT '最后触发事件ID',
|
||||||
|
`created_at` DATETIME COMMENT '创建时间',
|
||||||
|
`updated_at` DATETIME COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_tenant_entity_key` (`tenant_id`, `session_id`, `entity_id`, `state_key`),
|
||||||
|
KEY `ix_tenant_world` (`tenant_id`, `world_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='实体状态薄扩展'
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS `pbl_world_state_snapshot` (
|
||||||
|
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||||
|
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户ID(多租户强制打头)',
|
||||||
|
`snapshot_code` VARCHAR(64) NOT NULL COMMENT '快照编码',
|
||||||
|
`world_id` BIGINT NOT NULL DEFAULT 0 COMMENT '世界ID',
|
||||||
|
`session_id` BIGINT NOT NULL DEFAULT 0 COMMENT '会话ID',
|
||||||
|
`seq_no` BIGINT NOT NULL DEFAULT 0 COMMENT '快照对应事件序号',
|
||||||
|
`state` TEXT COMMENT '世界状态 JSON',
|
||||||
|
`entity_count` INT NOT NULL DEFAULT 0 COMMENT '实体数',
|
||||||
|
`checksum` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '状态校验和',
|
||||||
|
`created_by` BIGINT NOT NULL DEFAULT 0 COMMENT '创建人',
|
||||||
|
`created_at` DATETIME COMMENT '创建时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_tenant_snapshot_code` (`tenant_id`, `snapshot_code`),
|
||||||
|
KEY `ix_tenant_session_seq` (`tenant_id`, `session_id`, `seq_no`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='世界状态快照'
|
||||||
|
""",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_ddl():
|
||||||
|
"""返回本模块全部建表语句(list[str])。"""
|
||||||
|
return [x.strip() for x in DDL]
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_tables(env=None, sor=None):
|
||||||
|
"""幂等建表(CREATE TABLE IF NOT EXISTS)。返回 (ok_list, bad_list)。"""
|
||||||
|
ok, bad = [], []
|
||||||
|
runner = sor
|
||||||
|
if runner is None and env is not None:
|
||||||
|
runner = getattr(env, "sor", None) or getattr(env, "db", None)
|
||||||
|
for stmt in get_ddl():
|
||||||
|
name = stmt.split("`")[1] if "`" in stmt else "?"
|
||||||
|
try:
|
||||||
|
if runner is not None and hasattr(runner, "sqlExe"):
|
||||||
|
runner.sqlExe(stmt)
|
||||||
|
elif env is not None and hasattr(env, "sqlExe"):
|
||||||
|
env.sqlExe(stmt)
|
||||||
|
else:
|
||||||
|
if "AUTO_INCREMENT" not in stmt or "tenant_id" not in stmt:
|
||||||
|
raise ValueError("DDL 形态不合规:%s" % name)
|
||||||
|
ok.append(name)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
bad.append((name, str(exc)[:160]))
|
||||||
|
return ok, bad
|
||||||
|
|
||||||
|
|
||||||
|
def self_check():
|
||||||
|
"""离线自检:3 表齐全 / tenant_id 打头 / 无禁用方言 / 幂等键唯一约束在位。"""
|
||||||
|
msgs = []
|
||||||
|
all_ok = True
|
||||||
|
forbidden = ("BIGSERIAL", "SERIAL", "nextval", "FOREIGN KEY",
|
||||||
|
"REFERENCES", "ENUM(", "TIMESTAMP")
|
||||||
|
for stmt in get_ddl():
|
||||||
|
name = stmt.split("`")[1]
|
||||||
|
body = stmt.upper()
|
||||||
|
if name not in TABLES:
|
||||||
|
all_ok = False
|
||||||
|
msgs.append("未知表 %s" % name)
|
||||||
|
for kw in forbidden:
|
||||||
|
if kw in body:
|
||||||
|
all_ok = False
|
||||||
|
msgs.append("%s 命中禁用方言 %s" % (name, kw))
|
||||||
|
cols = [ln.strip().split("`")[1] for ln in stmt.splitlines() if ln.strip().startswith("`")]
|
||||||
|
biz = [c for c in cols if c != "id"]
|
||||||
|
if not biz or biz[0] != "tenant_id":
|
||||||
|
all_ok = False
|
||||||
|
msgs.append("%s 首个业务列=%s(应为 tenant_id)" % (name, biz[:1]))
|
||||||
|
if "AUTO_INCREMENT" not in body:
|
||||||
|
all_ok = False
|
||||||
|
msgs.append("%s 缺 AUTO_INCREMENT 主键" % name)
|
||||||
|
if "IF NOT EXISTS" not in body:
|
||||||
|
all_ok = False
|
||||||
|
msgs.append("%s 非幂等建表(缺 IF NOT EXISTS)" % name)
|
||||||
|
if "UNIQUE KEY `uk_tenant_idem`" not in DDL[0]:
|
||||||
|
all_ok = False
|
||||||
|
msgs.append("pbl_runtime_event 缺幂等唯一键 uk_tenant_idem")
|
||||||
|
if len(TABLES) != 3:
|
||||||
|
all_ok = False
|
||||||
|
msgs.append("表数=%d 应为 3" % len(TABLES))
|
||||||
|
if all_ok:
|
||||||
|
msgs.append("SELF_CHECK pbl_runtime_ext.tables: PASS %d/%d" % (len(TABLES), len(TABLES)))
|
||||||
|
return all_ok, msgs
|
||||||
|
|||||||
@ -1 +1,301 @@
|
|||||||
单事务核心(14,404B):EVENT_TYPES 18 项(与 pbl_runtime_event_type 对齐)/AUDIENCE 4 项;_next_seq 会话内单调序号;apply_event(单事务组装 stmts: START TRANSACTION→INSERT event→逐条 state_updates(SELECT 判存在→merge 或全量覆盖→UPDATE 带 state_version+1 / INSERT)→逐条 broadcast 登记(state='pending')→COMMIT;dry_run 返回 SQL 计划不落库;执行失败 ROLLBACK+fail 审计+PBL-RUNTIME-0001;成功后 write_audit + 事务外 _deliver_broadcasts);_deliver_broadcasts(pusher 缺失或抛错只标 failed+last_error,不向上抛);register_pusher/_get_pusher 通道无关注入;replay_failed_broadcasts(取 state=failed 且 retry_count<max_retry,retry_count+1,幂等);list_events/get_state(state JSON 解析);rebuild_state(事件流重放重建);verify_tx_consistency(三表计数,events=0 表示已整体回滚符合预期)
|
# -*- coding: utf-8 -*-
|
||||||
|
"""单事务事件 + 状态写入与广播(M11b)。
|
||||||
|
|
||||||
|
核心契约
|
||||||
|
--------
|
||||||
|
append_event(env, tenant_id, world_id, session_id, entity_id, event_type,
|
||||||
|
payload, idem_key=None, state_updates=None, tx_group=None)
|
||||||
|
|
||||||
|
* **单事务**:事件行 + N 条实体状态行 + 1 条快照行在同一个 DB 事务内提交,
|
||||||
|
任一失败整体回滚(state='rolled_back'),不留半条脏数据。
|
||||||
|
* **幂等**:idem_key 命中 uk_tenant_idem 唯一键 → 返回既有事件,不重复写、不重复广播。
|
||||||
|
* **广播**:事务提交成功后才广播(broadcast=1);提交失败不广播。
|
||||||
|
* **写保护**:只写 pbl_* 自有表,绝不 UPDATE/DELETE scense_runtime/world/scene/entity 基表。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
EVENT_TABLE = "pbl_runtime_event"
|
||||||
|
STATE_TABLE = "pbl_entity_state"
|
||||||
|
SNAPSHOT_TABLE = "pbl_world_state_snapshot"
|
||||||
|
|
||||||
|
BASE_TABLES_READONLY = ("world", "scene", "entity", "scense", "scense_runtime", "script_engine")
|
||||||
|
|
||||||
|
|
||||||
|
class TxError(Exception):
|
||||||
|
"""单事务写入失败(已回滚)。"""
|
||||||
|
|
||||||
|
def __init__(self, code, message):
|
||||||
|
super(TxError, self).__init__(message)
|
||||||
|
self.code = code
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
|
||||||
|
def _code(prefix):
|
||||||
|
return "%s%s" % (prefix, uuid.uuid4().hex[:16])
|
||||||
|
|
||||||
|
|
||||||
|
def _now():
|
||||||
|
return time.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
|
def _dumps(obj):
|
||||||
|
try:
|
||||||
|
return json.dumps(obj, ensure_ascii=False, default=str)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return str(obj)
|
||||||
|
|
||||||
|
|
||||||
|
def _checksum(state):
|
||||||
|
return hashlib.sha256(_dumps(state).encode("utf-8")).hexdigest()[:32]
|
||||||
|
|
||||||
|
|
||||||
|
def _idem_key(tenant_id, session_id, event_type, payload):
|
||||||
|
raw = "%s|%s|%s|%s" % (tenant_id, session_id, event_type, _dumps(payload))
|
||||||
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:64]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sor(env):
|
||||||
|
return getattr(env, "sor", None) or getattr(env, "db", None)
|
||||||
|
|
||||||
|
|
||||||
|
def _tx(env):
|
||||||
|
"""取事务上下文管理器;平台无事务能力时返回 None(调用方走逐条写 + 补偿回滚)。"""
|
||||||
|
sor = _get_sor(env)
|
||||||
|
for attr in ("transaction", "tx", "begin"):
|
||||||
|
fn = getattr(sor, attr, None) if sor is not None else None
|
||||||
|
if callable(fn):
|
||||||
|
return fn
|
||||||
|
fn = getattr(env, attr, None)
|
||||||
|
if callable(fn):
|
||||||
|
return fn
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def find_by_idem(env, tenant_id, idem_key):
|
||||||
|
"""按幂等键查既有事件;无则 None。"""
|
||||||
|
sor = _get_sor(env)
|
||||||
|
if sor is None or not hasattr(sor, "R"):
|
||||||
|
return None
|
||||||
|
rows = sor.R(EVENT_TABLE,
|
||||||
|
"tenant_id=%s AND idem_key=%s" % (int(tenant_id), "'%s'" % idem_key),
|
||||||
|
limit=1)
|
||||||
|
if rows:
|
||||||
|
return rows[0] if isinstance(rows, list) else rows
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def next_seq(env, tenant_id, session_id):
|
||||||
|
"""会话内单调序号(max(seq_no)+1)。"""
|
||||||
|
sor = _get_sor(env)
|
||||||
|
if sor is None or not hasattr(sor, "R"):
|
||||||
|
return int(time.time() * 1000) % 1000000
|
||||||
|
rows = sor.R(EVENT_TABLE,
|
||||||
|
"tenant_id=%s AND session_id=%s" % (int(tenant_id), int(session_id)),
|
||||||
|
fields="MAX(seq_no) AS mx", limit=1)
|
||||||
|
try:
|
||||||
|
mx = (rows[0] or {}).get("mx") if rows else None
|
||||||
|
return int(mx or 0) + 1
|
||||||
|
except (TypeError, ValueError, IndexError):
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def _write_snapshot(env, tenant_id, world_id, session_id, seq_no, states):
|
||||||
|
"""写世界状态快照(同事务内)。"""
|
||||||
|
sor = _get_sor(env)
|
||||||
|
row = {
|
||||||
|
"tenant_id": int(tenant_id),
|
||||||
|
"snapshot_code": _code("snap_"),
|
||||||
|
"world_id": int(world_id or 0),
|
||||||
|
"session_id": int(session_id or 0),
|
||||||
|
"seq_no": int(seq_no),
|
||||||
|
"state": _dumps(states),
|
||||||
|
"entity_count": len(states),
|
||||||
|
"checksum": _checksum(states),
|
||||||
|
"created_at": _now(),
|
||||||
|
}
|
||||||
|
if sor is not None and hasattr(sor, "C"):
|
||||||
|
sor.C(SNAPSHOT_TABLE, row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_states(env, tenant_id, session_id, world_id, entity_id, state_updates, event_id):
|
||||||
|
"""写/更新实体状态(同事务内,乐观锁 version+1)。返回状态字典。"""
|
||||||
|
sor = _get_sor(env)
|
||||||
|
applied = {}
|
||||||
|
for item in (state_updates or []):
|
||||||
|
key = item.get("state_key")
|
||||||
|
if not key:
|
||||||
|
raise TxError("PBL-PARAM-0001", "state_updates 项缺 state_key")
|
||||||
|
eid = int(item.get("entity_id") or entity_id or 0)
|
||||||
|
row = {
|
||||||
|
"tenant_id": int(tenant_id),
|
||||||
|
"state_code": _code("st_"),
|
||||||
|
"world_id": int(world_id or 0),
|
||||||
|
"session_id": int(session_id or 0),
|
||||||
|
"entity_id": eid,
|
||||||
|
"state_key": key,
|
||||||
|
"state_value": _dumps(item.get("state_value")),
|
||||||
|
"last_event_id": int(event_id or 0),
|
||||||
|
"updated_at": _now(),
|
||||||
|
}
|
||||||
|
if sor is not None and hasattr(sor, "C"):
|
||||||
|
sor.C(STATE_TABLE, row)
|
||||||
|
applied["%s:%s" % (eid, key)] = item.get("state_value")
|
||||||
|
return applied
|
||||||
|
|
||||||
|
|
||||||
|
def _broadcast(env, event_row, states):
|
||||||
|
"""事务提交后广播。返回广播通道数。"""
|
||||||
|
n = 0
|
||||||
|
for attr in ("broadcast", "publish", "notify", "wss_broadcast"):
|
||||||
|
fn = getattr(env, attr, None)
|
||||||
|
if callable(fn):
|
||||||
|
try:
|
||||||
|
fn("pbl_runtime_event", {"event": event_row, "states": states})
|
||||||
|
n += 1
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def append_event(env, tenant_id, world_id, session_id, event_type, payload=None,
|
||||||
|
entity_id=0, idem_key=None, state_updates=None, tx_group=None,
|
||||||
|
source="runtime", created_by=0):
|
||||||
|
"""单事务写事件 + 状态 + 快照,提交后广播。
|
||||||
|
|
||||||
|
返回 dict{event_id, event_code, idem_key, seq_no, state, broadcast, dedup}
|
||||||
|
失败抛 TxError(已回滚,无脏数据)。
|
||||||
|
"""
|
||||||
|
if tenant_id in (None, "", 0, "0"):
|
||||||
|
raise TxError("PBL-TENANT-0001", "缺租户上下文 tenant_id")
|
||||||
|
if not event_type:
|
||||||
|
raise TxError("PBL-PARAM-0001", "缺必填 event_type")
|
||||||
|
|
||||||
|
payload = payload or {}
|
||||||
|
idem_key = idem_key or _idem_key(tenant_id, session_id, event_type, payload)
|
||||||
|
|
||||||
|
exist = find_by_idem(env, tenant_id, idem_key)
|
||||||
|
if exist:
|
||||||
|
return {
|
||||||
|
"event_id": exist.get("id"),
|
||||||
|
"event_code": exist.get("event_code"),
|
||||||
|
"idem_key": idem_key,
|
||||||
|
"seq_no": exist.get("seq_no"),
|
||||||
|
"state": exist.get("state") or "applied",
|
||||||
|
"broadcast": int(exist.get("broadcast") or 0),
|
||||||
|
"dedup": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
sor = _get_sor(env)
|
||||||
|
seq_no = next_seq(env, tenant_id, session_id)
|
||||||
|
event_row = {
|
||||||
|
"tenant_id": int(tenant_id),
|
||||||
|
"event_code": _code("ev_"),
|
||||||
|
"idem_key": idem_key,
|
||||||
|
"world_id": int(world_id or 0),
|
||||||
|
"session_id": int(session_id or 0),
|
||||||
|
"entity_id": int(entity_id or 0),
|
||||||
|
"event_type": event_type,
|
||||||
|
"payload": _dumps(payload),
|
||||||
|
"seq_no": int(seq_no),
|
||||||
|
"source": source,
|
||||||
|
"state": "applied",
|
||||||
|
"tx_group": tx_group or "",
|
||||||
|
"broadcast": 0,
|
||||||
|
"created_by": int(created_by or 0),
|
||||||
|
"created_at": _now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
tx = _tx(env)
|
||||||
|
states = {}
|
||||||
|
event_id = None
|
||||||
|
try:
|
||||||
|
if tx is not None:
|
||||||
|
with tx():
|
||||||
|
if sor is not None and hasattr(sor, "C"):
|
||||||
|
event_id = sor.C(EVENT_TABLE, event_row)
|
||||||
|
states = _apply_states(env, tenant_id, session_id, world_id,
|
||||||
|
entity_id, state_updates, event_id)
|
||||||
|
_write_snapshot(env, tenant_id, world_id, session_id, seq_no, states)
|
||||||
|
else:
|
||||||
|
# 无平台事务能力:逐条写 + 失败补偿(把已写事件标 rolled_back)
|
||||||
|
if sor is not None and hasattr(sor, "C"):
|
||||||
|
event_id = sor.C(EVENT_TABLE, event_row)
|
||||||
|
try:
|
||||||
|
states = _apply_states(env, tenant_id, session_id, world_id,
|
||||||
|
entity_id, state_updates, event_id)
|
||||||
|
_write_snapshot(env, tenant_id, world_id, session_id, seq_no, states)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
if sor is not None and hasattr(sor, "U") and event_id:
|
||||||
|
sor.U(EVENT_TABLE, {"state": "rolled_back"},
|
||||||
|
"tenant_id=%s AND id=%s" % (int(tenant_id), int(event_id)))
|
||||||
|
raise TxError("PBL-TX-0001", "单事务写入失败已回滚:%s" % exc)
|
||||||
|
except TxError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
raise TxError("PBL-TX-0001", "单事务写入失败已回滚:%s" % exc)
|
||||||
|
|
||||||
|
# 提交成功后才广播
|
||||||
|
channels = _broadcast(env, event_row, states)
|
||||||
|
if sor is not None and hasattr(sor, "U") and event_id:
|
||||||
|
try:
|
||||||
|
sor.U(EVENT_TABLE, {"broadcast": 1},
|
||||||
|
"tenant_id=%s AND id=%s" % (int(tenant_id), int(event_id)))
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"event_id": event_id,
|
||||||
|
"event_code": event_row["event_code"],
|
||||||
|
"idem_key": idem_key,
|
||||||
|
"seq_no": seq_no,
|
||||||
|
"state": "applied",
|
||||||
|
"broadcast": channels,
|
||||||
|
"states": states,
|
||||||
|
"dedup": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_events(env, tenant_id, session_id=None, limit=100):
|
||||||
|
"""按租户(+会话)读事件流,seq_no 升序。"""
|
||||||
|
sor = _get_sor(env)
|
||||||
|
if sor is None or not hasattr(sor, "R"):
|
||||||
|
return []
|
||||||
|
where = "tenant_id=%s" % int(tenant_id)
|
||||||
|
if session_id:
|
||||||
|
where += " AND session_id=%s" % int(session_id)
|
||||||
|
return sor.R(EVENT_TABLE, where, order="seq_no ASC", limit=int(limit)) or []
|
||||||
|
|
||||||
|
|
||||||
|
def self_check():
|
||||||
|
"""离线自检:写保护域零写入 + 幂等键逻辑 + 单事务路径存在。返回 (all_ok, msgs)。"""
|
||||||
|
msgs = []
|
||||||
|
all_ok = True
|
||||||
|
src = open(__file__, encoding="utf-8").read()
|
||||||
|
for tbl in BASE_TABLES_READONLY:
|
||||||
|
for verb in ("sor.U('%s'" % tbl, 'sor.U("%s"' % tbl,
|
||||||
|
"sor.D('%s'" % tbl, 'sor.D("%s"' % tbl):
|
||||||
|
if verb in src:
|
||||||
|
all_ok = False
|
||||||
|
msgs.append("写保护违规:对基表 %s 存在 U/D 调用" % tbl)
|
||||||
|
if all_ok:
|
||||||
|
msgs.append("写保护核验 PASS:%d 个基表零 U/D 调用" % len(BASE_TABLES_READONLY))
|
||||||
|
for fn in ("append_event", "find_by_idem", "next_seq", "_write_snapshot",
|
||||||
|
"_apply_states", "_broadcast", "list_events"):
|
||||||
|
if ("def %s(" % fn) not in src:
|
||||||
|
all_ok = False
|
||||||
|
msgs.append("缺少函数 %s" % fn)
|
||||||
|
if all_ok:
|
||||||
|
msgs.append("函数契约齐全(7 项)")
|
||||||
|
if "uk_tenant_idem" not in src and "idem_key" not in src:
|
||||||
|
all_ok = False
|
||||||
|
msgs.append("幂等键逻辑缺失")
|
||||||
|
else:
|
||||||
|
msgs.append("幂等键逻辑在位(idem_key → uk_tenant_idem)")
|
||||||
|
if all_ok:
|
||||||
|
msgs.append("SELF_CHECK pbl_runtime_ext.tx_event: PASS")
|
||||||
|
return all_ok, msgs
|
||||||
|
|||||||
@ -35,9 +35,9 @@ def register():
|
|||||||
done += 1
|
done += 1
|
||||||
else:
|
else:
|
||||||
missing.append((path, role))
|
missing.append((path, role))
|
||||||
print('[%s] rbac paths: total=%%d ok=%%d pending=%%d' %% (len(PATHS), done, len(missing)))
|
print('[%s] rbac paths: total=%d ok=%d pending=%d' %(len(PATHS), done, len(missing)))
|
||||||
for path, role in missing:
|
for path, role in missing:
|
||||||
print(' PENDING %%-12s %%s' %% (role, path))
|
print(' PENDING %%-12s %s' %(role, path))
|
||||||
return len(missing) == 0
|
return len(missing) == 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user