雨天小六

读懂 Codex(8.12):Rollout Reference Index 和被引用祖先保留

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

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

引用式 Fork 使 Rollout 文件不再彼此独立;RolloutReferenceIndex 扫描 active 与 archived 元数据,建立 source→referrer 的直接引用计数,供压缩和删除执行保留判断。

本节解决的不是“把对象存一下”这种抽象问题,而是把问题限定在:研究直接引用索引的构建和消费者,不把它误写成完整 lineage resolver。输入:active/archived Rollout 的 canonical SessionMeta.history_base;状态所有者:RolloutReferenceIndex;成功结果:每个物理源 Thread 的直接引用者集合/计数。先把这些边界钉住,后面的顺序、失败和恢复才不会混成一句“持久化失败后重试”。

状态边界

问题本节答案
输入active/archived Rollout 的 canonical SessionMeta.history_base
状态所有者RolloutReferenceIndex
成功产物每个物理源 Thread 的直接引用者集合/计数
研究范围研究直接引用索引的构建和消费者,不把它误写成完整 lineage resolver

正常路径:先看顺序点

Rollout Reference Index 和被引用祖先保留的正常路径时序图,展示按 active 后 archived 扫描物理 Rollout、读取 plain/zst canonical Meta、记录 child.history_base.thread_id、忽略 self reference/重复 Thread、消费者查询 source 是否仍被引用
图 8.12-1:按 active 后 archived 扫描物理 Rollout → 读取 plain/zst canonical Meta → 记录 child.history_base.thread_id → 忽略 self reference/重复 Thread → 消费者查询 source 是否仍被引用。这张图标出本节的实际顺序点与状态所有者。

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

  1. 按 active 后 archived 扫描物理 Rollout。
  2. 读取 plain/zst canonical Meta。
  3. 记录 child.history_base.thread_id。
  4. 忽略 self reference/重复 Thread。
  5. 消费者查询 source 是否仍被引用。

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

源码机制拆解

索引只读元数据头

构建引用关系无需加载整段 history;plain 和 compressed 都通过 RolloutFile 抽象读取 SessionMeta。这样删除预检成本与文件数相关,而非全部历史字节数。

Active 表示优先

同一 ThreadId 若同时在 active 与 archived 集合可见,扫描顺序和 seen set 让 active 版本胜出,避免双计数或选择陈旧 archive 元数据。

只统计直接物理引用

child 的 history_base 可以直接指向祖先物理文件;Index 记录该条边的 inverse map,不负责把祖父、父、子拼成逻辑历史。完整拼接由 RolloutLineage 完成。

Self-reference 不制造永久锁死

损坏元数据若指向自身,索引不会把它当作外部 referrer。Lineage resolver 会用 cycle detection 报错,保留判断不因此永远无法清理。

超时不返回半个索引

构建受 deadline 约束时,超时返回 None,而不是一个看似可用但漏掉部分 referrer 的 map。删除必须把“不知道”当成不能安全证明无引用。

Python 风格伪代码

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

async def build_reference_index(active, archived, deadline):
    inverse = defaultdict(set)
    seen = set()
    for collection in [active, archived]:
        for rollout in collection:
            if deadline.expired():
                return None
            meta = await rollout.read_canonical_meta_plain_or_zst()
            if meta.id in seen:
                continue
            seen.add(meta.id)
            base = meta.history_base
            if base and base.thread_id != meta.id:
                inverse[base.thread_id].add(meta.id)
    return ReferenceIndex(inverse)

def can_remove(index, source, deleting_set):
    return index.referrers(source).issubset(deleting_set)

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

失败、取消与恢复

Rollout Reference Index 和被引用祖先保留的失败路径图,区分索引构建超时、active/archive 重复文件、误把索引当 lineage、删除时忽略 batch 内引用
图 8.12-2:同一操作在不同故障点留下的状态不同,恢复责任必须回到拥有该状态的组件。
故障点已留下的状态可观察结果恢复责任
索引构建超时可能尚未扫描所有 child不能证明 source 无引用返回 None/阻止危险操作
active/archive 重复文件同一 child 出现两次若不去重会重复计数按 ThreadId seen,active 优先
误把索引当 lineage只掌握直接 inverse edge读取祖先顺序错误交给 RolloutLineage resolver
删除时忽略 batch 内引用父子一起删除单条检查会误拒绝以 deleting_set 抵消内部 referrer

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

必须保持的不变量

  • 索引不漏扫时才可证明无外部引用
  • 同一 referrer 每个 source 只计一次
  • self-reference 不进入 inverse map
  • 压缩与删除使用同一物理引用事实

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

设计取舍

扫描式索引避免另建必须强一致维护的引用数据库,但危险操作需要付出全目录元数据扫描和超时处理成本。

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

Mini Codex 复刻

从每个日志首行提取 history_base,构建 inverse adjacency;delete_many 先做全量预检,再按 batch 计算外部引用。

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

源码导航

相邻测试也很重要:

本节结论

引用式 Fork 使 Rollout 文件不再彼此独立;RolloutReferenceIndex 扫描 active 与 archived 元数据,建立 source→referrer 的直接引用计数,供压缩和删除执行保留判断。

阅读导航

上一节:8.11 · 下一节:8.13

评论


← 返回文章列表