朕喜技术(ZhenxiJS)
2026.10.23
1 分钟阅读

发票保留引擎「释放计划全错位 + 空判据表假绿」修复实录(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(取了最大值而不是求和)
3INV-1004 的 net_cents 是字符串 "41000",没被拒还参与排序
4INV-1002、INV-1005 各出现两次,报告 duplicate_invoice_no=0
5t-gamma 的运单 LD-04 状态是 failed,发票照样进了释放清单
6校验和每天都变(排序口径错)
7ok: 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 个文件:

  1. app/contract.py:契约读不到就回退内置 DEFAULTS 默认里 RETENTION_ROOT=/srv/state/work(错)、RETENTION_POLICY=keep(契约只有 hold/force)。 契约缺失/非法时不报错,直接带错值跑 → 违反 §1「必须 rc=3 拒跑」。
  2. app/lease.py:租约没有"空闲时长"概念leases() 给每份租约硬编码 DEFAULT_TTL_S = 600,与 RETENTION_LEASE_TTL_S 无关联, 也没读契约时钟 → TTL 策略形同虚设。
  3. app/rules.py·normalize:去重计数写错duplicate 用 sum(1 for n in counter.values() if n > 1) —— 数的是唯一号个数而非重复记录条数。 3 条同号应记 2,实际记 1。
  4. app/rules.py·sort_key:排序键写反return (invoice_no, -int(issued_day...)) —— invoice_no 优先、日期还取了负号降序。 契约 §3 要求 issued_day 升序在前、invoice_no 升序在后 → 释放清单错位、校验和天天变。
  5. app/rules.py·summarize:用 max 代替 sumtotal = max([...], default=0) → 报告金额是最大值 163500,不是总和 400000。
  6. app/rules.py·delete_plan:租约策略没生效 只要有租约文件就 pending,完全忽略 policy == "force" 与 TTL 空闲判定。
  7. app/rules.py·build_report:counts.purge 写成 batch 报告的是配置上限而不是实际释放条数 → counts.purge 永远是 4。
  8. app/health.py:空判据表假绿 只检查 4 个字段 is not None,不校验类型、不自洽、不复算,且 return EXIT_OK 无条件返回 0 → {"degraded": false, "issues": []} 且 rc=0。
  9. 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.pyleases(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']

六、经验与避坑

  1. 假绿的两种典型形态
    • 「空判据表」:for f in FIELDS: if report.get(f) is None: issues.append(f) + 无条件 return 0。 字段都在 ≠ 值正确。自检必须独立复算,不能只做存在性检查。
    • 「退出码恒 0」:main() 里没有异常分支。先定退出码口径,再写异常流。
  2. bool 是 int 的子类isinstance(True, int) is True。校验 JSON 整数必须 isinstance(v, int) and not isinstance(v, bool),否则 true 会被当整数放行。
  3. "41000" vs 41000int("41000") 能成功,所以不能靠 int() 判类型。要判原值是不是 JSON 整数, 在归一化前就拒掉,别等到下游算钱的地方才发现。
  4. 去重计数要数"记录条数",不是"唯一值个数" 3 条同号 → duplicate_invoice_no = 2。写成 sum(1 for v in counter.values() if v > 1) 会得 1,报告与事实静默不符。
  5. max 和 sum 长得不像,但报告字段名很像purge_net_total 是金额合计,务必 sum。用 max 出来的数字"看着合理"(163500 是真实存在的 某张发票金额),最难发现。
  6. counts.purge 是"实际释放条数"不是"配置上限" 写成 batch 后,只要候选数 ≥ batch 就永远是 4,上限调小/调大都看不出来。 凡 counts.* 一律从结果集 len() 取。
  7. 契约要 fail-closed,不要"带默认值继续跑" 内置 DEFAULTS 里 RETENTION_ROOT=/srv/state/work(多了一层)和 RETENTION_POLICY=keep (枚举外)都是定时炸弹。契约读不到就该 rc=3 拒跑。
  8. 租约 TTL 必须有"时间基准" 只有"文件存在/不存在"两态时,force 和 hold 无法区分,RETENTION_LEASE_TTL_S 也无处安放。 本场景用契约时钟 state/clock.txt 作为 now,租约续租时刻取文件内 since=, 退化到 mtime。注意:容器系统时间可能和契约时钟差好几个小时(本环境差约 7.6h), 不要用 time.time() 当基准,否则新租约会被判成"已空闲"。
  9. 写入报告用 .tmp + os.replace 避免并发/中断留下半个 JSON,让下游对账读到"半个真相"。
  10. 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)。修法是把契约文档当唯一口径逐条对齐,并让自检独立复算而不是查字段在不在。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-10-23-1601-retention-purge-plan-misalignment-fake-green

分享这篇文章