青雲的博客

Article

把 @ 文件搜索推到百万路径:Orca 的一次流式重构

· 14 分钟阅读

起点很小

Orca 输入框里敲 @,搜工作区文件。小仓库一直还行:首次建索引,后面缓存里模糊匹配,热路径能到几十微秒。卡顿主要在冷启动和缓存过期——git ls-files、目录遍历、索引重建、整份候选评分,都可能砸在用户正在打字的那条路径上。

常见修法是「重建丢后台」:旧缓存先回,后台刷完后下一键自然用上新结果。代码少,多数仓库够用。我一开始也列了三档:后台刷新、完成事件通知、完整流式搜索。只看当前痛点,第一档就够。

最后还是走了最重的。不是想把补全框做复杂,而是不想 @ 搜索永远停在「小仓库没事」。Coding Agent 进大项目后,几十万到上百万路径很常见。到那个规模,缓存只是推迟问题,回答不了:扫描没完能不能先出结果?连打时旧 query 会不会排队?离开 @ 谁停后台线程?旧 session 会不会盖住新 token?百万路径会不会同时挂两份 catalog?

这次动了 27 个文件,+3775 / -526。mentions.rs 里的同步索引拆成独立 orca-file-search crate,再经 TUI 事件流接回输入框。提交:a74ef2c8 feat: add streaming mention file search

旧实现:缓存里快,输入路径上慢

非空 @query 第一次出现时,两次 git ls-files,或非 Git 场景用 ignore walk 收路径,最多 10 万条后缓存。后续查询克隆这份快照,同步模糊评分。

用户输入 @query
  -> 检查五秒缓存
  -> git ls-files / ignore walk
  -> 建立最多十万条路径的索引
  -> 克隆候选列表
  -> 同步评分和排序
  -> 返回 TUI

缓存热时后半段很快,平均延迟看着没问题。交互更怕尾延迟:用户不在乎前十次是不是 50μs,只记得某次敲 @ 输入框顿了一下。

更麻烦的是模块边界:文件发现、缓存新鲜度、模糊匹配、展示、TUI 生命周期挤在一起,没有 search session,也没有明确的 worker 所有权。想异步化一步,很快撞上旧结果、重复线程、cwd 切换、退出清理。问题往往不在某次 git ls-files 多了几十毫秒,而在输入路径干了不该干的活。

Claude Code 和 Codex 两条路

动手前读了本地 Claude Code 和 Codex 的文件搜索。都比「每次输入重扫」成熟,路线不同。

Claude Code 更像做完整的缓存索引:Git 仓库先 git ls-files 拿 tracked,untracked 后台补;失败或非 Git 再回退 ripgrep。索引大约按 4ms 时间片分块建,没建完也能查已完成的 prefix。UI 50ms debounce,mount 时 prewarm。产品侧更齐:.git/index mtime 发现 tracked 变化,五秒刷新兜 untracked,~/ / ./ / 绝对路径有独立 path completion,还能配自定义 file suggestion command。

Codex 更接近我想要的上限:ignore::WalkBuilder 并行 walk,发现路径就注入常驻 nucleo::Nucleo。walker 还在扫,matcher 就能出结果。继续输入只更新 pattern,不重建索引;query 是在旧 query 后追加字符时走 reparse(..., append=true)

维度Claude CodeCodex
文件发现Git/RG 先收集路径,再渐进构建索引并行 walker 边扫描边注入 matcher
第一次查询预热或查询已完成的索引 prefix扫描过程中持续输出 snapshot
每键更新50ms debounce,查询 ready catalog复用常驻 Nucleo,append 增量 reparse
产品入口路径补全、自定义 command、资源 typeahead多 root、exclude、app-server session、统一 mention

Orca 选了 Codex 这一类内核,没原样照搬。我更在意 Codex 当时没完全收紧的几处:work channel unbounded;session Drop 只发 shutdown,没保存并 join worker handle;walker 会递归跟 symlink;空 @ 不 browse 目录;session 清掉 catalog 也立刻没了。

所以不是「把 Codex file search 搬过来」,而是同一类流式架构,把生命周期、安全和百万路径验收补到 Orca 能验收的程度。

设计围着 search session 转

一个 TUI 最多一个 mention search session,绑当前 workspace root 和光标所在 @token,拥有 walker、Nucleo matcher、查询状态、最新 snapshot、取消标记和全部 worker handle。

flowchart LR
  A[Composer 当前 @token] --> B[MentionSearchManager]
  B --> C[SearchSession]
  C --> D[Parallel ignore walker]
  D --> E[Nucleo injector]
  C --> F[Latest query slot]
  F --> G[Nucleo pattern reparse]
  E --> H[Matcher tick]
  G --> H
  H --> I[Latest snapshot slot]
  I --> J[TuiEvent MentionSearchDirty]
  J --> K[Popup projection]

目录扫描和用户查询是两条独立流。walker 扩 catalog,query 改 pattern;任一边变,matcher 都能出新的 top 12 snapshot。第一次查询不必等完整扫描:walker 找到一部分,弹窗就能先出结果,并显示已扫条数。

@ 后面空着时也不再只给空白提示:异步 browse workspace 根,同时预热完整 catalog;query 以 / 结尾则渐进读该目录直接子项。目录再大也不先收齐再排序——每批最多 256 条或 5ms,只留当前最好的 12 条。browse 和 fuzzy 共用同一 session:从 @@src/ 再到深层模糊查,不必反复毁 catalog。

打得越快,队列越不能「忠实」

