Tolaria 基于 Git 的增量 Vault 缓存:9000+ 笔记秒级启动的实现剖析
2026/9/13 19:35:26 网站建设 项目流程

Tolaria 基于 Git 的增量 Vault 缓存:9000+ 笔记秒级启动的实现剖析

【免费下载链接】tolariaDesktop app to manage markdown knowledge bases项目地址: https://gitcode.com/GitHub_Trending/to/tolaria

导读:本文以 Tolaria 的架构决策记录 ADR-0014(docs/adr/0014-git-based-vault-cache.md)为骨架,深入讲解其"以 Git 作为变更检测机制"的增量缓存方案:缓存文件的组织方式、同 commit / 跨 commit 两套增量更新路径、原子写入与并发保护、缓存失效与迁移机制,并结合 cache.rs 源码与测试用例逐层印证。读完你将掌握"git HEAD + git diff/status 增量扫描"这一可复用的桌面端知识库加速范式。

背景:全量扫描为何不可接受

Tolaria(原代号 Laputa)是一个桌面端 Markdown 知识库管理应用,vault 中的笔记、Type 定义、视图(.yml)、附件以纯文本形式存放在 git 仓库内。ADR-0014 给出了明确的问题定量:

  • 一个9000+ 个 Markdown 文件的 vault,每次启动全量扫描需要数秒;
  • 启动路径上的扫描不仅解析 frontmatter、正文摘要、wikilink 关系,还要为每个文件补齐 Git 提交历史中的创建/修改时间(mod.rs 中resolve_entry_dates的逻辑),成本更高;
  • 更麻烦的是,vault 会被外部编辑器、git pull、Finder 等外部手段修改,缓存必须能正确感知这些外部变化,否则会出现"界面还显示旧笔记"的错误。

因此设计目标不是简单的"缓存 + 过期清空",而是:

  1. 只重解析自上次扫描以来确实变化过的文件(增量);
  2. 对已提交变更(commit)与未提交变更(working tree)都保持正确;
  3. 在缓存未命中、损坏、版本升级时可靠地回退到全量扫描;
  4. 缓存写入绝不能破坏 vault 本身(外部存放 + 原子写)。

决策概览:用 Git 当"变更传感器"

ADR-0014 的决策原文可概括为:使用 Git 作为变更检测机制。缓存把所有VaultEntry序列化为 JSON,存放在~/.laputa/cache/<vault-hash>.json;加载时比较缓存记录的 HEAD commit hash 与当前 HEAD:

  • hash 相同:只重解析未提交(uncommitted)的变化文件;
  • hash 不同:用git diff找出两次 commit 之间变化的文件,选择性重解析;
  • 全量重扫仅在缓存未命中(Missing)或版本号升级(version bump)时发生。

配套的 ADR-0024(docs/adr/0024-cache-outside-vault.md)进一步约束了缓存的位置:早期版本的缓存.laputa-cache.json直接放在 vault 内部,会污染 git status、可能被误提交、还会干扰 vault 扫描本身(缓存文件自身也是 vault 中的一个文件)。因此最终落地为外部缓存路径 + 旧缓存自动迁移删除。

为什么在三个备选方案中选中 Git?ADR-0014 的方案对比表如下:

方案机制优点缺点
A(选定)Git 增量缓存复用已有 git 基建;变更检测精确;同时覆盖已提交与未提交变更要求 vault 是 git 仓库;缓存失效逻辑复杂
B文件修改时间(mtime)无需 git 即可工作跨文件系统不可靠(iCloud、Dropbox 同步会改 mtime),存在时钟偏差
C文件内容 hash永远正确必须读每个文件算 hash,等于没缓存

mtime 方案的致命伤在同步盘场景:iCloud/Dropbox 同步会大面积改写 mtime,导致缓存频繁整体失效;内容 hash 方案则把"读全部文件"的成本从启动期挪到缓存校验期,同样无法接受。Git 方案的关键洞察是:git 已经在维护一份精确的、与文件系统解耦的变更账本,应用只需付出几次 git 命令的代价即可获得"哪些文件变了"的答案。

