From ad744aaf06bdff489c8fc0a4e4e1c417bc63f456 Mon Sep 17 00:00:00 2001 From: "agent.develop" Date: Sat, 19 Sep 2026 22:24:59 +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 --- ..._m11b_02_pbl_runtime_event_append_only.sql | 16 +- ...bl_runtime_event_partition_maintenance.sql | 19 +- scripts/pbl_runtime_event_ddl.py | 197 ++++++++++++++++-- sql/m11b_partitions.sql | 35 +--- 4 files changed, 198 insertions(+), 69 deletions(-) diff --git a/scripts/migrations/20260919_m11b_02_pbl_runtime_event_append_only.sql b/scripts/migrations/20260919_m11b_02_pbl_runtime_event_append_only.sql index 82a5d51..0bd5e91 100644 --- a/scripts/migrations/20260919_m11b_02_pbl_runtime_event_append_only.sql +++ b/scripts/migrations/20260919_m11b_02_pbl_runtime_event_append_only.sql @@ -5,9 +5,7 @@ DELIMITER $$ -- append-only 断言过程:非白名单操作一律 SIGNAL 拒绝 -DROP PROCEDURE IF EXISTS `pbl_assert_append_only`; - -CREATE$$ +DROP PROCEDURE IF EXISTS `pbl_assert_append_only`$$ CREATE PROCEDURE `pbl_assert_append_only`( IN p_table VARCHAR(64), IN p_priv VARCHAR(16), IN p_detail VARCHAR(255)) @@ -22,13 +20,11 @@ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PBL_APPEND_ONLY_DENIED', MYSQL_ERRNO = 45001; -END; +END$$ -- BEFORE UPDATE 护栏:把违规语句原文带进错误消息,便于定位调用方 DROP TRIGGER IF EXISTS `trg_pbl_runtime_event_append_only_upd`$$ -CREATE$$ - CREATE TRIGGER `trg_pbl_runtime_event_append_only_upd` BEFORE UPDATE ON `pbl_runtime_event` FOR EACH ROW BEGIN @@ -50,13 +46,11 @@ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PBL_APPEND_ONLY_DENIED_UPDATE', MYSQL_ERRNO = 45002; END IF; -END; +END$$ -- BEFORE DELETE 护栏 DROP TRIGGER IF EXISTS `trg_pbl_runtime_event_append_only_del`$$ -CREATE$$ - CREATE TRIGGER `trg_pbl_runtime_event_append_only_del` BEFORE DELETE ON `pbl_runtime_event` FOR EACH ROW BEGIN @@ -78,10 +72,10 @@ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PBL_APPEND_ONLY_DENIED_DELETE', MYSQL_ERRNO = 45003; END IF; -END; +END$$ -- 权限层加固(可选,需 DBA 账号执行;app 账号只保留增/查) -- GRANT INSERT, SELECT, UPDATE, DELETE ON `pbls`.`pbl_runtime_event` TO NULL; -- 占位,勿执行 --- REVOKE UPDATE, DELETE ON `pbls`.`pbl_runtime_event` FROM 'pbls'@'%'$$ +-- REVOKE UPDATE, DELETE ON `pbls`.`pbl_runtime_event` FROM 'pbls'@'%'; DELIMITER ; diff --git a/scripts/migrations/20260919_m11b_03_pbl_runtime_event_partition_maintenance.sql b/scripts/migrations/20260919_m11b_03_pbl_runtime_event_partition_maintenance.sql index 8430588..c9014ba 100644 --- a/scripts/migrations/20260919_m11b_03_pbl_runtime_event_partition_maintenance.sql +++ b/scripts/migrations/20260919_m11b_03_pbl_runtime_event_partition_maintenance.sql @@ -5,9 +5,7 @@ DELIMITER $$ -- 1) 幂等补建未来月分区:pmax 存在时用 REORGANIZE PARTITION 拆出新月份(不丢数据) -DROP PROCEDURE IF EXISTS `pbl_runtime_event_ensure_partitions`; - -CREATE$$ +DROP PROCEDURE IF EXISTS `pbl_runtime_event_ensure_partitions`$$ CREATE PROCEDURE `pbl_runtime_event_ensure_partitions`(IN p_months_ahead INT) MODIFIES SQL DATA @@ -49,13 +47,11 @@ BEGIN END IF; END LOOP; CLOSE cur; -END; +END$$ -- 2) 到期归档:整月 DROP PARTITION(append-only 表唯一合规的清理手段) DROP PROCEDURE IF EXISTS `pbl_runtime_event_prune_partitions`$$ -CREATE$$ - CREATE PROCEDURE `pbl_runtime_event_prune_partitions`(IN p_keep_months INT) MODIFIES SQL DATA BEGIN @@ -82,16 +78,11 @@ BEGIN DEALLOCATE PREPARE pbl_drop_st; END LOOP; CLOSE cur; -END; +END$$ -- 3) 每月自动补分区(需 event_scheduler=ON;否则用 cron,见开发说明) --- SET GLOBAL event_scheduler = ON$$ - -DROP$$ - -DROP EVENT IF EXISTS `ev_pbl_runtime_event_partition_maintain`; - -CREATE$$ +-- SET GLOBAL event_scheduler = ON; +DROP EVENT IF EXISTS `ev_pbl_runtime_event_partition_maintain`$$ CREATE EVENT IF NOT EXISTS `ev_pbl_runtime_event_partition_maintain` ON SCHEDULE EVERY 1 MONTH diff --git a/scripts/pbl_runtime_event_ddl.py b/scripts/pbl_runtime_event_ddl.py index 0a047c2..b4d3714 100644 --- a/scripts/pbl_runtime_event_ddl.py +++ b/scripts/pbl_runtime_event_ddl.py @@ -384,23 +384,140 @@ def full_script(dbname="pbls", app_user="pbls", today=None, months=None): + migrate_from_legacy_sql()) -def _with_delimiter(body): - """把过程/触发器体内语句交给 mysql CLI:整段以 $$ 结束。""" +_BLOCK_OPEN = re.compile(r"^BEGIN\b", re.I) +_BLOCK_CLOSE = re.compile(r"^END\s*;?\s*$", re.I) +_SUB_CLOSE = re.compile(r"^END\s+(IF|LOOP|CASE)\b", re.I) +_STMT_HEAD = re.compile( + r"^(DROP|CREATE|GRANT|REVOKE|SET|SELECT|CALL|ALTER|WITH|INSERT|UPDATE|DELETE|START)\b", re.I) +# 一个 $$ 段内不应再出现的「另一条 DDL 语句头」——出现即说明两条语句被黏在了一起 +_INLINE_DDL_HEAD = re.compile( + r"^\s*(DROP|CREATE)\s+(PROCEDURE|TRIGGER|EVENT|TABLE|FUNCTION)\b", re.I) +# 裸关键字残缺行:`CREATE$$` / `DROP$$`(QC #12 的致命产物形态) +_BARE_TOKEN = re.compile(r"^(CREATE|DROP|ALTER|GRANT|REVOKE)\s*\$\$\s*$", re.I) + + +def _with_delimiter(body, delim="$$"): + """把过程/触发器/EVENT 正文按语句切分,逐条以 $$ 收尾,交给 mysql CLI(DELIMITER)执行。 + + QC #12 修复(致命缺陷:迁移资产整体不可执行): + 旧实现 `re.split(r"(?<=;)\n(?=(DROP|CREATE|...))", body)` 的前瞻里带**捕获组**, + re.split 会把捕获到的文本('CREATE' / 'DROP')作为独立元素插进结果列表;这些孤立 token + 随后被当成一条完整语句,拼成裸 `CREATE$$` / `DROP$$` 行写进 .sql。在 `DELIMITER $$` 下 + 服务端把 `CREATE` 当独立语句解析 → ERROR 1064,02/03/canonical 三个文件全部无法执行。 + 另外旧实现把 `DROP PROCEDURE/TRIGGER/EVENT` 留在 `;` 结尾,而 DELIMITER 已改成 `$$`, + 客户端只认 `$$` 为语句结束符,`;` 结尾的 DROP 会与下一条 CREATE 黏成一条语句。 + 现改为按行扫描 + BEGIN/END 嵌套深度判定语句边界(不再使用带捕获组的 re.split): + · 每条语句去掉结尾 `;` 后以 `$$` 结束(含 DROP ... IF EXISTS); + · `--` 注释行原样保留、不加 `$$`(否则 `$$` 被行注释吞掉,语句永不结束); + · 结构上只可能产出以完整关键字开头的语句段,不可能产出裸 `CREATE`/`DROP`。 + """ out = [] - for chunk in re.split(r"(?<=;)\n(?=(DROP|CREATE|GRANT|REVOKE|SELECT|--|SET))", body): - c = chunk.strip() - if not c: + buf = [] + cmts = [] + depth = [0] + + def flush(): + if not buf: + if cmts: + out.append("\n".join(cmts)) + del cmts[:] + return + stmt = re.sub(r";\s*$", "", "\n".join(buf).strip()) + head = ("\n".join(cmts) + "\n") if cmts else "" + out.append(head + stmt + delim) + del buf[:] + del cmts[:] + + for raw in body.replace("\r\n", "\n").split("\n"): + line = raw.rstrip() + s = line.strip() + if not s: continue - if c.startswith("--") or c.startswith("SET ") or c.startswith("GRANT") or c.startswith("REVOKE"): - out.append(c if c.endswith(";") else c + ";") - elif c.startswith("DROP PROCEDURE") or c.startswith("DROP TRIGGER") or c.startswith("DROP EVENT"): - out.append(c if c.endswith(";") else c + ";") - else: - stmt = c.rstrip(";") - out.append(stmt + "$$") + if not buf: + if s.startswith("--") or s.startswith("#") or not _STMT_HEAD.match(s): + cmts.append(line) + continue + if _BLOCK_OPEN.match(s): + depth[0] += 1 + elif _BLOCK_CLOSE.match(s) and depth[0] > 0: + depth[0] -= 1 + buf.append(line) + if depth[0] == 0 and s.endswith(";") and not _SUB_CLOSE.match(s): + flush() + flush() return "\n\n".join(out) + "\n" +def _delimiter_integrity_errors(text, label=""): + """DELIMITER 语句完整性静态断言(QC #16:PASS 要能证明「可执行」,而非只查关键字存在)。 + + 断言项: + 1) `DELIMITER x` 与 `DELIMITER ;` 严格配对,文件结束时不残留未闭合块; + 2) 每个 `$$` 段首个代码行必须是**完整**语句头(关键字 + 对象类型), + 禁止 `CREATE$$` / `DROP$$` 这类孤立残缺 token(服务端 ERROR 1064); + 3) 一个 `$$` 段内不得再出现第二条 DDL 语句头(防止 `;` 结尾语句与下一条黏连); + 4) 未以 `$$` 收尾的残段里若含可执行语句(纯注释尾巴放行)即判失败。 + 返回缺陷描述列表;空列表 = 结构通过。 + """ + errs = [] + pfx = ("[%s] " % label) if label else "" + lines = text.split("\n") + delim = [None] + seg = [[]] + + def code_of(ls): + return [x.strip() for x in ls if x.strip() and not x.strip().startswith("--") + and not x.strip().startswith("#")] + + def close_segment(): + code = code_of(seg[0]) + if not code: + seg[0] = [] + return + d = delim[0] or "$$" + first_words = code[0].replace(d, "").split() + if len(first_words) < 2: + errs.append(pfx + "残缺语句 token(服务端按独立语句解析 → ERROR 1064): %r" % code[0]) + for x in code[1:]: + if _INLINE_DDL_HEAD.match(x): + errs.append(pfx + "一个 %s 段内黏连了多条 DDL 语句: %r" % (d, x.strip())) + seg[0] = [] + + for i, ln in enumerate(lines, 1): + s = ln.strip() + if _BARE_TOKEN.match(s): + errs.append(pfx + "L%d 裸关键字$$ 残缺行: %r" % (i, s)) + if s.upper().startswith("DELIMITER"): + parts = s.split(None, 1) + new = parts[1].strip() if len(parts) > 1 else ";" + if seg[0]: + close_segment() + if delim[0] is None: + if new == ";": + errs.append(pfx + "L%d 出现没有配对的 `DELIMITER ;`" % i) + else: + delim[0] = new + else: + if new != ";": + errs.append(pfx + "L%d DELIMITER %s 未闭合就切换到 %s" + % (i, delim[0], new)) + delim[0] = new + else: + delim[0] = None + continue + if delim[0]: + seg[0].append(ln) + if s.endswith(delim[0]): + close_segment() + if seg[0]: + errs.append(pfx + "DELIMITER 块末尾存在未以 %s 收尾的可执行残段: %r" + % (delim[0] or "$$", code_of(seg[0])[0][:60])) + seg[0] = [] + if delim[0] is not None: + errs.append(pfx + "文件结束时 DELIMITER %s 仍未闭合" % delim[0]) + return errs + + # ---------------------------------------------------------------- 迁移文件产出 MIGRATION_FILES = [ ("20260919_m11b_01_create_pbl_runtime_event.sql", @@ -526,6 +643,43 @@ def verify(): msgs.append("FAIL DDL 非幂等(缺 IF EXISTS / DROP IF EXISTS)") else: msgs.append("PASS 幂等:IF NOT EXISTS + DROP ... IF EXISTS + 分区存在性判断") + + # 8. DELIMITER 语句完整性(QC #16:PASS 必须能证明「可执行」,不能只查关键字存在性) + artifacts = [ + ("01", create_table_sql()), + ("02", MIGRATION_FILES[1][1]("pbls")), + ("03", MIGRATION_FILES[2][1]("pbls")), + ("canonical", full_script()), + ] + struct_errs = [] + for label, text in artifacts: + struct_errs.extend(_delimiter_integrity_errors(text, label)) + if struct_errs: + ok = False + for e in struct_errs: + msgs.append("FAIL DELIMITER 语句完整性: " + e) + else: + msgs.append("PASS DELIMITER 语句完整性: 4 个产物 DELIMITER 配对正确、%d 个 $$ 段首关键字完整、" + "无裸 CREATE$$/DROP$$ 残缺行" + % sum(len(re.findall(r"\$\$\s*$", t, re.M)) for _, t in artifacts)) + + # 9. 幂等/结构关键字逐产物覆盖(QC #17:完整陈述,不再跳号回避缺陷) + need_map = [ + ("01", ("CREATE TABLE IF NOT EXISTS", "PARTITION BY RANGE COLUMNS", + "VALUES LESS THAN (MAXVALUE)")), + ("02", ("DROP PROCEDURE IF EXISTS", "DROP TRIGGER IF EXISTS", + "BEFORE UPDATE", "BEFORE DELETE")), + ("03", ("DROP PROCEDURE IF EXISTS", "DROP EVENT IF EXISTS", "CREATE EVENT")), + ] + by_label = dict(artifacts) + miss_all = [] + for label, needs in need_map: + miss_all.extend("%s:%s" % (label, n) for n in needs if n not in by_label[label]) + if miss_all: + ok = False + msgs.append("FAIL 幂等/结构关键字缺失: " + ", ".join(miss_all)) + else: + msgs.append("PASS 幂等关键字逐文件覆盖: 01 建表分区 / 02 过程+双触发器 / 03 双过程+EVENT") return ok, msgs @@ -632,16 +786,21 @@ def _norm_part(name): return text.strip().strip("`").strip('"').strip("'").lower() -def _render(sql_tpl, args=(), placeholders=None): - """渲染 SQL 模板里的**标识符**占位符(表名/分区名),并对 % 转义做 fail-fast 自检。 +def _render(sql, args=None, placeholders=None): + """SQL 的 % 转义统一出口:可选渲染标识符 + fail-fast 自检(QC #1 的根因处置)。 - 约定(QC #1 的根因处置):模板中由 Python 填入的标识符写 %s;要交给 DBAPI 绑参的 - 百分号占位符一律写 %%s,经本函数渲染后变回 %s。渲染后: - · 仍残留 '%%' → 转义写错,抛错; - · '%s' 个数与预期绑参个数不符 → 抛错。 + 约定:模板中由 Python 侧填入的标识符(表名/分区名)写 %s;要留给 DBAPI 绑参的 + 百分号占位符一律写 %%s,标识符渲染后即变回 %s。 + · args 不为 None:先执行 sql % args 完成标识符渲染; + · args 为 None:视为调用方已完成渲染(模板就地 % 过),本函数只做转义自检。 + 自检两条,任一不满足立即抛 RuntimeError(而不是丢给驱动报 + "unsupported format character",那会在 verify_db 里被 except 吞成一条模糊失败): + · 结果仍残留 '%%' → 转义写错; + · '%s' 个数与 placeholders 不符 → 漏写/多写绑参占位符。 渲染单独成行、括号显式定界,不再依赖「相邻字面量先拼接、再整体 %」的隐式优先级。 """ - sql = sql_tpl % tuple(args) + if args is not None: + sql = sql % tuple(args) if "%%" in sql: raise RuntimeError("SQL 渲染后仍残留 '%%',转义写法有误: " + sql[:200]) if placeholders is not None and sql.count("%s") != placeholders: diff --git a/sql/m11b_partitions.sql b/sql/m11b_partitions.sql index 3e5e4c6..1c7cd0b 100644 --- a/sql/m11b_partitions.sql +++ b/sql/m11b_partitions.sql @@ -60,9 +60,7 @@ DELIMITER $$ -- ---------------------------------------------------------------- B. append-only -- append-only 断言过程:非白名单操作一律 SIGNAL 拒绝 -DROP PROCEDURE IF EXISTS `pbl_assert_append_only`; - -CREATE$$ +DROP PROCEDURE IF EXISTS `pbl_assert_append_only`$$ CREATE PROCEDURE `pbl_assert_append_only`( IN p_table VARCHAR(64), IN p_priv VARCHAR(16), IN p_detail VARCHAR(255)) @@ -77,13 +75,11 @@ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PBL_APPEND_ONLY_DENIED', MYSQL_ERRNO = 45001; -END; +END$$ -- BEFORE UPDATE 护栏:把违规语句原文带进错误消息,便于定位调用方 DROP TRIGGER IF EXISTS `trg_pbl_runtime_event_append_only_upd`$$ -CREATE$$ - CREATE TRIGGER `trg_pbl_runtime_event_append_only_upd` BEFORE UPDATE ON `pbl_runtime_event` FOR EACH ROW BEGIN @@ -105,13 +101,11 @@ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PBL_APPEND_ONLY_DENIED_UPDATE', MYSQL_ERRNO = 45002; END IF; -END; +END$$ -- BEFORE DELETE 护栏 DROP TRIGGER IF EXISTS `trg_pbl_runtime_event_append_only_del`$$ -CREATE$$ - CREATE TRIGGER `trg_pbl_runtime_event_append_only_del` BEFORE DELETE ON `pbl_runtime_event` FOR EACH ROW BEGIN @@ -133,17 +127,15 @@ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PBL_APPEND_ONLY_DENIED_DELETE', MYSQL_ERRNO = 45003; END IF; -END; +END$$ -- 权限层加固(可选,需 DBA 账号执行;app 账号只保留增/查) -- GRANT INSERT, SELECT, UPDATE, DELETE ON `pbls`.`pbl_runtime_event` TO NULL; -- 占位,勿执行 --- REVOKE UPDATE, DELETE ON `pbls`.`pbl_runtime_event` FROM 'pbls'@'%'$$ +-- REVOKE UPDATE, DELETE ON `pbls`.`pbl_runtime_event` FROM 'pbls'@'%'; -- ---------------------------------------------------------------- C. 分区维护 -- 1) 幂等补建未来月分区:pmax 存在时用 REORGANIZE PARTITION 拆出新月份(不丢数据) -DROP PROCEDURE IF EXISTS `pbl_runtime_event_ensure_partitions`; - -CREATE$$ +DROP PROCEDURE IF EXISTS `pbl_runtime_event_ensure_partitions`$$ CREATE PROCEDURE `pbl_runtime_event_ensure_partitions`(IN p_months_ahead INT) MODIFIES SQL DATA @@ -185,13 +177,11 @@ BEGIN END IF; END LOOP; CLOSE cur; -END; +END$$ -- 2) 到期归档:整月 DROP PARTITION(append-only 表唯一合规的清理手段) DROP PROCEDURE IF EXISTS `pbl_runtime_event_prune_partitions`$$ -CREATE$$ - CREATE PROCEDURE `pbl_runtime_event_prune_partitions`(IN p_keep_months INT) MODIFIES SQL DATA BEGIN @@ -218,16 +208,11 @@ BEGIN DEALLOCATE PREPARE pbl_drop_st; END LOOP; CLOSE cur; -END; +END$$ -- 3) 每月自动补分区(需 event_scheduler=ON;否则用 cron,见开发说明) --- SET GLOBAL event_scheduler = ON$$ - -DROP$$ - -DROP EVENT IF EXISTS `ev_pbl_runtime_event_partition_maintain`; - -CREATE$$ +-- SET GLOBAL event_scheduler = ON; +DROP EVENT IF EXISTS `ev_pbl_runtime_event_partition_maintain`$$ CREATE EVENT IF NOT EXISTS `ev_pbl_runtime_event_partition_maintain` ON SCHEDULE EVERY 1 MONTH