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
+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;
}