缓存文件组织:外部存放 + 确定性命名

目录与文件名

源码 cache.rs 给出了完整的路径计算逻辑:

  • 缓存目录默认~/.laputa/cache/,可用环境变量LAPUTA_CACHE_DIR覆盖(测试隔离依赖此变量);
  • 文件名 =vault_path_hash(vault)的 16 位十六进制 +.json,即<vault-hash>.json
  • hash 由DefaultHasher对"规范化后的 vault 绝对路径"计算,保证同一路径结果确定、不同路径结果不同(对应测试test_vault_path_hash_is_deterministictest_different_vaults_get_different_hashes);
  • 并发写锁文件为同名的.lock后缀文件;
  • 临时文件采用<name>.<uuid>.tmp格式,保证多进程/多线程下临时文件名不冲突。

缓存内容结构

struct VaultCache { version: u32, // CACHE_VERSION,当前为 14 vault_path: String, // 写入时的 vault 路径,用于跨机器/移动目录失效检测 commit_hash: String, // 写入时的 git HEAD entries: Vec<VaultEntry>, }

其中VaultEntry是 vault 扫描的最小产物(定义见 entry.rs),序列化后的 JSON 字段携带笔记的完整索引信息:pathtitleisAbelongsTorelatedTomodifiedAtcreatedAtfileSizesnippetrelationships、Type 相关的icon/color/order/sidebarLabeloutgoingLinkspropertieswordCounthasH1fileKind等。也就是说,缓存不是文件清单,而是整个 vault 的可序列化索引快照,前端打开 vault 时可以直接消费这些条目渲染列表、搜索与关系图。

vault_path字段的校验是防呆设计:从另一台机器 clone 或移动 vault 目录后,缓存里的绝对路径已失效,cache_requires_full_rescan会因此判定需要全量重扫。测试test_scan_vault_cached_invalidates_stale_vault_path模拟了"篡改缓存路径模拟他机 clone"的场景并验证了失效逻辑。

为什么必须放在 vault 外

  • 不会出现在git status里,不污染用户仓库(ADR-0024 的核心动机);
  • 缓存文件自身不会被 vault 扫描器当作内容文件重复解析,避免"扫描器扫描自己"的循环问题;
  • 旧版本遗留的.laputa-cache.json会被migrate_legacy_cache自动迁移到新位置并从 git 跟踪与磁盘中删除(git rm --cached --ignore-unmatch+fs::remove_file),对应测试test_legacy_cache_migration断言迁移后新文件存在、旧文件消失。

加载判定:四种状态与两条增量路径

缓存读取状态机

load_cache把磁盘上的缓存文件归为四类状态(cache.rs):

  • Missing:文件不存在(首次启动或已被删除);
  • Loaded:JSON 解析成功,附带字节级 fingerprint;
  • Invalid:文件存在但 JSON 解析失败(如磁盘写了一半、手动编辑坏文件);
  • Unreadable:文件存在但无法读取(权限错误等)。

scan_vault_cached的主流程(cache.rs):

入口 scan_vault_cached(vault) ├─ 校验 vault 路径存在且为目录 ├─ migrate_legacy_cache(vault) # 首次运行迁移旧缓存 ├─ resolve_git_workspace(vault) │ └─ 失败(非 git 仓库)→ 直接全量 scan_vault,不缓存 ├─ git_head_hash(workspace) # rev-parse HEAD │ └─ 失败 → 直接全量 scan_vault ├─ load_cache(vault) │ ├─ Missing → 走全量扫描分支 │ ├─ Invalid → 删掉坏缓存文件,走全量扫描分支 │ ├─ Unreadable → 仅告警,走全量扫描分支 │ └─ Loaded │ ├─ 版本或 vault_path 不匹配 → 全量重扫(用旧指纹做 CAS 写入) │ ├─ commit_hash == 当前 HEAD → update_same_commit(未提交增量) │ └─ commit_hash != 当前 HEAD → update_different_commit(git diff 增量) └─ 无缓存 → scan_and_cache_full(全量扫描并写缓存)

路径一:同 commit 增量(update_same_commit)

当缓存的commit_hash与当前 HEAD 一致时,说明"提交历史没有前进",变化只可能发生在 working tree(外部编辑器保存、Finder 新增/删除、未 commit 的修改)。此时:

