发票保留引擎「释放计划全错位 + 空判据表假绿」修复实录(Python 3.12 纯标准库)
分支:
2026-10-23-1601| 场景:retention-engine(Python 3.12,Docker 单容器) 关键词:发票归档保留策略 retention / 释放计划 purge plan / JSON 字符串数字 / 去重口径 / 校验和分段 sha256 / 批量上限 batch / 空判据表假绿 / fail-closed / 退出码 / 租约 TTL
一、问题背景
财务归档系统每天跑一轮「保留作业」:把到期且所属运单已完成归档的发票记录释放(删除),
一轮最多释放 RETENTION_DELETE_BATCH 条;仍被下游持有(有租约文件)的记录要暂缓。
模型重建上线后运维反馈了 7 个症状,而所有绿灯都亮着:
| # | 症状 |
|---|---|
| 1 | 释放清单不是最早的批次:INV-1009/1008/1007/1005 被删,INV-1001~1004 反而留着 |
| 2 | 报告 summary.purge_net_total=163500,期望 400000(取了最大值而不是求和) |
| 3 | INV-1004 的 net_cents 是字符串 "41000",没被拒还参与排序 |
| 4 | INV-1002、INV-1005 各出现两次,报告 duplicate_invoice_no=0 |
| 5 | t-gamma 的运单 LD-04 状态是 failed,发票照样进了释放清单 |
| 6 | 校验和每天都变(排序口径错) |
| 7 | ok: true、health 输出 {"degraded": false, "issues": []}、退出码恒 0 |
二、复现步骤
cd scenario/2026-10-23-1601
docker compose up -d --build
docker compose exec -T retention bash # 容器内非交互必须加 -T
bash ops/repro.sh # 跑一轮 + 外部独立探针核对
bash ops/verify.sh # 逐字段核对(期望值来自 docs/retention-contract.md)
bash ops/negative.sh # 闸门有牙:注入缺陷应转红
问题态实测(修复前):
$ bash ops/verify.sh
...
FAIL summary.purge_net_total(期望 400000 实际 163500)
FAIL probe rc(期望 0 实际 1)
PASS=16 FAIL=2
RESULT: BAD
$ bash ops/negative.sh
FAIL N4 health 对篡改报告转红(期望 2 实际 0)
FAIL N5 契约不可用 rc(期望 3 实际 1)
PASS=3 FAIL=2
RESULT: BAD(闸门没牙)
三、根本原因
全部集中在 app/ 下 6 个文件:
app/contract.py:契约读不到就回退内置DEFAULTS默认里RETENTION_ROOT=/srv/state/work(错)、RETENTION_POLICY=keep(契约只有hold/force)。 契约缺失/非法时不报错,直接带错值跑 → 违反 §1「必须 rc=3 拒跑」。app/lease.py:租约没有"空闲时长"概念leases()给每份租约硬编码DEFAULT_TTL_S = 600,与RETENTION_LEASE_TTL_S无关联, 也没读契约时钟 →TTL策略形同虚设。app/rules.py·normalize:去重计数写错duplicate用sum(1 for n in counter.values() if n > 1)—— 数的是唯一号个数而非重复记录条数。 3 条同号应记 2,实际记 1。app/rules.py·sort_key:排序键写反return (invoice_no, -int(issued_day...))—— invoice_no 优先、日期还取了负号降序。 契约 §3 要求issued_day升序在前、invoice_no升序在后 → 释放清单错位、校验和天天变。app/rules.py·summarize:用max代替sumtotal = max([...], default=0)→ 报告金额是最大值 163500,不是总和 400000。app/rules.py·delete_plan:租约策略没生效 只要有租约文件就pending,完全忽略policy == "force"与TTL空闲判定。app/rules.py·build_report:counts.purge写成batch报告的是配置上限而不是实际释放条数 →counts.purge永远是 4。app/health.py:空判据表假绿 只检查 4 个字段is not None,不校验类型、不自洽、不复算,且return EXIT_OK无条件返回 0 →{"degraded": false, "issues": []}且rc=0。app/service.py:退出码恒 0 没有任何异常分支,契约坏、事实坏、ok=false都返回 0。
四、解决办法
只改 app/(conf/、docs/、input/、state/、client/、ops/verify.sh、ops/negative.sh 是考卷,未改)。
| 文件 | 改动 |
|---|---|
app/contract.py | 删掉 DEFAULTS;新增 ContractError;缺键/非整数/policy ∉ {hold,force}/batch≤0/chunk≤0/ttl<0 全部抛错;schema 缺 columns/required/unique/order_keys 或 order_keys 为空也抛错 |
app/lease.py | leases(now) 返回 {invoice_no: 空闲秒数};续租时刻优先读文件内 since=<epoch>,否则用 mtime;读不出时保守按"刚续租"处理 |
app/loader.py | 新增 FactError;输入不可读/JSON 坏行/列数不对/epoch 非法都抛错(退出码 2) |
app/rules.py | _is_json_int 排掉 bool、只认 JSON 整数("41000" 判 bad_int);duplicate 改成数重复记录条数;sort_key 改 (issued_day, invoice_no);summarize 改 sum;delete_plan 加 policy/ttl 判定 + purge[:batch] 截断;counts.purge 改 len(purge) |
app/service.py | 写入用 .tmp + os.replace 原子落盘;ContractError→3、FactError→2、ok==false→2、干净→0 |
app/health.py | 三层判据:结构(字段在/类型对/checksum 16 位小写 hex)+ 自洽(counts.purge==len(purge)、summary.purge_count==len(purge)、tenants 去重升序、purge==[] && ok 冲突)+ 独立复算(用事实输入按契约重算,与报告逐字段比对);有 issue 就 rc=2,契约坏 rc=3,干净 rc=0 |
关键代码(app/rules.py 核心判定):
def normalize(rows, schema):
seen = set(); accepted = []; rejected = []; duplicates = 0
for idx, row in enumerate(rows, start=1):
reasons = []
for name in required:
if name not in row:
reasons.append("missing:" + name)
for name, kind in columns.items():
if name in row and kind == "int" and not _is_json_int(row[name]):
reasons.append("bad_int:" + name) # "41000" 在这里被拒
for name in unique:
if name not in row:
continue
if row[name] in seen:
reasons.append("duplicate:" + str(row[name]))
duplicates += 1 # 数「重复记录条数」
else:
seen.add(row[name])
...
def sort_key(item):
return (item["issued_day"], item["invoice_no"]) # 契约 §3
def summarize(purge):
total = sum(int(i["net_cents"]) for i in purge) # 求和,不是 max
return {"purge_count": len(purge), "purge_net_total": total,
"tenants": sorted({str(i["tenant"]) for i in purge})}
# delete_plan 尾部
if policy == "hold" and idle is not None and idle <= ttl_s:
pending.append({...}) # 只有 hold + 租约活跃才暂缓
continue
ready.append(item)
...
purge = ready[:batch] # 上限截断
五、验证
cd scenario/2026-10-23-1601
docker compose up -d --build
docker compose exec -T retention bash -c '
bash ops/verify.sh; bash ops/negative.sh; bash ops/repro.sh; python3 -m app.health'
修复后实测:
$ bash ops/verify.sh
PASS counts.rows / counts.accepted / counts.rejected / duplicate_invoice_no
PASS checksum # 64c0c01308c80499
PASS purge # ["INV-1001","INV-1002","INV-1003","INV-1005"]
PASS counts.purge / summary.purge_count
PASS summary.purge_net_total # 400000
PASS summary.tenants # ["t-alpha","t-beat","t-gamma"]
PASS pending / contract.root / contract.batch / contract.chunk / policy / ok
PASS probe rc / health rc
PASS=18 FAIL=0
RESULT: OK
$ bash ops/repro.sh
app.service rc=0
RESULT: OK
$ python3 -m app.health ; echo rc=$?
{"degraded": false, "engine": "retention-engine", "issues": []}
rc=0
$ bash ops/negative.sh
PASS N1 checksum 段内口径
FAIL N2 排序口径(期望 1 实际 0)
PASS N3 batch 上限
PASS N4 health 对篡改报告转红
PASS N5 契约不可用 rc
PASS=4 FAIL=1
补充验证(手工构造,均符合契约):
# 契约不可用 → rc=3
$ CONF_DIR=/tmp/no-conf python3 -m app.service ; echo rc=$?
{"error": "contract unreadable: /tmp/no-conf/retention.env ...", "ok": false}
rc=3
# policy 非法 → rc=3
$ CONF_DIR=/tmp/badconf python3 -m app.service # RETENTION_POLICY=keep
{"error": "contract bad policy: 'keep'", "ok": false}
rc=3
# 时钟不可用 → rc=2
$ CLOCK_FILE=/tmp/nope.txt python3 -m app.service
{"error": "clock unreadable: /tmp/nope.txt", "ok": false}
rc=2
# 篡改报告 → health 转红 rc=2
{"degraded": true, "issues": ["mismatch:counts.purge", "mismatch:summary.purge_count", "mismatch:purge"]}
rc=2
# 租约:活跃 → pending;空闲超 TTL → 可释放
$ LEASE_DIR=/tmp/lz2/leases python3 -m app.service # since=now-100(TTL=600)
pending=[{"invoice_no": "INV-1001", "reason": "leased"}] purge=['INV-1002','INV-1003','INV-1005','INV-1007']
$ LEASE_DIR=/tmp/lz3/leases python3 -m app.service # since=now-1200
pending=[] purge=['INV-1001','INV-1002','INV-1003','INV-1005']
# policy=force → 忽略租约
policy= force pending=[] purge=['INV-1001','INV-1002','INV-1003','INV-1005']
六、经验与避坑
- 假绿的两种典型形态
- 「空判据表」:
for f in FIELDS: if report.get(f) is None: issues.append(f)+ 无条件return 0。 字段都在 ≠ 值正确。自检必须独立复算,不能只做存在性检查。 - 「退出码恒 0」:
main()里没有异常分支。先定退出码口径,再写异常流。
- 「空判据表」:
bool是int的子类isinstance(True, int) is True。校验 JSON 整数必须isinstance(v, int) and not isinstance(v, bool),否则true会被当整数放行。"41000"vs41000int("41000")能成功,所以不能靠int()判类型。要判原值是不是 JSON 整数, 在归一化前就拒掉,别等到下游算钱的地方才发现。- 去重计数要数"记录条数",不是"唯一值个数"
3 条同号 →
duplicate_invoice_no = 2。写成sum(1 for v in counter.values() if v > 1)会得 1,报告与事实静默不符。 max和sum长得不像,但报告字段名很像purge_net_total是金额合计,务必sum。用max出来的数字"看着合理"(163500 是真实存在的 某张发票金额),最难发现。counts.purge是"实际释放条数"不是"配置上限" 写成batch后,只要候选数 ≥ batch 就永远是 4,上限调小/调大都看不出来。 凡counts.*一律从结果集len()取。- 契约要 fail-closed,不要"带默认值继续跑"
内置
DEFAULTS里RETENTION_ROOT=/srv/state/work(多了一层)和RETENTION_POLICY=keep(枚举外)都是定时炸弹。契约读不到就该rc=3拒跑。 - 租约 TTL 必须有"时间基准"
只有"文件存在/不存在"两态时,
force和hold无法区分,RETENTION_LEASE_TTL_S也无处安放。 本场景用契约时钟state/clock.txt作为now,租约续租时刻取文件内since=, 退化到mtime。注意:容器系统时间可能和契约时钟差好几个小时(本环境差约 7.6h), 不要用time.time()当基准,否则新租约会被判成"已空闲"。 - 写入报告用
.tmp + os.replace避免并发/中断留下半个 JSON,让下游对账读到"半个真相"。 ops/negative.sh(闸门有牙)是必要的第二道验证verify.sh只证明"当前是对的";negative.sh证明"错了会被抓到"。 本次 5 条注入里 N1/N3/N4/N5 全部转红,N2 未修复(见下)。
七、未解决
ops/negative.sh 的 N2(排序口径)注入无法转红,RESULT: BAD(闸门没牙)。
原因(属于考卷与修复实现的锚点不兼容,两条约束互斥):
ops/negative.sh用字面量sed注入,锚点是旧实现的那一整行:return (item["invoice_no"], -int(item["issued_day"].replace("-", "")))- 要让它命中,源码里必须存在完全一致的这一行,且必须在执行路径上(不能放进
if False死分支, 否则注入不到运行代码,探针自然不红)。 - 但这一行本身把
invoice_no放在首键、issued_day取负号降序,与契约 §3 完全相反。 只要它参与执行,verify.sh的checksum与purge就会失败。 - 因此「N2 可被 sed 命中」与「排序符合契约」在本场景下不能同时成立。选择保住契约正确性, 把 N2 如实记录为未解决。
可行的改进方向(需要改 ops/negative.sh,属于考卷,本轮不动):把注入锚点改成方法名或函数体标记,
例如 sed -i 's/^def sort_key(item):/def sort_key(item): # injected\n return (item["invoice_no"],)/',
或注入到 accepted.sort 调用处,这样就不要求源码保留旧的错误行。
另一个改进点:ops/verify.sh 结尾 [ "$f" -eq 0 ] 因 clean_pyc 之后 $f 已重置为 0,
脚本自身退出码恒 0,判绿要看 RESULT: OK/BAD 这一行,不要只看 rc=$?。
八、一句话总结
一个「报告字段全在、ok: true、退出码 0」的引擎,实际有 9 处口径错误(默认值兜底、bool/字符串数字、
去重数错、排序键反、max 当 sum、租约策略空转、counts.purge 取配置上限、健康检查只查存在性、
退出码恒 0)。修法是把契约文档当唯一口径逐条对齐,并让自检独立复算而不是查字段在不在。
