JSONL 的恢复策略把“行边界安全”和“每行内容有效”分开:追加前补齐缺失换行,读取时跳过空行与坏行并计数,但绝不把坏行解释成完整记录。
本节解决的不是“把对象存一下”这种抽象问题,而是把问题限定在:研究行级容错与追加尾部修复,不讨论压缩文件。输入:可能缺末尾换行、包含空行或无效 JSON 的 Rollout;状态所有者:open_rollout_for_append、ensure_rollout_is_newline_terminated 与 loader;成功结果:不粘连旧尾部的新追加行,以及尽可能多的可解析记录。先把这些边界钉住,后面的顺序、失败和恢复才不会混成一句“持久化失败后重试”。
状态边界
| 问题 | 本节答案 |
|---|---|
| 输入 | 可能缺末尾换行、包含空行或无效 JSON 的 Rollout |
| 状态所有者 | open_rollout_for_append、ensure_rollout_is_newline_terminated 与 loader |
| 成功产物 | 不粘连旧尾部的新追加行,以及尽可能多的可解析记录 |
| 研究范围 | 研究行级容错与追加尾部修复,不讨论压缩文件 |
正常路径:先看顺序点
这条路径可以压缩成五步:
- 检查非空文件最后一个字节。
- 缺换行则先追加 newline。
- 新记录按 JSON+newline 写入。
- Loader 逐行解析。
- 坏行告警计数并继续。
图中的箭头不是“可能调用”的依赖图,而是源码中决定可见性和所有权转移的先后关系。前一步没有确认时,后一步不能替它作出更强的成功承诺。
源码机制拆解
换行是物理隔离保证
若上次崩溃留下无 newline 尾部,直接追加会把两个 JSON 对象粘成一条永久坏行。open-for-append 先确保非空文件以 newline 结束,再允许新写。
坏行不会阻断全部历史
load_rollout_items 对空行无操作;serde 解析失败会 warning、增加 parse error 数量并继续后续行。这保护后面的完整 Turn 不被一个局部损坏遮蔽。
容错不等于无损修复
Loader 无法知道半截 JSON 原本是什么,也不会猜测字段。跳过意味着该行对应的语义确实可能缺失,调用方仍应把 parse error 当成数据完整性信号。
首个有效 Meta 仍是硬前提
普通坏行可以越过,但 Loader 需要得到 canonical SessionMeta 才能确认 identity/history mode。没有有效头的文件不能作为正常 Thread 历史。
写路径以完整行为确认单位
JsonlWriter 先序列化整个对象,附加 newline,再 write_all 并 flush。这样 Reader 可以把 newline 作为完整候选记录边界,而不是解析任意字节前缀。
Python 风格伪代码
下面的伪代码只保留设计职责、状态和失败顺序;它不逐行翻译 Rust,也不借 Python 语法虚构源码中不存在的事务:
async def open_for_append(path):
file = await open_read_write(path)
if await file.length() > 0:
last = await file.read_byte_at(file.length - 1)
if last != NEWLINE:
await file.append(NEWLINE)
return file
async def load_jsonl(path):
items, parse_errors = [], 0
for raw_line in await read_lines(path):
if raw_line.strip() == b'':
continue
try:
items.append(parse_rollout_line(raw_line))
except ParseError:
parse_errors += 1
warn_bad_line()
continue
require_canonical_session_meta(items)
return items, parse_errors
阅读时要特别看三处:哪个对象拥有可变状态,哪一个 await 是可观察屏障,以及失败后保留的是已提交前缀、未提交后缀,还是完全独立的外部副作用。
失败、取消与恢复
| 故障点 | 已留下的状态 | 可观察结果 | 恢复责任 |
|---|---|---|---|
| 尾部无换行直接追加 | 旧半行与新 JSON 粘连 | 两条语义一起不可解析 | 追加前补 newline |
| 中间一行损坏 | 前后完整行仍存在 | 局部记录丢失 | 记录 parse error 后继续 |
| 首个 SessionMeta 损坏 | 无法确认 Thread 身份 | 整个加载失败 | 报告无有效 canonical meta |
| 把 skip bad line 当已修复 | 语义缺口仍存在 | 恢复状态可能少 Call/Turn | 向诊断层暴露错误计数 |
这里没有统一的“回滚一切”。内存状态、日志行、SQLite 投影、父子拓扑和工具造成的文件/网络变化分别有自己的提交点。恢复代码只能根据已经存在的权威证据继续,不能用较弱的投影替较强的事实背书。
必须保持的不变量
- 每次正常追加从新的行边界开始
- 解析失败不能生成伪造 RolloutItem
- 坏行之后的好行仍可读取
- canonical SessionMeta 缺失时不得静默返回空历史
这些不变量比“最终能 Resume”更严格:正常路径要成立,Writer 竞争、任务取消、坏尾行、投影落后和旧格式兼容时也必须成立。
设计取舍
跳过坏行优先可用性而非强一致审计;它能恢复尽可能多的历史,却必须明确告诉使用者存在无法还原的语义缺口。
源码可以直接证明字段、分支、调用顺序和测试期望;“为什么这样设计”的表述是基于这些事实作出的工程归纳,不把它包装成未公开的产品承诺。
Mini Codex 复刻
实现 ensure_newline_tail 与 tolerant loader;用空行、中间坏 JSON、末尾半行和损坏首行四组 fixture 分别断言。
复刻时先验证协议不变量,再补性能优化。一个能在故障注入下说明“留下了什么”的小实现,比一个只在正常路径调用 save() 的演示更接近真实 Runtime。
源码导航
- codex-rs/rollout/src/recorder.rs:尾部换行修复、逐行写入和容错 Loader
相邻测试也很重要:
- codex-rs/rollout/src/recorder_tests.rs:nonempty tail 修复、坏行与恢复追加测试
- codex-rs/rollout/src/reverse_jsonl_scanner_tests.rs:无末尾换行与非法记录继续扫描
本节结论
JSONL 的恢复策略把“行边界安全”和“每行内容有效”分开:追加前补齐缺失换行,读取时跳过空行与坏行并计数,但绝不把坏行解释成完整记录。
评论
登录后即可评论