  1. git status --porcelain收集修改/暂存文件,再用git ls-files --others --exclude-standard收集未跟踪文件(注释明确解释了为何补这一步:status --porcelain对新增目录只显示?? dir/,会隐藏目录内的具体文件,ls-files才能枚举出每个新文件);
  2. 从缓存 entries 中剔除这些文件的旧条目,只对变化文件调用parse_md_file/parse_non_md_file重新解析;
  3. 无论如何都执行prune_stale_entries——即使 git 报告"无变化",也会把磁盘上已不存在的文件从缓存中清掉(覆盖 Finder 删除等 git 感知不到的变更),这正是注释里强调的"always prunes stale entries even when git reports no changes";
  4. 若无变化且无剪枝,直接返回缓存,不重写缓存文件(配合 ADR-0166 的冷热启动优化)。

关键细节:git status --porcelain的行格式是XY pathparse_porcelain_line取前两个字符做状态码、其余为路径;路径还要经过workspace.vault_relative_pathnormalize_relative_path处理,屏蔽大小写、别名(如 macOS/private/tmp/tmp)差异,并且过滤隐藏路径段。

路径二:跨 commit 增量(update_different_commit)

当缓存记录的 hash 落后于当前 HEAD(用户git pullgit commit或切换到别的分支)时:

