* perf(index): ~34% faster fresh indexing, byte-identical graphs Profiling a fresh init on a medium TS repo (excalidraw, 657 files) showed the main thread as the critical path: per-row SQLite statement calls, repeated import-resolution walks, and per-row FTS trigger firings, with the parse workers ~75% idle behind it. This lands the semantics-preserving tranche of fixes: - Multi-row batched INSERTs (nodes/edges/unresolved refs/name segments) behind cached per-batch-size prepared statements; row order preserved, so rowid-based resolution determinism (#1015) is unchanged. - storeFileBundle: one transaction per file instead of four; nested transaction() calls now flatten (BEGIN-in-BEGIN previously threw, so no caller depended on nested rollback). - Dedicated store-writer thread for the fresh-DB bulk path (bundles applied in file order on a single writer connection; main thread does no DB work during the parse loop). Kill switch: CODEGRAPH_NO_STORE_WORKER=1. - Bulk FTS mode: drop the nodes_fts sync triggers during the bulk load, rebuild once at the end; crash inside the window self-heals on the next open. - Per-context memos for resolveImportPath/findExportedSymbol + a per-file exported-symbol index, invalidated exactly where clearCaches() already resets the resolver's own caches. - Fast-init on completely fresh DBs (journal in memory, no fsync until the index completes; interrupted init re-runs from scratch). Kill switch: CODEGRAPH_NO_FAST_INIT=1. - MaybeYield returns undefined on the not-due path so per-ref yield checks stop paying a promise + microtask hop each. - Parse pool prewarm for bulk indexing; compile-cache enabled at CLI and worker entry points. Excalidraw fresh init: 5.11s -> 3.36s median (n=5, warm cache, M-series). Graph dumps byte-identical across init, re-index, and sync paths; full suite green (2403 passed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * perf(resolution): parallel reference resolution with canonical admission Fan resolution batches across a pool of read-only worker threads, each hosting a full ReferenceResolver over its own SQLite connection; results are admitted on the main thread in chunk order, so edge insertion order, row cleanup, failure parking, and deferred post-pass queues are exactly the sequence the single-threaded loop produces. Per-ref inputs match the baseline because the sequential path already resolves each batch against the state committed BEFORE that batch. Validated byte-identical on excalidraw (pool forced on) and apache/dubbo (4,048 Java files): dubbo full index 39s -> 19s (2.05x) with identical graph dumps (91,495 nodes / 223,953 edges). The pool only engages when total pending refs clear a threshold (default 150k, CODEGRAPH_PARALLEL_RESOLVE_MIN to tune, CODEGRAPH_NO_PARALLEL_RESOLVE=1 to disable): measured on a ~58k-ref repo the workers' boot CPU contends with resolution on the same cores and makes indexing slower, so small repos keep the sequential path. When fast-init left the DB in memory-journal mode, WAL is restored before resolution only when the pool will run (readers + rollback-journal writers don't mix). Also: sqlite adapter readOnly open support. TreeCursor spine rewrite of the body walker was built, measured neutral on real repos and equal in a 20k-child microbench (web-tree-sitter's namedChild(i) is not quadratic in this binding), and rejected — per-node JS<->WASM marshaling is the floor, which a traversal swap cannot remove. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
120 lines
5.4 KiB
TypeScript
120 lines
5.4 KiB
TypeScript
/**
|
|
* Parse Worker
|
|
*
|
|
* Runs tree-sitter parsing in a separate thread so the main thread
|
|
* stays unblocked and the UI animation renders smoothly.
|
|
*/
|
|
|
|
// Compile cache FIRST: the worker's boot cost is dominated by re-requiring
|
|
// the extraction module graph; the persistent V8 cache (Node ≥22.8) makes
|
|
// that a bytecode load instead of a recompile. Safe no-op when unavailable.
|
|
try {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
(require('node:module') as { enableCompileCache?: () => void }).enableCompileCache?.();
|
|
} catch { /* cache is best-effort */ }
|
|
|
|
import { parentPort } from 'worker_threads';
|
|
import { extractFromSource } from './tree-sitter';
|
|
import { detectLanguage, loadGrammarsForLanguages, resetParser } from './grammars';
|
|
import type { Language, ExtractionResult } from '../types';
|
|
|
|
// Emscripten prints `Aborted()` (and a follow-up RuntimeError diag
|
|
// line) directly to stderr when WASM aborts — before the JS catch
|
|
// runs. Worker stderr is inherited by the parent, so each crash leaks
|
|
// a noise line to the user's terminal even though the JS layer
|
|
// already handles the failure cleanly. Filter these specific lines
|
|
// out at the source. Real diagnostic output (anything we log
|
|
// ourselves) goes through console.* / parentPort and is unaffected.
|
|
//
|
|
// Caveats deliberately accepted:
|
|
// - Per-call match: each `write()` call is matched in isolation.
|
|
// If Emscripten ever splits `Aborted(` across two write()s (it
|
|
// doesn't today — synchronous abort prints the whole line at
|
|
// once via libc puts) the first fragment would leak. Buffering
|
|
// across calls would add complexity for a hypothetical case.
|
|
// - Substring exactness: the prefix `Aborted(` is the literal
|
|
// Emscripten signature. Any user code that legitimately writes
|
|
// a stderr line starting with that prefix would also be filtered;
|
|
// in practice no real diagnostic does.
|
|
{
|
|
const realWrite = process.stderr.write.bind(process.stderr);
|
|
process.stderr.write = ((
|
|
chunk: string | Uint8Array,
|
|
encoding?: BufferEncoding | ((err?: Error | null) => void),
|
|
cb?: (err?: Error | null) => void
|
|
): boolean => {
|
|
const s = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf-8');
|
|
if (
|
|
s.startsWith('Aborted(') ||
|
|
s.includes('Build with -sASSERTIONS for more info')
|
|
) {
|
|
// Honour the Writable stream contract: callbacks must always
|
|
// fire even when the write is suppressed, or upstream code
|
|
// waiting on the drain signal would hang. Both overload forms
|
|
// are handled (`(chunk, cb)` and `(chunk, encoding, cb)`).
|
|
if (typeof encoding === 'function') encoding();
|
|
else if (cb) cb();
|
|
return true;
|
|
}
|
|
return realWrite(chunk as never, encoding as never, cb as never);
|
|
}) as typeof process.stderr.write;
|
|
}
|
|
|
|
const PARSER_RESET_INTERVAL = 5000;
|
|
const parseCounts = new Map<Language, number>();
|
|
|
|
parentPort!.on('message', async (msg: { type: string; id?: number; filePath?: string; content?: string; languages?: Language[]; frameworkNames?: string[]; language?: Language; grammarBuffers?: Record<string, Uint8Array> }) => {
|
|
if (msg.type === 'load-grammars') {
|
|
// Grammar WASM bytes pre-read by the main thread (when provided) make this
|
|
// a memory load instead of a per-spawn disk read — see issue #1231.
|
|
await loadGrammarsForLanguages(msg.languages!, msg.grammarBuffers);
|
|
parentPort!.postMessage({ type: 'grammars-loaded' });
|
|
} else if (msg.type === 'parse') {
|
|
const { id, filePath, content, frameworkNames } = msg;
|
|
// Worker-side parse clock: reported back with the result so the pool can
|
|
// tell a genuinely slow parse from a result whose delivery was delayed by
|
|
// a stalled main thread (issue #1231 false timeouts).
|
|
const t0 = performance.now();
|
|
try {
|
|
// The main thread resolves the language (it holds the project's
|
|
// codegraph.json extension overrides) and sends it; fall back to detection
|
|
// for older callers / safety.
|
|
const language = msg.language ?? detectLanguage(filePath!, content);
|
|
const result: ExtractionResult = extractFromSource(filePath!, content!, language, frameworkNames);
|
|
|
|
// Periodic parser reset to reclaim WASM heap memory
|
|
const count = (parseCounts.get(language) ?? 0) + 1;
|
|
parseCounts.set(language, count);
|
|
if (count % PARSER_RESET_INTERVAL === 0) {
|
|
resetParser(language);
|
|
}
|
|
|
|
parentPort!.postMessage({ type: 'parse-result', id, result, parseMs: performance.now() - t0 });
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
|
|
// WASM memory errors leave the module in a corrupted state — all
|
|
// subsequent parses would also fail (cascading failures). Crash the
|
|
// worker so the main thread spawns a fresh one with a clean heap.
|
|
if (message.includes('memory access out of bounds') || message.includes('out of memory')) {
|
|
process.exit(1);
|
|
}
|
|
|
|
parentPort!.postMessage({
|
|
type: 'parse-result',
|
|
id,
|
|
parseMs: performance.now() - t0,
|
|
result: {
|
|
nodes: [],
|
|
edges: [],
|
|
unresolvedReferences: [],
|
|
errors: [{ message: `Parse worker error: ${message}`, filePath: filePath!, severity: 'error', code: 'parse_error' }],
|
|
durationMs: 0,
|
|
} satisfies ExtractionResult,
|
|
});
|
|
}
|
|
} else if (msg.type === 'shutdown') {
|
|
parentPort!.postMessage({ type: 'shutdown-ack' });
|
|
}
|
|
});
|