异步搜索常见坑:每个按键都排进 channel。用户连打 mmamaimain,中间三个 query 已经没价值;worker 越认真逐个算,最终结果越晚到。

query 用 bounded latest-wins slot:新 query 直接盖掉未处理的旧的。Nucleo wakeup 合并,最多一个待处理通知。snapshot 也不排队:worker 只替换共享 latest snapshot;仅从「没有待消费 snapshot」变成「有」时,才往现有 TUI event channel 发一个很轻的 dirty event。

query:     只保留最新值
wakeup:    最多一个待处理
snapshot:  只保留最新投影
TUI event: 只负责叫醒,不搬运结果队列

补全不是日志。日志怕丢条;补全只关心现在。分不清这两类,异步化往往只是把输入线程的卡顿搬到后台队列。

过期结果:一个 query 字符串挡不住

流式结果回来时,界面可能已经不是原来的界面:@main 改成 @src,光标挪到另一个 @token,甚至 cwd 都换了。只比 snapshot 里的 query 字符串不够——两个不同位置的 token 完全可能同名。

三层 guard:

  • session generation:旧 session / 旧 root 的输出直接作废
  • 当前 token identity + query:只有光标正在控的那个 token 能收结果
  • popup pending query:弹窗只收自己还在等的查询

任一不匹配,snapshot 不进可见态。

选择状态另说。流式更新时排序会变;每个 snapshot 都把 selected index 重置到 0,用户刚按方向键就会跳。默认仍跟第一名;一旦用户手移,选择按 path 锚定——新 snapshot 里还有这条就继续选,没了再退到最接近的旧 index。benchmark 量不出这细节,但决定了流式是顺还是晃。

谁拥有线程

约束写死:search worker 不允许 detached。SearchSession 必须持有 handle。取消后 walker / matcher 至少每 10ms 或每 256 条路径查一次状态;可见 generation 立刻失效,晚到输出先在语义上切断。

TUI 主线程不能为 join 卡住,退出或切 root 时由 reaper 在线程外 join。新 catalog 也不会在旧 catalog 退干净前启动,避免百万路径场景短时间两套大索引并存。

symlink 一并收紧:不递归跟 symlink directory;只有 canonical target 仍在 workspace 内的 symlink 才有资格进候选。不会因为仓库里一个链接扫进整个 home 或外挂盘。

这些和模糊算法无关,却是长跑 Agent TUI 常翻车的地方:任务取消了线程还活着;cwd 变了旧结果又回来;用户退了后台还在扫;一个链接把边界带出工作区。

一百万条路径

第一版边界写死:单 workspace 最多验 100 万条 eligible path,不许靠截断 catalog 过测试。门槛:首个进度 snapshot p95 ≤ 100ms;warm catalog 追加字符 p95 ≤ 50ms;任意修改后完整 reparse p95 ≤ 150ms;完成态增量 RSS ≤ 512MiB;构建峰值 ≤ 768MiB;snapshot 发布 ≤ 60Hz。

benchmark 不是在磁盘上建一百万真实文件,而是往同一个 SearchSession 注入一百万条确定性 synthetic relative path,主验 catalog / matcher / 查询 / snapshot / 内存。真实 FS 的 ignore、symlink、browse、取消另用临时目录测。

macOS aarch64 / 12 CPU 上 release 重跑:

指标结果门槛
catalog 路径数1,000,0001,000,000
首个进度 snapshot0.17ms≤ 100ms
append query p9546.60ms≤ 50ms
任意 reparse p95100.95ms≤ 150ms
完成态增量 RSS238,518,272 bytes≤ 512MiB
峰值增量 RSS238,714,880 bytes≤ 768MiB
snapshot 发布频率47.62Hz≤ 60Hz

调优里有个很具体的点:matcher worker 一开始上限两个,百万路径下 query latency 过不去;改成 bounded top-12 selection 仍不够。三线程单次能过,25 样本 p95 还贴线。最后上限改成四个才有稳定余量。所以不能只写「用了并行搜索」——几个 worker、为什么是这个上限、内存会不会跟着翻、p95 稳不稳,都得落到可执行门槛。

没假装一次做完

重构后 @ 搜索内核和 Codex 同一类架构,几个边界甚至更完整:空 @ 和尾 / browse、30 秒 warm catalog、bounded latest-wins、三层 stale guard、path selection anchor、workspace-safe symlink、owned cancellation、百万路径硬门槛。

产品层还不如 Claude Code / Codex 齐。Claude Code 的启动预热、五秒 untracked freshness、自定义 suggestion command,以及 ~/ / 绝对路径补全更成熟。Codex 还有多 root、exclude、app-server search session,以及 files / skills / plugins / apps 统一的 atomic mention binding。

Orca 目前仍是单 workspace root;orca-file-search 虽独立成 crate,只接了 TUI;文件变化是 snapshot discovery,不是 filesystem watcher。同一 token 活跃期间,新建/删除/重命名不保证实时;untracked 和非 Git 变化最迟在 30 秒 warm-idle 过期后再 walk。这些写进了 ADR,没盖「实时搜索」四个字。

收束

@ 补全弹窗就 12 行,生命周期可能几秒,但用到的 session、generation、取消、背压、状态投影、资源上限、所有权,和 Agent runtime 是同一套问题。

做完这次,比较确定的几条:用户还在输入时,扫描、匹配、线程回收都不该堵在输入路径上等同步;异步结果回来时必须知道还属不属于当前 token;session 结束必须真把自己收干净。每天触发几十次的小交互,边界虚不虚,用户不用看 benchmark 也能感觉到。

Keep Reading

相关文章

评论