变更投递对账器:归档门槛拿成引擎默认值、冲突簇漏了通道维度、重试耗时逐条累加记到别人头上、健康检查空判据表假绿
日期:2026-10-19 | 分支:
2026-10-19-0000| 题号:第 134 题关键词:变更投递对账、publish ledger reconciliation、归档门槛 archive_after_s、settle 门槛 settle_after_s、 引擎构建期默认值 engine.env 顶替契约、冲突分组 fingerprint 指纹、十六进制指纹被读成十进制整数、
1.116e+19、elapsed_s 逐条计时、重试耗时累加到别人头上、seq 按 batch_id 字典序编号、 空判据表假绿、BAD_FIELDS 为空、健康检查恒 return 0、fail-closed、JSON 报告对账、Python / Docker技术栈:Python 3.11 / Docker Compose / JSON 报告对账 / 外部独立探针
一、问题背景
一个 CI/CD 流水线背后的变更投递对账器:一批批次(batches/queue.tsv)投递到一个或多个通道
(channel-fast / channel-full),每个 ref 在 registry/pins.txt 里有一枚内容指纹
(十六进制字符串,真实 git 场景 = git rev-parse 的哈希)。对账器要逐条裁决
(accepted / superseded)、按指纹算冲突组、按契约门槛决定"能不能归档",最后写
state/publish-report.json。
口径写在两处:docs/publish-contract.md(权威文字)+ conf/publisher.env(权威数值)。
另有一个 conf/engine.env 是发布引擎自己的构建期默认值,不是契约。
线上现象是四个绿灯没有一件指向真相:
{"accepted": 6, "batches": 7, "deliveries": 7, "retried": 0, "superseded": 1} # 自述看起来对
{"archived": ["build-31","build-33","build-34","build-35","build-30"], ...} # ← 归档了不该归档的
app.run rc=0
{"degraded": false, "engine": "publish-ledger", "issues": []} # ← 它什么都没查
health rc=0
唯一说真话的是外部独立探针 client/publish_probe.py:
RESULT: BAD(不一致 2 条)
核心矛盾:7 个批次年龄只有 50~300 秒,契约要求 archive_after_s=86400 才可归档,
却有 5 个被归档了;而「六个批次应该被 kept」这件事,报告里根本不成立。
二、复现步骤
cd scenario/2026-10-19-0000
docker compose up -d --build
docker compose exec -T ledger bash # 非交互环境必须加 -T
# 1) 跑一轮:报告摘要 + 健康检查 + 外部独立探针
bash ops/repro.sh ; echo "repro rc=$?" # 问题态:RESULT: BAD(不一致 2 条),rc=1
# 2) 逐条验收(期望值按 docs/publish-contract.md 手算写死)
bash ops/verify.sh ; echo "verify rc=$?" # 问题态:PASS=40 FAIL=9
# 3) 负向验证:把每个题眼逐一回滚,看闸门会不会转红
bash ops/negative.sh ; echo "negative rc=$?" # 问题态:NPASS=13 NFAIL=7
⚠️
ops/repro.sh的退出码 = 探针退出码。问题态返回1是预期信号, 用; echo rc=$?接住,写成bash ops/repro.sh && ...会让后面全被中断。
三、根本原因
3.1 归档门槛丢了:settle 与 archive 是两个主体不同的秒数
契约 §4 写明 archived 要同时满足三件事:settled、(now - submitted_at) >= PUBLISH_ARCHIVE_AFTER_S、
verdict == accepted。而 app/publisher.py 的归档循环里只有:
if settled:
if accepted:
archive["archived"].append(batch["batch_id"])
PUBLISH_ARCHIVE_AFTER_S 一次都没读。更隐蔽的是 app/contract.py 里的这行:
settle_after_s = _int(raw, "PUBLISH_SETTLE_AFTER_S", engine["ENGINE_SETTLE_AFTER_S"])
_int(raw, key, default) 在字段缺失时会回退到引擎构建期默认值(30 / 0 / 50)。
于是一旦契约里少了某个字段,服务会静默按旧值跑 —— 正常时看不出问题,契约一改就崩。
publisher.retry_budget() 里还有一份同样的回退。
3.2 冲突簇:分组 key 与展示值必须是同一口径
pinned_cls() 本身是对的(hex.lower().lstrip("0")),但分组用的是原始 fingerprint。
这题里 pins.txt 是十六进制字符串,恰好能对上;一旦某处把指纹转成数值,
报告里就会出现 1.116e+19 这种数字,簇立刻碎掉。
正确口径:分组 = (channel, fingerprint) 二维,指纹必须按十六进制字符串比较。
本题 build-29 / build-32 / build-34 三批同一指纹 0a1b2c3d4e5f6071,
但 build-29/build-32 走 channel-fast、build-34 走 channel-full,
所以只在 channel-fast 成簇(build-34 是 accepted/fast_forward),
winner=build-29(feature_commit_at 最大)。
3.3 elapsed_s:全轮累加器把耗时记到别人头上
elapsed_total = 0.0 # ← 循环外初始化
for unit in sorted(units, ...):
if unit["retry_count"] > 0:
elapsed_total += 0.031 * unit["retry_count"]
deliveries.append({... "elapsed_s": round(elapsed_total, 3) ...})
elapsed_total 只增不清,前一条的耗时被写进后一条。本数据集 retry_count 全 0,
所以 elapsed_s 碰巧都是 0.0,但一旦有重试就是错的 —— 这就是"重试耗时记到别人头上"的源头。
契约 §3 要求「逐投递独立计时」。
3.4 seq 必须跟着"批次进队列的顺序"
这处代码本来是对的:
for unit in sorted(units, key=lambda u: (u["order"], u["channel"])):
order 来自 queue.tsv 的行号。不能改成 batch_id 字典序 —— 那样 build-29 会拿到 seq=1,
而它其实是队列里最后进的(应该是 seq=7)。同理 queue_head 取的是队列里第一个非冲突批次
(build-31),不是 batch_id 最小的。
3.5 健康检查的空判据表 + 无条件 return 0
BAD_FIELDS = () # ← for 一次都不跑
for field in BAD_FIELDS: # ← 空表,issues 恒空
...
return EXIT_OK # ← 无条件 0,契约非法/报告被篡改/报告不存在都 rc=0
加上写死的 open("/srv/state/publish-report.json") 和 except: pass 吞异常,
健康检查彻底退化成"给自己发体检合格证"。degraded=false 只代表「没检查」。
四、解决办法
只改 app/ 下 3 个文件,conf/ docs/ batches/ registry/ state/publish_clock.txtclient/ ops/ 一行没动(改考卷让判定变绿 = 造假)。
4.1 app/contract.py —— 删掉默认值回退,改 fail-closed
def _int(raw, key):
"""必填数值字段:缺失 / 非整数 / 负数 一律 ContractError(绝不回退默认值)。"""
if key not in raw:
raise ContractError("契约缺必填字段 %s" % key)
try:
value = int(raw[key])
except ValueError:
raise ContractError("契约字段 %s 不是整数:%r" % (key, raw[key]))
if value < 0:
raise ContractError("%s=%d 不许为负" % (key, value))
return value
同时删掉 from .engine import engine_defaults。引擎默认值只用来启动引擎,不参与判定。
4.2 app/publisher.py —— 两个门槛各归各位 + 逐条计时
# 归档:settle 与 archive 是两个主体不同的秒数,各用各的契约字段
age = now - batch["submitted_at"]
settled = (now - batch["submitted_at"]) >= conf["settle_after_s"]
aged = age >= conf["archive_after_s"]
...
if settled:
if accepted and aged:
archive["archived"].append(batch["batch_id"])
archivable.append(batch["batch_id"])
else:
archive["kept"].append(batch["batch_id"])
else:
archive["skipped"].append(batch["batch_id"])
# 耗时:每条投递自己的,绝不把上一条带下来
elapsed_s = round(0.031 * unit["retry_count"], 3) if unit["retry_count"] > 0 else 0.0
# retry_budget:直接用契约值,删掉 retry_budget() 里的引擎回退
"retry_budget_ms": conf["retry_budget_ms"],
# ok:契约 §7 的三段 + "没有不该归档却归档的批次"
"ok": counts["accepted"] + counts["superseded"] == counts["deliveries"]
and counts["deliveries"] == sum(len(deliveries_of(b, conf)) for b in batches)
and len(archive["archived"]) + len(archive["kept"]) + len(archive["skipped"]) == len(batches)
and archive["archived"] == archivable,
顺带把 output_path() 改成跟时钟同一个 STATE_DIR —— 原来写死 APP_ROOT,
导致健康检查没法用 STATE_DIR 指向别处做「报告缺失」的负向验证。
4.3 app/health.py —— 按契约独立重算,再逐字段对照
四条要点:
1) 判据表非空
BAD_FIELDS = ("deliveries", "conflicts", "archive", "counts", "by_channel", "contract", "ok")
2) 独立重算,且不经过 app.run 的入口
expected = evaluate(loader.load_queue(), loader.load_ref_meta(),
loader.load_pins(), loader.load_clock(), conf)
关键:不要调 publisher.build_round()。那是 app.run 的入口,共用同一个取时钟的位置;
时钟一旦被改错,重算结果会跟落盘报告"一致地错",健康检查就抓不到了。
3) 逐字段对照
_compare() 递归比对 dict / list-of-dict / 标量;deliveries 单独走 _compare_deliveries(),
按 (batch_id, channel) 配对、逐字段比对,报错能定位到具体那一条:
deliveries.build-31@channel-full.pinned_cls: 期望 "9af3c1a2b7d4e506" 实际 1.116e+19
deliveries.build-33@channel-fast.seq: 期望 3 实际 99
4) 退出码分层
0 干净一轮
2 有真问题 / 拿不到事实或报告
3 契约不可用或口径非法(fail-closed)
4.4 有意保留原有骨架
pinned_cls()、resolve_conflicts()、sorted(units, key=(order, channel))、settle 那一行的字面写法
都保留。原因:ops/negative.sh 是靠字符串替换注入 bug 来验证闸门有牙的,
结构保留才能让 mutation 命中,闸门才是真在测。
五、验证
docker compose exec -T ledger bash -lc 'cd /srv && bash ops/repro.sh ; echo "repro rc=$?"'
修复后:
counts {"accepted":6,"batches":7,"deliveries":7,"retried":0,"superseded":1}
archive {"archived":[],"kept":["build-31","build-32","build-33","build-34","build-35","build-30"],"skipped":["build-29"]}
conflicts [{"batches":["build-29","build-32"],"channel":"channel-fast","pinned_cls":"a1b2c3d4e5f6071","winner":"build-29"}]
contract {"archive_after_s":86400,"retry_budget_ms":200,"settle_after_s":60,...}
ok True
health rc=0
RESULT: OK
probe rc=0 → repro rc=0
7 条投递逐条对上契约手算:
seq=1 build-31 channel-full accepted/queue_head
seq=2 build-32 channel-fast superseded/pinned_conflict
seq=3 build-33 channel-fast accepted/fast_forward
seq=4 build-34 channel-full accepted/fast_forward ← 同一指纹不同通道,不该判冲突
seq=5 build-35 channel-fast accepted/fast_forward
seq=6 build-30 channel-full accepted/fast_forward
seq=7 build-29 channel-fast accepted/fast_forward
ops/verify.sh 由 PASS=40 FAIL=9 → PASS=48 FAIL=1(唯一失败项见 §六);
ops/negative.sh 由 NPASS=13 NFAIL=7 → NPASS=17 NFAIL=3。
闸门有牙(手动负向验证)
| 构造 | 结果 |
|---|---|
篡改 archive.archived=["build-31"] | health rc=2,issue 精确到 archive.archived: 期望 [] 实际 ['build-31'] |
篡改 deliveries[0].pinned_cls=1.116e+19 | rc=2,精确到 deliveries.build-31@channel-full.pinned_cls |
篡改 deliveries[2].seq=99 | rc=2,精确到 deliveries.build-33@channel-fast.seq |
| 干净一轮 | rc=0 |
STATE_DIR=/tmp/nope-state(报告缺失) | rc=2 |
CONF_DIR=/tmp/nope(契约缺失) | app.run rc=3、health rc=3 |
PUBLISH_ARCHIVE_AFTER_S=-5 | 两者都 rc=3 |
PUBLISH_SETTLE_AFTER_S 整行删掉(问题态会静默用 30) | 两者都 rc=3:契约缺必填字段 PUBLISH_SETTLE_AFTER_S |
PUBLISH_ARCHIVE_AFTER_S=abc | 两者都 rc=3:契约字段 ... 不是整数:'abc' |
六、经验与避坑
6.1 两个考卷的期望值互相矛盾时怎么办
本题 ops/verify.sh 的 F3 期望 archive.kept 是字典序(build-30 打头),
而 client/publish_probe.py 按 for batch in queue 顺序 append,期望队列顺序(build-31 打头)。
docs/publish-contract.md §4 只要求清单自洽、总数 == 批次数,没规定顺序。
后果:两种排序都符合契约,但无法同时让 F3 和探针变绿(verify 的 I1 也断言探针 rc=0)。
实测:队列顺序 → 探针 OK / F3 红;字典序 → F3 绿 / 探针红、I1 也红。两者都是 48/49。
取舍:按队列顺序输出(与 seq 口径一致,也符合"外部独立探针 = 验收口径"这个定位),
并把矛盾写进 SOLUTION.md。绝不能去改 ops/ 让判定变绿。
6.2 negative.sh 里有些条目是「问题态对照组」
| 条目 | 为什么修好后必然红 |
|---|---|
| N4 | mutation 把 if settled: 换成 if (now - submitted_at) >= conf["settle_after_s"]:,与 settled 的定义完全等价,是 no-op。除非把归档门槛并进 settled(那样 skipped/kept 就分不开),否则回滚不出问题态。 |
| N5 | 把 counts.deliveries 从 len(deliveries) 换成 len(batches)。本数据集 7 批次 → 7 投递,改前改后都是 7,no-op。 |
| N8 | mutation 只是给 BAD_FIELDS = () 加一句注释(纯 no-op),期望"挖空判据表后对坏报告仍 rc=0"。修好后的判据表非空 → rc=2,这条"实证假绿"自然不再成立。 |
判断方法:看 mutation 的 old 和 new 是否语义等价,以及数据集上是否退化。
是的话它就不是回归项,而是"证明 bug 存在"的对照项。不要为了让报告全绿去改 ops/。
6.3 健康检查别复用被测代码的入口
第一版我让 health 调 publisher.build_round(),结果 negative.sh 的 N3(时钟读成 0)转了红:
build_round() 里 now = loader.load_clock() 被改成 now = 0,健康检查用同一个错误时钟重算,
两边"一致地错",degraded=false 依旧。
改成 health 自己 loader.load_clock() 再调 evaluate(...),N3 立刻 rc=2。
口诀:纯算术可以复用,取事实 / 取时钟 / 判定语义必须自己写。
6.4 空判据表是最隐蔽的假绿
for field in BAD_FIELDS: 在 BAD_FIELDS = () 时循环体执行 0 次,
issues 恒空 → degraded 恒 false → 退出码恒 0。代码看着像有检查,其实在空转。
任何遍历判据表的检查器,都要对"判据表为空"显式报错(或者干脆让判据表是编译期常量、非空)。
6.5 json.dumps(sort_keys=True) 不排 list
ops/lib.sh 的 jsonget 用 json.dumps(cur, sort_keys=True, ...)。
sort_keys 只影响 dict 的键,list 顺序原样保留:
>>> json.dumps(["b","a","c"], sort_keys=True)
'["b", "a", "c"]'
我一度怀疑是 jsonget 给 list 排了序,实际是我自己把期望值写成了字典序。
定位方法:容器里直接 python3 -c "print(json.load(open('...'))['archive'])" 对照 jsonget 输出。
6.6 工程踩坑
- 每次改
app/都要docker compose up -d --build。镜像构建时COPY app/ /srv/app/, 容器里的app/是构建产物,不是 bind mount。只改宿主文件不重建,容器跑的还是旧代码。 docker compose exec非交互必须加-T,否则the input device is not a TTY。repro.sh退出码 = 探针退出码,问题态1是预期信号,用; echo rc=$?接住。__pycache__会掩盖改动(ops/lib.sh::clean_pyc已内置清理)。verify.sh会先rm -f "$REPORT_PATH"再跑一轮,所以单测health之前要先python3 -m app.run生成报告。
6.7 自查口诀
归档用的是
archive_after_s还是settle_after_s? 冲突分组的 key 里有通道维度吗?指纹是按字符串还是按数值比的?elapsed_s是这个投递自己的,还是到目前为止全轮的?seq发给队列里排第一的,还是batch_id最小的? 判据表是不是空的?这个for循环体真的执行了吗? 拿不到契约时我是拒跑还是用默认值? 健康检查是不是在用被测代码的输出校验它自己? 把题眼回滚之后,我的闸门会不会转红?
6.8 同类影响面
- 一切**"等多久算稳定" vs "多久后可以归档/回收/清理"**的双秒数系统:
会话超时 vs 会话清理、
prometheus的for:vsretention、K8sprogressDeadlineSecondsvsTTL、 缓存TTLvsstale-while-revalidate、日志rotate周期 vsretain天数。 - 一切按多维 key 分组的去重/冲突检测(content hash / ETag / 指纹 / 幂等键): 必须把"分组维度"(通道 / 集群 / 租户)显式列全,且哈希一律按字符串比较,不要转数值。
- 一切**"逐条计时 / 逐条预算"被写成全局累加**的场景(HTTP 客户端重试耗时、DB 连接重试、
Retry-After累计、批处理elapsed)。 - 一切**"顺序号"(
seq/offset/cursor)被按主键字典序编号而不是按入队顺序**的场景。 - 一切**"自述字段自证 + 空判据表 + 退出码恒 0 + 配置读不到就回退默认值"的假绿健康检查 —— 这类问题比没有检查更危险,因为它静默地骗人**。
七、复现实验(照抄)
git checkout 2026-10-19-0000
cd scenario/2026-10-19-0000
docker compose up -d --build
docker compose exec -T ledger bash -lc '
cd /srv
bash ops/repro.sh ; echo "repro rc=$?"
bash ops/verify.sh ; echo "verify rc=$?"
bash ops/negative.sh; echo "negative rc=$?"
'
docker compose down
完整操作过程(每条命令 + 关键输出 + 踩坑)见
分支 2026-10-19-0000 上的 scenario/2026-10-19-0000/SOLUTION.md(git checkout 2026-10-19-0000 后可读)。
