一次操作要改多个文件,如何保证知识库永远不"半截":claude-obsidian 可恢复事务机制解析
【免费下载链接】claude-obsidianSelf-organizing AI second brain for Obsidian + Claude Code. Drop any source and Claude reads, links, and files it into one connected knowledge graph of plain Markdown you own. AI note-taking, personal knowledge management (PKM), and an open-source Notion alternative. Based on Karpathy's LLM Wiki pattern.项目地址: https://gitcode.com/GitHub_Trending/cl/claude-obsidian
本文拆解 claude-obsidian 的 Obsidian Markdown 知识库"可恢复 vault 事务"机制:它如何用一个持久日志、一份预检哈希和一把带所有权令牌的锁,把"一次改好几个笔记"的操作变成要么全成、要么全退的诚实承诺。
claude-obsidian 是一个开源的 AI 第二大脑工具:你把资料丢进inbox/,它自动阅读、提炼、归档,最后变成 Obsidian 里互相链接的 Markdown 知识图谱。听起来很美,但底层藏着一个朴素却致命的问题——它的每次"保存"从来不是改一个文件。
看官方文档 operation-transactions.md 里的"耦合写入清单":一次 Save 要同时更新笔记本身、索引index.md、日志log.md和热缓存hot.md四个文件;一次 Ingest(归档入库)要动源文件、新笔记、证据记录、索引、日志……十来个。普通文件系统不保证"多文件写入是原子的":写到第三个文件时进程崩了、断电了,你的知识库就停在"笔记写了、索引没更"的半截状态,而且没人知道它到底写到了哪。
源码开头 transaction.py 的第一段注释毫不客气地承认了这一点:"多文件文件系统更新在常见文件系统上无法做到真正的原子。"然后它给出的答案是"更强、更诚实的承诺"(a stronger, honest contract):一把进程持有的变更锁、预检哈希、一份持久日志、逐文件的原子替换,外加确定性的回滚与恢复。
下面按一个操作的生命周期走一遍:动作之前它怎么立字据,动作当下它怎么拿锁和落笔,动作失败之后它怎么收拾残局。
动笔之前:先立一份"书面变更计划"
这套机制的起点是一份叫transaction bundle(事务包)的 JSON 文档,schema 名为claude-obsidian.transaction.v1。它不是"我要写这些文件"的模糊声明,而是一份极其具体的字据,核心字段有四个:
operation_type:操作类型,比如save、ingest、capture;writes:每个目标路径、写入模式(create或replace)、以及新内容的 SHA-256;expected_hashes:每个目标路径"此刻应该长什么样"的预期哈希——文件不存在就填null;read_preconditions:只读依赖的文件的预期哈希(比如 Ingest 要读原始源文件,就顺手把它的指纹也钉死)。
expected_hashes是整个机制的枢纽。它意味着:你在起草计划时看到的每个文件,都必须在执行时保持"原样",否则直接拒绝。这就像二手房交易前的"标的物状态确认"——合同里写明了墙面颜色,签约时墙被刷了,交易不成立。执行阶段的对应错误码是EXPECTED_HASH_MISMATCH,语义是"这个文件在你起草之后被人改过"。
计划立好还不能直接执行。CLI 提供transaction inspect子命令做纯只读预演,并计算一个approval_sha256(审批哈希)。这个哈希由plan_approval_sha256生成,它把四样东西绞在一起:规范化后的 vault 根路径、vault 目录自身的"身份证号"(st_dev设备号 +st_inoinode 号)、扩展后 bundle 的规范 JSON 哈希、每个写入的(旧哈希、新哈希、权限模式)投影。
这带来了两个实用性质:改一个字节,哈希就变,所以审批的是精确到字节的内容;换一座 vault,哈希就废,所以别人没法拿这座库审批过的令牌去操作另一座库。真正执行时,transaction apply要求把这个哈希原样传回——"你批准的那个字节级计划,一个字节都不能差。"
变更锁:先拿到"唯一一支笔"
计划通过预演,接下来MutationLock登场。它要解决的问题是:两个进程(比如你手动跑了一次归档,同时后台 agent 也在存笔记)同时改同一座库。
锁的物理形态有点反直觉:不是锁文件,而是一个目录.vault-meta/mutation.lock。目录的创建在 POSIX 上是原子的——mkdir成功就是拿到锁,FileExistsError说明别人先到了。抢到锁的进程会在里面写入一份owner.json,记录 pid、主机名、开始时间和一个随机 token。
细节里最有意思的是三点:
锁是"钉在描述符上"的,不是钉在路径上的。整个操作期间,进程持有 vault 根目录、.vault-meta目录、锁目录各自的目录描述符(dirfd),后续所有打开、创建、删除都相对这些描述符进行,全程O_NOFOLLOW(绝不跟随符号链接)。这意味着即使有人趁操作进行到一半,把 vault 里的某个目录偷偷替换成指向/etc的符号链接,读写也不会被"拐跑"——路径在拿到那一刻就被物理固定了。_open_lock_parent_from_root_fd这类函数就是干这件事的。
释放锁之前要回读 token。MutationLock.release在拆锁前会重新读一次owner.json,用hmac.compare_digest比对 token 是否还是自己的。对不上就抛LOCK_OWNERSHIP_LOST——宁可不释放,也绝不拆掉"可能已经不是我的"锁。这是防止并发偷换锁的最后一道保险。
过期锁的回收极其保守。进程挂了,锁目录会一直留着。回收的条件是:同一主机、持有进程确认已死(os.kill(pid, 0)探测)、锁龄超过 1 小时(stale_after),三者同时满足才动手——而且删除前先改名隔离成mutation.lock.reaping-<pid>-<uuid>,改完名还要复查"这个目录还是我认定的那个目录",才允许删除。文档里明确写着:绝不允许仅凭"锁变老了"就偷锁,--force-stale-lock是留给运维的显式覆盖开关,"不要自动化它"。
等锁超时(默认 10 秒)拿不到,进程退出码 75 并报错——75 在这套体系里是约定俗成的"冲突"信号,上层 agent 看到 75 就知道:重读、重起草、重来。
日志与原子替换:每个文件是"换上去的",不是"写上去的"
锁到手,真正动笔。这里有两件并行的事。
写日志(journal)。在每个文件动之前,先在.vault-meta/transactions/<operation_id>/下原子写入一份journal.json,初始状态prepared,逐条记录每个写入的路径、模式、新哈希、旧哈希、旧权限、新权限、以及备份文件名。每完成一个文件,applied列表就补一条。这份日志是"如果我在第 3/7 个文件时死了,下一个人该怎么救场"的完整说明书。
逐文件原子替换。具体写入由_atomic_vault_write执行,套路是经典的"临时文件 + rename":在目标目录里创建.{文件名}.txn-{pid}-{uuid}临时文件,O_EXCL保证不覆盖任何已存在的东西,写完fsync落盘、fchmod设好权限,然后os.replace一步原子地顶掉目标文件,最后连父目录也fsync一次。读者永远只看到"旧文件"或"新文件"两种状态,看不到写了一半的东西。
被覆盖的旧内容不是丢进回收站就完事,而是存成backups/0001.original、0002.original……备份同样有预算:整个操作的备份总量和新增内容总量各自封顶 128 MiB。这个封顶不是抠门,而是恢复能力的边界——引擎只接受"它自己兜得起底"的操作,超了就拒绝,而不是赌。
失败之后:连回滚都要"证明文件没被动过"
半截状态被日志堵住了,但收拾残局本身也可能出事。回滚要删掉新文件(create 模式)或恢复备份(replace 模式),可万一恢复执行时,那个新文件已经被别人换掉了呢?删它等于误伤,不删又回滚不干净。
_confined_vault_unlink的解法是把"删文件"变成一场审讯:先按目录描述符打开目标,确认是普通文件,算 SHA-256 与日志里的预期值比对,然后再做一次 stat,核对设备号 + inode 号与刚才打开的是同一个对象——两次身份核验都通过才允许unlink。任何一步对不上,抛ROLLBACK_TARGET_CHANGED,停止一切动作。宁可留下一份"没删干净的现场"让人类处理,也不会在错误的位置下手。
恢复的入口有两处:下一次transaction apply会先扫描未完成的日志(recover_incomplete),自动决定补做还是回滚;也可以显式跑transaction recover。恢复逻辑只认日志和备份里记的哈希,不信任任何"看起来差不多"的状态。所有恢复路径共享同一个哲学:不确定就失败关闭(fail closed)——CORRUPT_RUNTIME_STATE、RUNTIME_NAMESPACE_CHANGED、VAULT_NAMESPACE_CHANGED这些错误码对应的都是"我认不出现场了,停下",而不是"我猜一个继续"。
顺带一提,连读一个 64 KB 的配置文件(比如 workspace 配置)都要做"读前 stat → 打开后 fstat → 读完再 fstat"三连比对,任何时刻文件身份变了就报错。这套"读也要防掉包"的执念,在 paths.py 里贯彻得很彻底。
谁能动哪里:操作类型是一道权限边界
还有一层容易被忽略的设计:operation_type不只是审计标签,而是权限边界。每个操作类型都被写死了可触碰的内容域:
capture只能创建.raw/captured/*下的原始载荷(内容寻址仓库,只进不改);save、markdown、lint-fix、generic被圈死在wiki/之内;fold只能写一个 fold 页加index.md、log.md;canvas只能动画布目录。
同时存在一份保留路径黑名单(.git、.vault-meta/transactions、.vault-meta/mutation.lock等),任何用户侧 bundle 都无权写入——哪怕它用的是"最宽泛"的generic类型。也就是说,一个精心构造的事务包既越不了操作类型的围栏,也碰不了系统自己的运行时状态。
配套的资源预算同样写死在 transaction.py 顶部:单文件 64 MiB、单操作总内容 128 MiB、单操作最多 1024 个写入、路径最长 1024 字节、运行时 JSON 8 MiB。它们和归档入口 capture.py 里那套 100 文件 / 256 MB 的批预算一样,都是"失败即中止"的硬限,不是建议值。
这些保证花掉了什么
诚实承诺的另一面是代价,值得摊开说:
性能。每个目标文件要读两遍(一遍算哈希、一遍存备份),每次写入要fsync文件加目录,锁的获取是 50 毫秒间隔的轮询。在一座几千页的库里连续操作,这是实打实的开销。换来的是崩溃恢复的确定性——这笔账在"个人知识库、分钟级写入频率"的场景下明显划算。
平台。描述符钉死(dirfd 封装)依赖 POSIX 的openat家族原语和fcntl.flock,paths.py 里的supports_confined_dirfd()会逐项检查。原生 Windows 没有这套原语,于是_require_write_platform在任何副作用发生之前就拒绝写入,报UNSUPPORTED_PLATFORM——只读检查和干跑可以原生跑,写入必须走 WSL(见 docs/windows-wsl.md)。同理,FAT/exFAT 这类不暴露稳定 inode 的文件系统也会被UNSAFE_VAULT_IDENTITY拦下,因为身份核验的地基不存在了。
可恢复性的半径。128 MiB 的总预算意味着超大迁移要按逻辑边界拆成多笔操作。这是设计者主动选择的:不保证"任何操作都可恢复",只保证"我接受的操作都可恢复"。
错误码与关键文件速查
| 错误码 / 退出码 | 含义 | 典型出处 |
|---|---|---|
LOCK_TIMEOUT(退出 75) | 变更锁被别的操作持有,等满超时 | MutationLock.acquire |
LOCK_OWNERSHIP_LOST | 锁的归属在持有期间变了,拒绝拆除 | MutationLock.release |
EXPECTED_HASH_MISMATCH(退出 75) | 目标文件在起草后被改过 | _prepare_writes |
ROLLBACK_TARGET_CHANGED(退出 3) | 回滚目标在验证期间被换过 | _confined_vault_unlink |
RAW_IS_CREATE_ONLY | 试图替换原始源载荷 | 写入范围校验 |
CASEFOLD_PATH_ALIAS | 库里已存在 NFC+大小写折叠后的别名路径 | 可移植别名审计 |
UNSAFE_VAULT_PATH/SYMLINK_WRITE_PATH | 目标路径越界或穿过了符号链接 | _safe_vault_path |
UNSUPPORTED_PLATFORM | 平台缺少 dirfd 封装原语,拒绝写入 | _require_write_platform |
| 文件 | 职责 |
|---|---|
| claude_obsidian/transaction.py | 事务引擎主体:锁、预检、日志、原子写、回滚与恢复 |
| claude_obsidian/paths.py | vault 根选择、路径包含性检查(assert_within)、符号链接识别 |
| claude_obsidian/capture.py | 归档入口的独立预算与队列锁(与事务层分头设防) |
| skills/wiki/references/operation-transactions.md | 事务契约、bundle 形状与失败行为的官方说明 |
| SECURITY.md | 安全边界与防御性不变量的声明 |
下次你在终端看到退出码 75,或者日志里蹦出一句ROLLBACK_TARGET_CHANGED,现在可以知道发生了什么:那不是 bug,而是这套机制在按剧本演出它最核心的承诺——知识库的每一处变更,要么完整成立,要么被证明地完整撤销;而它说不出话的每一种情况,它都会选择停下来。
【免费下载链接】claude-obsidianSelf-organizing AI second brain for Obsidian + Claude Code. Drop any source and Claude reads, links, and files it into one connected knowledge graph of plain Markdown you own. AI note-taking, personal knowledge management (PKM), and an open-source Notion alternative. Based on Karpathy's LLM Wiki pattern.项目地址: https://gitcode.com/GitHub_Trending/cl/claude-obsidian
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考