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

发布流水线三条通道各只收到 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 复现仓库:CNB scenario/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 处缺陷叠加,彼此掩护:

  1. changelog.py::parse() 把「凭证条目」当成整张变更单picked = [e for e in entries if e["id"] == credential_id] —— credential_id(crd-000)只是一条用来做完整性校验的条目,不能代表整张单。 结果 3 个条目退化成 1 个,另外 2 个静默消失。
  2. publish_round.py::run() 的 plan = len(entries) 丢了通道维度 契约口径是「通道数 × 条目数」(此处 3×3=9),代码算成「条目数」(1)。 于是 planned_deliveries: 1 对着 delivered: 3 竟然成了「3 ≥ 1,投递达标」—— 单位错了,所以少投 2/3 也看不出来。
  3. 通道拒收被 except ChannelRefused: pass 静默吞掉channel_gate.py 里 push 属于 KNOWN_BUT_REFUSED,会抛 ChannelRefused; 而投递侧的 except ...: pass 让被拒条目照样落盘、照样计入 delivered,rejected 恒 0。 (注意:通道「只收 publish/sync、不收 push」是正确行为, 要修的是投递侧对拒收的处理,不是把 push 加进白名单。)
  4. health.py 的 BAD_FIELDS = () 是空判据表for field, expected in BAD_FIELDS 一次都不循环 → issues 恒空 → degraded 恒 false; 且它只读自述报告,从不读三条通道真实产物、不读账本、不读契约;退出码无条件 return 0。
  5. 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。

六、经验与避坑

  1. 先怀疑「单位」,再怀疑「数据」。 少投 2/3 却看不出问题,往往不是校验太弱,而是校验算错了单位: planned_deliveries 表达的是「通道数 × 条目数」,写成 len(entries) 就丢了整个通道维度。 凡是计划量/配额,先问一句:它的单位是什么?分母有几个维度?
  2. 绿色信号要分三类看:进程没崩(弱)/自己写的自述(无判别力)/能被外部按契约独立复核的事实(强)。 只读自述报告的健康检查,等于让系统给自己发体检合格证。 健康检查必须读事实:通道真实产物、账本真实行数、契约本身。
  3. except XxxError: pass 是把失败洗成成功的头号手段。 拒收/限流的异常必须计数 + 保留错误信息 + 不产生副作用(不落盘、不计 delivered)。 排查时可用一个「外部探针」用被拒的动作名打一轮,直接看 rejected 是否 > 0。
  4. 拿不到事实 = 判不了 = 不通过(fail-closed)。 报告缺失/契约缺失时返回 0(「没问题」)是典型的假绿。退出码要分层: 0 正常 / 2 有真问题或拿不到事实 / 3 契约不可用或自相矛盾。
  5. 警惕空判据表与写死的 self_consistent。BAD_FIELDS = () 的 for 循环一次都不执行,issues 恒空; ok = delivered >= plan 拿同源的两个自述值互检,永远为真。 判据表要么非空,要么干脆不写这种「看起来在检查」的代码。
  6. 别修过头。 通道闸门「只收 publish/sync、不收 push」是正确行为, 为了让探针变绿而把 push 加进收件白名单 = 造假。要修的是投递侧对拒收的处理。
  7. Docker 复现小贴士:
    • rm -rf 挂载点目录会报 Device or resource busy,属噪声;
    • 非交互 docker compose exec 必须加 -T;
    • 挂载代码后改完先 rm -rf app/__pycache__ 再 restart,否则旧字节码会掩盖修改;
    • 验证脚本可能会 人为藏掉产物 来测判别力(本场景 D2 组),这是刻意设计,别当 bug 改掉。
  8. 契约是唯一权威来源。 conf/mergectl.env 只读、发布平台下发; 代码里出现与契约不同的默认值 = 口径分叉。本场景 E 组就是「把扩展名改成 xhtml, 代码必须跟着变」来测判据有没有写死。

可复用的排查清单(发布/投递类流水线): 计划量的单位对不对 → 异常有没有被吞 → 健康检查读的是事实还是自述 → 拿不到数据时退出码是什么 → 有没有人能把判据写死/走空。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-10-09-1002

分享这篇文章