发布流水线三条通道各只收到 1/3 产物,自述报告/健康检查/退出码却全绿 —— 计划投递条数丢了「通道维度」+ 通道拒收被 except:pass 静默吞掉
日期:2026-10-09 | 分支:
2026-10-09-1002(第 113 题) 环境:Python 3.11 / Docker Compose / 三条文件系统通道(docs·site·report)/ NDJSON 收件账本 关键词:变更单合并发布、planned_deliveries、计划投递条数、通道数 × 条目数、丢失通道维度、 凭证条目 credential_id 代表整张变更单、通道闸门拒收、except: pass静默吞掉异常、 ChannelRefused、自述报告自证 self_consistent、空判据表BAD_FIELDS = ()、 健康检查 fail-closed、退出码恒 0 return 0、绿通过但数据没到、 发布流水线只收到部分产物、silently swallowed exception、planned deliveries missing channel dimension 复现仓库:CNBscenario/2026-10-09-1002/
一、问题背景
mergectl 是变更单的合并发布流水线:一张变更单有 3 个条目,
发布时要把每一个条目投递到三条外部通道 docs / site / report。
部署契约 conf/mergectl.env(只读)写得很明白:
MERGECTL_CHANNEL_DOCS_DIR=/srv/channels/docs
MERGECTL_CHANNEL_SITE_DIR=/srv/channels/site
MERGECTL_CHANNEL_REPORT_DIR=/srv/channels/report
MERGECTL_DELIVERY_CRITERION=all_channels_received
契约页 docs/publish-contract.md 定义:
一次「成功发布」= 三条通道里每一条都收到了本次发布的每一个条目。 因此计划投递条数 = 通道数 × 条目数。
现象(这就是最坑的地方)
- 三条通道同时投诉:「只收到了 1/3 的产物」,每个通道只有
entry-000.html一个文件; - 流水线零报错:进程
rc=0、日志正常、reports/publish_report.json是合法 JSON; - 自述报告写着
planned_deliveries: 1、delivered: 3、ok: true、self_consistent: true; - 健康检查输出
{"degraded": false, "issues": []},退出码 0; - 而外部探针按契约独立重算:应交付
3 通道 × 3 条目 = 9条,实际账本只有 3 条,缺 6 条。
四个绿灯里没有一件指向真相:「一轮跑完了」只证明进程没崩;self_consistent: true 是写死常量;
degraded: false 来自空判据表;退出码 0 来自无条件 return 0。
二、复现步骤
cd scenario/2026-10-09-1002
docker compose up -d --build
docker compose exec -T mergectl bash # 非交互必须加 -T
# 1) 复现问题
bash /srv/ops/repro.sh ; echo "rc=$?" # -> RESULT: BAD,rc=1
# 2) 断言式验收(问题态基线)
bash /srv/ops/verify.sh ; echo "rc=$?" # -> PASS=1 FAIL=10 / RESULT: BAD
# 3) 两个外部独立探针
python3 /srv/client/delivery_probe.py # -> RESULT: BAD(rc=1)
python3 /srv/client/gate_probe.py # -> RESULT: BAD(rc=1)
问题态关键输出:
[2] 解析出的发布单元:entries=1(!期望 3 —— 另外 2 个条目被丢了)
[3] 计划投递条数 planned_deliveries=1(!期望 9 = 通道数 3 × 条目数 3)
[5] 通道闸门拒收计数 rejected=0(!被静默吞掉)
RESULT: OK(自述:计划 1 条已全部投递,delivered=3) demo rc=0
通道 docs 实收 1 条,缺 2 条 ['entry-001.html', 'entry-002.html']
账本真实条数 = 3 / 缺交付 = 6 条
RESULT: BAD probe rc=1
复现时踩的坑
rm -rf /srv/channels报Device or resource busy:这三个目录是 compose 的 bind mount 挂载点, 挂载点本身删不掉。脚本后面会mkdir -p,属噪声,不影响判定。docker compose exec无-T会报the input device is not a TTY。- 改完
app/若只 restart 不清__pycache__,会看到「改了但结果没变」——旧字节码会掩盖修改。
三、根本原因
5 处缺陷叠加,彼此掩护:
changelog.py::parse()把「凭证条目」当成整张变更单picked = [e for e in entries if e["id"] == credential_id]——credential_id(crd-000)只是一条用来做完整性校验的条目,不能代表整张单。 结果 3 个条目退化成 1 个,另外 2 个静默消失。publish_round.py::run()的plan = len(entries)丢了通道维度 契约口径是「通道数 × 条目数」(此处 3×3=9),代码算成「条目数」(1)。 于是planned_deliveries: 1对着delivered: 3竟然成了「3 ≥ 1,投递达标」—— 单位错了,所以少投 2/3 也看不出来。- 通道拒收被
except ChannelRefused: pass静默吞掉channel_gate.py里push属于KNOWN_BUT_REFUSED,会抛ChannelRefused; 而投递侧的except ...: pass让被拒条目照样落盘、照样计入 delivered,rejected恒 0。 (注意:通道「只收 publish/sync、不收 push」是正确行为, 要修的是投递侧对拒收的处理,不是把push加进白名单。) health.py的BAD_FIELDS = ()是空判据表for field, expected in BAD_FIELDS一次都不循环 →issues恒空 →degraded恒 false; 且它只读自述报告,从不读三条通道真实产物、不读账本、不读契约;退出码无条件return 0。aggregator.py的自述字段自证self_consistent: True是写死常量;ok = delivered >= plan拿「自己写的 delivered」 比「自己写的 plan」——两个值同源,永远为真。
核心教训:当「单位」被写错、异常被静默吞掉、健康检查判据为空时, 数据大量缺失也无法被发现,因为所有断言都在拿系统的自述去检查系统自己。
四、解决办法
只改 app/ 下 4 个文件(conf/、client/、ops/ 零改动)。
① changelog.py:发布单元 = 整份条目清单,凭证只做完整性校验
if credential_id not in ids:
raise ChangelogError("凭证条目 %r 不在条目清单里 %r,变更单不完整" % (credential_id, ids))
return {"version": version, "credential_id": credential_id, "entries": entries, "all": entries}
② publish_round.py:计划按契约口径 + 拒收如实冒泡(不落盘、不计 delivered)
plan = len(CHANNELS) * len(entries) # 通道数 × 条目数
try:
gates[name].accept(action, filename, e["body"])
except ChannelRefused as exc:
rejected += 1 # 如实计数
errors.append(str(exc)) # 保留错误信息
continue # 不落盘、不计 delivered
report["ok"] = delivered >= plan and rejected == 0
report["self_consistent"] = (delivered + rejected == plan)
③ aggregator.py:自述字段必须能对账,不再自证
新增 _count_reality() 按契约口径独立清点三条通道真实产物,然后:
self_consistent = delivered == plan and rejected == 0 and missing == 0
④ health.py:非空判据表 + 读事实 + 退出码分层(fail-closed)
BAD_FIELDS = (("self_consistent", True), ("ok", True), ("rejected", 0)) # 非空
# 读事实:逐通道 os.listdir 清点产物 vs 通道数×条目数;账本真实行数 vs 契约口径;
# 自述 delivered vs 账本真实行数
# 退出码:0 正常 / 2 有真问题或拿不到事实 / 3 契约不可用或自相矛盾
契约读取用 except ContractError 兜住,不再裸抛 traceback。
五、验证
docker compose restart mergectl
docker compose exec -T mergectl bash -lc 'rm -rf /srv/app/__pycache__; bash /srv/ops/verify.sh; echo rc=$?'
修复后:
[PASS] A1 planned_deliveries=9(= 通道数 × 条目数 9)
[PASS] A2 发布单元覆盖全部条目 entries=3
[PASS] B1 三条通道实收齐全(漏交付 0 条)
[PASS] B2 账本真实条数 9 == 契约口径 9
[PASS] C1 用被拒动作 push 投递时,投递侧 rejected > 0(拒收冒泡)
[PASS] C2 被拒收的条目没有落盘(通道实收 0)
[PASS] D1 交付齐全时 health 退出码 0 且报 delivery_complete=true
[PASS] D2 藏掉一条通道产物后 health 退出码 2 且 delivery_complete=false
[PASS] D3 契约缺失时 health 退出码 3
[PASS] E1 通道文件扩展名口径来自契约(改成 xhtml 后读到 xhtml)
[PASS] E2 契约判据非法时 health 退出码 3
PASS=11 FAIL=0
RESULT: OK
三个外部口径也全绿:ops/repro.sh、client/delivery_probe.py、
client/gate_probe.py 全部 RESULT: OK(rc=0);
账本 9 行、三条通道各有 entry-000/001/002.html、拒收路径 rejected=6 且通道实收全 0。
对抗性验证(证明判据有牙,不是把数字调好看)
- 篡改账本:手工删 1 行 → health 立刻报
["账本真实条数 8 ≠ 契约口径 9", "自述 delivered 9 ≠ 账本真实条数 8"],rc=2; - 契约缺失:
MERGECTL_CONTRACT=/srv/nope.env→rc=3,degraded: true(fail-closed); - 幂等:连续两轮
verify.sh均rc=0。
六、经验与避坑
- 先怀疑「单位」,再怀疑「数据」。
少投 2/3 却看不出问题,往往不是校验太弱,而是校验算错了单位:
planned_deliveries表达的是「通道数 × 条目数」,写成len(entries)就丢了整个通道维度。 凡是计划量/配额,先问一句:它的单位是什么?分母有几个维度? - 绿色信号要分三类看:
进程没崩(弱)/自己写的自述(无判别力)/能被外部按契约独立复核的事实(强)。 只读自述报告的健康检查,等于让系统给自己发体检合格证。 健康检查必须读事实:通道真实产物、账本真实行数、契约本身。 except XxxError: pass是把失败洗成成功的头号手段。 拒收/限流的异常必须计数 + 保留错误信息 + 不产生副作用(不落盘、不计 delivered)。 排查时可用一个「外部探针」用被拒的动作名打一轮,直接看rejected是否 > 0。- 拿不到事实 = 判不了 = 不通过(fail-closed)。
报告缺失/契约缺失时返回 0(「没问题」)是典型的假绿。退出码要分层:
0 正常 / 2 有真问题或拿不到事实 / 3 契约不可用或自相矛盾。 - 警惕空判据表与写死的
self_consistent。BAD_FIELDS = ()的for循环一次都不执行,issues恒空;ok = delivered >= plan拿同源的两个自述值互检,永远为真。 判据表要么非空,要么干脆不写这种「看起来在检查」的代码。 - 别修过头。 通道闸门「只收
publish/sync、不收push」是正确行为, 为了让探针变绿而把push加进收件白名单 = 造假。要修的是投递侧对拒收的处理。 - Docker 复现小贴士:
rm -rf挂载点目录会报Device or resource busy,属噪声;- 非交互
docker compose exec必须加-T; - 挂载代码后改完先
rm -rf app/__pycache__再 restart,否则旧字节码会掩盖修改; - 验证脚本可能会 人为藏掉产物 来测判别力(本场景 D2 组),这是刻意设计,别当 bug 改掉。
- 契约是唯一权威来源。
conf/mergectl.env只读、发布平台下发; 代码里出现与契约不同的默认值 = 口径分叉。本场景 E 组就是「把扩展名改成xhtml, 代码必须跟着变」来测判据有没有写死。
可复用的排查清单(发布/投递类流水线):
计划量的单位对不对 → 异常有没有被吞 → 健康检查读的是事实还是自述 → 拿不到数据时退出码是什么 → 有没有人能把判据写死/走空。
