pbl_runtime_ext/scripts/m11b2c_a3_patch_tx_write.py

276 lines
13 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-2c-A3 一次性补丁器:修 pbl_runtime_ext/tx_write.py 中 PBL-RTX-0004 的业务缺陷。
四段精确字符串替换;任一片段命中数 != 1 即报错退出(绝不静默跳过)。
改完由 py_compile + self_check + selftest + pytest 验证。
"""
import io
import os
import sys
TARGET = os.path.join("modules", "pbl_runtime_ext", "pbl_runtime_ext", "tx_write.py")
# ---------------------------------------------------------------- 1) required_columns
OLD_1 = '''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
'''
NEW_1 = '''def _is_not_null(fd):
"""字段是否声明 NOT NULL —— 兼容 models/*.json 并存的两种写法。
* ``{"null": false}`` :xlsx→json 产物写法(pbl_entity_state / pbl_world_state_snapshot)
* ``{"nullable": "no"}`` :database-table-definition-spec 规范写法(pbl_runtime_event)
M11b-2c-A3 修复(PBL-RTX-0004 **漏报侧**根因):此前只认 ``null: false``,
而 pbl_runtime_event 用的是规范写法 ``nullable: "no"``,使该表 ``required_columns()``
恒为 ``[]`` —— build_row 的 INSERT 完整性门禁对事件表**完全失效**
(该报的缺列不报,静默写出半空行)。
"""
if fd.get("null") is False:
return True
if str(fd.get("nullable") or "").strip().lower() in ("no", "false", "0"):
return True
return False
def _has_db_default(fd):
"""列是否在 DDL 中声明 DEFAULT(含 CURRENT_TIMESTAMP)。
``NOT NULL DEFAULT x`` 的列在 INSERT 省略时由数据库补值,**不构成"缺 NOT NULL 列"**。
M11b-2c-A3 修复(PBL-RTX-0004 **误报侧**根因之二):pbl_runtime_event 的
event_code/idem_key/scene_id/entity_id/actor_id/causation_id/seq/source/state/
tx_group/broadcast/created_by/created_at/updated_at 等列均为 NOT NULL DEFAULT,
写路径按语义不填它们;若计入必填集合,合法调用会被误报缺列。
(与 _recognize nullable 写法配套:只修识别不修默认值排除,事件表主链路会被误挡。)
"""
dv = fd.get("default")
if dv is None:
return False
s = str(dv).strip()
if s == "" or s.upper() in ("NULL", "NONE"):
return False
return True
def required_columns(table):
"""INSERT 必须**显式给值**的列:NOT NULL + 非自增 + 无数据库 DEFAULT。
自增列由数据库生成、NOT NULL DEFAULT 列由默认值补上,两者都不需要调用方提供;
纳入必填集合只会造成 PBL-RTX-0004 误报。真正的必填列(pbl_entity_state 的
tenant_id/session_id/entity_id/state_version/checksum/updated_by/created_at/
updated_at,pbl_runtime_event 的 event_uid/event_type)缺值时数据库严格模式会
直接报错,故必须在这里 fail-closed 拦住,不得放宽。
"""
spec = _model_spec(table)
if not spec:
return []
out = []
for fd in spec.get("fields") or []:
if not isinstance(fd, dict) or not fd.get("name"):
continue
if _is_not_null(fd) and not fd.get("auto_increment") and not _has_db_default(fd):
out.append(fd["name"])
return out
'''
# ---------------------------------------------------------------- 2) build_row
OLD_2 = '''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)
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
'''
NEW_2 = '''def build_row(table, wanted, strict=True, full_row=True):
"""按表真实列过滤字段。strict=True 时未知列直接抛错(防静默丢列写空行)。
full_row=True(默认,**INSERT 语义**):额外校验 required_columns() 齐备,
缺列抛 PBL-RTX-0004。
full_row=False(**UPDATE SET 子句语义**):被改动的行在库中已存在,未出现在 SET
列表里的 NOT NULL 列保持原值,**不存在"缺列"**,因此不得套用 INSERT 的完整性
校验;同时禁止 SET 不可变列(tenant_id/id/created_at),违者抛 PBL-RTX-0006。
PBL-RTX-0004 闭环(M11b-2c-A3,A1 判定为业务实现缺陷):误报有两条独立路径——
(1) UPDATE 分支沿用 INSERT 校验(此前用 strict=False 绕,但 strict 只管未知列,
缺列校验照跑 → tenant_id/session_id/entity_id/created_at 必被误报)。
现按 full_row=False 构造:定位维度走 WHERE、created_at 保持建档原值。
(2) 必填集合把「自增列 / NOT NULL DEFAULT 列」也算进来 → required_columns()
已排除(见 _has_db_default)。
漏报侧同修:required_columns() 此前不认 ``nullable: "no"`` 写法,事件表门禁恒真。
修后语义:该报的(真缺 event_uid/event_type 等无默认 NOT NULL 列)一定报,
不该报的(UPDATE SET 子集、数据库能补值的列)一定不报。
"""
cols = table_columns(table)
if not cols:
if strict:
raise rtx_db.RtxError("PBL-RTX-0003",
"models/%s.json 不可读,拒绝按猜列写入" % table)
return dict(wanted)
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)
if full_row:
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))
else:
immutable = [c for c in ("tenant_id", "id", "created_at") if c in row]
if immutable:
raise rtx_db.RtxError("PBL-RTX-0006",
"%s UPDATE 禁止 SET 不可变列 %s" % (table, immutable))
return row
'''
# ---------------------------------------------------------------- 3) UPDATE 调用点
OLD_3 = ''' "checksum": checksum, "updated_by": actor_id, "updated_at": now,
}, strict=False)'''
NEW_3 = ''' "checksum": checksum, "updated_by": actor_id, "updated_at": now,
}, strict=True, full_row=False)'''
# ---------------------------------------------------------------- 4) self_check 闭环门禁
OLD_4 = ''' for name, fn in (("apply_runtime_event", apply_runtime_event),
("poll_events", poll_events), ("read_states", read_states)):'''
NEW_4 = ''' # ---- PBL-RTX-0004 闭环门禁(M11b-2c-A3):误报侧不报 + 漏报侧必报 ----
try:
upd_row = build_row(STATE_TABLE, {
STATE_COL_VALUE: "{}", EVENT_COL_STATE_VERSION: 2, "checksum": "c",
"updated_by": "server", "updated_at": "2026-09-21 12:00:00",
}, strict=True, full_row=False)
expect = [STATE_COL_VALUE, EVENT_COL_STATE_VERSION, "checksum", "updated_by",
"updated_at"]
if sorted(upd_row.keys()) != sorted(expect):
ok = False
msgs.append("tx_write FAIL:UPDATE SET 列被意外裁剪 %s(应为 %s)"
% (sorted(upd_row.keys()), sorted(expect)))
else:
msgs.append("tx_write PASS:UPDATE SET 语义不误报缺 NOT NULL 列"
"(PBL-RTX-0004 误报侧已闭环)")
except rtx_db.RtxError as exc:
ok = False
msgs.append("tx_write FAIL:UPDATE SET 仍被 %s 拒绝(PBL-RTX-0004 未闭环)" % exc.code)
try:
build_row(STATE_TABLE, {"session_id": 11, STATE_COL_KEY: "pos"})
ok = False
msgs.append("tx_write FAIL:INSERT 真缺 NOT NULL 列未被拦(门禁恒真 = 漏报回归)")
except rtx_db.RtxError as exc:
if exc.code == "PBL-RTX-0004":
msgs.append("tx_write PASS:INSERT 真缺列仍被 PBL-RTX-0004 拦下(fail-closed 未弱化)")
else:
ok = False
msgs.append("tx_write FAIL:INSERT 缺列错误码异常 %s(应为 PBL-RTX-0004)" % exc.code)
# 事件表用 nullable:"no" 写法,识别修复后必填集合必须非空(否则门禁又退回恒真)
for tbl in (EVENT_TABLE, STATE_TABLE, SNAPSHOT_TABLE):
req = required_columns(tbl)
if not req:
ok = False
msgs.append("tx_write FAIL:%s required_columns 为空"
"(模型 NOT NULL 写法未被识别,INSERT 门禁失效)" % tbl)
else:
msgs.append("tx_write PASS:%s 必填列 %d 个(已排除自增/DEFAULT 列):%s"
% (tbl, len(req), ",".join(req)))
# 只给「必填列真子集」必须被拦(事件表缺 event_type / 状态表缺 tenant_id)
for tbl, partial in ((EVENT_TABLE, {EVENT_COL_UID: "u1"}),
(STATE_TABLE, {"session_id": 11, STATE_COL_KEY: "pos",
EVENT_COL_STATE_VERSION: 1, "checksum": "c",
"updated_by": "a", "created_at": "2026-09-21",
"updated_at": "2026-09-21"})):
try:
build_row(tbl, partial)
ok = False
msgs.append("tx_write FAIL:%s 只给部分必填列却通过校验" % tbl)
except rtx_db.RtxError as exc:
if exc.code != "PBL-RTX-0004":
ok = False
msgs.append("tx_write FAIL:%s 部分列错误码 %s" % (tbl, exc.code))
else:
msgs.append("tx_write PASS:%s 部分必填列被 PBL-RTX-0004 拦下" % tbl)
# 写路径实际使用的 INSERT 列集合必须通过(防修复过头把主链路挡死)
try:
build_row(EVENT_TABLE, {
"tenant_id": "7", EVENT_COL_UID: "u", "session_id": 11, "world_id": 1,
"actor_id": "server", "event_type": "move", EVENT_COL_PAYLOAD: "{}",
"causation_id": "", EVENT_COL_STATE_VERSION: 0, EVENT_COL_SEQ: 1,
"occurred_at": "2026-09-21 12:00:00", "created_at": "2026-09-21 12:00:00",
"updated_at": "2026-09-21 12:00:00"})
build_row(SNAPSHOT_TABLE, {
"tenant_id": "7", "session_id": 11, "snapshot_uid": "s",
"state_fingerprint": "f", "entities_json": "{}", "events_through_seq": 1,
"reason": "apply_runtime_event", "created_at": "2026-09-21",
"updated_at": "2026-09-21"})
msgs.append("tx_write PASS:事件表/快照表 INSERT 写路径列集合通过校验(无回归)")
except rtx_db.RtxError as exc:
ok = False
msgs.append("tx_write FAIL:写路径 INSERT 列集合被 %s 拒绝(修复过头)" % exc.code)
# 不可变列守卫:SET tenant_id/created_at 必须被 PBL-RTX-0006 拒绝
try:
build_row(STATE_TABLE, {"tenant_id": "7", STATE_COL_VALUE: "{}"},
strict=True, full_row=False)
ok = False
msgs.append("tx_write FAIL:UPDATE SET 不可变列未被拦")
except rtx_db.RtxError as exc:
if exc.code == "PBL-RTX-0006":
msgs.append("tx_write PASS:UPDATE 禁止 SET 不可变列(PBL-RTX-0006)")
else:
ok = False
msgs.append("tx_write FAIL:不可变列错误码 %s(应为 PBL-RTX-0006)" % exc.code)
for name, fn in (("apply_runtime_event", apply_runtime_event),
("poll_events", poll_events), ("read_states", read_states)):'''
def main():
with io.open(TARGET, "r", encoding="utf-8") as f:
src = f.read()
pairs = [("required_columns", OLD_1, NEW_1), ("build_row", OLD_2, NEW_2),
("_apply_state UPDATE 调用点", OLD_3, NEW_3),
("self_check 闭环门禁", OLD_4, NEW_4)]
for tag, old, new in pairs:
n = src.count(old)
if n != 1:
print("FAIL: 片段[%s] 命中 %d 次(应为 1)" % (tag, n))
return 2
src = src.replace(old, new)
with io.open(TARGET, "w", encoding="utf-8") as f:
f.write(src)
print("OK: 已应用 %d 处补丁 -> %s" % (len(pairs), TARGET))
return 0
if __name__ == "__main__":
sys.exit(main())