一个 Paginated child 的逻辑历史是有序的物理 segment 列表;Lineage resolver 沿 history_base 追溯、验证每个 cutoff 并反转顺序,所有 Reader 只能消费这些有界段。
本节解决的不是“把对象存一下”这种抽象问题,而是把问题限定在:研究 lineage 构造、循环/身份/边界校验及其对读取和删除的影响。输入:requested child ThreadId 与每个 SessionMeta.history_base;状态所有者:RolloutLineage;成功结果:ancestor→child 排列的 RolloutLineageSegment[]。先把这些边界钉住,后面的顺序、失败和恢复才不会混成一句“持久化失败后重试”。
状态边界
| 问题 | 本节答案 |
|---|---|
| 输入 | requested child ThreadId 与每个 SessionMeta.history_base |
| 状态所有者 | RolloutLineage |
| 成功产物 | ancestor→child 排列的 RolloutLineageSegment[] |
| 研究范围 | 研究 lineage 构造、循环/身份/边界校验及其对读取和删除的影响 |
正常路径:先看顺序点
这条路径可以压缩成五步:
- 从 child 开始读取 canonical meta。
- 记录当前 segment 的 frozen end。
- 跟随 history_base.thread_id 到祖先。
- 检测 cycle/identity/mode/bounds。
- 反转为祖先到 child 顺序。
图中的箭头不是“可能调用”的依赖图,而是源码中决定可见性和所有权转移的先后关系。前一步没有确认时,后一步不能替它作出更强的成功承诺。
源码机制拆解
Segment 同时有 start 与可选 end
start_ordinal 由该物理文件自己的 history_base.end_ordinal_exclusive+1 推出,无 base 时从 1 开始;end 来自后代指向它的 HistoryPosition,child 当前段通常没有 end。
每层必须验证物理身份
resolved path 的首个 SessionMeta.id 必须等于正在追踪的 ThreadId,history_mode 必须是 Paginated。路径同名或 SQLite 指错不能静默拼进历史。
seen set 阻止引用环
A→B→A 或 self cycle 会返回 invalid paginated history lineage。没有这个检查,恢复器会无限追溯或重复同一物理段。
Cutoff 不能包含源 Meta 或越过文件
end_ordinal_exclusive=0 会把祖先 SessionMeta 当继承正文;end_byte_offset 大于 file len 指向不存在内容。两者在建立 segment 时立即拒绝。
删除约束来自物理引用而非逻辑可见项
即便 child 只继承祖先很短前缀,该 source 文件仍必须存在;单独删除 ancestor 会被 ReferenceIndex 阻止。只有把所有 referrer 一起纳入 batch 才能安全清除。
Python 风格伪代码
下面的伪代码只保留设计职责、状态和失败顺序;它不逐行翻译 Rust,也不借 Python 语法虚构源码中不存在的事务:
async def resolve_lineage(requested_id, representation='existing'):
reverse_segments = []
seen = set()
thread_id, frozen_end = requested_id, None
while True:
if thread_id in seen:
raise InvalidLineage('cycle detected')
seen.add(thread_id)
path = await resolve_rollout_path(thread_id, include_archived=True)
if representation == 'plain-for-reference':
path = await materialize_under_writer_lock(path)
meta = await read_canonical_meta(path)
require(meta.id == thread_id and meta.history_mode == PAGINATED)
if frozen_end:
validate_cutoff(path, frozen_end)
start = meta.history_base.end_ordinal_exclusive + 1 if meta.history_base else 1
reverse_segments.append(Segment(thread_id, path, start, frozen_end))
if meta.history_base is None:
break
thread_id, frozen_end = meta.history_base.thread_id, meta.history_base
return Lineage(list(reversed(reverse_segments)))
阅读时要特别看三处:哪个对象拥有可变状态,哪一个 await 是可观察屏障,以及失败后保留的是已提交前缀、未提交后缀,还是完全独立的外部副作用。
失败、取消与恢复
| 故障点 | 已留下的状态 | 可观察结果 | 恢复责任 |
|---|---|---|---|
| history_base 形成环 | seen 已含 ThreadId | InvalidRequest | 停止追溯 |
| 源文件 Meta.id 不符 | 路径指向别的 Thread | 拒绝 lineage | 修复索引/路径而非拼接 |
| 祖先不是 Paginated | position 语义不兼容 | 拒绝 lineage | 使用 copied 兼容路径 |
| 单删被 child 引用的祖先 | child segment 悬空 | Conflict | 保留祖先或批量删除整个引用集合 |
这里没有统一的“回滚一切”。内存状态、日志行、SQLite 投影、父子拓扑和工具造成的文件/网络变化分别有自己的提交点。恢复代码只能根据已经存在的权威证据继续,不能用较弱的投影替较强的事实背书。
必须保持的不变量
- Lineage 中 ThreadId 不重复
- segment 顺序从最老祖先到 requested child
- 每个 frozen end 在对应物理文件边界内
- Reader 不读取 segment.end 之后的后来追加
这些不变量比“最终能 Resume”更严格:正常路径要成立,Writer 竞争、任务取消、坏尾行、投影落后和旧格式兼容时也必须成立。
设计取舍
指针链减少复制,却把一个逻辑 Thread 的可用性扩展为多个物理文件的共同可用性,删除和归档必须理解依赖图。
源码可以直接证明字段、分支、调用顺序和测试期望;“为什么这样设计”的表述是基于这些事实作出的工程归纳,不把它包装成未公开的产品承诺。
Mini Codex 复刻
Lineage 是唯一跟随 parent pointer 的模块;分页、搜索、模型上下文都只接收已验证 Segment[],不各自重新追指针。
复刻时先验证协议不变量,再补性能优化。一个能在故障注入下说明“留下了什么”的小实现,比一个只在正常路径调用 save() 的演示更接近真实 Runtime。
源码导航
- codex-rs/thread-store/src/local/rollout_lineage.rs:segment 构造、plain representation、cycle 与 cutoff 校验
- codex-rs/rollout/src/rollout_reference_index.rs:inverse direct references
- codex-rs/thread-store/src/local/delete_thread.rs:引用约束的删除执行
相邻测试也很重要:
- codex-rs/thread-store/src/local/rollout_lineage_tests.rs:多层、截断、循环、身份和 bounds
- codex-rs/thread-store/src/local/model_context_tests.rs:Reader 沿有界 lineage 选择模型上下文
本节结论
一个 Paginated child 的逻辑历史是有序的物理 segment 列表;Lineage resolver 沿 history_base 追溯、验证每个 cutoff 并反转顺序,所有 Reader 只能消费这些有界段。
评论
登录后即可评论