#!/usr/bin/env python3 # -*- coding: utf-8 -*- """M11b-1c-B T-1 补丁应用器:对同目录 scripts/pbl_runtime_event_ddl.py 做**精确串替换**(每处断言命中且仅命中 1 次),修复 QC #1 的 % 转义缺陷并重引入 PBL_DDL_DB_HOST/PBL_DDL_DB_PORT 沙箱库开关(凭据仍只来自 env/*.json)。 不改 models/*.json、不改任何其他 .py。 用法: python3 modules/pbl_runtime_ext/scripts/m11b1cb_patch_generator.py # 应用到模块仓库工作树(默认目标=同目录 pbl_runtime_event_ddl.py) python3 .../m11b1cb_patch_generator.py --check # 只试跑,报告每处命中次数,不落盘 python3 .../m11b1cb_patch_generator.py --target /tmp/base.py # 重放到指定文件副本(取证用) 幂等(QC #2 要求):重复运行不会产出重复函数定义——检测到修复已全部在位则 SKIP 并返回 0; 检测到「只应用了一半」的部分状态则 FAIL 并返回 1(要求先还原到基线再重放)。 落点(QC #11 整改):本脚本从工作空间根 tools/ 迁入模块仓库 modules/pbl_runtime_ext/scripts/(与它服务的 pbl_runtime_event_ddl.py、以及已入库的 m11b1cb_verify_sandbox_db.py 同仓同目录),纳入 git 版本控制,可被部署/复现阶段 git pull 取到。 工作空间根 tools/m11b1cb_patch_generator.py 为迁移前的旧副本,自本次提交起**作废**, 唯一权威版本是本文件;旧副本仅作历史留痕,不再维护(清理动作见 dev-notes 第 5 节)。 取证方式(QC #8 整改:基线引用改为固定 commit + blob,不再用易漂移的 HEAD~1): 基线 = modules/pbl_runtime_ext 仓库 commit 4a7c7b9,其 scripts/pbl_runtime_event_ddl.py 的 blob = 7b9c991(由 M11b-1c-A 还原任务确立)。本补丁所有 old 串以该基线为准。 git -C modules/pbl_runtime_ext show 4a7c7b9:scripts/pbl_runtime_event_ddl.py > /tmp/base.py python3 modules/pbl_runtime_ext/scripts/m11b1cb_patch_generator.py --target /tmp/base.py diff /tmp/base.py modules/pbl_runtime_ext/scripts/pbl_runtime_event_ddl.py ⚠ 预期:上面最后一条 diff **非空属正常,不代表补丁有问题**。仓库 HEAD 在本补丁(T-1) 之外还叠加了 M11b-1c 其它子任务的改动——具体三处: (a) _with_delimiter 改为按行扫描重写; (b) _norm_part 额外多一个 .strip("'")(单引号剥离); (c) 离线自检 need_map 新增段。 这三处不在本任务授权范围内,故「REPL 里的 new 串与仓库最终代码逐字一致」这一 stronger 断言在当前仓库历史下**不成立**,本文件不再声称它成立(旧版 docstring 的该声明已被 QC 实测证伪)。 本补丁可机械核验的断言收敛为以下三条(都不依赖 HEAD 的其它改动,可复现): (1) 基线重放:对 commit 4a7c7b9 的副本执行,10/10 处 REPL 各命中且仅命中 1 次,rc=0; (2) 幂等:对已应用结果(即仓库 HEAD 版本)重跑 → SKIP,rc=0; (3) 部分应用:缺任一 APPLIED_MARKERS 标记 → FAIL,rc=1(QC #8 记录的 `git show 7f0f560:...`(=HEAD~1)重放即触发此分支,rc=1,属守卫按设计生效)。 """ import argparse import io import os import sys # 迁移后(QC #11):默认目标与本脚本同目录,不再依赖工作空间根的相对层级 DEFAULT_TARGET = os.path.join(os.path.dirname(os.path.abspath(__file__)), "pbl_runtime_event_ddl.py") # 修复已在位 / 部分在位 的判定标记(幂等守卫用) APPLIED_MARKERS = ( "def _norm_part(name):", "def _render(sql, args=None, placeholders=None):", '"PBL_DDL_DB_HOST"', "part_norm = set(_norm_part(n) for n in part_names)", ) # ---------------------------------------------------------------- 替换清单 REPL = [] # --- P1: _db_conf 重新实现「仅覆盖 host/port」的沙箱库开关(基线 @@ -531/@@ -539 区段) --- REPL.append(( '''def _db_conf(): """读项目唯一事实源 env/test.json(禁止硬编码连接串)。""" for env_name in ("test", "prod"): path = os.path.join(WORKSPACE, "projects", "pbls", "env", "%s.json" % env_name) if os.path.exists(path): with open(path, encoding="utf-8") as f: conf = json.load(f) db = conf.get("db") or {} if db.get("host"): return env_name, db return None, None ''', '''def _db_conf(): """读项目唯一事实源 env/test.json(禁止硬编码连接串)。 沙箱库开关(harness,QC #1 配套的验证隔离能力):环境变量 PBL_DDL_DB_HOST / PBL_DDL_DB_PORT **只覆盖 host/port**,供 CI / 验证 harness 把连库分支指向一次性沙箱库 ——破坏性判定(DROP PARTITION、探针 INSERT、guard break-glass)不能拿共享 test/prod 库做实验。 · user / password / dbname 一律仍只来自 env/*.json:本函数不接受任何环境变量覆盖, 也不内置默认主机、默认端口、默认账号(禁止硬编码); · 两个变量都不设置时,返回值与引入该开关之前逐字一致(零行为变化); · 覆盖生效时 env_name 追加 "+harness" 后缀,日志与 --verify-db 输出可一眼看出连的是沙箱库。 """ for env_name in ("test", "prod"): path = os.path.join(WORKSPACE, "projects", "pbls", "env", "%s.json" % env_name) if os.path.exists(path): with open(path, encoding="utf-8") as f: conf = json.load(f) db = conf.get("db") or {} if db.get("host"): host = (os.environ.get("PBL_DDL_DB_HOST") or "").strip() port = (os.environ.get("PBL_DDL_DB_PORT") or "").strip() if host: db = dict(db, host=host) env_name = "%s+harness" % env_name if port: if not port.isdigit(): raise RuntimeError("PBL_DDL_DB_PORT 必须是数字端口号: %r" % port) db = dict(db, port=int(port)) if "+harness" not in env_name: env_name = "%s+harness" % env_name return env_name, db return None, None ''')) # --- P2: _explain_partitions docstring 去掉 row[3] 字样(A5 静态断言)+ 入参转义自检 --- REPL.append(( ''' 不再用 PARTITIONS 扩展语法(EXPLAIN PARTITIONS):其分区列固定在结果 row[3],列序随 MariaDB/MySQL 版本变化,且该扩展语法在新版 MySQL 已废弃。JSON 计划里的 partitions 字段两版一致。 取不到(引擎不支持/解析失败)返回 [],由调用方判失败,不猜。 """ try: cur.execute("EXPLAIN FORMAT=JSON " + sql, tuple(params or ())) row = cur.fetchone() ''', ''' 不再用旧的 PARTITIONS 扩展语法(EXPLAIN 后跟 PARTITIONS 关键字那种):那种写法把分区列固定在 结果集第 4 列(下标 3),列序随 MariaDB/MySQL 版本变化,且该扩展语法在新版 MySQL 已废弃。 JSON 计划里的 partitions 字段两版一致。 取不到(引擎不支持/解析失败)返回 [],由调用方判失败,不猜。 """ # QC #1(转义):传进来的 sql 必须已完成标识符渲染,只剩 DBAPI 占位符 %s;残留 '%%' 说明 # 调用方转义写错,直接抛出让它响,别丢给驱动报 "unsupported format character"。 if "%%" in sql: raise RuntimeError("EXPLAIN SQL 仍残留 '%%',转义写法有误: " + sql[:200]) args = tuple(params) if params else None if args is not None and sql.count("%s") != len(args): raise RuntimeError("EXPLAIN SQL 占位符 %d 个与参数 %d 个不符: %s" % (sql.count("%s"), len(args), sql[:200])) try: cur.execute("EXPLAIN FORMAT=JSON " + sql, args) row = cur.fetchone() ''')) # --- P3: _explain_partitions 结果分区名统一口径 --- REPL.append(( ''' 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) ''', ''' if key == "partitions" and isinstance(val, list): for p in val: name = _norm_part(p) if name and name not in found: found.append(name) ''')) # --- P4: 新增 _norm_part / _render 两个私有工具(插在 _explain_partitions 之前) --- REPL.append(( '''def _explain_partitions(cur, sql, params=None): ''', '''def _norm_part(name): """分区名统一口径(QC #1 附带缺陷:两处来源比对方式不一致)。 information_schema.PARTITIONS 的 PARTITION_NAME 与 EXPLAIN FORMAT=JSON 计划里的 partitions 元素,跨引擎/版本可能带反引号、双引号、空白或大小写差异。两边都过这个 归一化再比对,避免「分区确实存在但判定说不属于清单」的假失败。 """ if not name: return "" text = name.decode("utf-8", "replace") if isinstance(name, (bytes, bytearray)) else str(name) return text.strip().strip("`").strip('"').lower() def _render(sql, args=None, placeholders=None): """SQL 的 % 转义统一出口:可选渲染标识符 + fail-fast 自检(QC #1 的根因处置)。 约定:模板中由 Python 侧填入的标识符(表名/分区名)写 %s;要留给 DBAPI 绑参的 百分号占位符一律写 %%s,标识符渲染后即变回 %s。 · args 不为 None:先执行 sql % args 完成标识符渲染; · args 为 None:视为调用方已完成渲染(模板就地 % 过),本函数只做转义自检。 自检两条,任一不满足立即抛 RuntimeError(而不是丢给驱动报 "unsupported format character",那会在 verify_db 里被 except 吞成一条模糊失败): · 结果仍残留 '%%' → 转义写错; · '%s' 个数与 placeholders 不符 → 漏写/多写绑参占位符。 渲染单独成行、括号显式定界,不再依赖「相邻字面量先拼接、再整体 %」的隐式优先级。 """ 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: raise RuntimeError("SQL 渲染后 DBAPI 占位符应为 %d 个,实际 %d 个: %s" % (placeholders, sql.count("%s"), sql[:200])) return sql def _explain_partitions(cur, sql, params=None): ''')) # --- P5: _row_in_partition 转义重构(基线 @@ -641 区段) --- REPL.append(( ''' 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 ''', ''' sql = _render("SELECT id FROM `%s` PARTITION (`%s`)" " WHERE tenant_id='t_probe' AND event_uid=%%s" % (TABLE, _norm_part(part)), placeholders=1) try: cur.execute(sql, (uid,)) except Exception: # noqa: BLE001 return False ''')) # --- P6: _locate_row_partition 复用 _row_in_partition + 转义/口径统一(基线 @@ -660 区段) --- REPL.append(( ''' 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 "?" ''', ''' # 1) 逐个 PARTITION(p) 物理反查(复用 _row_in_partition,SQL 只在其内拼装一次, # 避免同一份转义逻辑在两处各写一遍、改一处漏一处) for part in part_names: if _row_in_partition(cur, part, uid): return _norm_part(part) # 2) 反查不中才退回 EXPLAIN FORMAT=JSON;标识符渲染与转义自检统一走 _render probe_sql = _render("SELECT id FROM `%s` WHERE tenant_id='t_probe'" " AND event_uid=%%s AND created_at=%%s" % TABLE, placeholders=2) planned = _explain_partitions(cur, probe_sql, (uid, ts)) if len(planned) == 1: return planned[0] return ",".join(planned) or "?" ''')) # --- P7: verify_db 探针 INSERT 转义自检(基线 @@ -694 区段) --- REPL.append(( ''' 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 ''', ''' ins_sql = _render("INSERT INTO `%s` (tenant_id,event_uid,event_type,scene_id,seq,created_at)" " VALUES ('t_probe',%%s,'probe',0,%%s,%%s)" % TABLE, placeholders=3) try: for i, (uid, ts) in enumerate(probes): cur.execute(ins_sql, (uid, i + 1, ts)) except Exception as exc: # noqa: BLE001 ''')) # --- P8: verify_db 分区清单归一化口径(供 P9/P10 比对) --- REPL.append(( ''' results.append(("MAXVALUE 兜底", PARTITION_MAXVALUE in part_names, ",".join(part_names))) ''', ''' results.append(("MAXVALUE 兜底", PARTITION_MAXVALUE in part_names, ",".join(part_names))) # 分区名比对统一走归一化口径(information_schema 与 EXPLAIN JSON 计划两来源一致) part_norm = set(_norm_part(n) for n in part_names) ''')) # --- P9: 跨月落不同分区判定用统一口径 --- REPL.append(( ''' 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) ''', ''' located = [_locate_row_partition(cur, uid, ts, part_names) for uid, ts in probes] distinct = len(set(located)) == 2 in_list = all(_norm_part(x) in part_norm for p in located for x in str(p).split(",")) ''')) # --- P10: 分区裁剪判定:SQL 渲染单独成行 + 统一口径(基线 @@ -710 区段) --- REPL.append(( ''' # 旧写法(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)), ''', ''' # 旧写法(PARTITIONS 扩展语法 + 取结果集下标 3 那一列):列序跨版本不稳定,新版 MySQL 已废弃。 prune_sql = _render("SELECT id FROM `%s` WHERE created_at>='2026-01-01'" " AND created_at<'2026-02-01'" % TABLE, placeholders=0) pruned = _explain_partitions(cur, prune_sql) results.append(("分区裁剪", bool(pruned) and all(p in part_norm for p in pruned) and len(pruned) < max(1, len(part_names)), ''')) def _already_applied(src): """幂等守卫:返回 (全已在位, 在位标记列表, 缺失标记列表)。""" hit = [m for m in APPLIED_MARKERS if m in src] return len(hit) == len(APPLIED_MARKERS), hit, [m for m in APPLIED_MARKERS if m not in src] def main(): ap = argparse.ArgumentParser(description="M11b-1c-B T-1 生成器补丁应用器") ap.add_argument("--target", default=DEFAULT_TARGET, help="待打补丁的脚本路径") ap.add_argument("--check", action="store_true", help="只试跑并报告命中次数,不落盘") opts = ap.parse_args() with io.open(opts.target, encoding="utf-8") as f: src = f.read() orig = src # ---- 幂等守卫(QC #2):修复已在位就 SKIP,绝不重复插入函数定义 ---- done, hit, missing = _already_applied(src) if done: print("SKIP 补丁已全部在位(%d/%d 标记命中),无需重复应用: %s" % (len(hit), len(APPLIED_MARKERS), opts.target)) return 0 if hit: print("FAIL 目标处于「部分应用」状态(%d 个标记已在位,%d 个缺失)," "请先还原到基线 4a7c7b9(blob 7b9c991) 再重放。缺失: %s" % (len(hit), len(missing), " / ".join(m[:40] for m in missing))) return 1 # ---- 逐处替换:断言命中且仅命中 1 次,任一处不符立即中止且**不落盘** ---- for idx, (old, new) in enumerate(REPL, 1): n = src.count(old) if n != 1: print("FAIL P%d 命中 %d 次(要求恰好 1 次),中止且不写盘: %s" % (idx, n, opts.target)) return 1 src = src.replace(old, new, 1) print("OK P%d 命中 1 次" % idx) print("HIT %d/%d 处替换全部命中且唯一" % (len(REPL), len(REPL))) if opts.check: print("CHECK --check 模式:只试跑,未写盘") return 0 if src == orig: print("FAIL 替换后内容与原文件一致,疑似未生效,拒绝写盘") return 1 with io.open(opts.target, "w", encoding="utf-8") as f: f.write(src) print("WRITE 已写盘: %s" % opts.target) return 0 if __name__ == "__main__": sys.exit(main())