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
|
||||
else:
|
||||
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:
|
||||
print(' PENDING %%-12s %%s' %% (role, path))
|
||||
print(' PENDING %%-12s %s' %(role, path))
|
||||
return len(missing) == 0
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user