Skip to content

Incremental callgraph refresh re-reads the whole graph per edit, and skips three optimizations the cold-build path already has #214

Description

@iceteaSA

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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions