雨天小六

读懂 Codex(8.6):pending items 的前缀提交和失败重试

· 更新于 2026-08-03 · 专栏:读懂 Codex

#Codex#Agent Runtime#持久化#Rollout#软件架构

一次批量写入可能只成功前几行;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 items 的前缀提交和失败重试的正常路径时序图,展示从 pending[0] 开始逐行写、每行成功后推进 committed count、成功后才推进 ordinal、故障时只 drain 已写前缀、丢弃句柄重开并重试后缀
图 8.6-1:从 pending[0] 开始逐行写 → 每行成功后推进 committed count → 成功后才推进 ordinal → 故障时只 drain 已写前缀 → 丢弃句柄重开并重试后缀。这张图标出本节的实际顺序点与状态所有者。

这条路径可以压缩成五步:

  1. 从 pending[0] 开始逐行写。
  2. 每行成功后推进 committed count。
  3. 成功后才推进 ordinal。
  4. 故障时只 drain 已写前缀。
  5. 丢弃句柄重开并重试后缀。

图中的箭头不是“可能调用”的依赖图,而是源码中决定可见性和所有权转移的先后关系。前一步没有确认时,后一步不能替它作出更强的成功承诺。

源码机制拆解

提交单位是成功行前缀

循环记录已经完成的行数;无论后续在哪一行失败,函数退出前只从 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 是可观察屏障,以及失败后保留的是已提交前缀、未提交后缀,还是完全独立的外部副作用。

失败、取消与恢复

pending items 的前缀提交和失败重试的失败路径图,区分第 k 行写失败、写成功却未 drain 前缀、失败前先递增 ordinal、重开后再次失败
图 8.6-2:同一操作在不同故障点留下的状态不同,恢复责任必须回到拥有该状态的组件。
故障点已留下的状态可观察结果恢复责任
第 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。

源码导航

相邻测试也很重要:

本节结论

一次批量写入可能只成功前几行;Recorder 只删除已经确认写出的 pending 前缀,并在重开文件后继续未写后缀,避免整批重试造成重复。

阅读导航

上一节:8.5 · 下一节:8.7

评论


← 返回文章列表