248 lines
13 KiB
Python
248 lines
13 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""m11b-1a 运行时缺陷验证 harness(响应 QC #1 % 转义 / QC #6 verify_db 判定 / QC #5 落点)。
|
||
|
||
被验对象:scripts/pbl_runtime_event_ddl.py(唯一被改文件)。
|
||
|
||
两层:
|
||
A. 离线层(无依赖,必跑)
|
||
A1 maintenance_sql() 渲染不再抛 ValueError(QC #1 崩溃点)
|
||
A2 渲染正文里 DATE_FORMAT 参数为**单 %** 形式('%Y-%m-01' / '%Y%m'),且无 '%%Y' 残留
|
||
A3 append_only_sql()/full_script() 既有转义语义未改坏:'%(dbname)s'/'%(app_user)s'
|
||
被 replace 掉、host 通配符输出为单 % ('pbls'@'%'),无 '@'%%'' 残留
|
||
A4 DDL 未被改动:create_table_sql() 的 sha256 == 修复前基线(验收标准 4)
|
||
A5 源码无 `EXPLAIN PARTITIONS ` 废弃语法残留、无「查询结果未使用」的空转 for 循环
|
||
A6 verify_db 判定改用 information_schema.PARTITIONS(按 ORDINAL 排序) +
|
||
EXPLAIN FORMAT=JSON + PARTITION(p) 反查(静态断言三要素齐备)
|
||
A7 CLI 冒烟:--verify / --print / --print-maintain / --emit 全部 rc=0
|
||
B. 实库层(仅当 M11B1A_LOCAL_DB=1,由 tests/run_m11b1a_localdb.sh 拉起一次性
|
||
MariaDB 后运行;**不会**连项目 test/prod 库,避免 DROP PARTITION 误伤真数据)
|
||
B1 用 mariadb CLI(DELIMITER) 装载 full_script(),核对表/分区/过程/触发器齐备
|
||
B2 --verify-db 全项 PASS(分区数/兜底/跨月落不同分区/分区裁剪/UPDATE·DELETE 被拒/探针清理)
|
||
B3 CALL pbl_runtime_event_ensure_partitions(9) 真能 REORGANIZE 出新月分区(A2 的单 % SQL 在
|
||
服务端可执行,不只是渲染正确)
|
||
B4 CALL pbl_runtime_event_prune_partitions(2) 真能 DROP 旧月分区且保留 pmax
|
||
|
||
用法:
|
||
python3 tests/test_m11b1a_ddl_runtime.py # 只跑 A 层
|
||
bash tests/run_m11b1a_localdb.sh # A + B 层(自带一次性库)
|
||
退出码:0 = 无 FAIL;1 = 有 FAIL;2 = B 层被跳过且 A 层通过时仍返回 0(B 缺失会显式打印 SKIP 行)。
|
||
"""
|
||
|
||
import hashlib
|
||
import importlib.util
|
||
import os
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
MODULE_DIR = os.path.dirname(HERE) # modules/pbl_runtime_ext
|
||
SCRIPT = os.path.join(MODULE_DIR, "scripts", "pbl_runtime_event_ddl.py")
|
||
WORKSPACE = os.path.dirname(os.path.dirname(MODULE_DIR)) # 机构工作空间根
|
||
ENV_JSON = os.path.join(WORKSPACE, "projects", "pbls", "env", "test.json")
|
||
|
||
# 修复前(git 049e09a)`--print` 输出的 sha256 —— DDL 未改动的机械证据(验收标准 4)
|
||
DDL_BASELINE_SHA256 = "30a83c8a9d729db987f82de1a5bc30229dc4a7b1cc9881c7a5a45673b1a62619"
|
||
|
||
RESULTS = []
|
||
|
||
|
||
def chk(name, passed, detail=""):
|
||
RESULTS.append((name, bool(passed), detail))
|
||
print(" %-4s %-46s %s" % ("PASS" if passed else "FAIL", name, detail))
|
||
return bool(passed)
|
||
|
||
|
||
def load_gen():
|
||
spec = importlib.util.spec_from_file_location("pbl_runtime_event_ddl", SCRIPT)
|
||
mod = importlib.util.module_from_spec(spec)
|
||
spec.loader.exec_module(mod)
|
||
return mod
|
||
|
||
|
||
# ------------------------------------------------------------------ A. 离线层
|
||
def layer_a():
|
||
print("[A] 离线层(% 转义 + 判定逻辑静态核验)")
|
||
src = open(SCRIPT, encoding="utf-8").read()
|
||
|
||
# A1/A2 —— QC #1 崩溃点
|
||
try:
|
||
maint = subprocess.run([sys.executable, SCRIPT, "--print-maintain"],
|
||
capture_output=True, text=True)
|
||
rc_maint = maint.returncode
|
||
body = maint.stdout
|
||
except Exception as exc: # noqa: BLE001
|
||
rc_maint, body = 99, ""
|
||
chk("A1 maintenance_sql 渲染不抛异常", False, str(exc))
|
||
chk("A1 maintenance_sql 渲染不抛异常(QC#1)", rc_maint == 0,
|
||
"rc=%s, %d bytes(修复前 rc=1 ValueError: unsupported format character 'Y')"
|
||
% (rc_maint, len(body)))
|
||
for token in ("pbl_runtime_event_ensure_partitions", "pbl_runtime_event_prune_partitions",
|
||
"ev_pbl_runtime_event_partition_maintain"):
|
||
chk("A2 维护段含完整对象 %s" % token, token in body, "%d 处" % body.count(token))
|
||
chk("A2 DATE_FORMAT 参数为单 %% 形式('%Y-%m-01')",
|
||
"DATE_FORMAT(CURDATE(), '%Y-%m-01')" in body and "DATE_FORMAT(month_start, '%Y%m')" in body,
|
||
"命中 %d 处单 %% 形式" % (body.count("'%Y-%m-01'") + body.count("'%Y%m'")))
|
||
chk("A2 无 '%%Y' 双写转义残留", "%%Y" not in body and "%%'" not in body,
|
||
"grep -c '%%Y' = %d" % body.count("%%Y"))
|
||
|
||
# A3 —— append_only 既有转义语义
|
||
full = subprocess.run([sys.executable, SCRIPT, "--print-maintain"], capture_output=True, text=True)
|
||
assert full.returncode == 0
|
||
script_txt = subprocess.run([sys.executable, "-c",
|
||
"import sys;sys.path.insert(0,%r);"
|
||
"import importlib.util as u;"
|
||
"s=u.spec_from_file_location('m',%r);m=u.module_from_spec(s);s.loader.exec_module(m);"
|
||
"print(m.full_script(),end='')" % (MODULE_DIR, SCRIPT)],
|
||
capture_output=True, text=True)
|
||
chk("A3 full_script() rc=0", script_txt.returncode == 0, "rc=%s" % script_txt.returncode)
|
||
fs = script_txt.stdout
|
||
chk("A3 %(dbname)s/%(app_user)s 已被替换", "%(dbname)s" not in fs and "%(app_user)s" not in fs,
|
||
"残留 %d 处" % (fs.count("%(dbname)s") + fs.count("%(app_user)s")))
|
||
chk("A3 REVOKE 主机通配符为单 %%('pbls'@'%')", "FROM 'pbls'@'%'" in fs and "@'%%'" not in fs,
|
||
"命中 'pbls'@'%%' = %d,'@%%' = %d" % (fs.count("FROM 'pbls'@'%'"), fs.count("@'%%'")))
|
||
chk("A3 break-glass ctx 原样输出", "append_only_admin" in fs, "%d 处" % fs.count("append_only_admin"))
|
||
|
||
# A4 —— DDL 逐字未动
|
||
gen = load_gen()
|
||
digest = hashlib.sha256((gen.create_table_sql() + "\n").encode("utf-8")).hexdigest()
|
||
chk("A4 create_table_sql sha256 == 修复前基线", digest == DDL_BASELINE_SHA256, digest[:16] + "…")
|
||
|
||
# A5/A6 —— 判定逻辑(QC #6)
|
||
chk("A5 源码无 'EXPLAIN PARTITIONS ' 废弃语法", "EXPLAIN PARTITIONS " not in src,
|
||
"grep 命中 %d" % src.count("EXPLAIN PARTITIONS "))
|
||
chk("A5 无未使用查询结果的空转 for 循环",
|
||
not re.search(r"for\s+k\s+in\s*\(", src) and "row[3]" not in src,
|
||
"row[3] 命中 %d,for k in 命中 %d" % (src.count("row[3]"), len(re.findall(r"for\s+k\s+in\s*\(", src))))
|
||
vsrc = src[src.index("def verify_db("):]
|
||
chk("A6 分区清单来自 information_schema.PARTITIONS+ORDINAL",
|
||
"information_schema.PARTITIONS" in vsrc and "PARTITION_ORDINAL_POSITION" in vsrc, "OK")
|
||
chk("A6 落分区判定用 PARTITION(p) 反查 + EXPLAIN FORMAT=JSON",
|
||
"_row_in_partition" in src and "PARTITION (`%s`)" in src
|
||
and "EXPLAIN FORMAT=JSON" in src and "_locate_row_partition" in vsrc, "OK")
|
||
chk("A6 探针清理 break-glass 逻辑保留",
|
||
"SET @pbl_guard_ctx=" in vsrc and "GUARD_ADMIN_CTX" in vsrc
|
||
and "SET @pbl_guard_ctx=NULL" in vsrc,
|
||
"guard_ctx 设置 %d 处 / 复位 %d 处"
|
||
% (vsrc.count("SET @pbl_guard_ctx="), vsrc.count("SET @pbl_guard_ctx=NULL")))
|
||
|
||
# A7 —— CLI 冒烟
|
||
for args in (["--verify"], ["--print"], ["--print-guard"], ["--print-maintain"]):
|
||
r = subprocess.run([sys.executable, SCRIPT] + args, capture_output=True, text=True)
|
||
tail = (r.stdout.strip().splitlines() or [""])[-1][:40]
|
||
chk("A7 CLI %s rc=0" % " ".join(args), r.returncode == 0, "末行: %s" % tail)
|
||
tmp_emit = os.path.join(HERE, "_emit_check")
|
||
r = subprocess.run([sys.executable, SCRIPT, "--emit", tmp_emit, "--dbname", "pbls"],
|
||
capture_output=True, text=True)
|
||
files = sorted(os.listdir(tmp_emit)) if os.path.isdir(tmp_emit) else []
|
||
chk("A7 CLI --emit rc=0", r.returncode == 0 and len(files) >= 3,
|
||
"%d 文件: %s" % (len(files), ",".join(files)))
|
||
# 生成的维护 SQL 同样必须是单 % 形式(emit 路径回归)
|
||
m3 = os.path.join(tmp_emit, "20260919_m11b_03_pbl_runtime_event_partition_maintenance.sql")
|
||
if os.path.exists(m3):
|
||
t = open(m3, encoding="utf-8").read()
|
||
chk("A7 --emit 维护段 DATE_FORMAT 单 %", "DATE_FORMAT(CURDATE(), '%Y-%m-01')" in t and "%%Y" not in t,
|
||
"%d bytes" % len(t))
|
||
subprocess.run(["rm", "-rf", tmp_emit])
|
||
return gen
|
||
|
||
|
||
# ------------------------------------------------------------------ B. 实库层
|
||
def layer_b(gen):
|
||
print("\n[B] 实库层(一次性本地 MariaDB,不触碰项目 test/prod 库)")
|
||
if os.environ.get("M11B1A_LOCAL_DB") != "1":
|
||
print(" SKIP B 层:未设 M11B1A_LOCAL_DB=1(用 bash tests/run_m11b1a_localdb.sh 跑全量)")
|
||
return True
|
||
import json
|
||
import pymysql
|
||
conf = json.load(open(ENV_JSON, encoding="utf-8"))["db"]
|
||
db = dict(conf)
|
||
db["host"] = os.environ.get("M11B1A_DB_HOST", "127.0.0.1")
|
||
db["port"] = int(os.environ.get("M11B1A_DB_PORT", "13306"))
|
||
os.environ["PBL_DDL_DB_HOST"] = db["host"]
|
||
os.environ["PBL_DDL_DB_PORT"] = str(db["port"])
|
||
|
||
conn0 = pymysql.connect(host=db["host"], port=db["port"], user=db["user"],
|
||
password=db["password"], database=db["dbname"],
|
||
charset="utf8mb4", autocommit=True)
|
||
cur0 = conn0.cursor()
|
||
cur0.execute("SELECT COUNT(*) FROM information_schema.PARTITIONS "
|
||
"WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s "
|
||
"AND PARTITION_NAME IS NOT NULL", (gen.TABLE,))
|
||
n0 = cur0.fetchone()[0]
|
||
cur0.execute("SELECT COUNT(*) FROM information_schema.ROUTINES "
|
||
"WHERE ROUTINE_SCHEMA=DATABASE() AND ROUTINE_NAME LIKE 'pbl_runtime_event%%'")
|
||
n_proc = cur0.fetchone()[0]
|
||
cur0.execute("SELECT COUNT(*) FROM information_schema.TRIGGERS "
|
||
"WHERE EVENT_OBJECT_TABLE=%s", (gen.TABLE,))
|
||
n_trg = cur0.fetchone()[0]
|
||
cur0.close(); conn0.close()
|
||
chk("B1 CLI(mariadb+DELIMITER) 装载 full_script() 后对象齐备",
|
||
n0 >= gen.MONTHS_PRECREATE + 1 and n_proc >= 2 and n_trg >= 2,
|
||
"分区 %d 个 / 维护过程 %d 个 / 触发器 %d 个" % (n0, n_proc, n_trg))
|
||
rc2 = gen.verify_db()
|
||
chk("B2 verify_db() rc=0 全项 PASS(QC#6)", rc2 == 0, "verify-db rc=%s" % rc2)
|
||
|
||
conn = pymysql.connect(host=db["host"], port=db["port"], user=db["user"],
|
||
password=db["password"], database=db["dbname"],
|
||
charset="utf8mb4", autocommit=True)
|
||
cur = conn.cursor()
|
||
|
||
def parts():
|
||
cur.execute("SELECT PARTITION_NAME FROM information_schema.PARTITIONS "
|
||
"WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s "
|
||
"AND PARTITION_NAME IS NOT NULL ORDER BY PARTITION_ORDINAL_POSITION", (gen.TABLE,))
|
||
return [r[0] for r in cur.fetchall()]
|
||
|
||
before = parts()
|
||
cur.execute("CALL pbl_runtime_event_ensure_partitions(9)")
|
||
after = parts()
|
||
new = [p for p in after if p not in before]
|
||
chk("B3 CALL ensure_partitions(9) 补建出新月分区", len(new) >= 3 and after.count("pmax") == 1,
|
||
"新增 %s(分区数 %d→%d)" % (",".join(new), len(before), len(after)))
|
||
|
||
cur.execute("CALL pbl_runtime_event_prune_partitions(2)")
|
||
pruned = parts()
|
||
dropped = [p for p in after if p not in pruned]
|
||
chk("B4 CALL prune_partitions(2) DROP 旧月分区且保留 pmax",
|
||
len(dropped) >= 1 and "pmax" in pruned,
|
||
"DROP %s(剩余 %d 个)" % (",".join(dropped), len(pruned)))
|
||
|
||
# 分区裁剪 + 落分区判定的服务端可执行性(EXPLAIN FORMAT=JSON 解析)
|
||
planned = gen._explain_partitions(cur, "SELECT id FROM `%s` WHERE created_at>='2026-01-01'"
|
||
" AND created_at<'2026-02-01'" % gen.TABLE)
|
||
chk("B5 _explain_partitions 解析出分区清单", bool(planned) and len(planned) < len(pruned),
|
||
"命中 %s(表内共 %d 分区)" % (",".join(planned), len(pruned)))
|
||
cur.execute("INSERT INTO `%s` (tenant_id,event_uid,event_type,scene_id,seq,created_at)"
|
||
" VALUES ('t_harness',%%s,'probe',0,%%s,%%s)" % gen.TABLE,
|
||
("h_a", 1, "2026-01-05 00:00:00"))
|
||
located = gen._locate_row_partition(cur, "h_a", "2026-01-05 00:00:00", pruned)
|
||
chk("B6 _locate_row_partition 定位到 information_schema 清单内的分区",
|
||
located in pruned, "h_a -> %s(清单 %s)" % (located, ",".join(pruned)))
|
||
cur.execute("SET @pbl_guard_ctx='%s'" % gen.GUARD_ADMIN_CTX)
|
||
cur.execute("DELETE FROM `%s` WHERE tenant_id='t_harness'" % gen.TABLE)
|
||
cur.execute("SET @pbl_guard_ctx=NULL")
|
||
cur.close(); conn.close()
|
||
return True
|
||
|
||
|
||
def main():
|
||
print("harness: %s" % SCRIPT)
|
||
gen = layer_a()
|
||
ok_b = layer_b(gen)
|
||
fails = [n for n, p, _ in RESULTS if not p]
|
||
print("\n[汇总] 共 %d 项,PASS %d,FAIL %d%s"
|
||
% (len(RESULTS), len(RESULTS) - len(fails), len(fails),
|
||
"" if ok_b else "(B 层 SKIP)"))
|
||
if fails:
|
||
print("FAIL 明细: %s" % " | ".join(fails))
|
||
print("HARNESS FAIL")
|
||
return 1
|
||
print("HARNESS PASS")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|