雨天小六

读懂 Codex(8.16):JSONL 先写、SQLite 后投影的顺序

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

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

Paginated 写入采用单向一致性:规范 JSONL 必须先成功,SQLite Projection 才能 best-effort 追赶;数据库允许落后,绝不允许先行宣称一条尚未耐久的历史。

本节解决的不是“把对象存一下”这种抽象问题,而是把问题限定在:研究 write_and_project 和增量物化的提交顺序。输入:已筛选 canonical items、当前 JSONL 与 projection position;状态所有者:LocalThreadStore live_writer + thread_history_materialization;成功结果:耐久 JSONL 前缀和不超过该前缀的 SQLite 位置。先把这些边界钉住,后面的顺序、失败和恢复才不会混成一句“持久化失败后重试”。

状态边界

问题本节答案
输入已筛选 canonical items、当前 JSONL 与 projection position
状态所有者LocalThreadStore live_writer + thread_history_materialization
成功产物耐久 JSONL 前缀和不超过该前缀的 SQLite 位置
研究范围研究 write_and_project 和增量物化的提交顺序

正常路径:先看顺序点

JSONL 先写、SQLite 后投影的顺序的正常路径时序图,展示Store 再次应用 policy、Recorder append+flush JSONL、读取 projection 的 next byte offset、只解析完整新行、SQLite 事务应用 changes 与新 position
图 8.16-1:Store 再次应用 policy → Recorder append+flush JSONL → 读取 projection 的 next byte offset → 只解析完整新行 → SQLite 事务应用 changes 与新 position。这张图标出本节的实际顺序点与状态所有者。

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

  1. Store 再次应用 policy。
  2. Recorder append+flush JSONL。
  3. 读取 projection 的 next byte offset。
  4. 只解析完整新行。
  5. SQLite 事务应用 changes 与新 position。

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

源码机制拆解

Store 层再次筛选防止绕过

即使 Session 通常已经提交合理 item,write_and_project 仍调用 persistence policy。若结果为空直接返回,不制造 JSONL 或 projection 写。

Durable write 是前置条件

Paginated 分支先 record_canonical_items 再 flush;任何 JSONL 错误直接返回,不调用 materialize_to_sqlite。

投影只读 newline-terminated 前缀

Materializer 从数据库保存的 byte offset 开始读,若文件缩短则报错。末尾没有 newline 的 partial record 被留给下次,而完整坏行可推进 offset 但不产生 projection changes。

SQLite 位置和变更同事务提交

apply_projection 使用 BEGIN IMMEDIATE,重查 expected offset/ordinal,应用 Turn/Item 变化并推进 position。SQL 中途错误整笔 rollback,仍可从旧位置重算。

Projection 失败是告警而非 Rollout 失败

JSONL 已经确认后,SQL 物化错误会 warning。调用方不能重发业务 items,否则会重复规范日志;查询层稍后用 materialization/backfill 追赶。

Python 风格伪代码

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

async def append_paginated(store, thread_id, raw_items):
    items = policy.filter(raw_items, mode=PAGINATED)
    if not items:
        return

    await store.recorder(thread_id).add(items)
    await store.recorder(thread_id).flush()       # canonical commit first

    try:
        await materialize_increment(store, thread_id)
    except SqliteError as error:
        warn('projection lagging', error=error)   # do not re-append items

async def materialize_increment(store, thread_id):
    expected = await db.projection_state(thread_id)
    complete_lines = await jsonl.read_complete_lines_from(expected.byte_offset)
    changes, new_position = project(complete_lines, expected.next_ordinal)
    async with db.begin_immediate() as tx:
        assert await tx.position(thread_id) == expected
        await tx.apply(changes)
        await tx.set_position(thread_id, new_position)

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

失败、取消与恢复

JSONL 先写、SQLite 后投影的顺序的失败路径图,区分JSONL flush 失败、SQLite 事务失败、文件尾半行、文件被截短
图 8.16-2:同一操作在不同故障点留下的状态不同,恢复责任必须回到拥有该状态的组件。
故障点已留下的状态可观察结果恢复责任
JSONL flush 失败规范项未确认SQLite 不更新保留 pending 后续重试
SQLite 事务失败JSONL 已完整分页查询暂时落后从旧 projection position 重跑
文件尾半行不是完整记录offset 停在该行之前下一次读到 newline 后再投影
文件被截短stored offset 超出新长度拒绝继续增量诊断/重建 projection

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

必须保持的不变量

  • projected position <= complete durable JSONL prefix
  • SQL 失败不触发规范 item 重写
  • changes 与 position 在同一事务
  • partial trailing line 不进入投影

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

设计取舍

查询允许短暂陈旧换来了简单可靠的权威链:JSONL 可独立恢复,SQLite 可以随时删除后重建。

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

Mini Codex 复刻

把 event log 作为唯一 append authority,read model 由 projector 异步追赶;使用 offset checkpoint 和事务 compare-and-advance。

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

源码导航

相邻测试也很重要:

本节结论

Paginated 写入采用单向一致性:规范 JSONL 必须先成功,SQLite Projection 才能 best-effort 追赶;数据库允许落后,绝不允许先行宣称一条尚未耐久的历史。

阅读导航

上一节:8.15 · 下一节:8.17

评论


← 返回文章列表