pbl_runtime_ext/scripts/m11b1cb_patch_generator.py

358 lines
18 KiB
Python
Raw Permalink 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.

#!/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 版本)重跑 → SKIPrc=0
(3) 部分应用:缺任一 APPLIED_MARKERS 标记 → FAILrc=1QC #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禁止硬编码连接串
沙箱库开关harnessQC #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_partitionSQL 只在其内拼装一次,
# 避免同一份转义逻辑在两处各写一遍、改一处漏一处)
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())