Skip to content

fix(python): batch rooted usage-graph existence checks - #5

Merged
sontek merged 2 commits into
all-fixes-combinedfrom
pr/issue-19-python-relational-batching
Sep 4, 2026
Merged

fix(python): batch rooted usage-graph existence checks#5
sontek merged 2 commits into
all-fixes-combinedfrom
pr/issue-19-python-relational-batching

Conversation

@sontek

@sontek sontek commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Python's whole-workspace usage-graph scan resolved every candidate reference with a live, unbatched relational-store query, one round trip per reference. On a large real-world Python repo this made the scan take tens of minutes. Every other language already batches this same kind of lookup. Python's scan just never routed through it.

record and two sibling methods now defer their existence check into a buffer, in whole-workspace mode only. Targeted scans already resolve in-memory and stay untouched. After each file's walk, one batched call warms the same cache the point lookup already reads. The deferred checks then resolve against it.

Measured directly against a real Python repo checkout, isolating the changed code from the unrelated one-time indexing cost: 28.2 minutes before, 12.6 minutes after. Same edge count both times (226,484), confirming a pure speedup with no resolution difference.

Also fixes a correctness bug the review surfaced. A namespace-import candidate could get recorded as an edge in a targeted scan even when it was not one of the caller's requested targets. The fallback path recorded it directly instead of routing back through the target-set check. Added a regression test that reproduces it and confirmed it fails on the prior commit.

Two smaller costs remain, called out for a follow-up rather than folded into this fix. An ancestor-hierarchy lookup now runs unconditionally on a common path instead of only after a failed direct match, though it is bounded by distinct class count, not reference count, and already memoized per class. The new batching is also per file rather than across the whole workspace, unlike this codebase's own Rust and Java equivalents. Both are real but small next to the fix above.

Python's whole-workspace ("rooted") usage-graph scan resolved every
candidate reference with a live, unbatched SQLite query via
IAnalyzer::definitions(), one round trip per reference. On a large
Python monorepo this took 25+ minutes; the largest Go monorepo
measured with an already-batched equivalent path takes under 10.

record()'s existence check, and two sibling methods with the same
shape, now defer their check into a pending buffer in rooted mode
only (bounded mode, already a cheap in-memory hash check, is
unaffected). After each file's walk, prefetch_definitions batches
every deferred name into a couple of store round trips before the
checks are resolved for real, reusing the exact same underlying
lookup and cache other languages already use via the same trait
method.

build_python_edges also opened its relational frontier fresh per
file instead of once for the whole parallel scan, losing cross-file
batching for the (smaller) receiver-type-resolution path too. It now
opens once around the whole fan-out, matching Java's existing
build_java_edges/with_java_graph_source placement.
…p accept/record logic

record_direct_or_namespace_fallback's fallback loop called
edges.record_kind directly instead of routing back through the
accepts_target check, so in bounded (targeted) scans a namespace
re-export candidate outside the caller's requested target set could
still get recorded as an edge, since record_kind's own guard only
checks workspace membership, not target-set membership. Added a
regression test that reproduces it with a re-exported symbol whose
direct fqn doesn't resolve, confirmed it fails on the prior commit,
and fixed it by routing every fallback candidate through the same
accept-and-record helper the direct path already uses.

Also extracted record_direct/record_namespace_fallback/
record_ancestor_fallback, shared between each pending variant's
bounded-mode immediate branch and its resolve_pending replay, so the
two copies of each variant's accept/record logic can't drift apart
again the way this bug just showed they can.
@sontek
sontek merged commit 7122153 into all-fixes-combined Sep 4, 2026
1 check failed
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