perf(resolution): defer checkpoints and double-buffer persist during resolution, byte-identical graphs (#1320)

Fresh init on a 4,402-file Java repo (dubbo): 18.2s -> 13.5s (-26%), with
the resolution phase going 12.6s -> 7.9s (-38%). Graphs verified
byte-identical on both the pool path (dubbo) and the sequential path
(excalidraw).

Two changes:

1. The fastInit+pool path restored WAL for the resolver workers but left
   wal_autocheckpoint at its default, so the persist loop inline-checkpointed
   hot pages all phase long (#1231's pathology inside resolution — measured
   at 58% of resolution wall). Checkpointing is now deferred behind the
   bounded valve and folded once at maintenance, mirroring the deferWal path.

2. The resolution loop is double-buffered: batch k+1 is prefetched (OFFSET
   past batch k's still-pending rows, under an explicit ORDER BY rowid) and
   fanned out across the pool while batch k's ref cleanup runs on the main
   thread. Batch settle-waits dropped 2572ms -> 117ms.

Correctness invariant found by the byte-identical gate and now documented in
the loop: batch k+1's resolution READS batch k's edges (resolveMethodOnType
walks supertype chains over extends/implements edges that resolution itself
inserts), so edges must persist BEFORE the next batch fans out; only the ref
cleanup overlaps.

Also extends the CODEGRAPH_SYNTH_TIMINGS instrumentation with phase labels
(grammar-init, parse-loop, fts-rebuild, resolver-reinit, resolution,
callback-synthesis) and pool timings (worker open/resolve, per-batch mode,
persist), so the next profile is one env var away.

Suite green (2444). Sync path timings unchanged. Pool floor re-validated:
forced-on at 40k refs is still net-slower, so the 150k threshold stands.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-07-16 17:26:29 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent 1de7e8f8b5
commit a2f3c31a97
6 changed files with 152 additions and 40 deletions
+6 -1
View File
@@ -2061,8 +2061,13 @@ export class QueryBuilder {
*/
getUnresolvedReferencesBatch(offset: number, limit: number): UnresolvedReference[] {
if (!this.stmts.getUnresolvedBatch) {
// ORDER BY rowid is load-bearing for the pipelined resolution loop: it
// prefetches batch k+1 at OFFSET batch_k.length while batch k's rows are
// still pending, which is only exact under a stable enumeration. (A plain
// scan and the status index both return rowid order anyway — this pins
// it.)
this.stmts.getUnresolvedBatch = this.db.prepare(
"SELECT * FROM unresolved_refs WHERE status = 'pending' LIMIT ? OFFSET ?"
"SELECT * FROM unresolved_refs WHERE status = 'pending' ORDER BY rowid LIMIT ? OFFSET ?"
);
}
const rows = this.stmts.getUnresolvedBatch.all(limit, offset) as UnresolvedRefRow[];
+4
View File
@@ -1520,7 +1520,9 @@ export class ExtractionOrchestrator {
// in file order preserves the #1015 determinism exactly.
storeWriterOpts?: { dbPath: string; fastInit: boolean } | null
): Promise<IndexResult> {
const tGrammar = Date.now();
await initGrammars();
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] grammar-init: ${Date.now() - tGrammar}ms`);
const startTime = Date.now();
const errors: ExtractionError[] = [];
let filesIndexed = 0;
@@ -1816,6 +1818,7 @@ export class ExtractionOrchestrator {
}
};
const tParseLoop = Date.now();
for (let i = 0; i < files.length; i += FILE_IO_BATCH_SIZE) {
if (signal?.aborted) { aborted = true; break; }
@@ -1907,6 +1910,7 @@ export class ExtractionOrchestrator {
}
}
}
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] parse-loop: ${Date.now() - tParseLoop}ms`);
if (signal?.aborted || aborted) {
if (storeWriter) await storeWriter.close();
+28 -1
View File
@@ -464,6 +464,9 @@ export class CodeGraph {
const deferWal = !fastInit && process.env.CODEGRAPH_NO_WAL_DEFER !== '1' && this.db.getJournalMode() === 'wal';
let walValve: WalCheckpointValve | null = null;
let priorAutocheckpoint = 1000;
// Set when the fastInit+pool path below defers autocheckpointing, so the
// finally knows to restore the interval on that path too.
let restoreAutocheckpoint = false;
if (deferWal) {
priorAutocheckpoint = this.db.getWalAutocheckpoint();
this.db.setWalAutocheckpoint(0);
@@ -503,7 +506,9 @@ export class CodeGraph {
freshDb ? { dbPath: this.db.getPath(), fastInit } : null
);
} finally {
const tFts = Date.now();
this.db.endBulkNodeLoad();
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] fts-rebuild: ${Date.now() - tFts}ms`);
}
// Fold the parse phase's WAL BEFORE the first post-parse reads
@@ -521,10 +526,12 @@ export class CodeGraph {
// and silently drop themselves. Re-initializing here gives them a
// chance to see the actual project before resolution runs.
if (result.success && result.filesIndexed > 0) {
const tReinit = Date.now();
this.resolver.initialize();
// Cross-file finalization (e.g. NestJS RouterModule prefixes). Runs
// before resolution so updated names show up in subsequent reads.
this.resolver.runPostExtract();
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] resolver-reinit: ${Date.now() - tReinit}ms`);
}
// Resolve references to create call/import/extends edges
@@ -542,6 +549,24 @@ export class CodeGraph {
try {
this.db.getDb().pragma('synchronous = NORMAL');
this.db.getDb().pragma('journal_mode = WAL');
// Defer auto-checkpointing for the resolution phase, same
// rationale as the deferWal path above: at the default 1000-page
// interval, the persist loop's edge inserts + ref deletes make
// SQLite re-write hot B-tree pages into the main DB file inline
// on the writer over and over (#1231's pathology — measured as
// ~58% of the resolution phase on a 255k-ref repo). The valve
// bounds WAL growth off-thread; runMaintenance does the final
// fold and the finally restores the interval.
priorAutocheckpoint = this.db.getWalAutocheckpoint();
this.db.setWalAutocheckpoint(0);
restoreAutocheckpoint = true;
walValve = new WalCheckpointValve(
this.db,
undefined,
undefined,
options.verbose ? (m) => console.log(`[wal-valve] ${m}`) : undefined
);
walValve.start();
} catch { /* keep current mode; resolution still works sequentially */ }
}
@@ -551,6 +576,7 @@ export class CodeGraph {
total: unresolvedCount,
});
const tResolve = Date.now();
await this.resolveReferencesBatched(
(current, total) => {
options.onProgress?.({
@@ -567,6 +593,7 @@ export class CodeGraph {
});
}
);
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] resolution: ${Date.now() - tResolve}ms`);
// Second pass: chained calls whose method lives on a supertype the
// receiver conforms to (protocol-extension / inherited / default-
@@ -658,7 +685,7 @@ export class CodeGraph {
// (SQLite replays the WAL on the next open) and the follow-up write
// that folds it is the known cost of a failed run.
if (walValve) { walValve.stop(); await walValve.drain(); }
if (deferWal) {
if (deferWal || restoreAutocheckpoint) {
try { this.db.setWalAutocheckpoint(priorAutocheckpoint); } catch { /* connection may be closing */ }
}
if (fastInit) {
+108 -38
View File
@@ -1357,52 +1357,98 @@ export class ReferenceResolver {
// costs zero wall-clock. Any failure downgrades to sequential permanently.
let pool: ResolverPool | null = null;
let poolReady = false;
const tPoolStart = Date.now();
if (parallel && total >= minRefsForPool()) {
pool = ResolverPool.tryCreate(parallel.dbPath, this.projectRoot);
pool?.ready().then(
() => { poolReady = true; },
() => {
poolReady = true;
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[pool-timing] pool ready after ${Date.now() - tPoolStart}ms`);
},
() => { void pool?.destroy().catch(() => undefined); pool = null; }
);
}
// Process in batches. We always read from offset 0 because every ref the
// batch processed leaves the pending set (resolved rows are deleted,
// Process in PIPELINED batches (double-buffer). The enumeration is the
// head of the pending set in rowid order; every ref a persisted batch
// processed leaves the pending set (resolved rows are deleted,
// unresolvable ones flip to status='failed'), shifting the remaining
// pending rows forward.
let prevRemaining = Number.POSITIVE_INFINITY;
try {
while (true) {
const batch = this.queries.getUnresolvedReferencesBatch(0, batchSize);
if (batch.length === 0) break;
let result: ResolutionResult;
// Fan-out result of ResolverPool.resolveBatch, settled (never rejecting)
// so a fan-out begun before the previous batch's persist can't produce an
// unhandled rejection while it waits to be awaited.
type PoolSettled =
| { ok: true; out: Awaited<ReturnType<ResolverPool['resolveBatch']>> }
| { ok: false; err: unknown };
type InFlight = { mode: 'pool'; settled: Promise<PoolSettled> } | { mode: 'seq' };
// Begin one batch: fan out to the pool when it's ready and the batch is
// big enough — workers then resolve batch k+1 WHILE the main thread
// persists batch k (persist measured at ~58% of resolution wall on a
// 255k-ref repo, all of it previously spent with the pool idle).
// Sequential batches stay lazy: they run on the main thread at settle
// time, where an early start would only contend with the persist.
const beginBatch = (batch: UnresolvedReference[]): InFlight => {
if (pool && poolReady && ResolverPool.worthParallel(batch.length)) {
try {
const out = await pool.resolveBatch(batch);
// Deferred post-pass refs ride back from the workers; re-queue them
// in admission order so the post-passes see the sequential order.
this.appendDeferredFromWorkers(out.deferredChain, out.deferredThisMember);
result = {
resolved: out.resolved,
unresolved: out.unresolved,
return {
mode: 'pool',
settled: pool.resolveBatch(batch).then(
(out) => ({ ok: true as const, out }),
(err: unknown) => ({ ok: false as const, err })
),
};
}
return { mode: 'seq' };
};
// Settle an in-flight batch to a ResolutionResult. Deferred post-pass refs
// are appended HERE, in loop order — never inside the fan-out promise — so
// admission order stays exactly the sequential order even while a later
// batch resolves concurrently. A pool failure downgrades to sequential
// permanently and re-resolves this batch on the main thread.
const settleBatch = async (
inFlight: InFlight,
batch: UnresolvedReference[]
): Promise<ResolutionResult> => {
if (inFlight.mode === 'pool') {
const settled = await inFlight.settled;
if (settled.ok) {
this.appendDeferredFromWorkers(settled.out.deferredChain, settled.out.deferredThisMember);
return {
resolved: settled.out.resolved,
unresolved: settled.out.unresolved,
stats: {
total: batch.length,
resolved: out.resolved.length,
unresolved: out.unresolved.length,
byMethod: out.byMethod,
resolved: settled.out.resolved.length,
unresolved: settled.out.unresolved.length,
byMethod: settled.out.byMethod,
},
};
} catch (err) {
logDebug('Parallel resolution failed; falling back to sequential', {
error: err instanceof Error ? err.message : String(err),
});
await pool.destroy().catch(() => undefined);
pool = null;
result = await this.resolveBatchYielding(batch, maybeYield);
}
} else {
result = await this.resolveBatchYielding(batch, maybeYield);
logDebug('Parallel resolution failed; falling back to sequential', {
error: settled.err instanceof Error ? settled.err.message : String(settled.err),
});
if (pool) await pool.destroy().catch(() => undefined);
pool = null;
}
return this.resolveBatchYielding(batch, maybeYield);
};
try {
let batch = this.queries.getUnresolvedReferencesBatch(0, batchSize);
let inFlight: InFlight | null = batch.length > 0 ? beginBatch(batch) : null;
while (batch.length > 0 && inFlight) {
// Prefetch the NEXT batch before this one persists: this batch's rows
// are still pending (nothing has mutated the table since they were
// read), so skipping exactly batch.length rows in the same rowid
// enumeration yields the following batch.
const nextBatch = this.queries.getUnresolvedReferencesBatch(batch.length, batchSize);
const tBatch = Date.now();
const result = await settleBatch(inFlight, batch);
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[pool-timing] batch ${inFlight.mode}: ${batch.length} refs in ${Date.now() - tBatch}ms`);
// Persist in bounded sub-transactions with yields between: a whole
// batch's edge insert / keyed deletes are otherwise one solid
@@ -1412,14 +1458,27 @@ export class ReferenceResolver {
// land before their refs are deleted, so a kill mid-way re-resolves
// the remainder idempotently on the next run/sweep (#1187).
const PERSIST_CHUNK = 1000;
const tPersist = Date.now();
// Persist edges immediately
// Persist edges BEFORE fanning out the next batch: later batches read
// this batch's edges — resolveMethodOnType walks supertype chains over
// `extends`/`implements` edges that earlier batches resolved, so a
// receiver typed as a subclass only reaches a method declared on its
// base class if those edges are visible. (Validated on dubbo: fanning
// out first downgraded exactly those supertype-method resolutions from
// the 0.9 typed-receiver path to the 0.65 word-overlap fallback.)
const edges = this.createEdges(result.resolved);
for (let i = 0; i < edges.length; i += PERSIST_CHUNK) {
this.queries.insertEdges(edges.slice(i, i + PERSIST_CHUNK));
await maybeYield();
}
// NOW fan the next batch out — workers see exactly the edge state the
// sequential baseline would (every batch ≤ this one committed), while
// the main thread spends the REST of the persist (ref deletes + failed
// parking below) overlapped with their resolution — the double-buffer.
const nextInFlight = nextBatch.length > 0 ? beginBatch(nextBatch) : null;
// Clean up resolved refs so they don't appear in the next batch —
// by row id, so a same-key sibling ref in a LATER batch (same caller
// calling the same callee at another line) is left pending for its own
@@ -1448,6 +1507,8 @@ export class ReferenceResolver {
await maybeYield();
}
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[pool-timing] batch persist: ${Date.now() - tPersist}ms`);
// Aggregate stats
aggregateStats.total += result.stats.total;
aggregateStats.resolved += result.stats.resolved;
@@ -1470,18 +1531,25 @@ export class ReferenceResolver {
// batch one and left the rest of the table as permanent orphans (#1187).
// The count-based guard below catches the true no-progress case.
// Non-progress guard (defense-in-depth). Because we re-read from offset 0
// each pass, the PENDING population MUST shrink every iteration — resolved
// refs are deleted and unresolvable ones are marked failed above, and both
// leave the pending set the batch reader sees. If it didn't shrink, a
// resolver returned a match whose `original.referenceName` differs from the
// stored row, so the keyed delete/update no-ops, and we'd re-read +
// re-resolve + re-insert the same rows forever (the runaway that grew a
// 99-file repo to 5M edges / 1.4 GB before the Go-fallback fix). Stop
// rather than grow the graph without bound.
// Non-progress guard (defense-in-depth). Each iteration enumerates from
// the head of the pending set, so the PENDING population MUST shrink
// every iteration — resolved refs are deleted and unresolvable ones are
// marked failed above, and both leave the pending set the batch reader
// sees. If it didn't shrink, a resolver returned a match whose
// `original.referenceName` differs from the stored row, so the keyed
// delete/update no-ops, and we'd re-read + re-resolve + re-insert the
// same rows forever (the runaway that grew a 99-file repo to 5M edges /
// 1.4 GB before the Go-fallback fix). Stop rather than grow the graph
// without bound. (An in-flight prefetched batch is abandoned unsettled —
// fan-out has no side effects until settleBatch appends its results.)
const remaining = this.queries.getUnresolvedReferencesCount();
if (remaining >= prevRemaining) break;
prevRemaining = remaining;
// Advance the pipeline: the prefetched batch (already fanned out when
// the pool is on) becomes the current one.
batch = nextBatch;
inFlight = nextInFlight;
}
} finally {
if (pool) await pool.destroy().catch(() => undefined);
@@ -1491,6 +1559,7 @@ export class ReferenceResolver {
// synthesize observer/callback dispatch edges (dispatcher → registered
// callbacks) that static parsing leaves out. Best-effort — never fail the
// index on it. See docs/design/callback-edge-synthesis.md.
const tSynth = Date.now();
try {
aggregateStats.byMethod['callback-synthesis'] = await synthesizeCallbackEdges(
this.queries,
@@ -1500,6 +1569,7 @@ export class ReferenceResolver {
} catch {
// synthesis is additive and optional; ignore failures
}
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] callback-synthesis: ${Date.now() - tSynth}ms`);
return {
resolved: [],
+5
View File
@@ -43,19 +43,24 @@ port.on('message', (msg: InMessage) => {
try {
switch (msg.type) {
case 'open': {
const tOpen = Date.now();
const created = createDatabase(msg.dbPath, { readOnly: true });
db = created.db;
db.pragma('busy_timeout = 5000');
db.pragma('cache_size = -32000');
const tDb = Date.now();
const queries = new QueryBuilder(db);
resolver = new ReferenceResolver(msg.projectRoot, queries);
resolver.initialize();
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[pool-timing] worker open: db=${tDb - tOpen}ms init=${Date.now() - tDb}ms`);
port.postMessage({ type: 'ready' });
break;
}
case 'resolve': {
if (!resolver) throw new Error('resolver-worker: resolve before open');
const tRes = Date.now();
const out = resolver.resolveListForAdmission(msg.refs);
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[pool-timing] worker resolve: ${msg.refs.length} refs in ${Date.now() - tRes}ms`);
port.postMessage({ type: 'result', id: msg.id, ...out });
break;
}