2026-09-16 19:57:41 +08:00

302 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

# -*- coding: utf-8 -*-
"""单事务事件 + 状态写入与广播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