  1. 执行git diff <from_hash>..<to_hash> --name-only -- <vault_pathspec>,得到两次 commit 间变化的所有文件;
  2. 再叠加git_uncommitted_files(未提交的修改/暂存/未跟踪文件),保证增量集合同时覆盖已提交与未提交变更;
  3. 剔除缓存中这些文件的旧条目,重新解析,用当前 HEAD hash写回缓存。

注意这里 diff 收集的是 vault 内所有非隐藏文件而不只是 .md(注释:"Includes all non-hidden files (not just .md) so the cache picks up view files (.yml), binary assets, etc.")——因为 vault 扫描器本身会识别视图文件(views/*.yml)与各类资源,缓存必须与扫描器的文件面保持一致。测试test_incremental_different_commit_picks_up_yml_file验证了提交一个.yml视图文件后增量更新能把它纳入 entries。

何时走全量重扫

  • 缓存 Missing(首次启动、缓存被删);
  • 缓存 Invalid(JSON 损坏,删除后重扫);
  • cache_requires_full_rescanversion不等于当前CACHE_VERSION,或vault_path与当前路径不一致(他机 clone / 目录移动);
  • reload_vault/refresh_vault_cache显式触发的强制刷新。

CACHE_VERSION当前为 14,注释记录了 bump 历史("v12: fix gray_matter YAML sanitization… v14: preserve scalar-array custom frontmatter properties in VaultEntry")。版本号的每次提升意味着"旧的索引结构与新解析器不再兼容",必须强制全量重扫以免陈旧字段污染界面。测试test_stale_cache_version_forces_rescan_of_archived_yes构造了一个"旧版本解析器把Archived: Yes误判为 false"的陈旧缓存,验证版本失效后重扫能纠正结果。

原子写入与并发安全

临时文件 + 原子 rename

write_cache(cache.rs)遵循经典的原子替换范式:

1. 尝试获取写锁(.lock 文件,create_new 语义) 2. 读当前缓存指纹,与加载时的期望指纹比对(CAS 前提) 3. 序列化 VaultCache 为 JSON 4. 写入 <uuid>.tmp 临时文件,并 sync_all 刷盘 5. fs::rename 覆盖正式缓存文件 6. unix 下额外 sync 父目录,保证目录项落盘
  • 崩溃安全:任何一步失败都不会留下半个缓存文件,临时文件会被清理(测试test_cache_write_no_tmp_file_left断言写完后缓存目录中不存在.tmp文件);
  • 顺序一致:rename 在同文件系统内是原子的,读者永远看到"旧完整版"或"新完整版";
  • 指纹 CAS:写入前把磁盘上的指纹与"加载时读到的指纹"比对,若已被其他扫描更新则跳过,避免旧数据覆盖新数据(SkippedConcurrentUpdate)。

写锁与陈旧锁回收

  • 写锁文件以create_new(true)方式创建并写入 PID,已存在则视为有其他活跃写入者(SkippedActiveWriter);
  • 锁文件超过CACHE_WRITE_LOCK_STALE_SECS = 30秒未更新即判定为陈旧锁,予以删除后重试——防止进程崩溃留下死锁;
  • 测试test_write_cache_skips_when_writer_lock_is_held验证锁存在时写入被跳过且不产生缓存文件;test_write_cache_skips_overwriting_newer_cache验证指纹不匹配时返回SkippedConcurrentUpdate且保留较新内容。

这套"锁 + 指纹 CAS + 原子 rename"的组合,让增量更新天然支持多窗口/多进程并发的场景(Tolaria 支持独立笔记窗口与多个 mounted workspace)。

失效、迁移与崩溃安全

强制失效:reload_vault

ADR-0014 明确约定:reload_vault命令在重扫前删除缓存文件,保证显式刷新一定读到磁盘真相。当前实现更精细——命令层的 scan_cmds.rs 调用的是vault::refresh_vault_cache,它采用"保留旧快照、后台重建、原子替换"的路径:

  • 先把现有缓存文件的字节指纹作为期望指纹(即使旧缓存是坏的也能事务性替换,注释解释了为何不能用"解析再比较"——那会把损坏文件误判成并发写入);
  • 全量重扫后一次性替换;
  • 期间read_vault_snapshot仍能读到旧快照,应用崩溃也不会让下次启动失去可用缓存。

同时invalidate_cache仍被保留为独立的公开 API(测试test_invalidate_cache_deletes_cache_filetest_invalidate_then_scan_forces_full_rescan验证其删除文件与强制重扫的行为),供需要在启动路径上强制清缓存的场景使用。

旧缓存自动迁移

migrate_legacy_cache在每次scan_vault_cached/refresh_vault_cache入口执行:

  1. 若 vault 内存在.laputa-cache.json而外部缓存不存在,将其复制到新位置(临时文件 + rename);
  2. git rm --cached --ignore-unmatch .laputa-cache.json将其移出 git 跟踪;
  3. 从磁盘删除旧文件。

这保证升级到外部缓存方案的用户无需手动清理,旧缓存数据也不会丢失。

剪枝与去重

prune_stale_entries每次写缓存前执行:

  • 剔除path指向的文件在磁盘上已不存在的条目(覆盖外部删除);
  • 按大小写折叠(case-folded)的相对路径去重——这是为 macOS APFS 等大小写不敏感文件系统上的"仅大小写重命名"准备的(测试test_case_rename_no_duplicates模拟删除Note.md、新建note.md后断言缓存无重复条目)。

随后 entries 按modified_at降序排序再序列化,保证缓存内部顺序稳定。

冷启动热启动与"快照优先"演进

ADR-0014 之后的 ADR-0166(docs/adr/0166-snapshot-first-progressive-vault-startup.md)把这一缓存机制升级为stale-while-revalidate 的快照优先启动管线

  • read_vault_snapshot只做版本/路径校验后直接返回缓存 entries,不跑任何 git 命令、不做逐文件磁盘检查、不排序、不写缓存——它读取的是一个结构合法但可能过期的快照;
  • React 立即用快照渲染、清除阻塞式 loading,list_vault随后在后台做 git 对账与删除清理,只替换该 workspace 的 entries;
  • 同 commit 且无变化的对账返回缓存而不重写相同内容(对应 cache.rs 中update_same_commit的早退分支);
  • 嵌套 mounted workspace 复用最近祖先已供应的 entries,重复路径优先最具体的包含 workspace;
  • 启动里程碑被埋点测量:热启动活跃 vault 目标 800ms、React shell 目标 300ms(仅上报时间/计数,不上报 vault 路径与笔记内容)。

也就是说,ADR-0014 奠定了"以 git 做增量对账"的正确性基础,ADR-0166 则把它与 UI 渲染解耦,把"对账"从阻塞路径挪到后台,让大规模 vault 的启动体验从"数秒等待"进一步收敛到"快照秒开 + 后台静默刷新"。

关键实现细节与边界处理

从源码与测试中可以整理出若干容易踩坑的实现要点,这些也是评审缓存方案时的通用检查清单:

  • 非 git vault 优雅降级resolve_git_workspacegit rev-parse HEAD失败时,直接退回全量scan_vault(测试test_scan_vault_cached_no_git验证无 git 目录也能扫出 1 条 entry)。注意此时不写缓存——没有 git 就没有可靠的增量基准,写缓存反而可能误导后续加载。这与 ADR-0085 的"non-git vault support"决策衔接;
  • 嵌套仓库路径范围:vault 可以是父 git 仓库的子目录,vault_pathspec保证 diff/status 只关心 vault 子树内的文件,父仓库其他目录的变化不会触发本 vault 重解析(测试test_nested_vault_incremental_changes_exclude_parent_files);
  • 非 ASCII 路径:git 的core.quotePath会转义中文等非 ASCII 路径,porcelain 解析与相对路径还原必须正确处理(测试test_git_uncommitted_files_preserves_chinese_markdown_path);
  • 未跟踪子目录ls-files --others与去重逻辑确保新建子目录中的文件也能进入增量集合(测试test_update_same_commit_new_files_in_new_subdirectory);
  • 并发写入的最终一致性:指纹 CAS + 写锁让"谁先完成谁生效",日志区分SkippedConcurrentUpdateSkippedActiveWriter,便于排障;
  • 日期来源的合并:Git 日期(get_all_file_dates_for_workspace)与文件系统 mtime 通过resolve_entry_dates合并(fs.max(git)取较大者),并配合测试中的PANIC_ON_GIT_DATE_LOOKUP守卫——热缓存命中路径严禁再次加载全量 git 日期历史,保证启动路径的 I/O 预算可控。

总结:可复用的增量索引范式

ADR-0014 本质上回答了一个通用问题:"如何为一个纯文本文件集合维护一份始终新鲜的二级索引,而不用每次全量重建?"Tolaria 的答案是三层组合:

  1. 变化感知:以 git 为单一事实来源,HEAD hash 判断"提交是否前进",git diff/git status --porcelain/git ls-files给出精确的变更文件集合;
  2. 索引存储:外部目录 + 确定性 hash 文件名 + 原子 rename + 写锁/指纹 CAS,保证缓存既不污染数据仓库,也不会在并发或崩溃场景下损坏;
  3. 失效纪律:版本号 bump 驱动全量重扫、vault_path 校验防跨机污染、剪枝清删除、显式 reload 强制刷新,把"缓存正确性"从概率问题变成确定性保证。

这套设计在 Tolaria 仓库中可完整追溯:决策依据见 docs/adr/0014-git-based-vault-cache.md 与 docs/adr/0024-cache-outside-vault.md,实现主体在 src-tauri/src/vault/cache.rs,入口接线在 src-tauri/src/vault/mod.rs 与 src-tauri/src/commands/vault/scan_cmds.rs,后续演进见 docs/adr/0166-snapshot-first-progressive-vault-startup.md。若你的项目同样以 git 管理文本型内容(笔记、文档库、配置集),这套"git 账本 + 外部原子缓存"的增量索引方案值得直接借鉴。

【免费下载链接】tolariaDesktop app to manage markdown knowledge bases项目地址: https://gitcode.com/GitHub_Trending/to/tolaria

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询