diff --git a/models/pbl_runtime_event.json b/models/pbl_runtime_event.json index c15b3b9..893144b 100644 --- a/models/pbl_runtime_event.json +++ b/models/pbl_runtime_event.json @@ -3,7 +3,7 @@ { "name": "pbl_runtime_event", "title": "运行时事件流(append-only)", - "comment": "运行时事件流:append-only(禁 UPDATE/DELETE,由存储过程 pbl_assert_append_only + BEFORE UPDATE/BEFORE DELETE 触发器强制),按 created_at 做按月 RANGE COLUMNS(created_at) 分区(分区键禁 TIMESTAMP,故用 DATETIME),分区保留 14 个月(超期由维护过程 DROP PARTITION)。物理主键必须为复合主键 (id, created_at) —— MySQL/MariaDB 要求分区表的所有唯一键(含主键)必须包含分区列,否则建表报错 1503;id 仍是逻辑主键与自增列。本 json 与 scripts/pbl_runtime_event_ddl.py 的 COLUMNS/PRIMARY_KEY/UNIQUE_KEYS/SECONDARY_KEYS 为同一真源的两个视图,DDL 侧为权威派生源(列顺序=物理列顺序),任何一侧变更必须同步另一侧。", + "comment": "运行时事件流:append-only(禁 UPDATE/DELETE,由存储过程 pbl_assert_append_only + BEFORE UPDATE/BEFORE DELETE 触发器强制),按 created_at 做按月 RANGE COLUMNS(created_at) 分区(分区键禁 TIMESTAMP,故用 DATETIME),分区保留 14 个月(超期由维护过程 DROP PARTITION)。物理主键必须为复合主键 (id, created_at) —— MySQL/MariaDB 要求分区表的所有唯一键(含主键)必须包含分区列,否则建表报错 1503;id 仍是逻辑主键与自增列。本 json 与 scripts/pbl_runtime_event_ddl.py 的 COLUMNS/PRIMARY_KEY/UNIQUE_KEYS/SECONDARY_KEYS 为同一真源的两个视图:DDL 侧为权威派生源(列顺序=物理列顺序,每列 ddl_type 记录其物理类型串),本 json 用规范抽象类型(long/str/text/datetime/short)表达同一结构,任何一侧变更必须同步另一侧。occurred_at 仅作 M11a 兼容可空列保留,不再是任何唯一键/主键/分区键成员。", "module": "pbl_runtime_ext", "engine": "mariadb", "charset": "utf8mb4", @@ -36,42 +36,41 @@ } ], "fields": [ - {"name": "id", "title": "自增ID", "comment": "自增ID(逻辑主键;物理主键=(id,created_at))", "null": false, "type": "int", "length": 20, "unsigned": true, "auto_increment": true}, - {"name": "tenant_id", "title": "租户ID", "comment": "租户ID(多租户强制打头)", "null": false, "type": "str", "length": 32, "default": ""}, - {"name": "event_uid", "title": "事件UID", "comment": "事件UID(uuid,应用层生成)", "null": false, "type": "str", "length": 64}, - {"name": "event_code", "title": "事件编码", "comment": "事件编码", "null": false, "type": "str", "length": 64, "default": ""}, - {"name": "idem_key", "title": "幂等键", "comment": "幂等键(同键重复投递只落一条)", "null": false, "type": "str", "length": 128, "default": ""}, - {"name": "world_id", "title": "世界ID", "comment": "世界ID(引用 world 基表,只读)", "null": false, "type": "bigint", "default": 0}, - {"name": "scene_id", "title": "场景/会话ID", "comment": "场景/会话ID(引用 scense 基表,只读)", "null": false, "type": "bigint", "default": 0}, - {"name": "session_id", "title": "游戏会话ID", "comment": "游戏会话ID(M11a 语义,与 scene_id 同域)", "null": false, "type": "bigint", "default": 0}, - {"name": "entity_id", "title": "实体ID", "comment": "实体ID(引用 entity 基表,只读)", "null": false, "type": "str", "length": 64, "default": ""}, - {"name": "actor_id", "title": "触发者", "comment": "触发者(服务端会话解析,客户端禁填)", "null": false, "type": "str", "length": 64, "default": ""}, - {"name": "event_type", "title": "事件类型", "comment": "事件类型", "null": false, "type": "str", "length": 64}, - {"name": "payload", "title": "事件负载", "comment": "事件负载JSON(任务书字段)", "null": true, "type": "text"}, - {"name": "payload_json", "title": "事件负载JSON", "comment": "事件负载JSON(M11a 写路径列名,与 payload 同义留宽)", "null": true, "type": "text"}, - {"name": "causation_id", "title": "因果链", "comment": "因果链:引发本事件的事件UID", "null": false, "type": "str", "length": 64, "default": ""}, - {"name": "seq", "title": "会话内单调序号", "comment": "会话内单调序号(轮询游标,应用层生成)", "null": false, "type": "bigint", "default": 0}, - {"name": "seq_no", "title": "会话内单调序号(M11a)", "comment": "会话内单调序号(M11a 写路径列名)", "null": false, "type": "bigint", "default": 0}, - {"name": "source", "title": "来源", "comment": "来源:runtime/agent/script/client_intent", "null": false, "type": "str", "length": 32, "default": "runtime"}, - {"name": "state", "title": "事件状态", "comment": "applied/rejected/rolled_back", "null": false, "type": "str", "length": 16, "default": "applied"}, - {"name": "state_version", "title": "服务端权威版本", "comment": "本事件推进到的服务端权威版本", "null": false, "type": "bigint", "default": 0}, - {"name": "tx_group", "title": "事务组", "comment": "事务组(同组同事务)", "null": false, "type": "str", "length": 64, "default": ""}, - {"name": "broadcast", "title": "是否参与广播", "comment": "是否参与广播 0/1", "null": false, "type": "short", "default": 1}, - {"name": "created_by", "title": "创建人", "comment": "创建人", "null": false, "type": "bigint", "default": 0}, - {"name": "created_at", "title": "创建时间", "comment": "创建时间(分区键,禁TIMESTAMP)", "null": false, "type": "datetime", "default": "CURRENT_TIMESTAMP"}, - {"name": "updated_at", "title": "记录时间戳", "comment": "记录时间戳(append-only:永不 UPDATE)", "null": false, "type": "datetime", "default": "CURRENT_TIMESTAMP"}, - {"name": "occurred_at", "title": "兼容列(旧分区键)", "comment": "兼容列(M11a 旧分区键,新写入可空;不再作为任何唯一键/分区键成员)", "null": true, "type": "datetime"} + {"name": "id", "title": "自增ID", "comment": "自增ID(逻辑主键;物理主键=(id,created_at))", "type": "long", "nullable": "no", "auto_increment": true, "ddl_type": "BIGINT NOT NULL AUTO_INCREMENT"}, + {"name": "tenant_id", "title": "租户ID", "comment": "租户ID(多租户强制打头)", "type": "str", "length": 32, "nullable": "no", "default": "", "ddl_type": "VARCHAR(32) NOT NULL DEFAULT ''"}, + {"name": "event_uid", "title": "事件UID", "comment": "事件UID(uuid,应用层生成)", "type": "str", "length": 64, "nullable": "no", "ddl_type": "VARCHAR(64) NOT NULL"}, + {"name": "event_code", "title": "事件编码", "comment": "事件编码", "type": "str", "length": 64, "nullable": "no", "default": "", "ddl_type": "VARCHAR(64) NOT NULL DEFAULT ''"}, + {"name": "idem_key", "title": "幂等键", "comment": "幂等键(同键重复投递只落一条)", "type": "str", "length": 128, "nullable": "no", "default": "", "ddl_type": "VARCHAR(128) NOT NULL DEFAULT ''"}, + {"name": "world_id", "title": "世界ID", "comment": "世界ID(引用 world 基表,只读)", "type": "long", "nullable": "no", "default": 0, "ddl_type": "BIGINT NOT NULL DEFAULT 0"}, + {"name": "scene_id", "title": "场景/会话ID", "comment": "场景/会话ID(引用 scense 基表,只读)", "type": "long", "nullable": "no", "default": 0, "ddl_type": "BIGINT NOT NULL DEFAULT 0"}, + {"name": "session_id", "title": "游戏会话ID", "comment": "游戏会话ID(M11a 语义,与 scene_id 同域)", "type": "long", "nullable": "no", "default": 0, "ddl_type": "BIGINT NOT NULL DEFAULT 0"}, + {"name": "entity_id", "title": "实体ID", "comment": "实体ID(引用 entity 基表,只读)", "type": "str", "length": 64, "nullable": "no", "default": "", "ddl_type": "VARCHAR(64) NOT NULL DEFAULT ''"}, + {"name": "actor_id", "title": "触发者", "comment": "触发者(服务端会话解析,客户端禁填)", "type": "str", "length": 64, "nullable": "no", "default": "", "ddl_type": "VARCHAR(64) NOT NULL DEFAULT ''"}, + {"name": "event_type", "title": "事件类型", "comment": "事件类型", "type": "str", "length": 64, "nullable": "no", "ddl_type": "VARCHAR(64) NOT NULL"}, + {"name": "payload", "title": "事件负载", "comment": "事件负载JSON(任务书字段)", "type": "text", "nullable": "yes", "ddl_type": "TEXT NULL"}, + {"name": "payload_json", "title": "事件负载JSON", "comment": "事件负载JSON(M11a 写路径列名,与 payload 同义留宽)", "type": "text", "nullable": "yes", "ddl_type": "LONGTEXT NULL"}, + {"name": "causation_id", "title": "因果链", "comment": "因果链:引发本事件的事件UID", "type": "str", "length": 64, "nullable": "no", "default": "", "ddl_type": "VARCHAR(64) NOT NULL DEFAULT ''"}, + {"name": "seq", "title": "会话内单调序号", "comment": "会话内单调序号(轮询游标,应用层生成)", "type": "long", "nullable": "no", "default": 0, "ddl_type": "BIGINT NOT NULL DEFAULT 0"}, + {"name": "seq_no", "title": "会话内单调序号(M11a)", "comment": "会话内单调序号(M11a 写路径列名)", "type": "long", "nullable": "no", "default": 0, "ddl_type": "BIGINT NOT NULL DEFAULT 0"}, + {"name": "source", "title": "来源", "comment": "来源:runtime/agent/script/client_intent", "type": "str", "length": 32, "nullable": "no", "default": "runtime", "ddl_type": "VARCHAR(32) NOT NULL DEFAULT 'runtime'"}, + {"name": "state", "title": "事件状态", "comment": "applied/rejected/rolled_back", "type": "str", "length": 16, "nullable": "no", "default": "applied", "ddl_type": "VARCHAR(16) NOT NULL DEFAULT 'applied'"}, + {"name": "state_version", "title": "服务端权威版本", "comment": "本事件推进到的服务端权威版本", "type": "long", "nullable": "no", "default": 0, "ddl_type": "BIGINT NOT NULL DEFAULT 0"}, + {"name": "tx_group", "title": "事务组", "comment": "事务组(同组同事务)", "type": "str", "length": 64, "nullable": "no", "default": "", "ddl_type": "VARCHAR(64) NOT NULL DEFAULT ''"}, + {"name": "broadcast", "title": "是否参与广播", "comment": "是否参与广播 0/1", "type": "short", "nullable": "no", "default": 1, "ddl_type": "TINYINT NOT NULL DEFAULT 1"}, + {"name": "created_by", "title": "创建人", "comment": "创建人", "type": "long", "nullable": "no", "default": 0, "ddl_type": "BIGINT NOT NULL DEFAULT 0"}, + {"name": "created_at", "title": "创建时间", "comment": "创建时间(分区键,禁TIMESTAMP)", "type": "datetime", "nullable": "no", "default": "CURRENT_TIMESTAMP", "ddl_type": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP"}, + {"name": "updated_at", "title": "记录时间戳", "comment": "记录时间戳(append-only:永不 UPDATE)", "type": "datetime", "nullable": "no", "default": "CURRENT_TIMESTAMP", "ddl_type": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP"}, + {"name": "occurred_at", "title": "兼容列(旧分区键)", "comment": "兼容列(M11a 旧分区键,新写入可空)", "type": "datetime", "nullable": "yes", "ddl_type": "DATETIME NULL"} ], "indexes": [ - {"name": "PRIMARY", "idxtype": "primary", "unique": true, "fields": ["id", "created_at"], "idxfields": ["id", "created_at"], "comment": "复合主键:分区表唯一键必须包含分区列 created_at"}, - {"name": "uk_event_uid", "idxtype": "unique", "unique": true, "fields": ["tenant_id", "event_uid", "created_at"], "idxfields": ["tenant_id", "event_uid", "created_at"]}, - {"name": "uk_tenant_idem", "idxtype": "unique", "unique": true, "fields": ["tenant_id", "idem_key", "created_at"], "idxfields": ["tenant_id", "idem_key", "created_at"]}, - {"name": "ix_scene_created", "idxtype": "index", "unique": false, "fields": ["scene_id", "created_at"], "idxfields": ["scene_id", "created_at"]}, - {"name": "ix_tenant_session_seq", "idxtype": "index", "unique": false, "fields": ["tenant_id", "session_id", "seq"], "idxfields": ["tenant_id", "session_id", "seq"]}, - {"name": "ix_tenant_session_seqno", "idxtype": "index", "unique": false, "fields": ["tenant_id", "session_id", "seq_no"], "idxfields": ["tenant_id", "session_id", "seq_no"]}, - {"name": "ix_tenant_world_type", "idxtype": "index", "unique": false, "fields": ["tenant_id", "world_id", "event_type"], "idxfields": ["tenant_id", "world_id", "event_type"]}, - {"name": "ix_tx_group", "idxtype": "index", "unique": false, "fields": ["tx_group"], "idxfields": ["tx_group"]}, - {"name": "ix_created_at", "idxtype": "index", "unique": false, "fields": ["created_at"], "idxfields": ["created_at"]} + {"name": "uk_event_uid", "idxtype": "unique", "idxfields": ["tenant_id", "event_uid", "created_at"], "comment": "事件UID幂等唯一;含分区键 created_at(分区表唯一键必须包含分区列)"}, + {"name": "uk_tenant_idem", "idxtype": "unique", "idxfields": ["tenant_id", "idem_key", "created_at"], "comment": "租户内幂等键唯一;含分区键 created_at"}, + {"name": "ix_scene_created", "idxtype": "index", "idxfields": ["scene_id", "created_at"], "comment": "按场景/会话+时间范围拉取事件(分区裁剪)"}, + {"name": "ix_tenant_session_seq", "idxtype": "index", "idxfields": ["tenant_id", "session_id", "seq"], "comment": "会话内序号游标"}, + {"name": "ix_tenant_session_seqno", "idxtype": "index", "idxfields": ["tenant_id", "session_id", "seq_no"], "comment": "会话内序号游标(M11a 列名)"}, + {"name": "ix_tenant_world_type", "idxtype": "index", "idxfields": ["tenant_id", "world_id", "event_type"], "comment": "世界维度事件类型检索"}, + {"name": "ix_tx_group", "idxtype": "index", "idxfields": ["tx_group"], "comment": "事务组回查"}, + {"name": "ix_created_at", "idxtype": "index", "idxfields": ["created_at"], "comment": "时间轴/分区维护辅助索引"} ], "codes": [ {"field": "event_type", "appcode": "pbl_event_type"} diff --git a/scripts/_m11b1a_patch.py b/scripts/_m11b1a_patch.py deleted file mode 100644 index 608ef90..0000000 --- a/scripts/_m11b1a_patch.py +++ /dev/null @@ -1,177 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -# M11b-1a 定点补丁脚本(一次性工具,跑完即删): -# Patch1 maintenance_sql(): 百分号格式化渲染 → token + str.replace,避开 % 格式化崩溃(QC #1) -# Patch2 verify_db(): 删空转查询;落分区/分区裁剪改用 information_schema 分区清单 -# + SELECT ... PARTITION(p) 反查 + EXPLAIN FORMAT=JSON 解析 partitions(QC #6) -# Patch3 新增连库辅助函数 _explain_partitions/_row_in_partition/_locate_row_partition -# DDL 列定义/索引/分区计划文本一律不动。 -import os - -HERE = os.path.dirname(os.path.abspath(__file__)) -P = os.path.join(os.path.dirname(HERE), "scripts", "pbl_runtime_event_ddl.py") -src = open(P, encoding="utf-8").read() -orig = src - -# ================================================================ Patch 1 -i0 = src.index("def maintenance_sql(") -i1 = src.index("def migrate_from_legacy_sql()") -region = src[i0:i1] -assert "%(table)s" in region and "%(pmax)s" in region and "%(months)s" in region, "占位符缺失" - -new_region = (region - .replace("%(table)s", "__TABLE__") - .replace("%(pmax)s", "__PMAX__") - .replace("%(months)s", "__MONTHS__")) - -old_doc = ' """分区维护:过程(幂等补建月分区 / 到期 DROP 旧分区)+ 每月 EVENT。"""' -new_doc = (' """分区维护:过程(幂等补建月分区 / 到期 DROP 旧分区)+ 每月 EVENT。\n' - '\n' - " QC #1 修复:正文含大量字面百分号(DATE_FORMAT(...,'%Y-%m-01')、'%Y%m'),\n" - ' 原先用「三引号 % 字典」渲染会抛 ValueError: unsupported format character。\n' - ' 现改用 __TABLE__/__PMAX__/__MONTHS__ token + str.replace,\n' - ' 字面百分号原样输出、无需再写双写转义。\n' - ' """') -assert old_doc in new_region, "docstring 未命中" -new_region = new_region.replace(old_doc, new_doc) - -old_tail = ('""" % {"table": TABLE, "pmax": PARTITION_MAXVALUE, "months": months,\n' - ' "today": (today or date.today()).isoformat()}') -new_tail = ('""".replace("__TABLE__", TABLE)\n' - ' .replace("__PMAX__", PARTITION_MAXVALUE)\n' - ' .replace("__MONTHS__", str(months))') -assert old_tail in new_region, "maintenance_sql 渲染尾段未命中" -new_region = new_region.replace(old_tail, new_tail) - -src = src[:i0] + new_region + src[i1:] - -# ================================================================ Patch 2 -s2 = src.index(' cur.execute("SELECT PARTITION_NAME FROM information_schema.PARTITIONS "') -e2_marker = (' results.append(("分区裁剪", pruned.count(",") == 0 and "p" in pruned,' - ' "EXPLAIN partitions=%s" % pruned))') -e2 = src.index(e2_marker) + len(e2_marker) -old_block = src[s2:e2] -assert "EXPLAIN PARTITIONS SELECT id FROM `%s` WHERE event_uid" in old_block, "旧判定块未命中" -assert "for k in" in old_block, "空转 for 循环不在预期块内" - -new_block = ''' # 分区清单:以 information_schema.PARTITIONS 为权威来源(按 ordinal 排序), - # 后面「落哪个分区」「分区裁剪」两项判定都基于这份清单(QC #6) - cur.execute("SELECT PARTITION_NAME, PARTITION_DESCRIPTION FROM information_schema.PARTITIONS " - "WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s " - "AND PARTITION_NAME IS NOT NULL ORDER BY PARTITION_ORDINAL_POSITION", (TABLE,)) - part_rows = [(r[0], r[1]) for r in cur.fetchall()] - part_names = [n for n, _ in part_rows if n] - results.append(("MAXVALUE 兜底", PARTITION_MAXVALUE in part_names, ",".join(part_names))) - - # 插入两条不同月份的探针行 → 应落在两个不同的物理分区 - probe = "m11bprobe%s" % date.today().strftime("%H%M%S") - probes = (("%s_a" % probe, "2026-01-05 00:00:00"), - ("%s_b" % probe, "2027-03-05 00:00:00")) - try: - for i, (uid, ts) in enumerate(probes): - cur.execute("INSERT INTO `%s` (tenant_id,event_uid,event_type,scene_id,seq,created_at)" - " VALUES ('t_probe',%%s,'probe',0,%%s,%%s)" % TABLE, (uid, i + 1, ts)) - except Exception as exc: # noqa: BLE001 - results.append(("跨月插入", False, str(exc)[:160])) - else: - results.append(("跨月插入", True, "%d 条探针行(不同月份)" % len(probes))) - located = [_locate_row_partition(cur, uid, ts, part_names) for uid, ts in probes] - distinct = len(set(located)) == 2 - in_list = all(p in part_names for p in located) - results.append(("跨月落不同分区", distinct and in_list, - " | ".join("%s -> %s" % (uid, p) - for (uid, _), p in zip(probes, located)))) - - # 分区裁剪:created_at 区间只应命中该区间所属的少数分区,而非全部分区。 - # 旧写法 EXPLAIN PARTITIONS 取 row[3]:列序跨版本不稳定,且该语法新版 MySQL 已废弃。 - pruned = _explain_partitions(cur, - "SELECT id FROM `%s` WHERE created_at>='2026-01-01'" - " AND created_at<'2026-02-01'" % TABLE) - results.append(("分区裁剪", - bool(pruned) and all(p in part_names for p in pruned) - and len(pruned) < max(1, len(part_names)), - "命中 %d/%d 分区: %s" % (len(pruned), len(part_names), - ",".join(pruned) or "N/A")))''' - -src = src[:s2] + new_block + src[e2:] - -# ================================================================ Patch 3 -helpers = '''def _explain_partitions(cur, sql, params=None): - """EXPLAIN FORMAT=JSON 取执行计划实际扫描的分区清单(QC #6)。 - - 不再用 EXPLAIN PARTITIONS:其分区列固定在结果 row[3],列序随 MariaDB/MySQL - 版本变化,且该扩展语法在新版 MySQL 已废弃。JSON 计划里的 partitions 字段两版一致。 - 取不到(引擎不支持/解析失败)返回 [],由调用方判失败,不猜。 - """ - try: - cur.execute("EXPLAIN FORMAT=JSON " + sql, tuple(params or ())) - row = cur.fetchone() - except Exception: # noqa: BLE001 - return [] - raw = row[0] if row else None - if isinstance(raw, (bytes, bytearray)): - raw = raw.decode("utf-8", "replace") - if not raw: - return [] - try: - plan = json.loads(raw) - except (ValueError, TypeError): - return [] - found = [] - - def walk(node): - if isinstance(node, dict): - for key, val in node.items(): - if key == "partitions" and isinstance(val, list): - for p in val: - if isinstance(p, str) and p and p not in found: - found.append(p) - else: - walk(val) - elif isinstance(node, list): - for val in node: - walk(val) - - walk(plan) - return found - - -def _row_in_partition(cur, part, uid): - """SELECT ... FROM t PARTITION(p) 反查:探针行是否物理落在该分区。""" - try: - cur.execute("SELECT id FROM `%s` PARTITION (`%s`)" - " WHERE tenant_id='t_probe' AND event_uid=%%s" % (TABLE, part), (uid,)) - except Exception: # noqa: BLE001 - return False - return bool(cur.fetchone()) - - -def _locate_row_partition(cur, uid, ts, part_names): - """定位某探针行实际所在分区(QC #6)。 - - 1) 首选按 information_schema 分区清单逐个 PARTITION(p) 反查物理位置——与 EXPLAIN - 输出列序无关,最可靠; - 2) 反查不中(引擎不支持 PARTITION 子句等)时退回 EXPLAIN FORMAT=JSON 解析 - partitions(带 tenant_id+event_uid+created_at 等值条件,裁剪后应只剩 1 个分区)。 - """ - for part in part_names: - if _row_in_partition(cur, part, uid): - return part - planned = _explain_partitions(cur, - "SELECT id FROM `%s` WHERE tenant_id='t_probe'" - " AND event_uid=%%s AND created_at=%%s" % TABLE, - (uid, ts)) - if len(planned) == 1: - return planned[0] - return ",".join(planned) or "?" - - -''' -marker = "def verify_db():" -assert src.count(marker) == 1, "verify_db 定位异常" -src = src.replace(marker, helpers + marker) - -open(P, "w", encoding="utf-8").write(src) -print("PATCHED %s: %d -> %d bytes" % (P, len(orig), len(src))) -print("residual 'EXPLAIN PARTITIONS ' count:", src.count("EXPLAIN PARTITIONS ")) -print("residual 'for k in' count:", src.count("for k in"))