反向读取不是把整份日志载入内存后 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 记录流 |
| 研究范围 | 解释反向分块、冻结末端、行重组和错误返回 |
正常路径:先看顺序点
这条路径可以压缩成五步:
- 校验冻结 offset 不越界。
- 从 cursor 向前读取至多 8KiB。
- 逆向寻找 newline 并拼接跨块行。
- 跳过空白记录。
- 返回 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 是可观察屏障,以及失败后保留的是已提交前缀、未提交后缀,还是完全独立的外部副作用。
失败、取消与恢复
| 故障点 | 已留下的状态 | 可观察结果 | 恢复责任 |
|---|---|---|---|
| 冻结 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。
源码导航
- codex-rs/rollout/src/reverse_jsonl_scanner.rs:8KiB 反向分块、记录拼接与 Parsed/Rejected
相邻测试也很重要:
- codex-rs/rollout/src/reverse_jsonl_scanner_tests.rs:逆序、坏行继续、无换行、冻结前缀和跨三块记录
本节结论
反向读取不是把整份日志载入内存后 reverse;ReverseJsonlScanner 从给定 byte offset 以 8 KiB 块向前读,跨块拼接一行,并让坏记录不会终止继续扫描。
评论
登录后即可评论