gbrain Doctor 前端元数据扫描增量化的架构设计:从有界磁盘遍历到 DB-backed 增量状态(Phase 2)
【免费下载链接】gbrainGarry's Opinionated OpenClaw/Hermes Agent Brain项目地址: https://gitcode.com/gh_mirrors/gb/gbrain
本篇技术指南以 gbrain 仓库中 docs/architecture/frontmatter-scan-incremental.md 为核心,系统讲解 Doctorfrontmatter_integrity检查的现状(有界磁盘遍历)与 Phase 2 增量化的完整设计:包括frontmatter_scan_state表结构、sync 侧 UPSERT 写入、增量扫描命令与 autopilot 周期相位、O(1) 的 Doctor 读取查询,以及首次扫描、源归档、路径重命名等排序关注点。读完你既能理解 gbrain 现有 30 秒有界遍历的实现细节(walkDir、pruneDir、GBRAIN_DOCTOR_FM_TIMEOUT_MS、partial 状态语义),也能掌握一套把 O(N) 全量扫描演进为 O(1) SQL 聚合的落地方案与迁移步骤。
一、为什么需要增量扫描:现状与稳态成本
gbrain 的 Doctor 诊断中包含一项frontmatter_integrity子检查,它负责在磁盘上遍历大脑(brain)目录,逐个解析所有.md文件的前端元数据(frontmatter)完整性。当前实现的核心特征如下:
- 磁盘遍历:通过
src/core/brain-writer.ts中的scanBrainSources入口启动,内部使用walkDir递归遍历每个 source 的local_path。 - 下潜时剪枝(pruneDir):
walkDir在每次进入子目录前调用 src/core/sync.ts 导出的pruneDir(name, parentDir)作为唯一剪枝门。跳过规则包括:所有点前缀目录(如.git、.obsidian)、node_modules、ops、以.raw结尾的目录(gbrain sidecar 约定)、以及以 "gitfile" 形态出现的 git 子模块目录。该剪枝是 v0.38.2.0(PR #1287)解决 216K 页面大脑上gbrain doctor挂死问题的核心修复——此前 walker 会下潜到每个子树再在叶节点用isSyncable过滤,白白为成千上万个永远不会被解析的 vendor 条目支付stat的 IO 成本。 - 有界墙钟(bounded wall-clock):整个扫描在
src/commands/doctor.ts的frontmatter_integrity分支(src/commands/doctor.ts)中受一个 deadline 约束,超时默认 30 秒,可用环境变量GBRAIN_DOCTOR_FM_TIMEOUT_MS覆盖(解析逻辑见 src/commands/doctor.ts:非有限或非正数时回退到 30000ms)。 - 诚实的部分状态:
scanBrainSources对每个 source 输出scanned / partial / skipped三态,超时触发时 Doctor 会输出"已扫描约 N 个文件(该 source 在 DB 中约 M 个页面)"这样的诚实提示,而不是把不完整的结果伪装成权威结论(见 src/core/brain-writer.ts 中的markRemainingSkipped、between-source 截止检查、COUNT 查询与 deadline 的竞态处理)。
这套设计保证了"大多数大脑秒级完成、任何大脑有界时间内完成",但其稳态成本是O(N):每次 Doctor 运行都重新遍历文件系统并重新解析每个.md文件。对于 20 万以上页面的用户,稳态耗时仍在秒级;而要想让 Doctor 达到适合 cron 监控健康检查的亚秒级稳态,扫描就必须增量化了——这正是本文档要解决的问题。
二、Phase 2 目标:与大脑规模无关的 Doctor
设计文档给出明确目标:Doctor 的frontmatter_integrity检查无论大脑规模多大,都只执行 O(1) 次 SQL 查询完成,同时保持与有界遍历相同的 per-source 明细和 partial-state 语义。
关键的分摊思路是:增量刷新(incremental refresh)作为sync 侧的写入加一个autopilot 周期相位运行,把稳态工作量分摊到本来就会触碰每个文件的既有工作流中——sync 本来就逐文件解析,顺带写一行状态即可;autopilot 本来就周期性跑维护任务,加一个相位即可。这样增量扫描的成本被"摊销"(amortized)掉了,而不是新增一条独立的常驻成本。
三、Schema 设计:frontmatter_scan_state表
设计文档给出的新表定义如下:
CREATE TABLE frontmatter_scan_state ( source_id TEXT NOT NULL REFERENCES sources(id) ON DELETE CASCADE, path TEXT NOT NULL, -- relative to source.local_path mtime_ms BIGINT NOT NULL, content_hash TEXT NOT NULL, -- sha256 of file content at scan time codes JSONB NOT NULL DEFAULT '[]'::jsonb, -- ParseValidationCode[] last_scanned_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (source_id, path) ); CREATE INDEX frontmatter_scan_state_has_issues_idx ON frontmatter_scan_state (source_id) WHERE codes != '[]'::jsonb;各列的设计意图与工程权衡:
mtime_ms+content_hash:增量检查在两者之间择一。mtime更快(无需读文件内容,一次stat即可),但可能被touch之类的操作欺骗(内容没变也算变了);content_hash是"真相"(truth),能击败"改了 mtime 但内容没变"的场景。增量 walker 的策略是:用 mtime 做快速闸门,当 mtime 提示文件变更时再用 content_hash 兜底裁决——兼顾了速度与正确性。codesJSONB:每行存放一个ParseValidationCode[]错误码列表,NULL或[]表示该文件干净。Doctor 聚合时用jsonb_array_length(codes) > 0判断是否有问题。- 部分索引
WHERE codes != '[]'::jsonb:Doctor 的聚合查询只遍历"有问题"的行。由于大部分页面是干净的,有问题行的比例很小,索引因此保持轻量。这是本设计的性能关键:全表里干净行占绝大多数,部分索引让"发现问题"的路径只扫到问题行。 - 外键
ON DELETE CASCADE:source 被删除时级联清理本表数据,保证没有孤儿行。
这里遵循了 gbrain 仓库中applyForwardReferenceBootstrap的经典模式(见 src/core/pglite-engine.ts 与 src/core/postgres-engine.ts):新增的表/列都要进入两个引擎的 bootstrap probe 集合。这样做的原因写在了 CLAUDE.md 中——旧大脑沿着 schema 链向前演进时,如果代码引用了尚不存在的表就会卡死(wedge),bootstrap probe 用一次性information_schema探测避免这一情况。
四、迁移形态:追加 MIGRATIONS 条目 + bootstrap probe
设计文档给出了迁移代码的落点与形状:
// src/core/migrate.ts — append after the CURRENT last entry in the // MIGRATIONS array (take the next unused version number at implementation // time; the numbers below are placeholders, not a reserved slot) const migrations = [ // ...existing entries... { version: NEXT_VERSION, // next unused number in the MIGRATIONS array name: 'frontmatter_scan_state', sql: ` CREATE TABLE IF NOT EXISTS frontmatter_scan_state (...); CREATE INDEX IF NOT EXISTS frontmatter_scan_state_has_issues_idx ...; `, }, ];实际仓库中 MIGRATIONS 数组位于 src/core/migrate.ts,实现时版本号取当前数组中的下一个未用数字(文档特别强调占位数字不预留槽位,以避免冲突)。迁移除了 SQL 本身,还需要:
- 在
pglite-engine.ts与postgres-engine.ts两个引擎的 bootstrap probe 集合中各加一条frontmatter_scan_state表存在性探测; - 扩展
test/schema-bootstrap-coverage.test.ts中的REQUIRED_BOOTSTRAP_COVERAGE,保证新表进入 bootstrap 覆盖清单,防止未来有人把它漏掉。
迁移 SQL 中的CREATE TABLE IF NOT EXISTS/CREATE INDEX IF NOT EXISTS保证了幂等性,这是 gbrain 迁移链一贯的风格——旧大脑逐个版本向前走,不会因重复执行而报错。
五、写入路径:两条数据写入管线
1. Sync 侧写入(canonical 路径)
src/core/sync.ts中的performSync本来就会解析它触碰的每一个文件。设计要求在现有的parseMarkdown调用之后,把文件的path / mtime / content_hash / codes以UPSERT方式写入frontmatter_scan_state。
其成本分析非常关键:每个被 sync 的文件多一行 UPSERT,而解析工作在 sync 中本来就已经发生,零额外解析开销——相比 sync 已有的 parse + DB 写,这一行 UPSERT 可忽略不计。
仓库中parseMarkdown的实际调用形态可以从 src/core/brain-writer.ts 看到:parseMarkdown(content, relPath, { validate: true, expectedSlug })。这个{ validate: true }就是校验 frontmatter 的入口,产出的ParseValidationCode[]正是codes列要存的内容——单一事实来源(single source of truth),Phase 2 不引入独立的校验规则集。
2. 增量扫描(gbrain frontmatter scan --incremental)
增量扫描器通过walkBrainTree遍历磁盘,对每个文件检查:
mtime > last_scanned_at(mtime 快速闸门),或content_hash != stored(hash 兜底裁决)
只有变更过的文件才会被重新解析。大部分运行周期里,首次全量回填之后就是零工作——文件没变,mtime 闸门直接放行,连文件内容都不用读。
该命令还暴露为 autopilot 的周期相位(frontmatter_scan),与 sync / extract / embed 等其他周期性维护相位并列运行(设计文档明确"不加新后台守护进程"——而是挂进现有的autopilot-cycleMinion handler 作为新相位)。
增量 walker 弥补了 sync 漏掉的两类场景:
- sync 之外的编辑:用户直接在编辑器里改文件、保存,但从未
git commit——sync 只看到 git 触碰过的文件; local_path不是 git 仓库的 source:sync 只处理 git-tracked 文件,这类 source 的文件 sync 完全看不到。
这两类场景正是磁盘 walker 存在的意义,也是它作为"安全网"不能被替换的原因。
六、Doctor 读取器:一次 SQL,恒定时间
Phase 2 的 Doctor 读取形态如下:
// src/commands/doctor.ts:frontmatter_integrity (Phase 2 shape) const rows = await engine.executeRaw<{ source_id: string; issues: number }>( `SELECT source_id, count(*) FILTER (WHERE jsonb_array_length(codes) > 0)::int AS issues FROM frontmatter_scan_state GROUP BY source_id`, );- 一条 SQL 查询,与大脑规模无关:无论 1K 还是 200K 页面,都是同一查询。
jsonb_array_length(codes) > 0过滤有问题行,配合部分索引frontmatter_scan_state_has_issues_idx(只覆盖codes != '[]'的行),聚合只扫问题行——干净行多的典型大脑上,这条查询极快。 - partial-state 语义保留:当
frontmatter_scan_state数据过期时——某个已注册 source 没有任何行,或某个 source 的last_scanned_at超过 24 小时未更新——Doctor 会警告数据新鲜度,而不是把可能过期的数据当作权威结论报告。这与现有 bounded-walk 的诚实部分状态哲学一脉相承(现有实现里partial/skipped/aborted_at_source字段会喂给 JSON 消费者,见 src/commands/doctor.ts 的注释)。
七、排序关注点:三个必须处理的边界情况
1. 首次扫描(fresh upgrade)
升级后frontmatter_scan_state是空表,有两种方案:
- Lazy(推荐):Doctor 报告"尚无扫描状态,请先运行
gbrain frontmatter scan --incremental"(操作者驱动); - Eager:创建表的迁移同时向 autopilot 排入一次全量扫描任务。
文档明确推荐lazy + 清晰提示。理由是 autopilot 路径是更重的表面积:需要把新相位frontmatter_scan加进现有的 cycle.ts 机制以及 doctor-routed 后台任务系统。第一版先保持轻量。
2. 源归档 / 删除
frontmatter_scan_state.source_id带有ON DELETE CASCADE,所以现有的"软删除 + 72 小时 TTL + purge"流程会自动把它清理干净,不需要额外逻辑。
3. source 内部的路径重命名
这是设计中最容易积累垃圾的场景:sync 需要按 path 删除旧行、插入新行(通过周期性 reconcile 步骤)。如果没有这一步,表里会积累陈旧的 path 行。两种处置方案:
- 增量扫描器内的 reconcile 步骤:遍历期间任何未被看到的 path 行直接删除;
- 或作为新鲜度信号:Doctor 报告"N 条陈旧行",用
gbrain frontmatter scan --reconcile作为补救命令。
八、成本估算
设计文档给出的量化评估:
- 每个被 sync 的文件多一条 UPSERT:相对 sync 已有的 parse + DB 写可忽略不计;
- 增量刷新运行时:主要由 mtime 的
stat支配,SSD 上约每 1000 个文件毫秒级; - Doctor 读取:一条走索引的 SQL 查询,任意大脑规模下亚 100ms。
这组数字直接支撑了本文开头的目标表述:稳态成本从 O(N)(每次全量遍历 + 全量解析)降到"sync 顺带写一行 + mtime 级增量刷新 + 一条索引查询"。
九、设计明确不做的事(边界声明)
- 不替换 bounded-walk 安全网:Phase 2 只是让稳态变便宜,磁盘 walker(含 deadline 检查)继续作为 source 状态缺失或过期时的 source-of-truth 兜底。双保险(belt-and-suspenders)。
- 不引入独立的 frontmatter 校验规则集:复用
parseMarkdown(..., { validate: true })和现有ParseValidationCode枚举,单一事实来源。 - 不新增后台守护进程:挂进现有
autopilot-cycleMinion handler 作为新相位,与 sync / extract / embed 并列。
十、留给实现者的开问题
- 路径规范化:
pages.source_path与磁盘 walker 计算的相对路径相似但不完全相同(斜杠、前导./等)。增量扫描器必须与 sync 存储的路径完全一致,否则 UPSERT 无法正确按 key 命中。动手前先审计。 - 软删除交互:DB 中软删除的页面在磁盘上仍有文件。增量扫描是否继续跟踪它的 frontmatter 状态?文档倾向"应该继续"(这样未来的
restore_page不会因过期 frontmatter 而意外),但需要与软删除模块的负责人确认。 - 两阶段 rollout:先落地表 + 写入,让数据回填一个发布周期,再切换 Doctor 读取器。这能避免"Phase 2 上线了但表是空的"这一尴尬局面——否则 Doctor 会回退到报告"无扫描状态"。
十一、TODO 条目(后续实施入口)
设计文档末尾给出了可直接落到 TODO 的条目:
- [ ] Implement Phase 2: DB-backed frontmatter scan state. Design lives at docs/architecture/frontmatter-scan-incremental.md. New schema migration + sync-side UPSERT + incremental scan command + autopilot cycle phase + doctor reader. Two-phase rollout: ship table + writes first; flip the reader one release later.延伸阅读
- 现有有界遍历的实现:scanBrainSources / walkDir(含 deadline、partial 状态、
visitDir测试钩子) - 剪枝规则的唯一事实来源:pruneDir
- Doctor 侧当前调用与超时配置:frontmatter_integrity 分支
- Bootstrap probe 模式:pglite-engine.ts、postgres-engine.ts
- 迁移数组与版本编排:src/core/migrate.ts
- 校验入口与错误码类型:src/core/brain-writer.ts
【免费下载链接】gbrainGarry's Opinionated OpenClaw/Hermes Agent Brain项目地址: https://gitcode.com/gh_mirrors/gb/gbrain
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考