Large-codebase indexing died at the end of "Resolving refs" two ways: watchdog kills of healthy work (24k-file Java on Windows, #1212 — third iteration of the #1091/#1122 class) and hard OOMs (Linux kernel scale, where v1.3.0 could not complete at any watchdog setting). Root causes: ~31 of 37 dynamic-edge synthesis passes ran start-to-finish with no yield points, several materialized whole-graph snapshots (kotlin expect/actual opened with getAllNodes() — 2M nodes in one array; the C fn-pointer pass retained every C file's contents twice plus every function node), and the post-index WAL checkpoint ran minutes of synchronous IO on the main thread, killing even a successful index at the finish line. The pipeline tail now follows the same discipline as the rest: never hold O(graph) in the heap, yield everywhere. - All synthesis passes stream node-kind scans (cursors, not arrays) and yield on time-budgeted checkpoints; language gates skip passes whose filters a project's file languages provably can't satisfy. - kotlin expect/actual filters SQL-side; c-fnptr caches are LRU-bounded, units stream one file at a time, and the all-functions array + write-only id map are gone; spring reads each .java once, not twice. - runMaintenance moved to a worker thread (own SQLite connection); per-file store commits chunk with yields behind a serialized flush chain (preserving #1015 file-order determinism); resolver warm-up streams the DISTINCT name set; resolution batch-tail and merged-edge inserts run in bounded sub-transactions. - Daemon: fixed a socket-handoff race that could leave a fresh MCP session permanently silent (client-hello tail unshifted into a flowing stream with zero listeners — the long-standing #662 test flake was this real bug); first tool call no longer queues behind the query pool's cold start (pool.ready gate). Validation: Linux kernel (70,129 files, 2.05M nodes, 6.4M edges) fully indexes in 27m8s on a 2-core/6GB container at default heap + default watchdog; llvm-project (180k files) completes under 1GB RSS including kill-and-sync recovery; synthesized-edge and full-graph parity are byte-identical vs baseline on elasticsearch/redis/vim; the ex-flaky daemon test passed 25/25 under load. Env-gated diagnostics kept: CODEGRAPH_SYNTH_TIMINGS pass/phase timings, CODEGRAPH_MCP_DEBUG hop tracing. Design record: docs/design/main-thread-stall-followup.md. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
58b6bf5c60
commit
a3f90089e8
+60
-20
@@ -328,6 +328,30 @@ export class ReferenceResolver {
|
||||
this.cachesWarmed = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* warmCaches for the async resolution entry points: streams the distinct
|
||||
* name set with periodic yields instead of one synchronous `.all()`. On a
|
||||
* multi-million-node index the DISTINCT scan is a solid multi-second block
|
||||
* (measured up to 28s inside `codegraph sync` on the Linux kernel index),
|
||||
* long enough to matter to the #850 watchdog on slower hardware. Same
|
||||
* result, same memory — only the event loop keeps turning.
|
||||
*/
|
||||
async warmCachesYielding(onYield: MaybeYield): Promise<void> {
|
||||
if (this.cachesWarmed) return;
|
||||
|
||||
this.knownFiles = new Set(this.queries.getAllFilePaths());
|
||||
|
||||
const names = new Set<string>();
|
||||
let scanned = 0;
|
||||
for (const name of this.queries.iterateNodeNames()) {
|
||||
names.add(name);
|
||||
if ((++scanned & 8191) === 0) await onYield();
|
||||
}
|
||||
this.knownNames = names;
|
||||
|
||||
this.cachesWarmed = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear internal caches
|
||||
*/
|
||||
@@ -421,6 +445,12 @@ export class ReferenceResolver {
|
||||
return result;
|
||||
},
|
||||
|
||||
// Streamed, uncached — synthesizers scan-and-filter whole kinds, and
|
||||
// both the materialized array AND the per-kind cache retention are
|
||||
// O(nodes) memory (#1212). Per-ref resolvers keep the cached array
|
||||
// variant above.
|
||||
iterateNodesByKind: (kind: Node['kind']) => this.queries.iterateNodesByKind(kind),
|
||||
|
||||
fileExists: (filePath: string) => {
|
||||
// Check pre-built known files set first (O(1))
|
||||
if (this.knownFiles) {
|
||||
@@ -1113,8 +1143,6 @@ export class ReferenceResolver {
|
||||
onProgress?: (current: number, total: number) => void,
|
||||
batchSize: number = 5000
|
||||
): Promise<ResolutionResult> {
|
||||
this.warmCaches();
|
||||
|
||||
// Resolution runs on the indexer's MAIN thread, and the #850 liveness
|
||||
// watchdog SIGKILLs a process whose event loop stalls past its window (60s
|
||||
// by default). A single dense batch's resolveAll — or the synthesis pass
|
||||
@@ -1123,6 +1151,8 @@ export class ReferenceResolver {
|
||||
// window to fire; see ./cooperative-yield.
|
||||
const maybeYield = createYielder();
|
||||
|
||||
await this.warmCachesYielding(maybeYield);
|
||||
|
||||
const total = this.queries.getUnresolvedReferencesCount();
|
||||
let processed = 0;
|
||||
const aggregateStats = {
|
||||
@@ -1141,32 +1171,42 @@ export class ReferenceResolver {
|
||||
|
||||
const result = await this.resolveBatchYielding(batch, maybeYield);
|
||||
|
||||
// Persist in bounded sub-transactions with yields between: a whole
|
||||
// batch's edge insert / keyed deletes are otherwise one solid
|
||||
// synchronous span each on a multi-GB index, sitting BETWEEN the
|
||||
// per-ref yields — the last unyielded stretch of the resolution loop.
|
||||
// Crash semantics are unchanged (already several transactions): edges
|
||||
// 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;
|
||||
|
||||
// Persist edges immediately
|
||||
const edges = this.createEdges(result.resolved);
|
||||
if (edges.length > 0) {
|
||||
this.queries.insertEdges(edges);
|
||||
for (let i = 0; i < edges.length; i += PERSIST_CHUNK) {
|
||||
this.queries.insertEdges(edges.slice(i, i + PERSIST_CHUNK));
|
||||
await maybeYield();
|
||||
}
|
||||
|
||||
// Clean up resolved refs so they don't appear in the next batch
|
||||
if (result.resolved.length > 0) {
|
||||
this.queries.deleteSpecificResolvedReferences(
|
||||
result.resolved.map((r) => ({
|
||||
fromNodeId: r.original.fromNodeId,
|
||||
referenceName: r.original.referenceName,
|
||||
referenceKind: r.original.referenceKind,
|
||||
}))
|
||||
);
|
||||
const resolvedKeys = result.resolved.map((r) => ({
|
||||
fromNodeId: r.original.fromNodeId,
|
||||
referenceName: r.original.referenceName,
|
||||
referenceKind: r.original.referenceKind,
|
||||
}));
|
||||
for (let i = 0; i < resolvedKeys.length; i += PERSIST_CHUNK) {
|
||||
this.queries.deleteSpecificResolvedReferences(resolvedKeys.slice(i, i + PERSIST_CHUNK));
|
||||
await maybeYield();
|
||||
}
|
||||
|
||||
// Delete unresolvable refs from this batch to avoid re-processing them
|
||||
if (result.unresolved.length > 0) {
|
||||
this.queries.deleteSpecificResolvedReferences(
|
||||
result.unresolved.map((r) => ({
|
||||
fromNodeId: r.fromNodeId,
|
||||
referenceName: r.referenceName,
|
||||
referenceKind: r.referenceKind,
|
||||
}))
|
||||
);
|
||||
const unresolvedKeys = result.unresolved.map((r) => ({
|
||||
fromNodeId: r.fromNodeId,
|
||||
referenceName: r.referenceName,
|
||||
referenceKind: r.referenceKind,
|
||||
}));
|
||||
for (let i = 0; i < unresolvedKeys.length; i += PERSIST_CHUNK) {
|
||||
this.queries.deleteSpecificResolvedReferences(unresolvedKeys.slice(i, i + PERSIST_CHUNK));
|
||||
await maybeYield();
|
||||
}
|
||||
|
||||
// Aggregate stats
|
||||
|
||||
Reference in New Issue
Block a user