Skip to content

perf(resolver): keep single-file edits fast during daemon warmup#197

Merged
zzet merged 6 commits into
mainfrom
feat/lean-index-hot-path
Jun 29, 2026
Merged

perf(resolver): keep single-file edits fast during daemon warmup#197
zzet merged 6 commits into
mainfrom
feat/lean-index-hot-path

Conversation

@zzet

@zzet zzet commented Jun 29, 2026

Copy link
Copy Markdown
Owner

Problem

A single-file edit (file save / edit_file) while the daemon is warming up blocked for tens of seconds — comparable to indexing the whole repo, for one file. Measured: editing a heavily-referenced file (internal/config/config.go) during warmup took 44–120 s.

Root cause

Interactive edits and the warmup passes share one global resolve mutex (g.resolveMu): every Resolver built from a graph shares it, ResolveFileAndIncoming takes it, and three warmup passes held it for their whole duration:

  • the cross-repo resolve (~70 s),
  • the master same-repo resolve (~24 s),
  • the janitor's clone detection, which re-detected all tracked repos on every cycle that touched a single file (~52 s for 21 repos).

So an edit landing mid-pass waited out the full pass — the edit wasn't doing work, it was blocked.

Fix

  • Chunk both resolve passes. Process the pending set in super-chunks and release the resolve mutex between chunks (default 2048; GORTEX_RESOLVE_CHUNK=0 disables, GORTEX_RESOLVE_CHUNK_SIZE tunes). Each chunk's compute+apply is atomic under the lock; only the inter-chunk gap is unlocked, where the pass holds no partial graph state — so a waiting edit gets in within one chunk.
  • Scope the janitor's clone detection + Rebuild to the repos that actually changed this cycle (warmup already did this; the periodic reconcile path didn't).
  • Liveness guards. An edit during an inter-chunk yield can evict an edge the pass already resolved; reindexing an evicted edge half-resurrects it (re-adds its in-edges bucket while its out-edges slot is gone) and can panic. The per-chunk apply and guardCrossPackageCallEdges drop an edge that is no longer live — validating only the resolved batch, not every pending edge.

Result (measured)

Editing config.go during warmup: 44–120 s → ~35 ms. Warmup-log timings: master resolve 5.95 s, cross-repo resolve 28.6 s, clone passes scoped to 1 repo (2.2 s vs 52 s for 21).

Safety

Two concurrent-edit stress tests (TestCrossRepoResolveAll_ConcurrentEdits, TestResolveAll_ConcurrentEdits) run each ResolveAll while an editor goroutine evicts files under the same mutex — green under -race -count=5. Full build, golangci-lint (0 issues), and the indexer race suite pass.

zzet added 6 commits June 29, 2026 09:41
… resolveEdge

Preparatory, behaviour-preserving: adds a validateLiveness flag (default off)
and two guards in CrossRepoResolver.resolveEdge — skip an edge that is no
longer live (edgeStillLive), and refuse a resolution whose target node was
evicted (leaving it unresolved). Both are no-ops on the existing
whole-pass-locked path, where nothing mutates the graph mid-pass. They become
load-bearing for the chunked resolve that releases the resolve mutex between
chunks so interactive edits can interleave.
CrossRepoResolver.ResolveAll held the global resolve mutex for its entire
pass (~70s on a large warmup), and the same mutex guards an interactive
edit's ResolveFileAndIncoming — so any edit landing mid-pass blocked for the
full remaining duration. Process the pending set in super-chunks instead:
each chunk's compute+apply runs under the lock (atomic, fresh reads), and the
mutex is released between chunks (where the pass holds no partial state) so a
waiting edit gets in within one chunk. resolveEdge's liveness guards handle
any edge/candidate a yielded edit evicted. Default chunk 2048;
GORTEX_RESOLVE_CHUNK=0 restores the whole-pass-locked behaviour.
…solve

Runs CrossRepoResolver.ResolveAll while an editor goroutine evicts and
re-indexes caller files under the same resolve mutex an interactive edit
takes — the exact interleaving the chunked path enables. Asserts no panic /
race (the half-resurrection an unguarded ReindexEdge on an evicted edge would
cause) and that no resolved edge points at a missing node. Passes under
-race -count=3.
ReconcileAll (the periodic reconcile janitor) ran RunGlobalGraphPasses
UNSCOPED whenever any repo reindexed — re-detecting clones and reseeding the
clone index for ALL tracked repos, holding the global resolve mutex for tens
of seconds (~52s for 21 repos in one trace). That mutex also guards every
interactive edit's resolve, so each janitor cycle that touched even one file
stalled concurrent edits for the full pass. Feature B armed the changed-repo
scope only on the warmup path; arm it here too so the janitor's clone passes
run only for the repos that actually changed this cycle.
The chunked cross-repo resolve ran its liveness guards (edgeStillLive ->
GetOutEdges) inside resolveEdge, i.e. once per PENDING edge — O(pending *
out-degree). On a warmup pass of ~198k mostly-unresolvable edges that
ballooned the pass from ~70s to ~296s and pushed graph-queryable to 377s.
Move the guards to the apply: validate only the resolved batch (the few
thousand edges that actually changed) before ReindexEdges. Same safety (drop
an evicted edge, revert a dead-target resolution) at a fraction of the cost.
The same-repo Resolver.ResolveAll held the global resolve mutex for its whole
pass (~24s pre-ready on a large warmup). That mutex also guards every
interactive edit's ResolveFileAndIncoming, so a single-file edit during the
pre-ready resolve phase stalled tens of seconds — comparable to indexing the
whole repo, for one file. Process pending in super-chunks and release the
mutex between chunks (compute mutates a clone, so the only live-edge write is
the per-chunk apply, which skips an edge a yielded edit evicted). Accumulated
jobs feed the post-resolve passes once; guardCrossPackageCallEdges gets the
same evicted-edge guard. Validated by TestResolveAll_ConcurrentEdits under
-race -count=5.
@zzet zzet merged commit 72b5c7e into main Jun 29, 2026
9 checks passed
@zzet zzet deleted the feat/lean-index-hot-path branch June 29, 2026 22:26
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