Skip to content

fix(analyze): store 版本校验缺失导致升级二进制后 stale 缓存死循环 - #157

Merged
c2j merged 3 commits into
mainfrom
fix/analyze-stale-store-version
Sep 7, 2026
Merged

c2j merged 3 commits into
mainfrom
fix/analyze-stale-store-version

Conversation

@c2j

@c2j c2j commented Sep 7, 2026

Copy link
Copy Markdown
Owner

问题

升级 codeweb 二进制(STORE_VERSION 7→8,#148 引入)后,在旧项目目录出现死循环:

$ codeweb analyze
Up to date. 136 files, 0 nodes, 0 edges.        ← 不重建

$ codeweb stats
error: unsupported cache version 7, expected 8 — run `codeweb analyze` to regenerate

错误信息建议跑 analyze 重建,但 analyze 因文件指纹未变拒绝重建 —— 循环无出口。

根因

analyze() 的 up-to-date 判定只比较文件指纹 vs manifest 边车load_manifest_onlycompute_changes),从不校验 store 文件本身:

  • STORE_VERSION 升级作废的是 store;manifest 边车无版本头,且 FileRecord 布局 v7↔v8 未变,旧边车在新二进制下反序列化成功
  • 指纹未变 → 提前返回 → v7 store 原样保留 → 所有读命令(stats/trace/export)被 load_bincode 版本门禁拒绝

次生问题:up-to-date 提前返回时 self.storeNone,报告的 nodes/edges 来自 unwrap_or(0),输出永远显示 0 nodes, 0 edges(与 store 实际内容无关),误导用户以为图谱为空。

修复

Commit 1 — 核心修复src/graph/store.rs + src/project/mod.rs):

  • 新增 GraphStore::peek_version():仅读 13 字节 magic+version 头,不做全量反序列化;缺文件/无头旧格式(pre-store: bincode 跨版本反序列化失败时错误信息无版本诊断价值 #110)/版本不符均返回 None
  • 新增 GraphStore::file_is_current():头版本 == STORE_VERSION
  • Project::store_is_current():bincode 走头探测(快路径语义保持);JSON 格式复用 load_json 自带版本门禁(is_ok() 即"当前布局")
  • analyze() 判定改为 changes.is_empty() && self.store_is_current() —— 版本不符走既有重建路径自愈(save_bincode 覆写为当前版本并重写边车)
  • STORE_VERSION 保持 8 不变(本修复不改变 v8 布局,无需再 bump)

Commit 2 — 输出修复src/main.rs):

  • 提取 format_up_to_date_line():快路径只报文件数,不再打印无意义的 0 计数;全量/增量构建输出不变(那里有真实计数)

测试(TDD:失败 → 通过)

  • analyze_rebuilds_when_store_version_stale:模拟 v7 头 store + 指纹未变 → 断言不报 up-to-date、重建后 store 通过当前版本门禁
  • peek_version_returns_header_version / peek_version_none_for_legacy_headerless_file / peek_version_none_for_missing_file:新 API 边界
  • up_to_date_line_reports_files_without_zero_counts:输出不含 nodes/edges 计数

验证矩阵

cargo fmt --all -- --check                                    ✅ 干净
cargo clippy --features full -- -D warnings                   ✅ 干净
cargo test --features full -- --skip test_path_mapping_applied --skip test_serve_   ✅ 768 通过 0 失败

真实场景端到端验证(136 个 .sql 的遗留项目目录,store 头手动降级回 v7 复现原 bug):

修复前: analyze → "Up to date"(死循环);stats → unsupported cache version 7
修复后: analyze → incremental build: 136 files → 196 nodes, 535 edges (0.1s)  ← 自愈
        stats  → Project: baseline, 26 procedures, 28 functions, 10 packages...
        analyze → Up to date. 136 files.

Out of scope

  • diff 命令未加版本校验(只展示文件差异,不读 store)
  • store 迁移不做(full rebuild 即自愈,与既有"拒绝 + 重建"设计一致)

🤖 Generated with Sisyphus

