一次批量写入可能只成功前几行;Recorder 只删除已经确认写出的 pending 前缀,并在重开文件后继续未写后缀,避免整批重试造成重复。
本节解决的不是“把对象存一下”这种抽象问题,而是把问题限定在:研究部分写失败时 pending、ordinal 和文件句柄的处理。输入:有序 pending_items 与逐行 write result;状态所有者:RolloutWriterState::write_pending_items_once;成功结果:已提交前缀、保留后缀和可继续的下一个 ordinal。先把这些边界钉住,后面的顺序、失败和恢复才不会混成一句“持久化失败后重试”。
状态边界
| 问题 | 本节答案 |
|---|---|
| 输入 | 有序 pending_items 与逐行 write result |
| 状态所有者 | RolloutWriterState::write_pending_items_once |
| 成功产物 | 已提交前缀、保留后缀和可继续的下一个 ordinal |
| 研究范围 | 研究部分写失败时 pending、ordinal 和文件句柄的处理 |
正常路径:先看顺序点
这条路径可以压缩成五步:
- 从 pending[0] 开始逐行写。
- 每行成功后推进 committed count。
- 成功后才推进 ordinal。
- 故障时只 drain 已写前缀。
- 丢弃句柄重开并重试后缀。
图中的箭头不是“可能调用”的依赖图,而是源码中决定可见性和所有权转移的先后关系。前一步没有确认时,后一步不能替它作出更强的成功承诺。
源码机制拆解
提交单位是成功行前缀
循环记录已经完成的行数;无论后续在哪一行失败,函数退出前只从 pending 头部 drain 这个数量。失败行及其后缀保持原顺序。
Ordinal 与物理成功绑定
Paginated 模式只有在当前行 write_line 成功后才计算并安装下一个 ordinal。若写失败就提前递增,重试会产生缺号或同一语义使用错误位置。
恢复先放弃可疑句柄
第一次失败后把 writer 置空,再通过 open-for-append 重新建立文件状态与 ordinal。旧句柄的 offset 或错误状态不再被信任。
重试对象是剩余后缀
调用者不需要重新提交整批 items。WriterState 已知道哪些行越过了写屏障,重开后只处理仍在 pending 的后缀。
第二次失败必须上报
恢复不是无限循环。Flush 的 recovery path 只给一次重新打开机会;连续失败保留 pending 并向上返回错误,让关闭或后续屏障再次处理。
Python 风格伪代码
下面的伪代码只保留设计职责、状态和失败顺序;它不逐行翻译 Rust,也不借 Python 语法虚构源码中不存在的事务:
async def write_pending_once(state):
committed = 0
try:
while committed < len(state.pending):
item = state.pending[committed]
line = encode_line(item, ordinal=state.next_ordinal)
await state.writer.write_line(line)
committed += 1
state.next_ordinal = advance_after_success(state.next_ordinal)
finally:
del state.pending[:committed]
async def flush_with_one_reopen(state):
try:
await write_pending_once(state)
except IOError:
state.writer = None
await state.reopen_for_append()
await write_pending_once(state) # only the unwritten suffix remains
await state.writer.flush()
阅读时要特别看三处:哪个对象拥有可变状态,哪一个 await 是可观察屏障,以及失败后保留的是已提交前缀、未提交后缀,还是完全独立的外部副作用。
失败、取消与恢复
| 故障点 | 已留下的状态 | 可观察结果 | 恢复责任 |
|---|---|---|---|
| 第 k 行写失败 | 前 k-1 行已在文件 | pending 仅保留 k..n | 重开后从失败行继续 |
| 写成功却未 drain 前缀 | 文件已有记录、内存仍保留 | 下一次产生重复行 | 在同一函数退出路径清理 committed prefix |
| 失败前先递增 ordinal | 文件没有对应行 | 历史位置出现缺口 | 只在 write_line 成功后推进 |
| 重开后再次失败 | 后缀仍未耐久 | Flush 返回错误 | 保留 pending 等后续显式重试 |
这里没有统一的“回滚一切”。内存状态、日志行、SQLite 投影、父子拓扑和工具造成的文件/网络变化分别有自己的提交点。恢复代码只能根据已经存在的权威证据继续,不能用较弱的投影替较强的事实背书。
必须保持的不变量
- pending 始终等于尚未确认写出的连续后缀
- next_ordinal 指向下一条尚未写入的 Paginated 行
- 已提交行不会由 Recorder 自行再次发送
- 恢复不能清空失败后缀
这些不变量比“最终能 Resume”更严格:正常路径要成立,Writer 竞争、任务取消、坏尾行、投影落后和旧格式兼容时也必须成立。
设计取舍
逐行确认和前缀记账比整批 write 更复杂,但把非原子的文件 I/O 转换成可恢复、不会主动重复的状态机。
源码可以直接证明字段、分支、调用顺序和测试期望;“为什么这样设计”的表述是基于这些事实作出的工程归纳,不把它包装成未公开的产品承诺。
Mini Codex 复刻
维护 pending deque 和 committed_count;注入“第 k 次 write 失败”的 fake writer,断言文件前缀、内存后缀和重试结果精确一致。
复刻时先验证协议不变量,再补性能优化。一个能在故障注入下说明“留下了什么”的小实现,比一个只在正常路径调用 save() 的演示更接近真实 Runtime。
源码导航
- codex-rs/rollout/src/recorder.rs:pending_items、write_pending_items_once 与恢复重开
- codex-rs/rollout/src/ordinal.rs:成功写入后的 ordinal 推进规则
相邻测试也很重要:
- codex-rs/rollout/src/recorder_tests.rs:写入失败、重开、尾部修复和恢复追加
- codex-rs/rollout/src/persistence_metrics_tests.rs:重试与错误结果可观察性
本节结论
一次批量写入可能只成功前几行;Recorder 只删除已经确认写出的 pending 前缀,并在重开文件后继续未写后缀,避免整批重试造成重复。
评论
登录后即可评论