fix(indexing): HDD-class storage — false parse timeouts, dropped files, and WAL checkpoint write-back (#1231) (#1242)

Parse timeouts are now judged by the worker's own clock: the base timer
only marks a job late (after a long synchronous store stall, Node runs the
timers phase before the poll phase, so the timer fired before an
already-delivered result was processed — killing workers over parses that
took milliseconds, even on 0-byte files); a result arriving before a 3×
hard-kill backstop is accepted, timed-out files are retried, and
CODEGRAPH_PARSE_TIMEOUT_MS overrides the budget. Grammar WASM bytes are
pre-read once on the main thread and handed to every worker, so
spawns/respawns load grammars from memory instead of re-reading a
saturated disk.

Bulk indexing defers WAL auto-checkpointing for the whole run: the default
1000-page interval re-writes hot B-tree/FTS pages into the main DB file
over and over — ~95% of all disk I/O under throttled measurement. A
WalCheckpointValve bounds WAL growth with off-thread PASSIVE backfill
passes (never blocking the writer or the #850 watchdog heartbeat), pauses
the writer for a full backfill if the disk truly can't keep up, and folds
the WAL at the parse→resolution boundary so post-parse reads never page a
bulk-write-sized WAL. Opt out with CODEGRAPH_NO_WAL_DEFER=1; tune with
CODEGRAPH_WAL_VALVE_MB.

Measured at 150 IOPS (HDD class): commons-lang 1526s → 59s with 0 dropped
files (was 8); guava-scale completes in 7.6 min with a full graph where
v1.3.1 needed 25 min for a repo 5× smaller. Unthrottled: no change.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-07-10 03:42:30 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent e76a355df5
commit a11a439002
12 changed files with 928 additions and 73 deletions
+50 -1
View File
@@ -24,6 +24,7 @@ import {
FindRelevantContextOptions,
} from './types';
import { DatabaseConnection, getDatabasePath, removeDatabaseFiles } from './db';
import { WalCheckpointValve } from './db/wal-valve';
import { QueryBuilder } from './db/queries';
import {
isInitialized,
@@ -435,6 +436,29 @@ export class CodeGraph {
} catch {
return { success: false, filesIndexed: 0, filesSkipped: 0, filesErrored: 0, nodesCreated: 0, edgesCreated: 0, errors: [{ message: 'Could not acquire file lock - another process may be indexing', severity: 'error' as const }], durationMs: 0 };
}
// Defer WAL auto-checkpointing for the whole bulk run (#1231): the
// default 1000-page interval re-writes hot pages into the main DB file
// over and over — ~95% of all disk I/O during a bulk index, and a
// 19+min → 45s difference on HDD-class storage. The valve bounds WAL
// growth by backfilling PASSIVEly on a worker thread (never blocking
// the writer or the #850 watchdog heartbeat); runMaintenance below does
// the final fold-up before the interval is restored in the finally.
// Kill switch: CODEGRAPH_NO_WAL_DEFER=1. Non-WAL journal modes (some
// network filesystems) have no WAL to defer — skip.
const deferWal = process.env.CODEGRAPH_NO_WAL_DEFER !== '1' && this.db.getJournalMode() === 'wal';
let walValve: WalCheckpointValve | null = null;
let priorAutocheckpoint = 1000;
if (deferWal) {
priorAutocheckpoint = this.db.getWalAutocheckpoint();
this.db.setWalAutocheckpoint(0);
walValve = new WalCheckpointValve(
this.db,
undefined,
undefined,
options.verbose ? (m) => console.log(`[wal-valve] ${m}`) : undefined
);
walValve.start();
}
try {
const before = this.queries.getNodeAndEdgeCount();
// Mark the index as in-flight BEFORE any writes: a run killed
@@ -446,7 +470,19 @@ export class CodeGraph {
// path as every file (re-)indexes below — so a full index is also the
// orphan-cleanup pass for names deleted since the last one.
try { this.queries.clearNameSegmentVocab(); } catch { /* vocab is advisory — never fail an index over it */ }
const result = await this.orchestrator.indexAll(options.onProgress, options.signal, options.verbose);
const result = await this.orchestrator.indexAll(
options.onProgress,
options.signal,
options.verbose,
walValve ? () => walValve!.backpressure() : undefined
);
// Fold the parse phase's WAL BEFORE the first post-parse reads
// (resolver re-init and resolution both read on the main thread):
// paging a bulk-write-sized WAL there is what blew the #850
// watchdog's 60s window in the #1231 repro. Off-thread + awaited,
// so the event loop keeps turning.
if (walValve) await walValve.foldNow();
// Re-detect frameworks now that the index is populated. The resolver
// is constructed with createResolver() before any files exist, so
@@ -501,6 +537,10 @@ export class CodeGraph {
// successful index. Never load-bearing for correctness.
if (result.success && result.filesIndexed > 0) {
const tMaint = Date.now();
// Quiesce the valve first so its in-flight checkpoint and the
// maintenance checkpoint don't contend for the checkpointer lock
// (the loser would silently no-op and leave the WAL unfolded).
if (walValve) { walValve.stop(); await walValve.drain(); }
await this.db.runMaintenance();
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] maintenance: ${Date.now() - tMaint}ms`);
}
@@ -561,6 +601,15 @@ export class CodeGraph {
return result;
} finally {
// Restore the auto-checkpoint interval AFTER the fold-up above so the
// next ordinary write doesn't inherit a giant inline checkpoint. On
// the error path the WAL may still be large; correctness is unchanged
// (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) {
try { this.db.setWalAutocheckpoint(priorAutocheckpoint); } catch { /* connection may be closing */ }
}
this.fileLock.release();
}
});