1
0
Эх сурвалжийг харах

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>
Colby McHenry 1 сар өмнө
parent
commit
8abcb61959

+ 1 - 0
CHANGELOG.md

@@ -11,6 +11,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### New Features
 
+- Reference resolution now runs in parallel on large projects. When a project has enough pending references to make it worthwhile (roughly 150k+, typical for big Java/Kotlin/Spring codebases), resolution fans out across worker threads while results are applied in the exact order the single-threaded path would have used — the graph comes out byte-for-byte identical, about twice as fast end-to-end on a 4,000-file Java project in our testing. Small projects keep the single-threaded path automatically (the fan-out costs more than it saves there). Set `CODEGRAPH_NO_PARALLEL_RESOLVE=1` to disable, or `CODEGRAPH_PARALLEL_RESOLVE_MIN=<count>` to tune when it engages.
 - Indexing is significantly faster — a fresh `codegraph init` on a medium TypeScript project takes about a third less wall-clock time, with the same graph produced byte-for-byte. The gains come from batching database writes, storing files on a dedicated writer thread, memoizing repeated import-resolution lookups, skipping per-row search-index maintenance during the bulk build (rebuilt once at the end), and — on completely fresh databases only — deferring disk durability until the index completes, since an interrupted first index is simply re-run. Set `CODEGRAPH_NO_FAST_INIT=1` to keep full crash-durability during the initial build, or `CODEGRAPH_NO_STORE_WORKER=1` to store on the main thread.
 - `codegraph install` and `codegraph upgrade` now offer CodeGraph Pro beta access after finishing — answer yes, type your email, and you join the same waitlist as the getcodegraph.com homepage form. Strictly opt-in and asked at most once per machine total: nothing is sent unless you say yes and enter an email, either answer is remembered so no later install or upgrade ever re-asks, and non-interactive runs (`--yes`, scripts, CI) never see the question.
 - Every release is now cryptographically verifiable: npm packages publish with npm provenance (the "Provenance" badge on npmjs.com, proving each version was built by this repository's release workflow from a specific commit), and the GitHub Release bundles carry signed build attestations you can check with `gh attestation verify <file> -R colbymchenry/codegraph`.

+ 4 - 4
src/db/sqlite-adapter.ts

@@ -51,10 +51,10 @@ class NodeSqliteAdapter implements SqliteDatabase {
   private _db: any;
   private _txDepth = 0;
 
-  constructor(dbPath: string) {
+  constructor(dbPath: string, opts?: { readOnly?: boolean }) {
     // eslint-disable-next-line @typescript-eslint/no-require-imports
     const { DatabaseSync } = require('node:sqlite');
-    this._db = new DatabaseSync(dbPath);
+    this._db = opts?.readOnly ? new DatabaseSync(dbPath, { readOnly: true }) : new DatabaseSync(dbPath);
   }
 
   get open(): boolean {
@@ -151,9 +151,9 @@ class NodeSqliteAdapter implements SqliteDatabase {
  * report it per-instance — MCP can open multiple project DBs in one process, so
  * a process-global would race.
  */
-export function createDatabase(dbPath: string): { db: SqliteDatabase; backend: SqliteBackend } {
+export function createDatabase(dbPath: string, opts?: { readOnly?: boolean }): { db: SqliteDatabase; backend: SqliteBackend } {
   try {
-    return { db: new NodeSqliteAdapter(dbPath), backend: 'node-sqlite' };
+    return { db: new NodeSqliteAdapter(dbPath, opts), backend: 'node-sqlite' };
   } catch (error) {
     const msg = error instanceof Error ? error.message : String(error);
     throw new Error(

+ 17 - 1
src/index.ts

@@ -55,6 +55,7 @@ import { deriveProjectNameTokens } from './search/query-utils';
 import { CodeGraphPackageVersion } from './mcp/version';
 import { segmentLookupVariants, splitIdentifierSegments } from './search/identifier-segments';
 import { createYielder } from './resolution/cooperative-yield';
+import { minRefsForPool } from './resolution/resolver-pool';
 
 // Re-export types for consumers
 export * from './types';
@@ -530,6 +531,19 @@ export class CodeGraph {
           // Get count without loading all refs into memory
           const unresolvedCount = this.queries.getUnresolvedReferencesCount();
 
+          // Fast-init leaves the DB in memory-journal (rollback) mode, where
+          // the parallel resolver pool's read connections would contend with
+          // the main writer's exclusive commits. When the pool will actually
+          // run (enough pending refs), restore WAL BEFORE resolution so
+          // readers never block the writer; otherwise stay in the fast mode
+          // until the finally — sequential resolution has no readers.
+          if (fastInit && unresolvedCount >= minRefsForPool()) {
+            try {
+              this.db.getDb().pragma('synchronous = NORMAL');
+              this.db.getDb().pragma('journal_mode = WAL');
+            } catch { /* keep current mode; resolution still works sequentially */ }
+          }
+
           options.onProgress?.({
             phase: 'resolving',
             current: 0,
@@ -1067,7 +1081,9 @@ export class CodeGraph {
     onProgress?: (current: number, total: number) => void,
     onSynthesisProgress?: (done: number, total: number) => void
   ): Promise<ResolutionResult> {
-    return this.resolver.resolveAndPersistBatched(onProgress, undefined, onSynthesisProgress);
+    return this.resolver.resolveAndPersistBatched(onProgress, undefined, onSynthesisProgress, {
+      dbPath: this.db.getPath(),
+    });
   }
 
   /**

+ 112 - 2
src/resolution/index.ts

@@ -18,6 +18,7 @@ import {
 } from './types';
 import { matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesKnownFamily } from './name-matcher';
 import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, clearImportResolverMemos } from './import-resolver';
+import { ResolverPool, minRefsForPool } from './resolver-pool';
 import { detectFrameworks } from './frameworks';
 import { synthesizeCallbackEdges } from './callback-synthesizer';
 import { createYielder, type MaybeYield } from './cooperative-yield';
@@ -1257,6 +1258,64 @@ export class ReferenceResolver {
     };
   }
 
+  /**
+   * Resolve a list of refs and return everything the ADMISSION side needs to
+   * persist the outcome: resolutions, failures, the deferred post-pass refs
+   * this run produced (drained, so the caller owns routing them), and stats.
+   * This is the resolver-worker entry point — it runs the exact per-ref loop
+   * of resolveBatchYielding, minus the main-thread yields (worker threads have
+   * no watchdog heartbeat to starve). Results are in input order.
+   */
+  resolveListForAdmission(refs: UnresolvedReference[]): {
+    resolved: ResolvedRef[];
+    unresolved: UnresolvedRef[];
+    deferredChain: UnresolvedRef[];
+    deferredThisMember: UnresolvedRef[];
+    byMethod: Record<string, number>;
+  } {
+    this.warmCaches();
+    const resolved: ResolvedRef[] = [];
+    const unresolved: UnresolvedRef[] = [];
+    const byMethod: Record<string, number> = {};
+    for (const raw of refs) {
+      const ref: UnresolvedRef = {
+        fromNodeId: raw.fromNodeId,
+        referenceName: raw.referenceName,
+        referenceKind: raw.referenceKind,
+        line: raw.line,
+        column: raw.column,
+        filePath: raw.filePath || this.getFilePathFromNodeId(raw.fromNodeId),
+        language: raw.language || this.getLanguageFromNodeId(raw.fromNodeId),
+        rowId: raw.rowId,
+      };
+      const result = this.resolveOne(ref);
+      if (result) {
+        resolved.push(result);
+        byMethod[result.resolvedBy] = (byMethod[result.resolvedBy] || 0) + 1;
+      } else {
+        unresolved.push(ref);
+      }
+    }
+    return {
+      resolved,
+      unresolved,
+      deferredChain: this.deferredChainRefs.splice(0),
+      deferredThisMember: this.deferredThisMemberRefs.splice(0),
+      byMethod,
+    };
+  }
+
+  /**
+   * Re-queue deferred post-pass refs produced by resolver workers, preserving
+   * their admission order so resolveChainedCallsViaConformance /
+   * resolveDeferredThisMemberRefs process them exactly as the sequential path
+   * would have.
+   */
+  appendDeferredFromWorkers(deferredChain: UnresolvedRef[], deferredThisMember: UnresolvedRef[]): void {
+    this.deferredChainRefs.push(...deferredChain);
+    this.deferredThisMemberRefs.push(...deferredThisMember);
+  }
+
   /**
    * Resolve and persist in batches to keep memory bounded.
    * Processes unresolved references in chunks, persisting edges and cleaning
@@ -1265,7 +1324,12 @@ export class ReferenceResolver {
   async resolveAndPersistBatched(
     onProgress?: (current: number, total: number) => void,
     batchSize: number = 5000,
-    onSynthesisProgress?: (done: number, total: number) => void
+    onSynthesisProgress?: (done: number, total: number) => void,
+    // When provided, big batches fan out across a read-only resolver-worker
+    // pool with results admitted in canonical order (see resolver-pool.ts).
+    // Sequential fallback on any pool failure. CODEGRAPH_NO_PARALLEL_RESOLVE=1
+    // disables entirely.
+    parallel?: { dbPath: string }
   ): Promise<ResolutionResult> {
     // Resolution runs on the indexer's MAIN thread, and the #850 liveness
     // watchdog SIGKILLs a process whose event loop stalls past its window (60s
@@ -1286,16 +1350,59 @@ export class ReferenceResolver {
       byMethod: {} as Record<string, number>,
     };
 
+    // Parallel pool, started immediately but never awaited up front: early
+    // batches run sequentially while the workers boot (module load + readonly
+    // DB open + framework detect + cache warm ≈ hundreds of ms), and the loop
+    // switches to fan-out the moment the pool reports ready — so pool boot
+    // costs zero wall-clock. Any failure downgrades to sequential permanently.
+    let pool: ResolverPool | null = null;
+    let poolReady = false;
+    if (parallel && total >= minRefsForPool()) {
+      pool = ResolverPool.tryCreate(parallel.dbPath, this.projectRoot);
+      pool?.ready().then(
+        () => { poolReady = true; },
+        () => { 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,
     // 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;
 
-      const result = await this.resolveBatchYielding(batch, maybeYield);
+      let result: ResolutionResult;
+      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,
+            stats: {
+              total: batch.length,
+              resolved: out.resolved.length,
+              unresolved: out.unresolved.length,
+              byMethod: 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);
+      }
 
       // Persist in bounded sub-transactions with yields between: a whole
       // batch's edge insert / keyed deletes are otherwise one solid
@@ -1376,6 +1483,9 @@ export class ReferenceResolver {
       if (remaining >= prevRemaining) break;
       prevRemaining = remaining;
     }
+    } finally {
+      if (pool) await pool.destroy().catch(() => undefined);
+    }
 
     // Dynamic-edge synthesis: now that all base `calls` edges are persisted,
     // synthesize observer/callback dispatch edges (dispatcher → registered

+ 195 - 0
src/resolution/resolver-pool.ts

@@ -0,0 +1,195 @@
+/**
+ * ResolverPool — main-thread client for the parallel-resolution workers.
+ *
+ * resolveBatch() splits a rowid-ordered batch into ordered chunks, fans the
+ * chunks across the pool, and reassembles the results IN CHUNK ORDER, so the
+ * caller's admission (edge inserts, row cleanup, failure parking, deferred
+ * post-pass queues) is byte-for-byte the sequence the single-threaded loop
+ * would have produced. Any worker failure fails the batch — the caller falls
+ * back to the sequential path. Kill switch: CODEGRAPH_NO_PARALLEL_RESOLVE=1.
+ */
+
+import { Worker } from 'worker_threads';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import type { UnresolvedReference } from '../types';
+import type { ResolvedRef, UnresolvedRef } from './types';
+
+export interface ChunkResult {
+  resolved: ResolvedRef[];
+  unresolved: UnresolvedRef[];
+  deferredChain: UnresolvedRef[];
+  deferredThisMember: UnresolvedRef[];
+  byMethod: Record<string, number>;
+}
+
+interface PoolWorker {
+  worker: Worker;
+  ready: Promise<void>;
+  busy: number;
+}
+
+const MIN_PARALLEL_BATCH = 1000;
+const CHUNK_SIZE = 500;
+
+/**
+ * Minimum TOTAL pending refs before the pool is created at all. Pool boot
+ * (module load + readonly DB open + framework detect + cache warm, times N
+ * workers) costs real CPU that CONTENDS with sequential resolution on the
+ * same cores — measured on a medium repo (~40k refs, ~1.2s of resolution)
+ * the pool made indexing slower. It pays off when resolution runs for tens
+ * of seconds to minutes (large JVM/Spring-class repos). Override:
+ * CODEGRAPH_PARALLEL_RESOLVE_MIN=<refs> (0 forces the pool on).
+ */
+export function minRefsForPool(): number {
+  const raw = process.env.CODEGRAPH_PARALLEL_RESOLVE_MIN;
+  if (raw !== undefined) {
+    const parsed = Number.parseInt(raw, 10);
+    if (Number.isFinite(parsed) && parsed >= 0) return parsed;
+  }
+  return 150_000;
+}
+
+export class ResolverPool {
+  private workers: PoolWorker[] = [];
+  private nextId = 0;
+  private waiters = new Map<number, { resolve: (r: ChunkResult) => void; reject: (e: Error) => void }>();
+  private failed: Error | null = null;
+
+  /**
+   * Create a pool when the compiled worker exists (absent when running from
+   * source in tests → callers use the sequential path), the kill switch is
+   * off, and the machine has cores to spare. Returns null otherwise.
+   */
+  static tryCreate(dbPath: string, projectRoot: string): ResolverPool | null {
+    if (process.env.CODEGRAPH_NO_PARALLEL_RESOLVE === '1') return null;
+    const workerScript = path.join(__dirname, 'resolver-worker.js');
+    if (!fs.existsSync(workerScript)) return null;
+    const size = Math.max(1, Math.min(os.cpus().length - 2, 6));
+    if (size < 2) return null;
+    try {
+      return new ResolverPool(workerScript, dbPath, projectRoot, size);
+    } catch {
+      return null;
+    }
+  }
+
+  private constructor(workerScript: string, dbPath: string, projectRoot: string, size: number) {
+    for (let i = 0; i < size; i++) {
+      const worker = new Worker(workerScript);
+      let readyResolve!: () => void;
+      let readyReject!: (e: Error) => void;
+      const ready = new Promise<void>((resolve, reject) => {
+        readyResolve = resolve;
+        readyReject = reject;
+      });
+      const pw: PoolWorker = { worker, ready, busy: 0 };
+      worker.on('message', (msg: { type: string; id?: number; message?: string } & Partial<ChunkResult>) => {
+        if (msg.type === 'ready') {
+          readyResolve();
+        } else if (msg.type === 'result' && msg.id !== undefined) {
+          pw.busy--;
+          const waiter = this.waiters.get(msg.id);
+          this.waiters.delete(msg.id);
+          waiter?.resolve({
+            resolved: msg.resolved!,
+            unresolved: msg.unresolved!,
+            deferredChain: msg.deferredChain!,
+            deferredThisMember: msg.deferredThisMember!,
+            byMethod: msg.byMethod!,
+          });
+        } else if (msg.type === 'error') {
+          pw.busy--;
+          const err = new Error(`resolver worker: ${msg.message}`);
+          if (msg.id !== undefined && this.waiters.has(msg.id)) {
+            const waiter = this.waiters.get(msg.id)!;
+            this.waiters.delete(msg.id);
+            waiter.reject(err);
+          } else {
+            this.fail(err);
+          }
+        }
+      });
+      worker.on('error', (err) => {
+        this.fail(err instanceof Error ? err : new Error(String(err)));
+        readyReject(this.failed!);
+      });
+      worker.on('exit', (code) => {
+        if (code !== 0) {
+          this.fail(new Error(`resolver worker exited with code ${code}`));
+          readyReject(this.failed!);
+        }
+      });
+      worker.postMessage({ type: 'open', dbPath, projectRoot });
+      this.workers.push(pw);
+    }
+  }
+
+  private fail(err: Error): void {
+    if (!this.failed) this.failed = err;
+    for (const [, waiter] of this.waiters) waiter.reject(this.failed);
+    this.waiters.clear();
+  }
+
+  /** Whether this batch is worth fanning out. */
+  static worthParallel(batchLength: number): boolean {
+    return batchLength >= MIN_PARALLEL_BATCH;
+  }
+
+  async ready(): Promise<void> {
+    await Promise.all(this.workers.map((w) => w.ready));
+  }
+
+  /**
+   * Resolve `refs` across the pool. Chunks preserve input order; the returned
+   * arrays are the in-order concatenation of the chunk results.
+   */
+  async resolveBatch(refs: UnresolvedReference[]): Promise<ChunkResult> {
+    if (this.failed) throw this.failed;
+    const chunkPromises: Promise<ChunkResult>[] = [];
+    for (let i = 0; i < refs.length; i += CHUNK_SIZE) {
+      const chunk = refs.slice(i, i + CHUNK_SIZE);
+      const id = this.nextId++;
+      // Least-busy dispatch keeps workers evenly loaded regardless of chunk
+      // cost variance; result order is fixed by the promise array, not by
+      // completion order.
+      const pw = this.workers.reduce((a, b) => (b.busy < a.busy ? b : a));
+      pw.busy++;
+      chunkPromises.push(
+        new Promise<ChunkResult>((resolve, reject) => {
+          this.waiters.set(id, { resolve, reject });
+          pw.worker.postMessage({ type: 'resolve', id, refs: chunk });
+        })
+      );
+    }
+    const chunks = await Promise.all(chunkPromises);
+    const out: ChunkResult = { resolved: [], unresolved: [], deferredChain: [], deferredThisMember: [], byMethod: {} };
+    for (const c of chunks) {
+      out.resolved.push(...c.resolved);
+      out.unresolved.push(...c.unresolved);
+      out.deferredChain.push(...c.deferredChain);
+      out.deferredThisMember.push(...c.deferredThisMember);
+      for (const [k, v] of Object.entries(c.byMethod)) out.byMethod[k] = (out.byMethod[k] || 0) + v;
+    }
+    return out;
+  }
+
+  async destroy(): Promise<void> {
+    await Promise.all(
+      this.workers.map(
+        (pw) =>
+          new Promise<void>((resolve) => {
+            const t = setTimeout(() => {
+              void pw.worker.terminate().then(() => resolve());
+            }, 5000);
+            pw.worker.once('exit', () => {
+              clearTimeout(t);
+              resolve();
+            });
+            pw.worker.postMessage({ type: 'close' });
+          })
+      )
+    );
+  }
+}

+ 79 - 0
src/resolution/resolver-worker.ts

@@ -0,0 +1,79 @@
+/**
+ * Resolver worker — one member of the parallel-resolution pool.
+ *
+ * Opens the project database READ-ONLY on its own connection and hosts a full
+ * ReferenceResolver over it. The main thread partitions each resolution batch
+ * into ordered chunks, fans them across the pool, and ADMITS the results
+ * sequentially in chunk order — so edge insertion order (and every cleanup /
+ * parking side effect) is identical to the single-threaded loop. Workers only
+ * ever read; all writes stay on the main thread.
+ *
+ * Visibility note: the sequential baseline resolves every ref of a batch
+ * against the DB state committed BEFORE that batch (edges persist after the
+ * whole batch resolves). Workers read exactly that same committed state, so
+ * per-ref inputs match the baseline ref-for-ref.
+ */
+
+// Compile cache FIRST — same worker-boot rationale as parse-worker.ts.
+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 { createDatabase, SqliteDatabase } from '../db/sqlite-adapter';
+import { QueryBuilder } from '../db/queries';
+import { ReferenceResolver } from './index';
+import type { UnresolvedReference } from '../types';
+
+if (!parentPort) {
+  throw new Error('resolver-worker must be run as a worker thread');
+}
+const port = parentPort;
+
+let db: SqliteDatabase | null = null;
+let resolver: ReferenceResolver | null = null;
+
+type InMessage =
+  | { type: 'open'; dbPath: string; projectRoot: string }
+  | { type: 'resolve'; id: number; refs: UnresolvedReference[] }
+  | { type: 'close' };
+
+port.on('message', (msg: InMessage) => {
+  try {
+    switch (msg.type) {
+      case 'open': {
+        const created = createDatabase(msg.dbPath, { readOnly: true });
+        db = created.db;
+        db.pragma('busy_timeout = 5000');
+        db.pragma('cache_size = -32000');
+        const queries = new QueryBuilder(db);
+        resolver = new ReferenceResolver(msg.projectRoot, queries);
+        resolver.initialize();
+        port.postMessage({ type: 'ready' });
+        break;
+      }
+      case 'resolve': {
+        if (!resolver) throw new Error('resolver-worker: resolve before open');
+        const out = resolver.resolveListForAdmission(msg.refs);
+        port.postMessage({ type: 'result', id: msg.id, ...out });
+        break;
+      }
+      case 'close': {
+        try {
+          db?.close();
+        } catch {
+          /* already closed */
+        }
+        process.exit(0);
+        break;
+      }
+    }
+  } catch (err) {
+    port.postMessage({
+      type: 'error',
+      id: (msg as { id?: number }).id,
+      message: err instanceof Error ? err.message : String(err),
+    });
+  }
+});