Four independent findings on refresh_files_profiled_with_workspace_crate_prefix_cache, filed together because they share one hot path — the watcher-driven refresh that runs on every file edit. Each verified against source at 28930e09 (v0.50.0 + our gather branch); commands to reproduce each check are inline.
The theme: the cold-build path is optimized and the incremental path isn't, even though incremental runs orders of magnitude more often.
1. A one-file edit re-reads the entire graph into memory · biggest of the four
refresh_files_profiled_with_workspace_crate_prefix_cache calls ProjectIndex::from_db_and_callers per refresh batch (mod.rs:3108), which calls load_db_file_indexes (mod.rs:8205) — three unfiltered scans:
SELECT path, lang FROM files -- mod.rs:8304
SELECT file_path, id, name, scoped_name, exported, is_default_export
FROM nodes -- mod.rs:8326
SELECT ref_id, caller_file, kind, ... FROM refs
WHERE kind IN ('reexport','export_alias') -- mod.rs:8367
The write side is properly incremental — only changed rows. The read side is O(whole graph) per refresh. So editing one file re-materializes every file, every node, and every reexport ref in the repo to build the resolution index.
- Trigger: any single-file edit. The watcher enqueues a refresh per change, so this is the steady-state cost of typing.
- Severity: P2 — scales with repo size, not edit size, which is the wrong axis.
- Fix direction: scope the index load to the touched files plus their dependency closure, or cache the index across refreshes and invalidate only affected entries.
2. The write transaction is held across file IO and tree-sitter parsing
The refresh opens one transaction at mod.rs:2986 and holds it for the whole batch. First write is delete_file_rows (mod.rs:3137), which takes SQLite's write lock. Then, still inside that transaction:
mod.rs:3200 insert_method_dispatch_edges(&tx, ...)
→ mod.rs:8883 infer_receiver_type_state(...)
→ mod.rs:9281 std::fs::read_to_string(project_root.join(caller_file))
+ tree_sitter::Parser::new() / parse
So the write lock is held across disk reads and parses of source files. Any concurrent reader — including the dead-code projection opening the DB read-only — blocks on busy_timeout for that duration.
- Trigger: a refresh touching files with method-dispatch references whose sources aren't in the dispatch cache, concurrent with any read.
- Severity: P2.
- Fix direction: run dispatch-source inference before opening the write transaction (or in a separate read transaction), or pre-warm the source cache before the first write.
3. Incremental inserts re-prepare every statement; cold build doesn't
Same work, two paths, one optimized:
// incremental — mod.rs:3140
insert_file_extract(&tx, &self.project_root, extract)?;
// → mod.rs:8730 for node in &extract.nodes { tx.execute("INSERT OR REPLACE INTO nodes(...)")?; }
// cold build — mod.rs:2769
let mut inserts = ColdBuildInsertStatements::new(&tx)?;
insert_file_extract_prepared(&mut inserts, &workspace_root, extract)?;
tx.execute re-prepares the SQL each call. A file with N nodes and M refs issues N+M+2 separate prepares per refresh. The prepared-statement machinery already exists (ColdBuildInsertStatements, mod.rs:8521) — it just was never routed onto the incremental path.
- Severity: P2 — bites on large files and frequent edits.
- Fix direction: route the incremental refresh through
ColdBuildInsertStatements.
4. dispatch_hints has no index on file, and the refresh queries by file
-- mod.rs:11140, called per candidate file from the refresh loop (mod.rs:3121)
SELECT id, method_name, caller_node, file, line, byte_start, byte_end, provenance
FROM dispatch_hints WHERE file = ?1
The only index on that table is on method_name:
CREATE INDEX IF NOT EXISTS idx_dispatch_hints_method ON dispatch_hints(method_name); -- mod.rs:5961, 6136
So each per-file freshness comparison full-scans dispatch_hints. DELETE FROM dispatch_hints WHERE file = ?1 (mod.rs:11299) hits the same gap.
- Severity: P2, and the cheapest of the four to fix.
- Fix direction:
CREATE INDEX idx_dispatch_hints_file ON dispatch_hints(file), added to create_cold_build_secondary_indexes / drop_cold_build_secondary_indexes alongside the existing one.
Provenance and limits, so you can calibrate. These came out of a parallel read-only sweep across the codebase; I verified all four anchors myself against source before filing (the sed/grep targets are the line numbers above). What I have not done is measure any of them — no profile, no before/after. The claims are structural ("this code re-reads the whole table"), not empirical ("this costs X ms"). #1 and #2 are the ones I'd expect to matter most in practice, but you'd know better than a static read whether the dispatch-inference cache usually hits.
Line numbers are against 28930e09 (v0.50.0 plus our #152 branch, which touches none of this code).
Four independent findings on
refresh_files_profiled_with_workspace_crate_prefix_cache, filed together because they share one hot path — the watcher-driven refresh that runs on every file edit. Each verified against source at28930e09(v0.50.0 + our gather branch); commands to reproduce each check are inline.The theme: the cold-build path is optimized and the incremental path isn't, even though incremental runs orders of magnitude more often.
1. A one-file edit re-reads the entire graph into memory · biggest of the four
refresh_files_profiled_with_workspace_crate_prefix_cachecallsProjectIndex::from_db_and_callersper refresh batch (mod.rs:3108), which callsload_db_file_indexes(mod.rs:8205) — three unfiltered scans:The write side is properly incremental — only changed rows. The read side is O(whole graph) per refresh. So editing one file re-materializes every file, every node, and every reexport ref in the repo to build the resolution index.
2. The write transaction is held across file IO and tree-sitter parsing
The refresh opens one transaction at
mod.rs:2986and holds it for the whole batch. First write isdelete_file_rows(mod.rs:3137), which takes SQLite's write lock. Then, still inside that transaction:So the write lock is held across disk reads and parses of source files. Any concurrent reader — including the dead-code projection opening the DB read-only — blocks on
busy_timeoutfor that duration.3. Incremental inserts re-prepare every statement; cold build doesn't
Same work, two paths, one optimized:
tx.executere-prepares the SQL each call. A file with N nodes and M refs issues N+M+2 separate prepares per refresh. The prepared-statement machinery already exists (ColdBuildInsertStatements,mod.rs:8521) — it just was never routed onto the incremental path.ColdBuildInsertStatements.4.
dispatch_hintshas no index onfile, and the refresh queries byfileThe only index on that table is on
method_name:So each per-file freshness comparison full-scans
dispatch_hints.DELETE FROM dispatch_hints WHERE file = ?1(mod.rs:11299) hits the same gap.CREATE INDEX idx_dispatch_hints_file ON dispatch_hints(file), added tocreate_cold_build_secondary_indexes/drop_cold_build_secondary_indexesalongside the existing one.Provenance and limits, so you can calibrate. These came out of a parallel read-only sweep across the codebase; I verified all four anchors myself against source before filing (the
sed/greptargets are the line numbers above). What I have not done is measure any of them — no profile, no before/after. The claims are structural ("this code re-reads the whole table"), not empirical ("this costs X ms"). #1 and #2 are the ones I'd expect to matter most in practice, but you'd know better than a static read whether the dispatch-inference cache usually hits.Line numbers are against
28930e09(v0.50.0 plus our #152 branch, which touches none of this code).