c2j and others added 2 commits September 7, 2026 17:52
根因:STORE_VERSION 升级(如 7→8)只作废 store 本身,而 analyze 的 up-to-date
判定只比较文件指纹与无版本头的 manifest 边车,从不校验 store 版本 —— 指纹未变
即提前返回,旧布局 store 原样保留,所有读命令(stats/trace/export)因版本门禁
拒绝读取,且其错误信息建议的 codeweb analyze 恰恰不会重建,形成死循环。

修复:
- store.rs 新增 peek_version()(仅读 13 字节 magic+version 头)与
  file_is_current()(缺文件/无头旧格式/版本不符均视为 stale)
- project/mod.rs analyze() 判定改为 changes.is_empty() && store_is_current();
  版本不符走既有重建路径自愈(save_bincode 覆写为当前版本并重写边车)
- STORE_VERSION 保持 8 不变(本修复不改变 v8 布局)

测试:analyze_rebuilds_when_store_version_stale(失败→通过)+
peek_version 三个边界用例(正常头/无头旧格式/文件缺失)

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
analyze 的 up-to-date 快路径从不加载 store,报告中的 nodes/edges 恒为
unwrap_or(0) 的占位值 —— 打印 "Up to date. N files, 0 nodes, 0 edges." 会
让用户误以为图谱为空(本次 bug 报告的混淆来源之一)。

提取 format_up_to_date_line():快路径只报文件数,不含计数;全量/增量构建
输出保持不变(那里有真实计数)。

测试:up_to_date_line_reports_files_without_zero_counts

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Comment thread src/graph/store.rs Outdated
]))
}

/// True when the on-disk bincode store is readable under the current

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] file_is_current is documented as "True when the on-disk bincode store is readable under the current STORE_VERSION", but it only compares the 13-byte header. A truncated or corrupt file with a current magic+version (the unit test peek_version_returns_header_version even writes a header-only file and asserts file_is_current) still takes the up-to-date fast path, and load_bincode then fails with a deserialize error rather than the "run codeweb analyze" version gate. That is the same deadlock shape for crash/partial-write, not for the 7→8 bump this PR fixes. The rest of this docstring (and store_is_current in src/project/mod.rs) also retells the incident rather than stating the contract.

Suggestion: Document the actual predicate: header magic matches and header version == STORE_VERSION; missing/unreadable/headerless ⇒ not current. Payload readability is not checked. Trim the incident writeup; the WHY that belongs here is "manifest sidecar has no version, so analyze must probe the store header."

Comment thread src/project/mod.rs Outdated
}
match self.config.store.format {
config::StoreFormat::Bincode => GraphStore::file_is_current(&store_path),
config::StoreFormat::Json => GraphStore::load_json(&store_path).is_ok(),

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] For StoreFormat::Json, every analyze() up-to-date check now load_json()s the entire graph and throws it away. Bincode keeps the cheap 13-byte probe; JSON does not. Default format is bincode so this is not the reported bug, but a large pretty-printed store will make a previously instant "Up to date" analyze allocate the full graph (and, on files-changed rebuilds, load the old store only to discard it and reparse everything anyway). is_ok() also collapses version mismatch, corrupt JSON, and I/O errors into "stale" — fine for self-heal, expensive for the happy path.

Suggestion: Probe JSON the same way as bincode: decode only version (a tiny { version: u32 } struct is enough for the gate; extra fields are ignored). Keep full load_json for actual reads. Optionally reuse a successful probe by storing it in self.store so the fast path does not pay twice.

评审意见(PR #157, review 5130971483):

1. file_is_current 文档宣称 "readable under current STORE_VERSION",实际仅
   比较 13 字节头 —— 截断/损坏但头部有效的文件会被判 current,load_bincode
   报裸反序列化错误而非版本门禁提示。改为文档化真实谓词(头魔数 + 头版本;
   payload 可读性不在检查范围),并精简 store_is_current 的事故叙述。

2. JSON 格式的 up-to-date 检查原先 load_json 全量加载整个图后丢弃。新增
   json_file_is_current:以 { version: u32 } 探测结构体只解码 version 字段
   (serde 忽略其余文档),快路径不再构建完整图;缺文件/损坏/版本不符仍折叠
   为 "stale"(自愈语义不变)。

测试:json_file_is_current 三个用例(当前版本全文档 / 旧版本 / 损坏与缺失)

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant