雨天小六

读懂 Codex(8.11):ReverseJsonlScanner 的反向分块算法

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

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

反向读取不是把整份日志载入内存后 reverse;ReverseJsonlScanner 从给定 byte offset 以 8 KiB 块向前读,跨块拼接一行,并让坏记录不会终止继续扫描。

本节解决的不是“把对象存一下”这种抽象问题,而是把问题限定在:解释反向分块、冻结末端、行重组和错误返回。输入:plain JSONL 文件与可选 end_byte_offset;状态所有者:ReverseJsonlScanner 的 cursor、chunk buffer 和 partial record;成功结果:从新到旧的 Parsed/Rejected 记录流。先把这些边界钉住,后面的顺序、失败和恢复才不会混成一句“持久化失败后重试”。

状态边界

问题本节答案
输入plain JSONL 文件与可选 end_byte_offset
状态所有者ReverseJsonlScanner 的 cursor、chunk buffer 和 partial record
成功产物从新到旧的 Parsed/Rejected 记录流
研究范围解释反向分块、冻结末端、行重组和错误返回

正常路径:先看顺序点

ReverseJsonlScanner 的反向分块算法的正常路径时序图,展示校验冻结 offset 不越界、从 cursor 向前读取至多 8KiB、逆向寻找 newline 并拼接跨块行、跳过空白记录、返回 Parsed 或 Rejected 后继续
图 8.11-1:校验冻结 offset 不越界 → 从 cursor 向前读取至多 8KiB → 逆向寻找 newline 并拼接跨块行 → 跳过空白记录 → 返回 Parsed 或 Rejected 后继续。这张图标出本节的实际顺序点与状态所有者。

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

  1. 校验冻结 offset 不越界。
  2. 从 cursor 向前读取至多 8KiB。
  3. 逆向寻找 newline 并拼接跨块行。
  4. 跳过空白记录。
  5. 返回 Parsed 或 Rejected 后继续。

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

源码机制拆解

Cursor 从逻辑末端向零移动

new_at(end_byte_offset) 允许 Fork 只扫描冻结前缀;构造时先验证 offset 不超过文件长度。每次填充 buffer 都从 cursor-chunk_size 读取,并把 cursor 前移。

单行可以跨任意多个块

Scanner 保存尚未遇到行首的 fragment。即使一条 JSON 大于 8KiB 甚至跨三块,也会按原字节顺序重组,而不是把块边界当记录边界。

末尾换行不是读取前提

如果文件最后一行完整但没有 newline,EOF 到末端的字节仍被视为候选记录。空白行会被跳过,不占用消费者的扫描结果。

Rejected 是数据结果而非 Scanner 终态

UTF-8/JSON 解析失败返回 Rejected,内部 cursor 已越过该行;下一次 scan_next 继续更旧记录,调用方可决定告警还是强制全量路径。

内存上界由最大行而非文件决定

常规情况下只持有一个 chunk 和当前跨块 record。极端超长单行仍需要相应内存,但无需为数百 MB 的完整 Rollout 建立 Vec。

Python 风格伪代码

下面的伪代码只保留设计职责、状态和失败顺序;它不逐行翻译 Rust,也不借 Python 语法虚构源码中不存在的事务:

class ReverseJsonlScanner:
    CHUNK = 8192

    async def next(self):
        record = bytearray()
        while True:
            if self.buffer_has_previous_line():
                record.prepend(self.take_previous_line_fragment())
                if self.found_line_boundary():
                    if record.strip() == b'':
                        record.clear(); continue
                    return parse_or_reject(record)

            if self.cursor == 0:
                return parse_or_reject(record) if record.strip() else EOF

            start = max(0, self.cursor - self.CHUNK)
            self.buffer = await pread(self.file, start, self.cursor - start)
            self.cursor = start

阅读时要特别看三处:哪个对象拥有可变状态,哪一个 await 是可观察屏障,以及失败后保留的是已提交前缀、未提交后缀,还是完全独立的外部副作用。

失败、取消与恢复

ReverseJsonlScanner 的反向分块算法的失败路径图,区分冻结 offset 大于文件长度、一行跨三块、最新一行无效、空白行很多
图 8.11-2:同一操作在不同故障点留下的状态不同,恢复责任必须回到拥有该状态的组件。
故障点已留下的状态可观察结果恢复责任
冻结 offset 大于文件长度引用边界无效构造 Scanner 失败先做 bounds check
一行跨三块局部 fragment 多次累积仍返回一个完整 record持续 prepend 直到 newline/BOF
最新一行无效Scanner 已识别其物理边界返回 Rejected消费者再次调用读取前一行
空白行很多没有业务记录不向上返回伪 item内部循环跳过

这里没有统一的“回滚一切”。内存状态、日志行、SQLite 投影、父子拓扑和工具造成的文件/网络变化分别有自己的提交点。恢复代码只能根据已经存在的权威证据继续,不能用较弱的投影替较强的事实背书。

必须保持的不变量

  • 返回顺序严格从新到旧
  • end_byte_offset 之后的字节永不读取
  • 坏行不污染相邻完整行的边界
  • 块大小不限制最大 JSONL 行大小

这些不变量比“最终能 Resume”更严格:正常路径要成立,Writer 竞争、任务取消、坏尾行、投影落后和旧格式兼容时也必须成立。

设计取舍

状态机比 read_to_string().lines().rev() 难读,但让大历史的恢复、Fork 和 bounded context 选择具有稳定内存成本。

源码可以直接证明字段、分支、调用顺序和测试期望;“为什么这样设计”的表述是基于这些事实作出的工程归纳,不把它包装成未公开的产品承诺。

Mini Codex 复刻

使用 os.pread 或 seek/read 实现倒序 chunk reader;测试无末尾换行、跨 1/2/3 块、坏 EOF、空行和冻结前缀。

复刻时先验证协议不变量,再补性能优化。一个能在故障注入下说明“留下了什么”的小实现,比一个只在正常路径调用 save() 的演示更接近真实 Runtime。

源码导航

相邻测试也很重要:

本节结论

反向读取不是把整份日志载入内存后 reverse;ReverseJsonlScanner 从给定 byte offset 以 8 KiB 块向前读,跨块拼接一行,并让坏记录不会终止继续扫描。

阅读导航

上一节:8.10 · 下一节:8.12

评论


← 返回文章列表