ARTICLE · 1234315
Codex 源码-State Runtime Store
Codex 源码解析系列
第 29 讲:State Runtime Store
基于 OpenAI Codex 源码 · 2026-10-23
💡 本讲一句话:Codex 的线程元数据、日志、目标记账、记忆任务、用户消息队列全部落在 SQLite 上——这一讲拆它为什么拆成 7 个库文件、怎么在"多进程并发 + 数据库损坏"两种最脏的场景下保住数据,读完你能看懂一套生产级嵌入式存储的完整防御体系。
一、为什么是 7 个 SQLite 文件,而不是一个大库?
state crate(约 2 万行)的入口是 codex-rs/state/src/sqlite.rs。它把全部运行时状态按"写入频率 × 数据量"拆成独立文件,每个文件一个连接池:
📄 codex-rs/state/src/sqlite.rs (第 29-34、298-311 行)
const LOGS_DB_FILENAME: &str = "logs_2.sqlite"; // 日志库:高频追加、按线程分区裁剪,单独成文件避免锁住主库const GOALS_DB_FILENAME: &str = "goals_1.sqlite"; // 目标记账库:goal 状态与续跑延迟记录const MEMORIES_DB_FILENAME: &str = "memories_1.sqlite"; // 记忆库:stage-1/stage-2 任务表 + 提取产物const QUEUE_DB_FILENAME: &str = "queue_1.sqlite"; // 持久化用户消息队列:崩溃不丢未发送的输入const STATE_DB_FILENAME: &str = "state_5.sqlite"; // 主状态库:线程元数据等,文件名后缀是 schema 世代pub async fn open_read_write_pool(&self, path: &Path) -> Result<SqlitePool, Error> { // 所有可写库的统一打开入口 let options = SqliteConnectOptions::new() // 构造连接选项,集中一处便于审计 .filename(path) // 指定目标文件路径 .create_if_missing(true) // 首次启动自动建文件,老用户则直接复用 .journal_mode(SqliteJournalMode::Wal) // WAL 模式:读者不阻塞写者,多进程并发的核心选择 .synchronous(SqliteSynchronous::Normal) // NORMAL 级别 fsync:WAL 下兼顾持久性与吞吐的平衡点 .auto_vacuum(SqliteAutoVacuum::Incremental) // 增量 vacuum:空闲页可小批量回收,避免整库 VACUUM 卡前台 .busy_timeout(Duration::from_secs(5)) // 锁竞争时最多等 5 秒再报 busy,多进程抢写不立即失败 .log_statements(LevelFilter::Off); // 关掉 SQL 日志:高频查询会刷爆日志盘 SqlitePoolOptions::new() // 连接池构建器 .max_connections(5) // 每库最多 5 条连接:SQLite 单写者,更多只会加剧锁竞争 .connect_with(options) // 用上述选项真正建池 .await // 异步等待连接就绪为什么这样设计:SQLite 是单写者模型——一个库文件同一时刻只能有一个进程持有写锁。Codex 的 TUI、app-server、后台记忆任务可能同时活着,如果全挤在一个库里,日志的高频追加会直接卡死线程列表查询。拆成 7 个文件后,"日志写入热路径"和"元数据读路径"物理隔离,busy_timeout(5s) 只兜底同库内的竞争。WAL + synchronous=NORMAL 是 SQLite 官方推荐的"近实时持久化"组合:崩溃最多丢最后一条未 checkpoint 的 WAL 帧,换来的是写放大减半。
注意文件名里的数字后缀(state_5、logs_2):这是 schema 世代标记,主库已经迭代到第 5 代。迁移文件数也说明问题——主库 55 个迁移是历史包袱最重的地方,而 logs/queue 这类新拆出来的库只有 2 个。
二、StateRuntime 初始化:级联回滚 + 每阶段遥测
runtime.rs 的 init_inner()(第 123-284 行)按固定顺序打开 5 个库,任何一步失败都要把已打开的池全部关掉——这段"级联清理"是初始化代码里最容易被忽略的部分:
📄 codex-rs/state/src/runtime.rs (第 139-160、255-266 行)
let has_memories_v2 = tokio::fs::try_exists(sqlite.memories_v2_db_path()).await?; // 先探测 v2 记忆库是否已存在,决定稍后要不要走损坏恢复let pool = match sqlite.open_state_db(&state_migrator, telemetry_override).await { // 打开主状态库并跑迁移(open+migrate 两阶段都带遥测) Ok(db) => Arc::new(db), // 成功:Arc 包装,后面多个 store 共享同一池 Err(err) => { // 失败分支:fail fast warn!("failed to open state db at {}: {err}", state_path.display()); // 日志带完整路径,方便定位是哪个文件坏了 return Err(err); // 直接返回错误,不留半初始化状态 }}; // (纯结构行)let logs_pool = match sqlite.open_logs_db(&logs_migrator, telemetry_override).await { // 第二个库:日志 Ok(db) => Arc::new(db), // 成功路径同上 Err(err) => { // 失败必须回滚已打开的池 warn!("failed to open logs db at {}: {err}", logs_path.display()); // 同样带路径告警 close_sqlite_pools(&[pool.as_ref()]).await; // 级联关闭:主库先关,避免泄漏连接 return Err(err); // 返回错误终止初始化 }}; // (纯结构行)let runtime = Arc::new(Self { // 组装 StateRuntime,整体用 Arc 共享给上层 thread_goals: GoalStore::new(Arc::clone(&goals_pool)), // goal store 独占自己的池 memories: MemoryStore::new(Arc::clone(&memories_pool), Arc::clone(&pool)), // 记忆 store 同时持有记忆库和主库:水位线要跨库对账 memories_v2: Arc::new(tokio::sync::OnceCell::new()), // v2 记忆懒加载:只有文件已存在才真正打开,不给新用户凭空建空库 thread_queue: SqliteQueueStore::new(queue_pool), // 持久化队列 store pool, logs_pool, sqlite, default_provider, // 池与配置直接移入结构体 thread_updated_at_millis: Arc::new(AtomicI64::new(thread_updated_at_millis)), // 启动时缓存 MAX(updated_at_ms),后续"最近活跃"排序免查库 thread_recency_at_millis: Arc::new(AtomicI64::new(thread_recency_at_millis)) // 同理缓存 recency 水位,原子变量供并发读});为什么这样设计:三个细节值得注意。其一,每个库的 open/migrate 都单独打点(record_init_result),线上能直接看到"哪个库、哪一阶段、耗时多少"——SQLite 初始化慢几乎总是迁移或锁等待,分阶段打点才能定位。其二,失败路径的 close_sqlite_pools 是递增列表(第 3 个库失败要关前 3 个),这是典型的资源获取-初始化-回滚模式,漏一个就是连接泄漏。其三,memories_v2 用 OnceCell 懒加载:v2 是灰度中的新格式,"存在才打开、不存在不创建"让老用户零感知。
迁移层还有一个反直觉的设计——migrations.rs(第 19-28 行):
📄 codex-rs/state/src/migrations.rs (第 19-28 行)
fn runtime_migrator(base: &'static Migrator) -> Migrator { // 给每个内嵌 migrator 套一层运行时包装 Migrator { // 手工构造包装后的 Migrator,逐字段复制原配置 migrations: Cow::Borrowed(base.migrations.as_ref()), // 复用编译期嵌入的迁移集,零拷贝借用 ignore_missing: true, // 关键开关:容忍"库比我新"——旧版二进制能打开被新版并行迁移过的库 locking: base.locking, // 保持原 migrator 的锁行为不变 no_tx: base.no_tx, // 保持原事务策略(是否每条迁移独立事务) table_name: base.table_name.clone(), // _sqlx_migrations 记录表名沿用 create_schemas: base.create_schemas.clone(), // schema 创建策略沿用 }}为什么这样设计:ignore_missing: true 解决的是"新旧版本并行"问题:用户可能同时开着旧版 TUI 和新版 app-server,新版把库迁到了 v56,旧版内嵌的迁移集只到 v55——默认行为是 sqlx 直接报错拒绝打开。放宽后,已知版本的 checksum 仍会校验(防篡改),只是不再要求"我认识所有已应用版本"。这是嵌入式数据库在多进程共存场景下少见的务实取舍。
三、日志分区预算:窗口函数裁剪 + 同事务保证
runtime/logs.rs(第 57-281 行)实现了日志的保留策略:每个"分区"(一个 thread_id,或无线程日志按 process_uuid,NULL 单独成区)最多留 10 MiB / 1000 行。核心是 prune_logs_after_insert()——它跑在插入的同一个事务里,调用方永远看不到"插了但还没裁"的中间态:
📄 codex-rs/state/src/runtime/logs.rs (第 69-136 行,裁剪 SQL 核心)
SELECT thread_id FROM logs WHERE thread_id IN (/* 本批涉及的线程 */)GROUP BY thread_id HAVING SUM(estimated_bytes) > /* 10MiB */ OR COUNT(*) > /* 1000 */ -- 廉价预检:只挑真正超预算的线程,避免对全表跑窗口函数DELETE FROM logs -- 只删超预算的行,其余原样保留WHERE id IN ( -- 子查询先算出要删的 id 集合 SELECT id FROM ( SELECT id, -- 行主键,外层用它定位删除目标 SUM(estimated_bytes) OVER ( -- 窗口累计字节:从最新一行往旧累加 PARTITION BY thread_id // 预算按线程独立计算,互不挤占 ORDER BY ts DESC, ts_nanos DESC, id DESC // 时间倒序;纳秒和主键做平局裁决,保证顺序稳定可复现 ) AS cumulative_bytes, -- 每行的"自最新累计字节数" ROW_NUMBER() OVER ( -- 同分区内的行号 PARTITION BY thread_id // 与字节累计同一分区维度 ORDER BY ts DESC, ts_nanos DESC, id DESC // 排序必须和累计一致,否则行数上限会误伤 ) AS row_number // 用于 1000 行的硬上限判断 FROM logs WHERE thread_id IN (/* 预检出的超预算线程 */) -- 只扫描受影响分区,不碰其他线程的数据 ) WHERE cumulative_bytes > /* 10MiB */ // 累计字节超出预算的行 → 删(即最旧的那部分) OR row_number > /* 1000 */ // 或者行数超上限 → 删,双保险为什么这样设计:窗口函数 SUM() OVER (PARTITION BY ... ORDER BY ts DESC) 是"按大小保留最新 N 字节"的标准解法——先算每行的累计值,再一刀切掉超预算的尾部。两个工程细节:一是预检(GROUP BY + HAVING)把窗口函数限制在真正超标的分区上,日常小批量插入几乎零开销;二是排序键用 ts, ts_nanos, id 三级——同一毫秒内写入的行很多,只按秒排会导致裁剪结果不确定(有时删 A 行、有时删 B 行),加上纳秒和主键后行为完全可复现。启动时还有 run_logs_startup_maintenance()(第 291-305 行):删掉超过 10 天的日志,然后跑 PRAGMA wal_checkpoint(PASSIVE)——PASSIVE 模式只回收"立即可用"的 WAL 帧、绝不等待活跃读者,注释里明说"启动清理不应阻塞前台工作"。
四、持久化队列:一条 SQL 原子完成"限长 + 定序"
runtime/queued_items.rs(第 77-105 行)把"用户已输入但还没发给模型的排队消息"落盘,上限 MAX_QUEUE_ITEMS = 100(lib.rs 第 100 行)。入队写法很讲究:
📄 codex-rs/state/src/runtime/queued_items.rs (第 77-105 行)
let row = sqlx::query( // 单条语句完成"限长检查+定序插入+回读新行",原子性由 SQLite 写锁保证 "INSERT INTO queued_items (id, thread_id, payload_json, queue_order, created_at_ms, updated_at_ms) -- 六列:身份、归属线程、负载、序号、两个时间戳 SELECT ?, ?, ?, // SELECT 形式而非 VALUES,才能内嵌子查询算序号和限长条件 COALESCE((SELECT MAX(queue_order) FROM queued_items WHERE thread_id = ?), -1) + 1, -- 新序号=当前最大+1;空队列时从 0 开始,COALESCE 兜底 NULL ?, ? // created_at / updated_at:初始两值相同,后续 update/reorder 只刷 updated WHERE (SELECT COUNT(*) FROM queued_items WHERE thread_id = ?) < ? // 限长检查内嵌在 INSERT 里:计数<上限才真正插入,单语句原子完成 RETURNING id, thread_id, payload_json", // 直接返回新行内容,省掉一次回查).bind(Uuid::now_v7().to_string()) // UUIDv7:时间有序,天然可排序、无中心发号器.bind(thread_id.to_string()) // 归属哪个线程的队列(按线程隔离).bind(payload_json) // 用户消息负载(JSON 序列化后的原文).bind(thread_id.to_string()) // MAX(queue_order) 子查询用的线程过滤条件.bind(now_ms) // created_at:入队时刻(毫秒时间戳).bind(now_ms) // updated_at:初始与创建一致,后续 update/reorder 会刷新.bind(thread_id.to_string()) // COUNT(*) 限长检查的线程过滤条件.bind(i64::try_from(MAX_QUEUE_ITEMS)?) // 上限常量 100,try_from 防溢出.fetch_one(self.pool.as_ref()) // 期望恰好一行:超限则无行返回、fetch_one 报错——失败语义清晰.await?;为什么这样设计:"先查长度再插入"在并发下是经典竞态(两个进程同时看到 99/100,都插成功变 101)。这里把 WHERE COUNT(*) < ? 塞进 INSERT 本身,SQLite 单写者保证这条语句原子执行——要么插成功、要么一行不返回,不存在中间态。重排 reorder()(第 164-201 行)则要求"必须包含全部排队项且各出现一次",否则整体回滚——防止客户端漏传某条消息导致序号空洞。另外 change_version()(第 34-47 行)用一条固定连接反复读 PRAGMA data_version:SQLite 的 data_version 只在写事务提交时递增,且同一条连接的观测是单调的——这是零成本变更通知(change notification)的标准技巧。
五、记忆任务租约:BEGIN IMMEDIATE + 所有权令牌 + 水位线幂等
runtime/memories.rs(5464 行,全 crate 最大文件)管理记忆提取的两级任务。抢任务的 try_claim_stage1_job()(第 678-850 行)是整段最精彩的并发控制:
📄 codex-rs/state/src/runtime/memories.rs (第 686-730、758-791 行)
let ownership_token = Uuid::new_v4().to_string(); // 随机所有权令牌:后续 mark done/failed 必须出示它,防止过期 worker 覆盖新状态let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; // 立即拿写锁再开事务:检查-插入必须原子,否则两个进程会同时抢到同一任务// —— 幂等短路 1:该线程已有更新或同版本的产物,直接跳过if existing_source_updated_at >= source_updated_at { // 旧输入不覆盖新输出 return Ok(Stage1JobClaimOutcome::SkippedUpToDate); // 明确返回"已最新",调用方无需重试}// —— 幂等短路 2:上次成功处理的水位线已经 ≥ 本次输入水位if last_success_watermark.is_some_and(|w| w >= source_updated_at) { // 同一份输入不重复提取(崩溃重放场景) return Ok(Stage1JobClaimOutcome::SkippedUpToDate); // 同上,幂等返回}// —— 核心 upsert:INSERT ... ON CONFLICT(kind, job_key) DO UPDATE SET ... WHEREWHERE // upsert 的接管条件全部写在这里,逐条 AND 收窄可抢范围 (jobs.status != 'running' OR jobs.lease_until IS NULL OR jobs.lease_until <= excluded.started_at) // 只接管"非运行中或租约已过期"的行:绝不抢活着的 worker AND (jobs.retry_at IS NULL OR jobs.retry_at <= excluded.started_at OR excluded.input_watermark > COALESCE(jobs.input_watermark, -1)) // 尊重退避窗口;输入更新时允许提前接管 AND (jobs.retry_remaining > 0 OR excluded.input_watermark > COALESCE(jobs.input_watermark, -1)) // 重试次数耗尽后,只有"输入真的变新了"才值得再跑 AND (SELECT COUNT(*) FROM jobs AS running_jobs WHERE running_jobs.kind = excluded.kind AND running_jobs.status = 'running' AND running_jobs.lease_until > excluded.started_at // 只统计"租约还活着"的任务,过期租约不算占位 AND running_jobs.job_key != excluded.job_key) < ? // 全局并发上限:存活 stage-1 任务数必须小于 max_running,在 SQL 里强制为什么这样设计:这是"数据库即协调器"的教科书实现——没有 Redis、没有分布式锁,全靠一条带复杂 WHERE 的 upsert:① BEGIN IMMEDIATE 把读检查也纳入写事务,消除 check-then-act 竞态;② 所有权令牌让"完成/失败"操作必须证明自己是当前持有者(mark_stage1_job_succeeded 第 880-897 行的 UPDATE 就带 AND ownership_token = ?),worker A 崩溃后 worker B 接管,A 的迟到写会被 WHERE 挡掉;③ 水位线(input_watermark / last_success_watermark)提供幂等:同一份输入无论重放多少次只提取一次;④ 并发上限用子查询 COUNT 在 SQL 层强制,应用层想绕过都绕不过。抢不到时还会回查状态并区分"退避中/重试耗尽/别人在跑"三种跳过原因(第 830-849 行),调用方可以据此决定是否值得再试。
六、损坏恢复:只搬坏库,不连坐其他 6 个文件
runtime/recovery.rs(第 69-230 行)处理最坏情况:某个库文件损坏。策略是"把这一个库连同它的 WAL/SHM 副车搬进带时间戳的备份目录,让 SQLite 重建它,其余 6 个库数据原封不动":
📄 codex-rs/state/src/runtime/recovery.rs (第 120-137、202-212 行)
fn sqlite_database_code_is_corruption(code: Cow<'_, str>) -> bool { // 按 SQLite 错误码判定"损坏" matches!(code.as_ref().to_ascii_lowercase().as_str(), // 错误码转小写后做集合匹配,大小写不敏感 "11" | "26" | "sqlite_corrupt" | "sqlite_notadb") // 11=SQLITE_CORRUPT、26=SQLITE_NOTADB:两个经典损坏码,大小写不敏感匹配}pub fn sqlite_error_detail_is_corruption(detail: &str) -> bool { // 错误信息兜底判定(有些驱动只给文本不给码) let detail = detail.to_ascii_lowercase(); // 统一小写再比较 detail.contains("database disk image is malformed") // 页级损坏的标准报错文案 || detail.contains("file is not a database") // 文件头不是 SQLite magic number || detail.contains("(code: 11)") // sqlx 把码拼在消息尾部的格式 || ... // 其余同族文案略}fn sqlite_paths(db_path: &Path) -> Vec<PathBuf> { // 枚举"一个库"的全部物理文件 let mut wal_path = db_path.as_os_str().to_os_string(); // 在路径末尾拼 -wal 得到 WAL 文件路径 wal_path.push("-wal"); // WAL 日志:必须和主库一起搬,否则新库+旧WAL=二次损坏 let mut shm_path = db_path.as_os_str().to_os_string(); // 同理拼出共享内存索引文件路径 shm_path.push("-shm"); // 共享内存索引文件:同理,是 WAL 模式的伴生文件 vec![db_path.to_path_buf(), PathBuf::from(wal_path), PathBuf::from(shm_path)] // 主库+两个副车三件套整体迁移}为什么这样设计:单文件数据库的损坏恢复有个陷阱:只删主库、留下 -wal/-shm,SQLite 重建时会尝试回放旧 WAL——等于把损坏数据再注入一遍。所以 sqlite_paths() 永远三件套一起搬。备份目录用 create_unique_backup_dir()(第 214-230 行)生成 db-backups/sqlite-{时间戳}-{序号},AlreadyExists 就自增序号——多次损坏互不覆盖,用户事后可人工抢救。判定逻辑同时看错误码和文案(第 107-137 行),因为不同 sqlx/SQLite 版本暴露的形式不一致,双通道兜底才稳。
七、机制总览:一张表看懂 State Runtime Store 的防御体系
贯穿全篇的一条主线:把并发正确性下沉到 SQL 层。限长、租约、并发上限、幂等——全部用单条语句的 WHERE/ON CONFLICT 表达,应用层代码只剩"执行 + 解释结果"。SQLite 的单写者模型在这里不是限制而是特性:它让"原子 check-and-act"不需要任何外部协调器。
八、启动路径数据流
启动路径(StateRuntime::init_inner)
① 打开主库:open_state_db → migrate
state_5.sqlite 先就位,失败即终止——它是其余 store 的对账基准。
▼
② 依次打开 logs / goals / memories / queue
每步失败都级联关闭已开池;open/migrate 两阶段各自打点遥测。
▼
③ 预热缓存:backfill 行 + MAX(updated_at/recency)
线程时间戳水位存入 AtomicI64,后续"最近活跃"排序免查库。
▼
④ v2 记忆按需参与损坏恢复
文件存在才打开并校验;不存在则不创建,老用户零感知。
▼
⑤ 日志启动维护:删 >10 天 + wal_checkpoint(PASSIVE)
PASSIVE 只回收立即可用帧、不等待活跃读者,清理不阻塞前台。
📚 系列导航
← 第 28 讲:Exec Server 远程/本地执行服务
→ 第 30 讲:Cloud Tasks + External Agent Migration
关注公众号「AI技术推荐官」获取更多源码解析内容