夜雨聆风学习资料网

ARTICLE · 1308791

Codex 源码-Thread Store 线程存储——JSONL 是正本,SQLite 是可重建视图

Codex 源码-Thread Store 线程存储——JSONL 是正本,SQLite 是可重建视图

Codex 源码解析系列

第 41 讲:Thread Store 线程存储——JSONL 是正本,SQLite 是可重建视图

基于 OpenAI Codex 源码 · 2026-11-04

💡 本讲一句话:Codex 的对话存在"两层"——JSONL 是唯一正本,SQLite 只是可随时重建的索引视图;ThreadStore trait 把本地/内存后端统一在一个边界后面,每次启动还有一个增量迁移引擎悄悄把旧文件升级成新格式。读完你能看懂 Codex 为什么"写失败不丢数据、读历史不全量回放"。

一、为什么需要一个"存储中立"的边界

上一讲我们拆了 rollout 录制与压缩:对话以 JSONL 事件流的形式落盘。但 core(会话运行时)并不想直接操作文件——它要支持本地 SQLite、内存后端(测试/云端)、未来可能的远端存储。于是 thread-store crate 定义了整条持久化链路的唯一入口:ThreadStore trait。

📄 codex-rs/thread-store/src/store.rs(第 66-76、129-249 行,核心方法摘录)

/// Future returned by [`ThreadStore`] operations. // 统一返回类型:把异步操作装箱成 Send future,trait 才能跨后端共享pub type ThreadStoreFuture<'a, T> = Pin<Box<dyn Future<Output = ThreadStoreResult<T>> + Send + 'a>; // 生命周期 'a 绑定到调用方持有的 store 引用/// Data a [`ThreadStore`] has already loaded while preparing a thread for resume. // 恢复线程前预加载的数据包pub struct ThreadStoreResumePreparation { // core 不解析内容,只负责原样传回给 store    state: Box<dyn Any + Send>, // 不透明类型:里面装的是实现私有的预加载状态(如已打开的游标)}impl ThreadStoreResumePreparation { // 提供装箱/拆箱两个操作    pub fn new<T: Any + Send>(state: T) -> Self { // 把任意类型塞进不透明包        Self { state: Box::new(state) } // 堆上分配,调用方不再持有原对象    }    pub fn downcast<T: Any + Send>(self) -> Result<T, Self> { // 拆箱:类型对就取出,不对就把原包还回去(不丢资源)        match self.state.downcast::<T>() { // 运行时类型检查            Ok(state) => Ok(*state), // 类型匹配:交出内部值            Err(state) => Err(Self { state }), // 类型不符:原样包回,调用方可继续尝试别的实现        }    }}/// Storage-neutral thread persistence boundary. // 存储中立的线程持久化边界:core 只认这个接口pub trait ThreadStore: Any + Send + Sync { // 要求 Any:才能 as_any() 下转;Send+Sync:跨线程共享    /// Return this store as [`Any`] for implementation-owned escape hatches. // 暴露具体类型句柄,供 core 调用实现专属能力    fn as_any(&self) -> &dyn Any; // 返回 trait object 引用,不消耗 self    /// Creates a new live thread. // 创建一条"活"线程并安装它的写者    fn create_thread(&self, params: CreateThreadParams) -> ThreadStoreFuture<'_, ()>; // 成功即线程可追加写入    /// Reopens an existing thread for live appends and returns its authoritative replay context. // 重开旧线程,返回可回放给模型的权威历史    fn resume_thread( // 多行签名:参数与返回值分行写        &self, // 借用 store,不转移所有权        params: ResumeThreadParams, // 携带 thread_id、可选自带历史、修订号等    ) -> ThreadStoreFuture<'_, Arc<Vec<RolloutItem>>>; // 返回值是共享引用:多个调用方复用同一份历史,不拷贝    /// Appends raw rollout items to a live thread. // 追加新对话记录(用户消息/模型输出/工具事件)    fn append_items(&self, params: AppendThreadItemsParams) -> ThreadStoreFuture<'_, ()>; // 写者必须已存在,否则报错    /// Materializes the thread if persistence is lazy, then persists all queued items. // 持久化检查点:让已入队内容全部落盘可读    fn persist_thread( // 三个参数分行:id + 场景枚举        &self, // 借用 store        thread_id: ThreadId, // 目标线程        context: PersistContext, // 调用方声明"为什么持久化",store 据此决定能否后台延后    ) -> ThreadStoreFuture<'_, ()>; // Standard 场景必须同步完成;TurnStart 等可先返回    /// Flushes all queued items and returns once they are durable/readable. // flush 屏障:等写入真正完成才返回    fn flush_thread(&self, thread_id: ThreadId) -> ThreadStoreFuture<'_, ()>; // 不产生新数据,只确认已入队内容落盘    /// Flushes pending items and closes the live thread writer. // 正常关闭:flush + 释放写者资源    fn shutdown_thread(&self, thread_id: ThreadId) -> ThreadStoreFuture<'_, ()>; // 之后该线程不再接受追加    /// Discards the live thread writer without forcing pending in-memory items to become durable. // 异常丢弃:初始化失败时不强制把脏数据写盘    fn discard_thread(&self, thread_id: ThreadId) -> ThreadStoreFuture<'_, ()>; // 与 shutdown 的区别:不等落盘,直接释放}

