引用式 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 |
正常路径:先看顺序点
这条路径可以压缩成五步:
- 按 active 后 archived 扫描物理 Rollout。
- 读取 plain/zst canonical Meta。
- 记录 child.history_base.thread_id。
- 忽略 self reference/重复 Thread。
- 消费者查询 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 是可观察屏障,以及失败后保留的是已提交前缀、未提交后缀,还是完全独立的外部副作用。
失败、取消与恢复
| 故障点 | 已留下的状态 | 可观察结果 | 恢复责任 |
|---|---|---|---|
| 索引构建超时 | 可能尚未扫描所有 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。
源码导航
- codex-rs/rollout/src/rollout_reference_index.rs:active/archive 扫描、去重、直接引用与 deadline
- codex-rs/thread-store/src/local/delete_thread.rs:删除预检如何消费引用索引
- codex-rs/rollout/src/compression.rs:冷压缩如何跳过被引用 Rollout
相邻测试也很重要:
- codex-rs/rollout/src/rollout_reference_index_tests.rs:直接引用、active 优先、压缩表示和自引用
- codex-rs/thread-store/src/local/delete_thread.rs:父被引用时拒绝、父子批量删除允许
本节结论
引用式 Fork 使 Rollout 文件不再彼此独立;RolloutReferenceIndex 扫描 active 与 archived 元数据,建立 source→referrer 的直接引用计数,供压缩和删除执行保留判断。
评论
登录后即可评论