11 KiB
Main-thread stall budget — extraction & resolution follow-up
Status: IMPLEMENTED (same branch as the #1212 tail fix — attribution runs promoted "suspects" to proven culprits fast enough to justify shipping together). What landed, per suspect:
- Post-index maintenance — the proven killer, not on the original suspect
list. The first full kernel
initon the FIXED tail completed every synthesis pass (cFnPtr alone ran 433s at default heap, yielding throughout) and was then SIGKILLed by the default-window watchdog atdb.runMaintenance():PRAGMA optimize+wal_checkpoint(PASSIVE)over a 4.2GB DB with a 593MB WAL is minutes of synchronous IO on 2 cores.runMaintenancenow runs on a worker thread with its own connection (checkpointing from a second connection is standard;PRAGMA optimizepersists stats in sqlite_stat tables), with a bounded in-line fallback that skips the checkpoint (close() checkpoints after the CLI disarms the watchdog). - Per-file store commits:
storeExtractionResultchunks its node/edge/ref inserts (2,000 rows) with time-budgeted yields between; the ordered-commit pump serializes async stores on a promise chain (preserving the #1015 file-order determinism invariant) and its backpressure now also waits on the commit chain so the parse buffer stays bounded. - Resolver warm-up:
warmCachesYieldingstreams the DISTINCT name set with yields (the syncwarmCachesstays for non-async callers). The 28.2ssyncstall dropped to ≤4s total across the whole sync. - Resolution batch-tail: edge inserts and keyed deletes run in 1,000-row sub-transactions with yields between (crash semantics unchanged — the batch was already several transactions, and #1187's sweep re-resolves leftovers).
- Scan: attributed (phase timings now in the code,
[phase-timing]onCODEGRAPH_SYNTH_TIMINGS) — it is the synchronous git enumeration (getGitVisibleFiles/collectGitFiles), NOT a hash loop. See "Accepted residuals" below for why it was left synchronous.
Verification: full-graph parity (every node id + edge, sorted dump diff)
byte-identical on fresh redis and vim indexes, baseline vs fixed; full test
suite green; kernel sync worst stall 28.2s → ~4s; ES synthesis tail worst
stall ≤2.7s.
Acceptance gate PASSED: fresh full kernel init (70,129 indexed files,
2,048,673 nodes / 6,402,391 edges) completed in 27m 8s on the 2-core/6GB
container at Node's default heap with the default 60s watchdog — EXIT 0,
identical node/edge counts to the pre-fix partial runs, maintenance 48.8s
off-thread with the WAL fully checkpointed (0 bytes). v1.3.0 could not finish
this repo at all (OOM at default heap; watchdog kill at the maintenance step
even with the tail fixed). Post-run, the one genuine synchronous span the run
exposed — the merged synthesized-edge insert (~275k rows, 20.2s in one
transaction) — was chunked (2k rows + yield) like the rest; redis parity
re-verified byte-identical after.
Accepted residuals (measured, documented, deliberately not fixed)
- Git enumeration (scan): 2.2–10.5s single sync span on ~95k-file repos.
Fixing it means async-ifying
collectGitFiles' recursive gitlink/submodule logic (#1038/#1065) or forking sync/async variants — high regression risk for a CPU-bound span ~6× under the watchdog window even on a 2-core container (its cost does not get the Windows/Defender per-file-IO multiplier; it scales with CPU only). - End-of-sync aggregates: ~2.7s (count recompute / vocab backfill on a 4.2GB DB).
- Warm-up first chunk: ~2.6s — the DISTINCT name scan's initial sort chunk before the first cursor row arrives; the rest of the scan yields.
- Worker-contention timer lag on tiny containers — with 2 cpuset cores, the off-thread checkpoint (and the parse pool early in the run) can delay main-loop timers 15–20s even though the main thread executes nothing. The stall monitor and the watchdog heartbeat both measure timer latency, so on a ~1-core box a long checkpoint could still starve heartbeats; if that ever reproduces, the mitigations are a niced worker or heartbeat-side allowance, not more yields.
If any of these ever shows up in a real watchdog kill, the async-refactor
shape for the scan is: thread a MaybeYield through collectGitFiles'
per-line loop and make getGitVisibleFiles async, keeping scanDirectory
(sync) on the walk fallback only.
Original plan below, kept for the record.
Context
The #850 liveness watchdog SIGKILLs the indexer when its event loop stalls past the window (default 60s). #1091 → #1122/#1137 → #1212 each moved the fix deeper: per-batch yields, per-ref yields, then (with #1212) yields + streamed queries + language gates across the entire dynamic-edge synthesis tail, which eliminated the 14–57s single-pass stalls and the two whole-graph OOMs.
While validating #1212 with an event-loop stall monitor over full init runs
(Linux kernel, 70k indexed files / 2.05M nodes, 2-core 6GB container; and
llvm-project, 180k tracked files, macOS), the phases before the synthesis
tail showed recurring single stalls that nothing currently yields through:
| Run | Phase | Observed single stalls |
|---|---|---|
| kernel (2 cores) | initial scan (t+14s, t+22s) | 5.1s, 10.5s |
| kernel (2 cores) | extraction (t+980–1080s) | 3.0–3.3s |
| kernel (2 cores) | extraction→resolution boundary (t+1354s) | 8.5s |
| llvm (mac, fast) | extraction / early resolution (t+1000–1320s) | 5–14s, recurring |
| kernel (2 cores) | codegraph sync on the same DB (110 files) |
28.2s (single stall) |
None of these approaches 60s on the tested hardware, and none are regressions — they pre-date #1212. But the #1212 pattern (Windows NTFS + Defender, small VMs) multiplies per-file and per-transaction costs several-fold, and 14s × a few-fold is a watchdog kill. These are the spans that will produce the fourth iteration of this bug class if left unmeasured.
Suspects (with code locations)
- Per-file store commits on the main thread —
ExtractionOrchestrator.storeExtractionResult(src/extraction/index.ts:2065) runs one synchronous transaction per file (insertNodes+insertEdges+ unresolved-ref batch + FTS triggers). A giant generated file (llvm has many multi-MB generated.inc/.cpp) inserts tens of thousands of nodes in one unyielding span. The parse pool (#1015) moved parsing off-thread; the commit is still a single main-thread block per file. - Resolver cache warm-up —
warmCaches(src/resolution/index.ts:319) callsgetAllNodeNames()(src/db/queries.ts:1879,SELECT DISTINCT nameover the whole node table) plusgetAllFilePaths()synchronously. On the kernel's 2M-row table the DISTINCT alone is seconds; it is the prime suspect for the 8.5s boundary stall and the 28.2ssyncstall (sync also enters resolution via the orphan sweep, #1191). - Resolution batch-tail DB ops — between the per-ref yields,
resolveAndPersistBatched(src/resolution/index.ts) runs per-5000-ref synchronous spans:insertEdges(batch),deleteSpecificResolvedReferences× 2 (a 5000-statement transaction), andgetUnresolvedReferencesCount(). On a multi-GB DB each is a solid block. - Initial scan (kernel t+14/22s) — file enumeration + content hashing before extraction starts. Unattributed; measure before assuming.
Diagnosis plan (before any fix)
Extend the env-gated timing that located #1212 (CODEGRAPH_SYNTH_TIMINGS) to
the suspects — or add a sibling CODEGRAPH_PHASE_TIMINGS — so each suspect
logs spans >250ms with a label:
- wrap
storeExtractionResult(log file path + node count when slow — this also identifies the offending generated files), - wrap
warmCaches(splitgetAllNodeNamesvsgetAllFilePaths), - wrap the three batch-tail ops in
resolveAndPersistBatched, - wrap the scan phase.
Re-run the stall monitor + timings on the two existing indexes (assets below). Attribution first: the fix for each suspect is different, and #1180 showed the first guess is often wrong.
Fix sketches (per suspect, once confirmed)
- Chunked per-file commits: split a file's node/edge/ref inserts into
bounded sub-transactions (e.g. 2–5k rows) with
maybeYield()between chunks. Invariant to preserve: files must still commit in scan order, whole-file at a time from the resolver's perspective (#1015 — resolution disambiguates same-named candidates by insertion order; chunking within one file keeps the order stable). The existing index-completeness marker (index_state) already covers a mid-file kill. - Yielding warm-up: stream
SELECT DISTINCT namewith a cursor (stmt.iterate()), building the Set with a periodicmaybeYield()— an asyncwarmCachesYielding()used from the async entry points (resolveAndPersistBatched, the sync path), leaving the syncwarmCaches()for callers that can't await. Memory is unchanged (the Set already exists). - Chunked batch-tail ops: split the keyed-delete transaction and the edge
insert into sub-transactions with yields between, same pattern as (1).
getUnresolvedReferencesCountis an indexed aggregate; leave it unless timing says otherwise. - Scan: measure first; likely chunk the hash loop with yields.
Acceptance criteria
- Instrumented full
initon the kernel index (2-core/6GB container) and llvm-project shows no single event-loop stall > ~2s in any phase. codegraph syncon the kernel DB shows the same bound (kills the 28.2s span).- Graph parity: byte-identical node/edge sets on a re-index of at least
elasticsearch + redis (the #1212 parity harness in the session scratchpad
automates the synthesized-edge half; extraction parity = compare
getNodeAndEdgeCount+ a sorted node-id dump). - No end-to-end throughput regression beyond noise (< ~5%) on the same runs — chunked transactions can slow bulk inserts; measure, don't assume.
Repro assets (from the #1212 investigation, 2026-07-08)
- Docker container
cg1212(2 cores / 6GB, node:22-bookworm) with the Linux kernel cloned at/work/linuxand its 4.2GB index. - llvm-project (180,074 files) + elasticsearch (45k) + redis + vim clones with indexes in the session scratchpad.
stall-monitor.cjs(preload; logs event-loop gaps >1s with timestamps),synth-only.mjs/synth-watchdog.mjs(drive resolution+synthesis directly against an existing index — ~2 min iteration instead of a 40-min re-index),parity.mjs(synthesized-edge set differ).- The #1091 methodology note applies: a real CLI run at a lowered
CODEGRAPH_WATCHDOG_TIMEOUT_MSis the authoritative kill/no-kill test.