注意三个设计点。第一,所有方法都返回 ThreadStoreFuture(装箱 future),而不是 async fn in trait——这是 Rust 在稳定版没有原生 async trait 之前的标准做法,代价是每次调用一次堆分配,换来的是 trait 对象可跨线程共享。第二,ThreadStoreResumePreparation 是一个 Box<dyn Any> 不透明包:core 在启动前让 store 预加载历史,拿到这个"凭证"后原样传回 resume_thread_with_preparation,store 内部 downcast 复用已打开的资源——core 全程不知道里面装了什么。第三,trait 把"写者生命周期"拆成了四个动词:persist(检查点)、flush(屏障)、shutdown(正常关)、discard(异常弃),每个语义不同,后面看本地实现时它们会落到完全不同的代码路径上。

再看 PersistContext——调用方必须声明"我为什么要求持久化",store 据此决定这个检查点能不能先返回、后台再写:

📄 codex-rs/thread-store/src/store.rs(第 100-126 行)

/// Why thread persistence is being requested. // 为什么要求持久化:不同场景的耐久性要求完全不同#[derive(Clone, Copy, Debug, Eq, PartialEq)] // 纯数据枚举:可拷贝、可比对,方便在参数里传递pub enum PersistContext { // 五种持久化场景,语义互斥    ThreadPreparation, // 线程初始化/上下文注入完成,但还没开始任何 turn    Standard,          // 标准检查点:让线程和所有已入队条目都变持久、可读    SubagentSpawn,     // 子代理派生:子历史可先入队,其耐久性栅栏与"父子关系落盘"重叠执行    TurnStart,         // turn 即将开始采样:输入已经记录完毕    SteeredUserInput,  // 用户中途注入新指令:在下一轮采样请求前记录下来}impl PersistContext { // 把"能否后台化"的策略收敛到一处,不散落在各调用点    /// Whether a store may enqueue this checkpoint before returning and fence it at a later durability barrier. // 文档注释:哪些检查点允许先返回、由后续栅栏兜底    pub fn allows_background_persistence(self) -> bool { // 按场景回答"能否后台写"        match self { // 按场景分派策略            Self::ThreadPreparation | Self::Standard => false, // 这两类必须同步完成:调用方紧接着就要读这份数据            Self::SubagentSpawn | Self::TurnStart | Self::SteeredUserInput => true, // 这三类可先入队,由后续 flush/shutdown 栅栏兜底        }    }}

为什么这样设计?因为"子代理派生"和"turn 开始采样"这两条路径上,调用方马上就要去做更贵的事(启动 MCP、发模型请求),如果持久化必须同步完成,延迟会直接叠加到用户可感知的关键路径上。而 Standard 检查点的调用方往往紧接着就 read_thread,数据没落盘就读不到——所以必须同步。把"能否后台化"编码进枚举而不是散落在各处的 if 里,是典型的用类型表达策略。

二、LocalThreadStore 的骨架:一张写者表 + 两把锁

本地实现 LocalThreadStore(local/mod.rs)的核心状态就四样:配置、活写者表、进程内协调锁、跨进程文件锁。先看它怎么组织"一个线程的写者":

📄 codex-rs/thread-store/src/local/mod.rs(第 164-192 行)

