路径锁与崩溃恢复
OpenViking 通过路径锁和持久化队列恢复两个简单原语保护核心写操作(rm、mv、add_resource、session.commit)的一致性,确保 VikingFS、VectorDB、QueueManager 三个子系统在故障时不会出现数据不一致。
设计哲学
OpenViking 是上下文数据库,FS 是源数据,VectorDB 是派生索引。索引丢了可从源数据重建,源数据丢失不可恢复。因此:
宁可搜不到,不要搜到坏结果。
设计原则
写互斥:通过路径锁保证同一路径同一时间只有一个写操作
默认生效:所有数据操作命令自动加锁,用户无需额外配置
锁即保护:进入 LockContext 时加锁,退出时释放,没有 undo/journal/commit 语义
仅 session_memory 需要崩溃恢复:通过持久化
session_commit队列在进程崩溃后恢复 Phase 2Queue 操作在锁外执行:SemanticQueue/EmbeddingQueue 的 enqueue 是幂等的,失败可重试
架构
两个核心组件
组件 1:PathLockEngine + LockManager + LockContext(路径锁系统)
PathLockEngine 实现基于文件的分布式锁,支持 EXACT 和 TREE 两种锁类型,使用 fencing token 防止 TOCTOU 竞争,自动检测并清理过期锁。
LockHandle 是轻量的锁持有者令牌:
LockManager 是全局单例,管理锁生命周期:
创建/释放 LockHandle
后台清理泄漏的锁(进程内安全网)
启动后由 QueueManager 恢复持久化的
session_commitPhase 2 任务
LockContext 是异步上下文管理器,封装加锁/解锁生命周期:
组件 2:持久化 session_commit 队列(崩溃恢复)
session.commit 的 Phase 2 不再使用独立 RedoLog。Phase 1 会先把 archive 元数据持久化,再把
SessionCommitMsg 写入持久化队列;进程重启后,QueueManager 会继续消费遗留的 session_commit
任务并恢复 Phase 2。
Memory 提取是幂等的,从同一个 archive 重新提取会得到相同结果。
一致性问题与解决方案
rm(uri)
| 问题 | 方案 |
|---|---|
| 先删文件再删索引 -> 文件已删但索引残留 -> 搜索返回不存在的文件 | 调换顺序:先删索引再删文件。索引删除失败 -> 源文件仍在,重试可完成可能只执行了一部分的索引清理 |
加锁策略(根据目标类型区分):
删除目录:
lock_mode="tree",锁目录自身及其整棵子树删除文件:
lock_mode="exact",锁文件路径本身
操作流程:
索引 URI 收集或 VectorDB 删除失败 -> 直接抛异常,锁自动释放,源文件仍在。多记录删除在部分 后端可能已经执行了一部分,但重试可以安全补完清理。FS 删除失败 -> VectorDB 已删但文件还在, 重试同样安全。
mv(old_uri, new_uri)
| 问题 | 方案 |
|---|---|
| 文件移到新路径但索引指向旧路径 -> 搜索返回旧路径(不存在) | 先 copy 再更新索引,失败时清理副本 |
加锁策略(通过 lock_mode="mv" 自动处理):
移动目录:源路径加 TreeLock,目标路径加 ExactPathLock
移动文件:源路径和目标路径各加 EXACT 锁
操作流程:
add_resource
| 问题 | 方案 |
|---|---|
| 文件从临时目录移到正式目录后崩溃 -> 文件存在但永远搜不到 | 首次添加与增量更新分离为两条独立路径 |
| 资源已落盘但语义处理/向量化还在跑时被 rm 删除 -> 处理白跑 | 生命周期 TreeLock,从落盘持续到处理完成 |
首次添加(target 不存在)— 在 ResourceProcessor.process_resource Phase 3.5 中处理:
如果本次调用关闭了摘要和索引(没有下游 DAG 接管),则在同一把 TreeLock
里把 temp 目录内容复制到 final_uri,清理 temp,然后释放锁。这里不调用
VikingFS.mv(temp, final_uri, lock_handle=handle),避免移动逻辑清理目录锁文件。
此期间 rm 尝试获取同路径 TreeLock 会失败,抛出 ResourceBusyError。
增量更新(target 已存在)— temp 保持不动:
注意:DAG callback 不在外层加锁。每个 VikingFS.rm 和 VikingFS.mv 内部各自有独立锁保护。外层锁会与内部锁冲突导致死锁。
首次添加和增量更新都只持有 TreeLock(resource_dir)。这里不再做
ExactPathLock(resource_dir) -> TreeLock(resource_dir) 的锁转交,避免两种锁复用
同一个 .path.ovlock 时出现释放顺序错误。
自动命名由资源层处理,不属于锁服务:ResourceProcessor 先用 exists(candidate_uri)
判断候选目录是否已占用;已存在则尝试 _1、_2 后缀。候选目录不存在时才尝试
获取该目录的 TreeLock,且不等待;如果同名正在被并发请求处理,就直接尝试下一个后缀。
服务重启恢复:SemanticMsg 持久化在 QueueFS 中。重启后 SemanticProcessor 发现 lifecycle_lock_handle_id 对应的 handle 不在内存中,会重新获取 TreeLock。
派生语义文件(.abstract.md / .overview.md)
.abstract.md 和 .overview.md 是后台生成的派生文件,不作为普通用户源文件写入。它们的并发保护分两层:
| 问题 | 方案 |
|---|---|
| 多个后台任务同时刷新同一个目录摘要,旧结果覆盖新结果 | 相同 dirty key 使用 coalesce_version,只有最新版本允许写回 |
| 最新任务写回派生文件时与另一个写回交错 | 写 .abstract.md、.overview.md 前获取各自的 ExactPathLock |
例子:同一目录下并发写入 a.md、b.md、c.md 时,前台写入分别持有 ExactPathLock(a.md)、ExactPathLock(b.md)、ExactPathLock(c.md),互不阻塞。后台可能产生多个 docs/ 摘要刷新任务,但只有最新 version 能写回 docs/.overview.md 和 docs/.abstract.md;旧任务在写回前发现自己过期后直接丢弃结果。
memory 目录摘要使用同一规则。比如并发更新:
两个文件写入各自持有 ExactPathLock;preferences/.overview.md 和 preferences/.abstract.md 的后台刷新不再持有长时间 TreeLock,而是通过 coalesce_version 淘汰旧任务,并在最终写派生文件时短暂获取 ExactPathLock。
session.commit()
| 问题 | 方案 |
|---|---|
| 消息已清空但 archive 未写入 -> 对话数据丢失 | Phase 1 无锁(archive 不完整无副作用)+ Phase 2 持久化 session_commit 队列 |
LLM 调用耗时不可控(5s~60s+),不能放在持锁操作内。设计拆为两个阶段:
崩溃恢复分析:
| 崩溃时间点 | 状态 | 恢复动作 |
|---|---|---|
| Phase 1 写 archive 中途 | 队列未发布 | archive 不完整,下次 commit 从 history/ 扫描 index,不受影响 |
| Phase 1 archive 完成但 messages 未清空 | 队列未发布 | archive 完整 + messages 仍在 = 数据冗余但安全 |
| Phase 2 记忆提取/写入中途 | session_commit 任务仍在持久化队列中 | 重启后继续消费该任务,从 archive 恢复 Phase 2 |
| Phase 2 完成 | archive 标记为完成 | 无需恢复 |
LockContext
LockContext 是异步上下文管理器,封装锁的获取和释放:
锁模式:
| lock_mode | 用途 | 行为 |
|---|---|---|
exact | 文件写入、单文件删除、派生文件写回 | 锁定指定路径;与同路径锁和祖先目录 TreeLock 冲突 |
tree | 删除目录、资源生命周期、目录级保护 | 锁定子树根节点;与同路径锁、后代锁和祖先 TreeLock 冲突 |
mv | 移动操作 | 目录移动:源路径 TreeLock + 目标路径 ExactPathLock;文件移动:源路径和目标路径均 ExactPathLock(通过 src_is_dir 控制) |
异常处理:__aexit__ 总是释放锁,不吞异常。获取锁失败时抛出 LockAcquisitionError。
锁类型(EXACT vs TREE)
锁机制使用两种锁类型来处理不同的冲突场景:
| 同路径 EXACT | 同路径 TREE | 后代 EXACT | 祖先 TREE | |
|---|---|---|---|---|
| EXACT | 冲突 | 冲突 | — | 冲突 |
| TREE | 冲突 | 冲突 | 冲突 | 冲突 |
EXACT (E):锁定一个具体路径本身。文件、目录名、尚未创建的目标路径都可以使用;若祖先目录持有 TreeLock 则阻塞。
TREE (T):用于删除目录、移动目录、资源生命周期保护等。逻辑上覆盖整棵子树,但只在根目录写一个锁文件。获取前扫描所有后代和祖先目录确认无冲突锁。目标目录不存在时,先做冲突检查;无冲突才创建目录并写锁。若创建后又发现并发冲突,本次加锁失败,但不回滚刚创建出来的空目录。
锁机制
锁协议
锁文件路径:
锁文件内容(Fencing Token):
其中 lock_type 为 E(EXACT)或 T(TREE)。
获取锁流程(EXACT 模式)
获取锁流程(TREE 模式)
缺失目录创建规则
锁系统允许为了放置锁文件而创建目录,但创建前必须先检查冲突:
例子:
如果两个请求同时创建 java-guide,两边都可能先看到“当前无冲突”,但最终只有
fencing token 校验通过的一方成功持有 TreeLock(java-guide);失败方会删除自己的锁,
已创建出来的空目录可以保留。
锁过期清理
陈旧锁检测:PathLockEngine 检查 fencing token 中的时间戳。超过 lock_expire(默认 30s)的锁被视为陈旧锁,在加锁过程中自动移除。
进程内清理:LockManager 每 60 秒检查活跃的 LockHandle。仍持有锁文件且失活时间超过 lock_expire 的 handle 会被强制释放。
孤儿锁:进程崩溃后遗留的锁文件,在下次任何操作尝试获取同一路径锁时,通过 stale lock 检测自动移除。
崩溃恢复
服务启动后,QueueManager 会继续消费持久化的 session_commit 任务:
| 场景 | 恢复方式 |
|---|---|
| session_memory 提取中途崩溃 | 从 archive 恢复 Phase 2 并继续消费 session_commit 任务 |
| 锁持有期间崩溃 | 锁文件留在 AGFS,下次获取时 stale 检测自动清理(默认 30s 过期) |
| enqueue 后 worker 处理前崩溃 | QueueFS SQLite 持久化,worker 重启后自动拉取 |
| 孤儿索引 | L2 按需加载时清理 |
防线总结
| 异常场景 | 防线 | 恢复时机 |
|---|---|---|
| 操作中途崩溃 | 锁自动过期 + stale 检测 | 下次获取同路径锁时 |
| add_resource 语义处理中途崩溃 | 生命周期锁过期 + SemanticProcessor 重启时重新获取 | worker 重启后 |
| session.commit Phase 2 崩溃 | 持久化 session_commit 队列 + 重试消费 | 重启时 |
| enqueue 后 worker 处理前崩溃 | QueueFS SQLite 持久化 | worker 重启后 |
| 孤儿索引 | L2 按需加载时清理 | 用户访问时 |
配置
路径锁默认启用,无需额外配置。推荐通过 storage.agfs.pathlock 配置过期时间。运行时等待超时固定为 0.0 秒,不再接受外部配置。storage.transaction 仅保留为兼容旧配置:lock_timeout 已废弃且会被忽略,lock_expire 会在未显式配置新字段时自动映射,redo_recovery_enabled 已废弃且会被忽略。
推荐写法:
兼容旧写法:
| 参数 | 类型 | 说明 | 默认值 |
|---|---|---|---|
lock_timeout | float | 已废弃且忽略。运行时等待超时固定为 0.0。 | 0.0 |
lock_expire | float | 已废弃。改用 storage.agfs.pathlock.lock_expire_secs。 | 30.0 |
QueueFS 持久化
路径锁机制依赖 QueueFS 使用 SQLite 后端,确保 enqueue 的任务在进程重启后可恢复。这是默认配置,无需手动设置。