From 309d374e1ce49abf26fb1ac1b1fa9417e9e5317b Mon Sep 17 00:00:00 2001 From: "agent.develop" Date: Sat, 19 Sep 2026 21:44:42 +0800 Subject: [PATCH] =?UTF-8?q?deliver:=20=E4=BA=A4=E4=BB=98=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=EF=BC=88=E5=BC=95=E6=93=8E=E4=BB=A3=E4=B8=BA=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- models/pbl_runtime_event.json | 14 +++++++++++--- scripts/pbl_runtime_event_ddl.py | 29 ++++++++++++++++++----------- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/models/pbl_runtime_event.json b/models/pbl_runtime_event.json index 893144b..8049344 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 侧为权威派生源(列顺序=物理列顺序,每列 ddl_type 记录其物理类型串),本 json 用规范抽象类型(long/str/text/datetime/short)表达同一结构,任何一侧变更必须同步另一侧。occurred_at 仅作 M11a 兼容可空列保留,不再是任何唯一键/主键/分区键成员。", + "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 兼容可空列保留(DDL 侧 LEGACY_COLUMNS),不再是任何唯一键/主键/分区键成员。", "module": "pbl_runtime_ext", "engine": "mariadb", "charset": "utf8mb4", @@ -19,6 +19,7 @@ "triggers": ["BEFORE UPDATE", "BEFORE DELETE"], "sqlstate": "45000", "break_glass": "@pbl_guard_ctx", + "admin_ctx": "append_only_admin", "note": "sor.U()/sor.D() 会被触发器拒绝;本模块所有写路径只发 INSERT(tx_event/tx_write 已如此)" } }, @@ -60,7 +61,7 @@ {"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"} + {"name": "occurred_at", "title": "兼容列(旧分区键)", "comment": "兼容列(M11a 旧分区键,新写入可空;不属任何键)", "type": "datetime", "nullable": "yes", "ddl_type": "DATETIME NULL"} ], "indexes": [ {"name": "uk_event_uid", "idxtype": "unique", "idxfields": ["tenant_id", "event_uid", "created_at"], "comment": "事件UID幂等唯一;含分区键 created_at(分区表唯一键必须包含分区列)"}, @@ -73,6 +74,13 @@ {"name": "ix_created_at", "idxtype": "index", "idxfields": ["created_at"], "comment": "时间轴/分区维护辅助索引"} ], "codes": [ - {"field": "event_type", "appcode": "pbl_event_type"} + { + "field": "event_type", + "table": "appcodes_kv", + "valuefield": "k", + "textfield": "v", + "cond": "parentid='pbl_event_type'", + "comment": "取值域由 pbl_appcodes 模块 seed 注入的 appcode 组 pbl_event_type 保障(appcodes.id=pbl_event_type,子项落 appcodes_kv.parentid);本表只引用不注入数据" + } ] } diff --git a/scripts/pbl_runtime_event_ddl.py b/scripts/pbl_runtime_event_ddl.py index 7b9c991..50366b0 100644 --- a/scripts/pbl_runtime_event_ddl.py +++ b/scripts/pbl_runtime_event_ddl.py @@ -641,9 +641,13 @@ def _explain_partitions(cur, sql, params=None): def _row_in_partition(cur, part, uid): """SELECT ... FROM t PARTITION(p) 反查:探针行是否物理落在该分区。""" + # QC #1(转义) 复核:模板里的 %s 由 Python 侧渲染成表名/分区名,DBAPI 的参数占位符 + # 必须写成 %%s 并在渲染后变成 %s。此处把渲染单独成行、加括号显式定界,避免 + # 「相邻字符串字面量先拼接、再整体 %」这种依赖隐式优先级的写法(易被后续改动打挂)。 + sql = ("SELECT id FROM `%s` PARTITION (`%s`) WHERE tenant_id='t_probe'" + " AND event_uid=%%s" % (TABLE, part)) try: - cur.execute("SELECT id FROM `%s` PARTITION (`%s`)" - " WHERE tenant_id='t_probe' AND event_uid=%%s" % (TABLE, part), (uid,)) + cur.execute(sql, (uid,)) except Exception: # noqa: BLE001 return False return bool(cur.fetchone()) @@ -660,10 +664,9 @@ def _locate_row_partition(cur, uid, ts, part_names): 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)) + probe_sql = ("SELECT id FROM `%s` WHERE tenant_id='t_probe'" + " AND event_uid=%%s AND created_at=%%s" % TABLE) + planned = _explain_partitions(cur, probe_sql, (uid, ts)) if len(planned) == 1: return planned[0] return ",".join(planned) or "?" @@ -694,9 +697,13 @@ def verify_db(): probes = (("%s_a" % probe, "2026-01-05 00:00:00"), ("%s_b" % probe, "2027-03-05 00:00:00")) try: + ins_sql = ("INSERT INTO `%s` (tenant_id,event_uid,event_type,scene_id,seq,created_at)" + " VALUES ('t_probe',%%s,'probe',0,%%s,%%s)" % TABLE) + # 渲染后必须只剩 3 个 DBAPI 占位符,且不再有 %% 残留(防转义回归) + if ins_sql.count("%s") != 3 or "%%" in ins_sql: + raise RuntimeError("探针 INSERT 语句 % 转义异常: %r" % ins_sql) 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)) + cur.execute(ins_sql, (uid, i + 1, ts)) except Exception as exc: # noqa: BLE001 results.append(("跨月插入", False, str(exc)[:160])) else: @@ -710,9 +717,9 @@ def verify_db(): # 分区裁剪:created_at 区间只应命中该区间所属的少数分区,而非全部分区。 # 旧写法(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) + prune_sql = ("SELECT id FROM `%s` WHERE created_at>='2026-01-01'" + " AND created_at<'2026-02-01'" % TABLE) + pruned = _explain_partitions(cur, prune_sql) results.append(("分区裁剪", bool(pruned) and all(p in part_names for p in pruned) and len(pruned) < max(1, len(part_names)),