struct LiveRecorderEntry { // 进程内"活写者表"的一条记录:一个线程同一时刻只能有一个    recorder: RolloutRecorder, // 真正的 JSONL 写者(rollout crate 提供)    rollout_id: ThreadId,      // 不可变的 rollout ID:SQLite 投影行按它索引,而不是按查找用的稳定 thread id    history_mode: ThreadHistoryMode, // Legacy 或 Paginated:决定写入后要不要做 SQLite 投影    writer_lock: WriterLockGuard,    // 跨进程文件锁的守卫:条目被移除时自动释放}#[derive(Default)] // 空表即可用,无需手动初始化struct LiveWriterLocks { // 进程内每线程一组协调锁的注册表    // Keep per-thread locks after a writer goes idle. Removing one while another caller is about to acquire it could let two operations for the same thread run at once. // 中文:写者空闲后保留锁,防止"刚要拿就被回收"导致同线程两个操作并发    by_thread: Mutex<HashMap<ThreadId, Arc<ThreadCoordination>>>, // 锁本身常驻:key 是线程 id,value 是该线程的协调锁组}#[derive(Default)] // 默认构造即两把空锁struct ThreadCoordination { // 每个线程一组进程内协调锁    // Serialize writes and capture consistent fork snapshots. // 中文:写串行化 + fork 快照一致性都靠它    writer: Arc<Mutex<()>>,   // 写串行化锁:append/flush/fork 快照都靠它独占    // Forks hold a shared lease until their child reference is durable; deletion, archive, and unarchive require exclusive access. Keeping this separate from `writer` lets the source accept writes during child initialization, including MCP startup that can take 30 seconds. // 中文:fork 持共享租约直到子引用落盘;删除/归档才需独占。与 writer 分开,源线程在子初始化(MCP 启动可达 30 秒)期间仍可接受写入    lifecycle: Arc<RwLock<()>>, // 生命周期读写锁:fork 持共享读,删除/归档持独占写}

这段代码里藏着两个容易踩坑的细节。第一,LiveRecorderEntry 同时保存 thread_id(查找键)和 rollout_id(投影键)——fork 之后,子线程有自己稳定的 thread id,但它的历史"正本"可能还指向父 rollout 的某段区间,两个 ID 从此分道扬镳。第二,writer 和 lifecycle 为什么拆成两把锁?注释说得很直白:如果 fork 期间源线程不能接受写入,那么子代理初始化(MCP 启动可能 30 秒)会把父会话的整个写路径卡死;拆开之后,fork 只持 lifecycle 的共享读,父线程照常 append,只有删除/归档这种"要动文件"的操作才需要独占写。锁的粒度就是并发模型的形状。

锁
作用域
谁持有
防住什么
writer(Mutex)
进程内,每线程一把
所有 append/flush/fork 快照
同一进程内对同一线程的并发写
lifecycle(RwLock)
进程内,每线程一把
fork 持共享读;删除/归档持独占写
子代理初始化期间源线程被删掉
WriterLockGuard(文件锁)
跨进程,每线程一把
拥有该写者的那个进程
两个 Codex 进程同时写同一份 rollout

第三把锁是跨进程的。本地存储的文件在磁盘上,谁都能打开——所以 acquire_writer_lock 走的是文件锁协调器:

📄 codex-rs/thread-store/src/local/mod.rs(第 374-403 行)

fn acquire_writer_lock(&self, thread_id: ThreadId) -> ThreadStoreResult<WriterLockGuard> { // 跨进程抢"该线程的写者身份"    self.writer_lock_coordinator // 文件锁协调器(按 codex_home 初始化,所有本地 store 共享同一把物理锁)        .acquire(thread_id) // 非阻塞尝试获取该线程的文件锁:拿不到立刻失败,不死等        .map(Arc::new) // 包成 Arc:guard 可随 LiveRecorderEntry 共享持有、延迟释放        .map_err(|err| { // IO 错误翻译成业务错误类型            if err.kind() == std::io::ErrorKind::WouldBlock { // WouldBlock = 别的进程正持有写者                ThreadStoreError::Conflict { message: err.to_string() } // 翻译成业务错误:冲突,调用方可以重试或报错            } else { // 非 WouldBlock 的其他 IO 错误                ThreadStoreError::Internal { message: err.to_string() } // 归为内部故障            }        }) // 结束错误映射闭包}async fn acquire_writer_locks(&self, thread_ids: &[ThreadId]) -> ThreadStoreResult<Vec<WriterLockGuard>> { // 批量抢锁(fork 场景要同时锁住父线程)    let mut writer_locks = Vec::with_capacity(thread_ids.len()); // 预分配容量,逐个收集锁守卫    for &thread_id in thread_ids { // 遍历要锁的线程列表        if self.live_recorders.lock().await.contains_key(&thread_id) { // 本进程已经是该线程的写者:跳过,避免自己锁死自己(文件锁不可重入)            continue; // 不重复获取文件锁        }        writer_locks.push(self.acquire_writer_lock(thread_id)?); // 逐个获取,任一失败整体回滚(已拿到的 guard drop 时自动释放)    }    Ok(writer_locks) // 全部拿到:guard 存活期间锁持续有效}

为什么这样设计?注意 WouldBlock → Conflict 的翻译:文件锁用非阻塞模式尝试,抢不到就立刻返回业务级"冲突"错误,而不是无限等待——因为持锁方可能是一个卡死的进程,死等比报错更糟。而 acquire_writer_locks 里"本进程已有写者就跳过"的判断,解决的是 fork 时父线程恰好在本进程活跃的问题:文件锁不可重入,自己锁自己会直接死锁。

三、写路径:JSONL 是正本,SQLite 是可重建视图

重开一个旧线程(resume)时,store 要回答一个问题:调用方手里那份历史还能用吗?live_writer.rs 里的三级判断是这段代码最精彩的部分:

📄 codex-rs/thread-store/src/local/live_writer.rs(第 110-131 行)

let history = match params.history { // 调用方可能自带一份历史(比如 core 内存里还留着),先判断能不能直接复用    Some(history) // 分支一:带了历史,且首行不是本线程的 SessionMeta        if !matches!( // 守卫条件:检查第一行是不是"本线程的会话头"            history.first(), // 取历史首条记录            Some(RolloutItem::SessionMeta(meta)) if meta.meta.id == params.thread_id // 是别的线程/导入的历史 → 走导入语义,原样采用        ) =>    {        history // 直接复用内存副本,零磁盘 IO    }    Some(history) // 分支二:带了历史且首行是本线程 SessionMeta,再看修订号        if params.history_revision.is_some() // 守卫条件一:必须带了修订号(缺失不能当作"没变过"的证据)            && params.history_revision // 守卫条件二:修订号与磁盘当前值相等 = 期间无人写入                == super::history_revision::read(&rollout_path).await // 异步读一次小文件,比回放整个 rollout 便宜得多    =>    {        history // 校验通过:复用内存副本    }    _ => Arc::new( // 分支三(兜底):从磁盘重新加载权威历史——revision 缺失或不匹配都走这里        super::model_context::load_from_rollout_path(store, params.thread_id, &rollout_path) // 全量读取正本文件并重建模型可见上下文            .await? // 异步读盘,失败向上抛            .items, // 取出历史条目列表    ), // Arc 包装:共享给多个调用方,避免拷贝};

为什么这样设计?这是典型的"乐观复用 + 廉价校验":读整个 rollout 文件是磁盘 IO,而 revision 只是一次小文件读取。三级判断的顺序也讲究——先处理语义特殊的历史(导入/覆盖),再做最便宜的 revision 比对,最后才走全量加载。trait 文档里那句"missing or unrecognized revisions must not be treated as proof that stored history is unchanged"(缺失或无法识别的修订号不能当作历史未变的证明)就是这条兜底路径存在的理由:宁可多读一次盘,也不能拿过期历史去喂模型。

真正写入时,所有操作都汇聚到 write_and_project。这里藏着整个存储层最重要的一条设计原则:

📄 codex-rs/thread-store/src/local/live_writer.rs(第 379-457 行)

/// The rollout writer has three distinct lifecycle moments: // 写者有三个不同的生命周期时刻(下面枚举逐一对应)/// - `AppendItems` is normal turn/event persistence and adds new rollout records. // AppendItems = 追加新记录(turn/事件持久化)/// - `Persist` makes the thread durable before any turn items exist; locally this can write the initial `SessionMeta`. // Persist = 让线程变持久:本地可能写出首个 SessionMeta/// - `Flush` writes any rollout records already queued in the recorder and ensures they are durably persisted. // Flush = 把已入队记录全部刷盘并确认持久enum RolloutWriteOp { // 三种写操作统一建模,后续分支只认这个枚举    AppendItems(Vec<RolloutItem>), // 追加新对话记录    Persist,                       // 让线程在磁盘上"存在"(可能写出初始 SessionMeta)    Flush,                         // 纯屏障:不产生新数据,只等已入队内容落盘}async fn write_and_project( // 所有活写操作的唯一入口(异步函数)    store: &LocalThreadStore, // store 引用:查锁表与写者表    thread_id: ThreadId, // 目标线程    write_op: RolloutWriteOp, // 三种写操作之一(追加/持久化/刷盘)) -> ThreadStoreResult<()> { // 成功返回 (),失败带业务错误    let _live_writer_guard = store.live_writer_locks.lock(thread_id).await; // 先拿进程内写锁:串行化本线程的所有写入    let (recorder, rollout_id, history_mode) = live_writer_parts(store, thread_id).await?; // 取出该线程的写者三元组(找不到说明已拆除,迟到写入在此失败)    let sync_rollout_path = matches!(&write_op, RolloutWriteOp::Persist | RolloutWriteOp::Flush); // Persist/Flush 之后要同步 state_db 里的 rollout 路径    if matches!(history_mode, ThreadHistoryMode::Legacy) { // Legacy 模式:只有正本,没有投影这回事        durable_write(&recorder, write_op).await?; // 只写 JSONL,失败直接报错(正本没写成就是真失败)    } else { // Paginated 模式:正本 + SQLite 视图两步走        let rollout_path = recorder.rollout_path(); // 记下正本路径,供投影与同步使用        // SQLite is a rebuildable view. The flush barrier must win before projection starts so it can lag JSONL after failure, but can never get ahead of canonical history. // 中文:SQLite 是可重建视图——失败后它可以落后于 JSONL,但绝不能跑到正本前面        durable_write(&recorder, write_op).await?; // 第一步永远先写正本:JSONL 落盘成功才算数        if let Err(err) = super::thread_history_materialization::materialize_to_sqlite( // 第二步才做投影;失败不致命——捕获错误只告警(视图可重建)            store, rollout_id, rollout_path, // 按不可变 rollout_id 物化进 turns/items 表        ).await { // 异步物化完成或失败            warn!("failed to project durable rollout for {thread_id}: {err}"); // 投影失败只告警不报错——正本没丢,下次可追平        }    }    if sync_rollout_path { // Persist/Flush 之后才需要同步路径引用(压缩/归档会换文件名)        sync_materialized_rollout_path(store, thread_id, recorder.rollout_path()).await?; // 把"当前正本文件路径"写回 state_db    }    Ok(()) // 写 + 投影全部完成}async fn durable_write(recorder: &RolloutRecorder, write: RolloutWriteOp) -> ThreadStoreResult<()> { // 对正本的唯一写入口    match write { // 按操作类型分派到 recorder 对应方法        RolloutWriteOp::AppendItems(items) => { // 追加:先记录再刷盘,两步都成功才算数            recorder.record_canonical_items(items.as_slice()).await.map_err(thread_store_io_error)?; // 记录规范化条目(内部按 history_mode 过滤)            recorder.flush().await.map_err(thread_store_io_error) // 立即刷盘:append 的语义就是"写完即可读"        }        RolloutWriteOp::Persist => recorder.persist().await.map_err(thread_store_io_error), // 写出初始 SessionMeta,让线程在磁盘上"存在"        RolloutWriteOp::Flush => recorder.flush().await.map_err(thread_store_io_error), // 纯屏障:不产生新数据,只等已入队内容落盘    }}

注释里那句"SQLite is a rebuildable view… it can lag JSONL after failure, but can never get ahead of canonical history"(SQLite 是可重建视图:失败后它可以落后于 JSONL,但绝不能跑到正本前面)值得抄下来。它定义了双写系统的容错方向——投影落后没关系,下次启动迁移/物化会追平;但投影超前就是数据损坏(SQLite 里有 JSONL 里不存在的记录)。所以代码顺序是硬性的:先 durable_write,后 materialize_to_sqlite;前者失败直接报错(正本没写成),后者失败只 warn(视图可重建)。同一个"写失败",两种后果完全不同——这就是正本/副本分层的价值。

写路径(live append + projection)

① 串行化:live_writer_locks.lock(thread_id)

进程内写锁 + 查活写者表,拿不到 recorder 说明线程已拆除,迟到写入在此失败。

▼

② 写正本:durable_write → JSONL

record_canonical_items + flush,失败直接报错——正本没写成就是真失败。

▼

③ 投影(仅 Paginated):materialize_to_sqlite

物化进 turns/items 表;失败只 warn,因为视图可重建、允许落后于正本。

▼

④ 同步路径:sync_materialized_rollout_path

Persist/Flush 后把当前正本文件路径写回 state_db,压缩换文件名也不丢引用。

关闭线程时(shutdown_thread),Paginated 模式还会再做一次 materialize_to_sqlite——因为运行期投影可能失败过,关门前必须保证视图追平正本;而 discard_thread(初始化失败的异常路径)则直接丢弃写者、顺手清掉未落盘的 pending metadata,绝不把半成品写进磁盘。四个动词,四条路径,语义一个都不含糊。

四、启动迁移引擎:把 legacy 文件升级成分页格式

Codex 的历史存储从"纯 JSONL 事件流(Legacy)"演进到了"JSONL + SQLite 分页(Paginated)"。老用户的磁盘上全是旧文件,升级不能靠用户手动操作——thread-store 在每次启动时跑一个增量迁移引擎。入口逻辑值得逐行看:

📄 codex-rs/thread-store/src/local/rollout_migration/startup.rs(第 61-128 行,节选)

pub(super) async fn migrate_rollouts_on_startup(store: &LocalThreadStore) -> ThreadStoreResult<()> { // 启动迁移入口(crate 内可见)    let Some(state_db) = store.state_db.as_ref() else { // let-else:没有 SQLite 状态库就直接返回(Legacy-only 部署不受影响)        return Ok(()); // 分页模式不可用,跳过整个迁移    };    let paths = find_all_rollout_paths(&store.config.codex_home).await?; // 扫描 codex_home 下所有 rollout JSONL 文件    let mut skipped_rollouts = state_db // 从 SQLite 读"跳过清单"(可变,后面会更新)        .list_rollout_migration_skipped_rollouts(LEGACY_TO_PAGINATED_MIGRATION_ID) // 按迁移 ID 查询:不同迁移互不干扰        .await // 异步读库        .map_err(migration_error)?; // 统一错误包装,避免反复处理已知问题文件    retry_busy_rollouts(store, skipped_rollouts.as_slice(), paths.as_slice()).await?; // 先重试上次"忙"的文件:当时被写者占着,现在可能空闲了    if !pending_migration_thread_ids(&store.config.codex_home).await?.is_empty() { // 检查是否有上次崩溃留下的未完成迁移标记        return migrate_all_rollouts(store, paths, skipped_rollouts.as_slice()).await; // 有 → 全量重跑(迁移幂等 + journal 兜底)    }    let state = state_db.get_rollout_migration_state(LEGACY_TO_PAGINATED_MIGRATION_ID) // 读迁移状态(含增量游标 last_checked_thread)        .await        .map_err(migration_error)?; // 统一错误包装    if state.is_none() { // 从未迁移过:首次全量        return migrate_all_rollouts(store, paths, skipped_rollouts.as_slice()).await; // 一次性把所有 legacy 文件升级    }    let last_checked_thread = state.and_then(|state| state.last_checked_thread); // 增量游标:上次检查到哪个线程为止(无则 None)    let lookback_created_at = last_checked_thread.as_ref().map(|cursor| { // 计算回看窗口起点,覆盖"上次检查窗口内刚写入"的边界竞态        cursor.thread_created_at.saturating_sub(CURSOR_LOOKBACK_SECONDS) // 游标时间 - N 秒(saturating 防下溢)    }); // 无游标时为 None,表示不设下限    let candidates = paths.iter() // 只挑候选:不在跳过清单里、且创建时间落在回看窗口内的新文件        .filter(|path| { /* 按文件名排除 skipped;thread_creation_cursor(path) >= lookback_created_at */ }) // 过滤条件见源码(此处节选)        .collect::<Vec<_>>(); // 收集成候选列表    if candidates.is_empty() { // 没有新文件:日常启动到此结束,开销趋近于零        return Ok(()); // 直接返回成功    }    let mut unresolved = false; // 标记是否有"正在被别的进程写"的文件    for path in candidates { // 逐个体检候选文件        match inspect_rollout_path(store, path).await? { // 读 SessionMeta + 前后指纹比对,判定状态            StartupInspection::Paginated | StartupInspection::Skipped => {} // 已是新格式 / 已知跳过:无事发生            StartupInspection::Legacy | StartupInspection::NeedsMigration => { // 发现需要迁移的 → 升级为全量迁移(保证整批一致)                return migrate_all_rollouts(store, paths, skipped_rollouts.as_slice()).await; // 立即执行并返回结果            }            StartupInspection::Unresolved => unresolved = true; // 文件正被别的进程写:本轮放弃,等下次启动再试        }    }    if unresolved { return Ok(()); } // 有未决文件:本轮收手(宁可这次不迁,不能迁坏在用文件)    advance_last_checked_thread(store, paths.as_slice()).await // 全部干净:把游标推进到当前快照,下次只查增量}

为什么这样设计?三个决策都很克制。第一,增量游标 + 回看窗口:老用户可能有几百个 rollout 文件,每次启动全量体检太贵;用"上次检查到哪"做游标、再往回退几秒兜住边界竞态,日常启动只扫新增文件。第二,跳过清单带原因:不是简单记"这个文件别碰了",而是区分 EMPTY(空文件)、BUSY(当时被占)、FAILED(迁移失败)、MALFORMED(SessionMeta 损坏)——不同原因的后续策略完全不同:

跳过原因
含义
后续策略
EMPTY_SKIP_REASON
文件为空且始终等不到 SessionMeta
永久跳过,不再重试
BUSY_SKIP_REASON
迁移时写者锁被其他进程持有
每次启动先重试(retry_busy_rollouts)
FAILED_SKIP_REASON
迁移失败且没有留下崩溃日志
永久跳过;若存在 journal 则保留,供崩溃恢复重放
MALFORMED_SESSION_META_SKIP_REASON
SessionMeta 行损坏无法解析
永久跳过,指纹(大小+mtime)记入 SQLite 供人工排查

第三,体检时的并发检测:inspect_rollout_path 在读 SessionMeta 前后各取一次文件指纹(大小 + mtime),如果两次不一致,说明有活写者正在追加——此时返回 Unresolved 而不是硬迁,把决定权留给下次启动。迁移真正执行时还要抢一把跨进程的 maintenance lock,抢不到就睡一会儿重试;遇到 Conflict 错误同样循环重试。整套机制的底线是:宁可这次不迁,也不能迁坏一个正在被使用的文件。

迁移本身是把旧事件流"重放"成新的分页格式。canonicalizer.rs 的模块注释把目标说得很清楚:"The goal is to preserve the model-visible conversation, not to preserve every legacy record byte-for-byte"(目标是保住模型可见的对话,而不是逐字节保留每条旧记录)。核心状态机长这样:

📄 codex-rs/thread-store/src/local/rollout_migration/canonicalizer.rs(第 35-40、464-499 行)

#[derive(Clone)] // ActiveTurn 需要 clone 进合成事件结构里struct ActiveTurn { // 重放过程中"当前正在写的 turn"状态    id: String,      // turn ID:显式的来自 TurnStarted 事件;隐式的合成 rollout-{源行号}(可追溯)    explicit: bool,  // 是否由旧文件显式开启(隐式 turn 结束时要补一个合成的 TurnComplete)    saw_user: bool,  // 本 turn 是否已见用户消息:决定隐式 turn 何时该收尾}async fn write_item( // 写一条规范化记录的唯一出口(异步函数)    &mut self, // 可变借用:要更新序号/偏移等状态    writer: &mut BufWriter<File>, // 缓冲文件写者:批量落盘减少 syscall    timestamp: &str, // 源行原始时间戳(RFC3339)——迁移时间不是对话发生时间    item: RolloutItem, // 待写入的规范化条目) -> ThreadStoreResult<()> { // 成功返回 (),失败带迁移错误    let item = match codex_rollout::persisted_rollout_item(&item, ThreadHistoryMode::Paginated) { // 按 Paginated 语义过滤/改写:返回借用、新值或 None        Some(Cow::Borrowed(_)) => item,   // 无需改写:原样写入(零拷贝)        Some(Cow::Owned(item)) => item,   // 需要按分页语义改写:用改写后的版本        None => return Ok(()),            // 该记录不属于分页历史(如旧版一次性事件):静默丢弃,不报错    };    let mut bytes = serde_json::to_vec(&RolloutLine { // 序列化成 JSONL 一行(时间戳 + ordinal + 条目)        timestamp: timestamp.to_string(), // 保留源行原始时间戳        ordinal: Some(self.next_ordinal), // 补上全局单调递增的序号(legacy 文件没有 ordinal,分页格式必须有)        item, // 条目本体    }).map_err(migration_error)?; // 序列化失败视为迁移错误,中止本文件    bytes.push(b'\n');                    // JSONL 约定:一条记录一行    writer.write_all(&bytes).await.map_err(migration_error)?; // 写入缓冲;IO 错误直接上抛(外层有回滚规划)    let byte_count = u64::try_from(bytes.len()).map_err(|_| migration_error("rollout record exceeds addressable size"))?; // 单条记录超过可寻址大小:视为损坏,报错中止    self.output_byte_offset = self.output_byte_offset.checked_add(byte_count) // 累计输出字节偏移:回滚规划和进度上报都靠它(checked 防溢出)        .ok_or_else(|| migration_error("paginated rollout byte offset overflow"))?; // 偏移溢出:宁可报错也不静默回绕    self.next_ordinal = self.next_ordinal.checked_add(1).ok_or_else(|| migration_error("paginated rollout ordinal overflow"))?; // 序号 +1(同样 checked)    Ok(()) // 本条写入成功,序号已推进}

为什么这样设计?旧格式里很多"完成事件"没有 turn 归属,canonicalizer 用 ActiveTurn 状态机把它们重新归位:遇到用户消息但没有活跃 turn 就合成一个隐式 turn(ID 直接用源行号,保证可追溯),turn 切换时给隐式 turn 补上 TurnComplete。所有计数都用 checked_add——迁移工具处理的是用户真实数据,任何一次静默溢出都可能让回滚规划算错字节偏移,宁可报错中止。而"不属于分页历史的记录直接丢弃"体现了模块注释的取舍:保对话语义,不保事件考古。

启动迁移路径(legacy → paginated)

① 扫描 + 重试:find_all_rollout_paths → retry_busy

列出全部 rollout 文件;先给上次"忙"的文件一次机会(含归档/压缩换名的路径匹配)。

▼

② 增量筛选:游标 + 回看窗口

只体检"上次检查之后新出现"的文件;无候选直接结束,日常启动近乎零成本。

▼

③ 体检:inspect_rollout_path

读 SessionMeta 判定 Legacy/Paginated;前后指纹不一致 = 有活写者 → Unresolved,本轮放弃。

▼

④ 迁移:maintenance lock + canonicalizer 重放

跨进程锁保护下逐行重放成带 ordinal 的分页 JSONL;失败按原因记入跳过清单。

▼

⑤ 收尾:更新跳过清单 + advance_last_checked_thread

游标只推进到"迁移前快照"——迁移期间新写的文件留给下次启动,边界干净。

五、本讲小结:一张表看懂两层存储

维度
Legacy 模式
Paginated 模式
历史载体
JSONL 事件流(唯一)
JSONL 正本 + SQLite turns/items 投影
读路径
全文件回放重建上下文
按 turn/item 分页查询,可直接读最新模型上下文
写路径
durable_write → JSONL
先写 JSONL,再 materialize_to_sqlite(失败只 warn)
容错方向
写失败 = 真失败,直接报错
投影可落后、不可超前;视图随时可从正本重建
升级方式
—
启动迁移引擎:增量游标 + 带原因跳过清单 + 崩溃 journal
并发控制
进程内 writer/lifecycle 双锁 + 跨进程文件锁(WouldBlock → Conflict)

回头看,thread-store 这一层回答了一个很朴素的问题:对话数据到底信谁?答案是 JSONL。SQLite、state_db、甚至内存里的历史副本,全都是"可以重算的缓存"——所以投影失败只告警、revision 不匹配就重读、迁移中断下次再来。把"正本唯一 + 副本可重建"这条纪律贯彻到每个分支之后,整个存储层才敢在 fork、压缩、归档这些复杂操作里保持简单:它们只需要保证正本不被写坏,其余的交给重建。

下一讲我们离开本地磁盘,进入 App Server RPC 网关——看看 IDE 和 CLI 是怎么通过一套请求处理器把"线程/turn/消息"操作路由到 core 的。

📚 系列导航

← 第 40 讲:Rollout 录制与压缩 + Rollout Trace

→ 第 42 讲:App Server RPC 网关

关注公众号「AI技术推荐官」获取更多源码解析内容

回首页,搜索你想要的文档

在首页搜索框输入关键词,快速定位资料

文库首页搜索指引 立即搜索

相关学习资料