pbl_evidence/tests/s3_replay_idempotency.py
agent.develop e0acb9f4af [S3-c] develop 自有 commit 真实收口探针文件(QC#16 落地)
task OqAv27u3w8DE9nirTwPp2(S3-c 子任务:git 收口取证,不改业务逻辑、
不改 gen_s3_log.py 的切片/统计逻辑、不重跑取证链)

- 6 个探针文件(s3_trigger_probe.py / s3_sql_probe.py / s3_replay_idempotency.py /
  s3_clock_skew_probe.py / s3_db_url.py / s3_clock_compare.py)文件头 docstring
  各加一行 task-key 收口标注 → 纳入 develop 自有 commit(真实变更,非 --allow-empty)
- tests/s3_evidence_chain.sh 头部注释块加一行同源标注
- tests/gen_s3_log.py 仅同步 §7「事实陈述」prose,使措辞与本 commit 的
  `git show --stat` 文件清单一致(原「探针未内嵌 task key / 非本轮新增」表述
  已与 git 事实矛盾,按 QC#16「纳入真实变更」路径改写);切片与统计代码零改动
- 选择性 git add(逐个列名 8 文件),未使用 git add -A,未纳入 __pycache__/logs
2026-09-23 03:47:15 +08:00

150 lines
7.1 KiB
Python
Executable File
Raw 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 -*-
"""S3 必做项4:collector 重放幂等取证(真库,复用 harness 的沙箱适配层,不改被测源码)。
task OqAv27u3w8DE9nirTwPp2(S3-c develop 自有 commit 收口标注):本探针随本轮 develop 自有提交纳入版本控制(非引擎代收口),与文档 §7 声明的文件清单一致。
对同一批 pbl_runtime_event 事件重放 collector 写入 N 次,验证:
- 主键 id 由应用层 pbl_evidence.pk.gen_pk 生成:VARCHAR(32)、长度 <=32、互不相同、
且**不依赖任何 DB trigger**(配合 s3_evidence_chain.sh 的 DROP TRIGGER 复验)
- upsert 幂等:count(*) 不增长、无 1062 Duplicate entry、skipped/updated 分支正常
路径全部由脚本自身位置推导(无绝对路径硬编码);期望行数默认由 harness 的种子常量
``EVENTS_SEED`` 派生(每个事件采出一条证据),也可用 --expect-rows 覆盖。
判定用 assert 语义落地到退出码:任一条不满足 -> 打印 REPLAY IDEMPOTENT FAIL 并 exit 1,
使上层 chain 脚本(set -euo pipefail)能真实拦截,不再出现「FAIL 也 RC=0」。
用法::
python3 tests/s3_replay_idempotency.py
python3 tests/s3_replay_idempotency.py --replays 3 --expect-rows 2
python3 tests/s3_replay_idempotency.py --help
"""
import argparse
import asyncio
import importlib.util
import pathlib
import sys
TESTS_DIR = pathlib.Path(__file__).resolve().parent
REPO_ROOT = TESTS_DIR.parent # modules/pbl_evidence
HARNESS = TESTS_DIR / "m5a_live_db_harness.py"
def load_harness():
spec = importlib.util.spec_from_file_location("m5a_h", str(HARNESS))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def main(argv=None):
ap = argparse.ArgumentParser(
description="S3 collector 重放幂等取证(真库;判定失败 -> exit 1)")
ap.add_argument("--replays", type=int, default=3,
help="对同一批事件重放 collector 的次数(默认 3)")
ap.add_argument("--expect-rows", type=int, default=None,
help="期望 pbl_evidence 行数;缺省由 harness 种子常量 EVENTS_SEED 派生")
ap.add_argument("--tenant", default="t1", help="租户 ID(默认 t1,与 harness 种子一致)")
ap.add_argument("--id-max-len", type=int, default=32,
help="主键最大长度(models/pbl_evidence.json 定义 VARCHAR(32))")
ns = ap.parse_args(argv)
h = load_harness()
expect_rows = ns.expect_rows if ns.expect_rows is not None else len(h.EVENTS_SEED)
collector = h.load_collector()
box = h.Sandbox(h.db_kwargs())
box.open() # 复用已存在的沙箱库(不 reset,保留 harness 写入的数据)
h.install_stub(collector, box)
def count():
rows = box.raw("SELECT COUNT(*) AS c FROM `pbl_evidence`")
return int((rows or [{"c": -1}])[0]["c"])
def ids():
rows = box.raw("SELECT id, LENGTH(id) AS len FROM `pbl_evidence` ORDER BY id")
return [(r["id"], int(r["len"])) for r in rows]
problems = []
try:
print("沙箱 schema = %s | 凭据来源 = %s(口令不打印)"
% (h.SANDBOX_SCHEMA, h._CRED_SOURCE[0]))
print("期望行数(由 harness 种子 EVENTS_SEED 派生,%d 个事件 -> %d 条证据)= %d"
% (len(h.EVENTS_SEED), len(h.EVENTS_SEED), expect_rows))
before = count()
print("重放前 count(*) = %d" % before)
print("重放前主键回显 (id, LENGTH(id)) = %s" % ids())
print("事件表 count(*) = %s"
% box.raw("SELECT COUNT(*) AS c FROM `pbl_runtime_event`"))
print("当前 trigger 数(应为 0,证明 id 非 trigger 产物)= %s"
% len(box.raw("SHOW TRIGGERS") or []))
if before != expect_rows:
problems.append("重放前 count(*)=%d != 期望 %d" % (before, expect_rows))
for i in range(ns.replays):
loop = asyncio.get_event_loop_policy().new_event_loop()
try:
st = loop.run_until_complete(
collector.collect_evidence_from_events(tenant_id=ns.tenant))
finally:
loop.close()
slim = {k: st[k] for k in ("scanned", "created", "skipped", "updated", "failed")
if k in st}
print("第 %d 次重放 collector stats = %s | 重放后 count(*) = %d"
% (i + 1, slim, count()))
if count() != expect_rows:
problems.append("第 %d 次重放后 count(*)=%d != 期望 %d(行数增长=幂等破)"
% (i + 1, count(), expect_rows))
if int(slim.get("failed", 0) or 0) != 0:
problems.append("第 %d 次重放 failed=%s(出现 1062/写入异常)"
% (i + 1, slim.get("failed")))
after = ids()
uniq = {i for i, _ in after}
print("重放后主键回显 (id, LENGTH(id)) = %s" % after)
print("重复主键检测:ids 去重后数量 = %d / 总数 = %d" % (len(uniq), len(after)))
print("id 长度集合 = %s(models 定义 VARCHAR(%d),全 <=%d)"
% (sorted({l for _, l in after}), ns.id_max_len, ns.id_max_len))
print("全部 id 以 ev 前缀(gen_pk 产物)= %s"
% all(i.startswith("ev") for i, _ in after))
if len(uniq) != len(after):
problems.append("存在重复主键(去重后 %d < 总数 %d)" % (len(uniq), len(after)))
if any(l > ns.id_max_len for _, l in after):
problems.append("存在 id 长度 > %d" % ns.id_max_len)
if not after:
problems.append("pbl_evidence 为空,无证据可判")
# DB 层唯一键拦截探针:同三元组再插一条,必须被 uk_ev_dedup 拦下(证明不是应用层侥幸)
probe = box.raw("SELECT source_event_id, evidence_type FROM `pbl_evidence` LIMIT 1")
if probe:
sql = ("INSERT INTO `pbl_evidence` (id, tenant_id, source_event_id, evidence_type,"
" occurred_at, dedup_key) VALUES ('dup_probe','%s','%s','%s',"
"'2026-09-22 10:00:00','x')"
% (ns.tenant, probe[0]["source_event_id"], probe[0]["evidence_type"]))
try:
with box.conn.cursor() as cur:
cur.execute(sql)
problems.append("探针重复三元组 INSERT 未被 DB 拦截(uk_ev_dedup 失效)")
print("探针插入未被拦截(异常!)")
except Exception as exc: # noqa: BLE001
print("探针重复三元组 INSERT 的 DB 原始报错 = %s"
% h._mask("%s: %s" % (type(exc).__name__, exc)))
print("count(*) 最终 = %d(重放 %d 次未增长)" % (count(), ns.replays))
finally:
box.close()
if problems:
for p in problems:
print("FAIL: " + p)
print("REPLAY IDEMPOTENT FAIL")
return 1
print("REPLAY IDEMPOTENT OK")
return 0
if __name__ == "__main__":
sys.exit(main())