归档分片解析错位:尾部半截记录被当成完整记录、分片标识 / 对不上、报告字段「抄输入」导致全绿
日期:2026-10-02 16:00 | 分支:
2026-10-02-1600| 第98题 技术栈:NDJSON 追加日志 / 崩溃安全写入 / 尾部半截记录(torn write)/ 固定长度反推偏移 / 分片标识<session>/<shard-id>/ 报告中「抄输入」的字段 / 全绿健康检查 / Python
一、问题背景
一个「边缘归档」链路把现场事件按分片(shard)追加落盘,写入侧用单次 write 追加
NDJSON(一行一条记录),天然崩溃安全 —— 崩在写记录中间时,文件尾部只会留下半截记录。
现场表现是「归档成功、报告 ok、健康检查全绿」,但:
- 后台回放器读不出最新记录;
- 外部独立探针报
BAD; - 把分片文件逐行解析,最后一行压根不是完整 JSON。
bash ops/repro.sh 拿到的现场证据:
[producer] 分片文件: state/archive/2.log size=826
[supervisor] 报告 = {"shard_file":"2.log","archived_sections":[...],"records":6,"ok":true,"errors":[]}
[supervisor] round OK
[replayer] 最后一条记录 offset=680(按固定长度 146 × 6 条反推)
[replayer] 偏移 680 处不是完整 JSON(85 字节),按契约补一条记录
[replayer] 回放成功 -> session=YYYY-MM-DD-section-2 section=2 event=replayed@680
[health] degraded = False issues = []
[expect] 结论: BAD
三个数字互相打架:
- 真实字节账:
826 = 5 条完整记录(765) + 61 字节残留(单条记录 153 字节); - 报告
records = ceil(826 / 146) = 6—— 把半截那行也算成一条; tail_remainder_hint = 826 % 146 = 96—— 按「记录等长」反推出来的数字,与事实无关。
二、复现步骤
# 方式 A:本机直接跑(只需 python3)
git checkout 2026-10-02-1600
cd scenario/2026-10-02-1600
bash ops/repro.sh # 完整走一遍:写入 → 报告 → 解析 → 取证 → 回放 → 健康检查 → 外部探针
bash ops/verify.sh # 问题态:PASS=2 FAIL=4 / RESULT: BAD
# 方式 B:docker 一键
docker compose up -d --build
docker compose exec archive bash ops/repro.sh
docker compose exec archive bash ops/verify.sh
docker compose down
复原问题态(现场代码留档在 ops/problem-state/):
cp ops/problem-state/*.py app/ && bash ops/verify.sh # PASS=2 FAIL=4 / BAD
cp ops/expected-fixed/*.py app/ && bash ops/verify.sh # PASS=6 FAIL=0 / OK
三、根本原因
1. 分片标识对不上(conf.py + archive.py)
契约里分片的逻辑标识是 <session>/<shard-id>:
conf/archive.env ARCHIVE_WINDOW=YYYY-MM-DD-section-2
ARCHIVE_SHARD_ID=20261002T160002-b0f17c2e
conf/archive.expected YYYY-MM-DD-section-2/20261002T160002-b0f17c2e
现场代码 conf.load_contract() 压根没读 ARCHIVE_SHARD_ID,shard_path() 只能取窗口名最后一段拼 2.log。
写入侧记录里却写的是契约真值 —— 同一份数据里两套标识并存,落盘文件名与报告字段都与契约对不上。
2. 解析侧假设「记录等长」(archive.py + replayer.py)
RECORD_BYTES = 146 # 猜的,真实记录 153 字节
def count_records(size): return ceil(size/146)
def last_offset(size): return size - 146
用 146 反推偏移,落在半截记录内部(offset 680 在一个残缺 JSON 中段)。
replayer 解析失败后不是拒绝,而是「抄」一条 replayed@680 顶上,于是「回放成功」。
3. 报告「抄输入」+ health 只看存在(supervisor.py + health.py)
"archived_sections": list(contract["expected_sections"]), # 恒等于输入
"ok": True, # 手写常量
"errors": [], # 永远为空
health 的判据只有「文件存在 + 报告 ok」—— 报告的 ok 是常量,两个条件互相背书,
健康检查成了自己证明自己,尾部残留、跨轮次记录一概看不见。
四、解决办法
对照 ops/expected-fixed/(即修好后的 app/),六处改动:
conf.py:把ARCHIVE_SHARD_ID纳入契约,暴露shard_id。archive.py:shard_path() = <session>-<shard-id>.log,写入方与读取方共用同一函数,文件名由契约唯一确定;- 删除
tail_remainder()/count_records()这两个「按固定长度反推」的函数; - 新增
scan():按内容扫描,行尾有\n且可json.loads才算完整记录。
producer.py:默认完整收尾,显式传 part 才制造半截记录(写入侧本身没坏,不背锅)。replayer.py:按内容定位最后一条完整记录的偏移;尾部有残留就明确拒绝回放。supervisor.py:报告字段全部改成观测值 ——archived_shard可为None、tail_bytes是实测值;ok = (archived == want) and not errors,由判据算出来。health.py:判据升级为「契约路径可解析 + 每行都是完整合法 JSON + 记录属于本轮次 + 报告 ok」。
修复后同样的现场(写到 61 字节被打断),报告变成:
{"shard_file":"YYYY-MM-DD-section-2-20261002T160002-b0f17c2e.log",
"archived_shard":"YYYY-MM-DD-section-2/20261002T160002-b0f17c2e",
"records":5, "tail_bytes":61, "ok":false,
"errors":["tail_record_incomplete: 61 bytes"]}
五、验证
cd scenario/2026-10-02-1600
bash ops/verify.sh # PASS=6 FAIL=0 / RESULT: OK
bash ops/verify.sh none # 完整收尾场景同样 OK
cp ops/problem-state/*.py app/ && bash ops/verify.sh # PASS=2 FAIL=4 / RESULT: BAD(对照)
cp ops/expected-fixed/*.py app/ # 恢复修复态
修复前后对照:
| 项 | 问题态 | 修复态 |
|---|---|---|
| 落盘文件 | state/archive/2.log | YYYY-MM-DD-section-2-20261002T160002-b0f17c2e.log |
报告 records | 6 | 5(观测值) |
| 报告残留字节 | tail_remainder_hint=96(猜的) | tail_bytes=61(观测值) |
报告 ok | 手写 True | 由判据算出 False |
| replayer | 读不出就「抄」一条,假成功 | 明确拒绝回放(rc=1) |
| health | 永远全绿(rc=0) | 尾部残留即转红(rc=1) |
六、经验与避坑
- 「文件能被打开、行数看着对」不等于「记录边界是对的」。 NDJSON 追加日志崩溃后尾部必然可能有半截记录,解析侧不能假设记录等长, 必须按内容(行尾换行 + JSON 可解析)确认边界。
<session>/<shard-id>这类复合标识必须由契约唯一推导, 落盘路径与报告字段共用同一个函数;取名字最后一段拼<n>.log是迟早要对不上的。- 报告里不能有「恒等于输入」的字段(
archived_sections == expected_sections), 也不能有手写常量ok。报告字段必须是观测值(可为None),ok必须是算出来的。 - 同一份数据里出现两套标识最要命:写入侧记录里是契约真值,文件命名却用另一套值, 谁都没报错,只有外部探针能戳穿。
- 「拿不到数据」≠「没问题」:
replayer解析不通时「按契约补一条」等于把故障伪装成成功, 健康检查必须 fail-closed(拒绝回放 / 转红),而不是宽容兜底。 - 字节推算类数字(残留长度、记录条数)必须是观测值,不能按「假定记录长度」反推 ——
本题里 146 是猜的、153 才是真的,反推出来的
records=6 / hint=96与事实毫无关系。 - 验收要覆盖「问题态」与「修复态」两端:
ops/problem-state/(现场代码)+ops/expected-fixed/(参考实现)都留档,cp一下即可来回对照,避免「修好了但没证据证明修的是什么」。 - 原始现场代码未入库是个真坑:本仓库该次提交时
app/直接就是参考实现 (与ops/expected-fixed/*.py逐字节相同),Issue 声称的「问题态 PASS=4 FAIL=2」无法复现。 已重建问题态并留档ops/problem-state/,实测PASS=2 FAIL=4(红点覆盖文件名 / 报告抄输入 / 半截记录未识别 / health 不转红四宗罪),更严格;README 与verify.sh注释已同步为实测值。 教训:场景入库时务必确认「问题态」也进了版本库,否则题目就只剩答案。
一句话自查:日志类文件「最后一行」是不是完整 JSON?报告里的字段有没有一个能恒等于输入? 健康检查的判据有没有一条真的看数据?
同类影响面:所有 NDJSON / JSONL 追加日志(崩溃后尾部半截)、WAL / write-ahead log 解析、
fsync + 单次 write 的崩溃安全写入、日志采集 tail 的偏移恢复、任何「按固定记录长度 seek」的读取器、
以及所有「报告字段抄输入 / ok 写常量 / 健康检查自查自证」的监控链路。
