Archive 是在 active/archived 集合之间移动 Rollout 并更新元数据;Delete 是不可恢复移除,必须先锁住生命周期、预检引用,并在批量场景区分内部引用与外部引用。
本节解决的不是“把对象存一下”这种抽象问题,而是把问题限定在:对比 archive/unarchive/delete 的锁、路径和引用语义。输入:一个或一组 ThreadId、writer/lifecycle 状态和 ReferenceIndex;状态所有者:LocalThreadStore archive_thread/delete_thread;成功结果:移动后的可恢复 Rollout,或安全删除的文件与投影。先把这些边界钉住,后面的顺序、失败和恢复才不会混成一句“持久化失败后重试”。
状态边界
| 问题 | 本节答案 |
|---|---|
| 输入 | 一个或一组 ThreadId、writer/lifecycle 状态和 ReferenceIndex |
| 状态所有者 | LocalThreadStore archive_thread/delete_thread |
| 成功产物 | 移动后的可恢复 Rollout,或安全删除的文件与投影 |
| 研究范围 | 对比 archive/unarchive/delete 的锁、路径和引用语义 |
正常路径:先看顺序点
这条路径可以压缩成五步:
- 解析精确 Thread 集合。
- 按稳定顺序获取生命周期和 writer locks。
- 检查 live/cross-process owner 与引用。
- 执行 move 或 delete。
- best-effort/严格更新 State DB。
图中的箭头不是“可能调用”的依赖图,而是源码中决定可见性和所有权转移的先后关系。前一步没有确认时,后一步不能替它作出更强的成功承诺。
源码机制拆解
Archive 不等于删除
archive 把 sessions 下的 canonical path rename 到 archived_sessions,保留同一文件名与内容,并在 State DB 标记 archived_at/path。Unarchive 根据文件名日期恢复层级目录并 touch mtime。
活动 Writer 阻止管理操作
批量 archive 先锁 lifecycle,再锁每 Thread writer 并检查 live_recorders/cross-process locks。不能在 Recorder 仍追加时移动它的路径。
单删必须没有外部直接引用
Delete 构建 RolloutReferenceIndex,发现任何其他 child 的 history_base 指向 source 就返回 Conflict;否则 child 的逻辑历史会立即不可读。
批量删除先全量预检
删除 parent+child 时,child 对 parent 的引用属于 batch 内部,可允许;仍在 batch 外的 referrer 会在删除任何文件前阻止整个操作,避免只删一半才发现依赖。
路径删除与投影清理协调
成功预检和锁定后,Store 删除 State DB thread/history projection 与 active/archived plain/zst Rollout,并移除 live recorder。NotFound 的幂等语义与严格批量计数由接口区分。
Python 风格伪代码
下面的伪代码只保留设计职责、状态和失败顺序;它不逐行翻译 Rust,也不借 Python 语法虚构源码中不存在的事务:
async def archive_many(store, ids, extra_lock_ids=[]):
ordered = sorted(unique(ids + extra_lock_ids))
async with acquire_lifecycle_locks(ordered):
async with acquire_writer_locks(ordered):
require(no_live_or_external_writer(ordered))
results = []
for thread_id in ids:
results.append(await move_to_archive_and_mark_db(thread_id))
return results
async def delete_many(store, ids):
deleting = set(ids)
async with acquire_lifecycle_and_writer_locks(sorted(deleting)):
refs = await build_complete_reference_index_or_fail()
for source in deleting:
external = refs.referrers(source) - deleting
if external:
raise Conflict(f'{source} is referenced by {external}')
# preflight complete: no file has been deleted yet
for thread_id in ids:
await store.delete_projection_and_rollout(thread_id)
阅读时要特别看三处:哪个对象拥有可变状态,哪一个 await 是可观察屏障,以及失败后保留的是已提交前缀、未提交后缀,还是完全独立的外部副作用。
失败、取消与恢复
| 故障点 | 已留下的状态 | 可观察结果 | 恢复责任 |
|---|---|---|---|
| Archive 时有 active writer | 文件可能继续追加 | Conflict | 先正常 Shutdown Thread |
| 单删被引用 parent | child 仍依赖前缀 | Conflict | 保留 parent 或一起删除 child |
| 批量中存在外部 referrer | 依赖不在删除集合 | 删除前整体拒绝 | 扩展集合或取消 |
| Unarchive 文件名无日期 | 无法恢复 canonical 目录 | InvalidRequest | 保留 archive 并修复命名 |
这里没有统一的“回滚一切”。内存状态、日志行、SQLite 投影、父子拓扑和工具造成的文件/网络变化分别有自己的提交点。恢复代码只能根据已经存在的权威证据继续,不能用较弱的投影替较强的事实背书。
必须保持的不变量
- Archive 保留 Rollout 内容和 ThreadId
- 任何文件操作发生前先完成批量外部引用预检
- 管理操作不与 active writer 并发
- 删除后不能留下指向已删除物理前缀的 durable child
这些不变量比“最终能 Resume”更严格:正常路径要成立,Writer 竞争、任务取消、坏尾行、投影落后和旧格式兼容时也必须成立。
设计取舍
严格预检与全局元数据扫描让删除更慢,但删除是不可恢复操作,必须优先保护所有仍可读取的引用历史。
源码可以直接证明字段、分支、调用顺序和测试期望;“为什么这样设计”的表述是基于这些事实作出的工程归纳,不把它包装成未公开的产品承诺。
Mini Codex 复刻
archive 用 rename,delete 用 preflight-plan-then-execute;引用检查返回具体 referrers,批量集合先闭包验证再修改文件。
复刻时先验证协议不变量,再补性能优化。一个能在故障注入下说明“留下了什么”的小实现,比一个只在正常路径调用 save() 的演示更接近真实 Runtime。
源码导航
- codex-rs/thread-store/src/local/archive_thread.rs:批量锁、active writer gate 与 move
- codex-rs/thread-store/src/local/unarchive_thread.rs:日期路径恢复与 DB 更新
- codex-rs/thread-store/src/local/delete_thread.rs:引用预检、稳定锁序和实际删除
相邻测试也很重要:
- codex-rs/thread-store/src/local/archive_thread.rs:reservation 等待、descendant writer、路径移动与 DB metadata
- codex-rs/thread-store/src/local/unarchive_thread.rs:恢复路径、mtime 与 section 保留
- codex-rs/thread-store/src/local/delete_thread.rs:引用阻止、batch parent+child、active writer 与 projection 清理
本节结论
Archive 是在 active/archived 集合之间移动 Rollout 并更新元数据;Delete 是不可恢复移除,必须先锁住生命周期、预检引用,并在批量场景区分内部引用与外部引用。
评论
登录后即可评论