From 926cf02fee177073c15eccad7c236c92daf43596 Mon Sep 17 00:00:00 2001 From: bompus Date: Tue, 27 Oct 2026 14:14:52 -0600 Subject: [PATCH] perf(mcp): pre-warm query pool, await-ready grace, compact worker conns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes to the MCP read path, all measured on gin + n8n: - Warm floor: the pool lazily grew one worker at a time, so an 8-query burst serialized through the spawn ladder. A bounded warm floor (CODEGRAPH_QUERY_POOL_WARM, default 4, capped by pool size) keeps a few workers hot; drain() trickles growth on progress events and the post-warm spawn cap rises 2 -> 4 so a real burst still fills the pool. Pools without a default project stay lazy. - awaitReady: the first tools/call could land inside the ~40 ms window before the eager worker posts 'ready', take the in-process fallback, and pay a multi-second lazy module load on the transport thread (reproducibly ~6 s on Bun). Calls now wait out a bounded 4 s grace for a pending start before falling back. - Compact read-only worker connections: pooled workers opened full writer connections (64 MiB cache, 256 MiB mmap, migration/heal/WAL probes at every spawn). They now openSync readOnly + compact (16 MiB cache, no mmap) — pure readers skip writer-side setup, and the pool's summed RSS stops multiplying per worker. Measured: gin 8-query wave 134 -> ~90 ms (upstream-Node ~114); n8n busy process-tree RSS 4.54 -> 3.10 GiB on Node (upstream 3.85) and 1.24 GiB on Bun (upstream ~3.5); n8n wave 1.91 -> 2.04 s Node / 1.63 -> 1.75 s Bun stays ahead of upstream (2.39 / 1.96); gin cold first answer no longer falls off the ~6 s cliff. --- __tests__/query-pool.test.ts | 57 +++++++++-- site/src/content/docs/reference/mcp-server.md | 4 + src/codegraph.ts | 4 +- src/db/index.ts | 22 +++-- src/mcp/query-pool.ts | 99 +++++++++++++++++-- src/mcp/query-worker.ts | 6 +- src/mcp/tools.ts | 9 +- 7 files changed, 177 insertions(+), 24 deletions(-) diff --git a/__tests__/query-pool.test.ts b/__tests__/query-pool.test.ts index c5ab411eea..aefa7edd96 100644 --- a/__tests__/query-pool.test.ts +++ b/__tests__/query-pool.test.ts @@ -121,11 +121,36 @@ describe('QueryPool', () => { let created = 0; const pool = new QueryPool({ root: '/x', size: 8, createWorker: () => { created++; return new FakeWorker((m) => ({ result: ok(`r${m.id}`) })); } }); await pool.run('codegraph_node', { symbol: 's' }); - // One eager worker + at most the cold-start cap — never all 8. - expect(created).toBeLessThanOrEqual(2); + await sleep(10); // let the warm-floor trickle finish + // Warm-floor pre-warm brings the pool to 4 — never all 8. + expect(created).toBeLessThanOrEqual(4); + expect(created).toBeLessThan(8); await pool.destroy(); }); + it('pre-warms to the floor ahead of demand so a burst finds idle workers', async () => { + let created = 0; + const pool = new QueryPool({ root: '/x', size: 8, createWorker: () => { created++; return new FakeWorker((m) => ({ result: ok(`r${m.id}`) })); } }); + await sleep(10); // eager worker's ready → drain → warm() fills the floor + expect(created).toBe(4); + expect(pool.liveWorkers).toBe(4); + await pool.destroy(); + }); + + it('honors a warmFloor override, including 0 for lazy-only growth', async () => { + let created = 0; + const lazy = new QueryPool({ root: '/x', size: 8, warmFloor: 0, createWorker: () => { created++; return new FakeWorker((m) => ({ result: ok(`r${m.id}`) })); } }); + await lazy.run('codegraph_node', {}); + await sleep(10); + expect(created).toBe(1); // the eager worker only + await lazy.destroy(); + + const capped = new QueryPool({ root: '/x', size: 2, warmFloor: 9, createWorker: () => new FakeWorker((m) => ({ result: ok(`r${m.id}`) })) }); + await sleep(10); + expect(capped.liveWorkers).toBe(2); // floor is capped at pool size + await capped.destroy(); + }); + it('recovers from a worker crash: retries the in-flight call and respawns', async () => { let calls = 0; const pool = new QueryPool({ @@ -256,13 +281,31 @@ describe('QueryPool', () => { expect(pool.ready).toBe(false); // destroyed pool must not be selected }); + it('awaitReady covers the cold-window race: true once a pending start lands, fast-false when nothing is coming', async () => { + // The eager spawn posts 'ready' on a macrotask; a call arriving in the gap + // must be able to wait it out rather than fall back to the in-process cold + // path (a multi-second lazy module load on the transport thread). + const pool = new QueryPool({ root: '/x', size: 1, createWorker: () => new FakeWorker((m) => ({ result: ok(`r:${m.toolName}`) })) }); + expect(pool.ready).toBe(false); + await expect(pool.awaitReady()).resolves.toBe(true); + expect(pool.ready).toBe(true); + // Ready already: resolves without waiting again. + await expect(pool.awaitReady()).resolves.toBe(true); + await pool.destroy(); + // Nothing pending (destroyed / project-less): answers false without hanging. + await expect(pool.awaitReady()).resolves.toBe(false); + const empty = new QueryPool({ root: null, size: 1, createWorker: () => new FakeWorker((m) => ({ result: ok('r') })) }); + await expect(empty.awaitReady(20)).resolves.toBe(false); + await empty.destroy(); + }); + it('a pool with no default project starts its first worker on warm()', async () => { let created = 0; const pool = new QueryPool({ root: null, size: 2, createWorker: () => { created++; return new FakeWorker((m) => ({ result: ok(`r:${m.toolName}`) })); } }); expect(created).toBe(0); pool.warm(); - pool.warm(); // one worker, however many calls ask - expect(created).toBe(1); + pool.warm(); // the floor is 2 however many calls ask + expect(created).toBe(2); await sleep(5); expect(pool.ready).toBe(true); await pool.destroy(); @@ -278,7 +321,7 @@ describe('QueryPool', () => { const rooted = make('/x'); await sleep(5); - expect(rooted.pool.liveWorkers).toBe(1); + expect(rooted.pool.liveWorkers).toBe(2); // eager worker + warm-floor trickle retire(rooted.pool); expect(rooted.pool.liveWorkers).toBe(1); // stays warm for the default project await rooted.pool.destroy(); @@ -291,8 +334,8 @@ describe('QueryPool', () => { retire(rootless.pool); expect(rootless.pool.liveWorkers).toBe(0); expect(rootless.pool.ready).toBe(false); // calls run in-process again - rootless.pool.warm(); // the next call that names a project starts one again - expect(rootless.created()).toBe(2); + rootless.pool.warm(); // the next call that names a project starts them again + expect(rootless.created()).toBe(4); await sleep(5); expect(rootless.pool.ready).toBe(true); await rootless.pool.destroy(); diff --git a/site/src/content/docs/reference/mcp-server.md b/site/src/content/docs/reference/mcp-server.md index 49ec864ee9..1528ae2ae6 100644 --- a/site/src/content/docs/reference/mcp-server.md +++ b/site/src/content/docs/reference/mcp-server.md @@ -45,6 +45,10 @@ The launcher can replace a daemon from an older release when its hello confirms `CODEGRAPH_DAEMON_INSTALL_CHECK_MS` sets the installation-check interval in milliseconds. It defaults to `30000`; `0` disables the check. +## Concurrent reads + +Read-heavy tool calls run on a pool of worker threads inside the server, each with its own SQLite read connection, so a burst of concurrent calls serves in parallel instead of starving the transport. The pool keeps a few workers warm ahead of demand and grows to `clamp(cores − 1, 1, 16)` under load. `CODEGRAPH_QUERY_POOL_SIZE` sets the cap (`0` serves every read on the server's own thread), `CODEGRAPH_QUERY_POOL_WARM` sets the warm floor (`0` grows only on demand), and `CODEGRAPH_QUERY_IDLE_RETIRE_MS` sets how long an idle worker lives before it is retired down to one (`0` never retires; default 60 seconds). + ## The other tools Seven more tools exist and stay fully functional, but are **unlisted by default** — everything they return already arrives inline on a `codegraph_explore` response (its blast-radius section, the relationship map, a symbol's body and its callee list): diff --git a/src/codegraph.ts b/src/codegraph.ts index bdb01e0087..39c5ac49f8 100644 --- a/src/codegraph.ts +++ b/src/codegraph.ts @@ -539,7 +539,7 @@ export class CodeGraph { * rebuilding them here (#1887): for a caller on a watched event loop, which * then awaits {@link recoverIndexes} before it relies on the project. */ - static openSync(projectRoot: string, options: Pick & { deferIndexRecovery?: boolean } = {}): CodeGraph { + static openSync(projectRoot: string, options: Pick & { deferIndexRecovery?: boolean; compactConnection?: boolean } = {}): CodeGraph { const resolvedRoot = path.resolve(projectRoot); // Check if initialized @@ -555,7 +555,7 @@ export class CodeGraph { // Open database const dbPath = getDatabasePath(resolvedRoot); - const db = DatabaseConnection.open(dbPath, { readOnly: options.readOnly, deferIndexRecovery: options.deferIndexRecovery }); + const db = DatabaseConnection.open(dbPath, { readOnly: options.readOnly, deferIndexRecovery: options.deferIndexRecovery, compactConnection: options.compactConnection }); const queries = new QueryBuilder(db.getDb()); return new CodeGraph(db, queries, resolvedRoot); diff --git a/src/db/index.ts b/src/db/index.ts index a3eb50ad1c..3610572f60 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -30,14 +30,24 @@ export { NodeSqliteUnavailableError } from './node-sqlite'; * on a writer, so this timeout only governs cross-process write contention * (e.g. the git-hook `codegraph sync` running while the MCP server writes). */ -function configureConnection(db: SqliteDatabase, readOnly = false): void { +function configureConnection(db: SqliteDatabase, readOnly = false, compact = false): void { db.pragma('busy_timeout = 5000'); // MUST be first — see above db.pragma('foreign_keys = ON'); if (!readOnly) db.pragma('journal_mode = WAL'); // node:sqlite supports WAL on every platform db.pragma('synchronous = NORMAL'); // safe with WAL mode - db.pragma('cache_size = -64000'); // 64 MB page cache + if (compact) { + // Pooled query workers: each holds its own connection, so per-connection + // caches multiply across the pool (and every process's RSS counts the + // same shared file pages again). A small page cache plus no private mmap + // keeps the pool's summed footprint near the daemon's own; hot pages still + // land in the OS page cache on the first read. + db.pragma('cache_size = -16384'); // 16 MB page cache + db.pragma('mmap_size = 0'); // no private memory-mapped I/O + } else { + db.pragma('cache_size = -64000'); // 64 MB page cache + db.pragma('mmap_size = 268435456'); // 256 MB memory-mapped I/O + } db.pragma('temp_store = MEMORY'); // temp tables in memory - db.pragma('mmap_size = 268435456'); // 256 MB memory-mapped I/O // Without a journal_size_limit the -wal file never shrinks below its // high-water mark while a connection lives: checkpoints fold frames back but // leave the file at full size, so one giant deferred-sync WAL stays giant @@ -191,7 +201,7 @@ export class DatabaseConnection { * liveness watchdog kills the process before the index commits, every time * it starts (#1887). Reads stay correct meanwhile, only slower. */ - static open(dbPath: string, options: { readOnly?: boolean; deferIndexRecovery?: boolean } = {}): DatabaseConnection { + static open(dbPath: string, options: { readOnly?: boolean; deferIndexRecovery?: boolean; compactConnection?: boolean } = {}): DatabaseConnection { if (!fs.existsSync(dbPath)) { throw new Error(`Database not found: ${dbPath}`); } @@ -205,9 +215,9 @@ export class DatabaseConnection { db: SqliteDatabase, backend: SqliteBackend, dbPath: string, - options: { readOnly?: boolean; deferIndexRecovery?: boolean }, + options: { readOnly?: boolean; deferIndexRecovery?: boolean; compactConnection?: boolean }, ): DatabaseConnection { - configureConnection(db, options.readOnly); + configureConnection(db, options.readOnly, options.compactConnection); // Detect FTS5 availability for search fallback (#1532) let fts5Available = true; diff --git a/src/mcp/query-pool.ts b/src/mcp/query-pool.ts index 46e62ba0e3..c81695e847 100644 --- a/src/mcp/query-pool.ts +++ b/src/mcp/query-pool.ts @@ -10,9 +10,12 @@ * connection) restores true multi-core parallelism and an idle main loop. * * Properties: - * - lazy growth: one warm worker on construct, grows to `size` on demand, so a - * single-agent session pays for one connection and a 10-subagent burst grows - * to the core budget. + * - warm floor: one eager worker on construct, then paced background growth + * to a small floor (see DEFAULT_WARM_FLOOR), so a burst of short calls + * parallelizes instead of finishing serially before the first spawn could + * help. Beyond the floor the pool still grows to `size` on demand, and + * idle workers retire back down (see DEFAULT_IDLE_RETIRE_MS), so the floor + * costs a few extra connections only while calls are flowing. * - crash recovery: a dead worker is respawned and its in-flight call retried * once; a poison call that keeps crashing fails gracefully (never wedges the * pool). A crash budget trips a circuit breaker (`healthy` → false) so the @@ -69,6 +72,34 @@ const CRASH_BUDGET = 12; */ const MAX_CONCURRENT_SPAWN = 2; +/** + * Max workers cold-starting at once once the pool is warm. The 2-at-a-time cap + * protects the COLD daemon — its first call can arrive before the eager worker + * finishes starting, and a herd of full starts would stall the main loop. Once + * `everReady`, a burst is what the pool exists for and the daemon is already + * serving, so a burst may grow at twice that pace. + */ +const MAX_WARM_SPAWN = 4; + +/** + * How many workers the pool trickle-spawns ahead of demand. A burst of short + * calls (e.g. 8 explores at ~15ms) finishes before the on-demand ladder could + * spawn a second worker — the jobs then run serially anyway, plus IPC overhead, + * which is strictly worse than an in-process serial dispatch. Keeping a small + * floor of warm workers lets a real burst actually parallelize. The floor is + * filled by paced background growth bounded by the spawn caps — never by a + * thundering herd — on pool progress events and {@link warm} calls. `0` + * restores lazy-only growth. + */ +const DEFAULT_WARM_FLOOR = 4; + +function resolveWarmFloor(): number { + const raw = process.env.CODEGRAPH_QUERY_POOL_WARM; + if (raw === undefined || raw === '') return DEFAULT_WARM_FLOOR; + const n = Number(raw); + return Number.isFinite(n) && n >= 0 ? Math.floor(n) : DEFAULT_WARM_FLOOR; +} + /** * A worker idle this long is retired, down to the one warm worker. Each holds a * V8 heap plus its own SQLite connection (page cache and memory map), so a @@ -95,6 +126,17 @@ function resolveIdleRetireMs(): number { */ const WORKER_START_SETTLE_MS = 15_000; +/** + * How long a read call may wait for the pool's first worker to finish cold- + * starting before falling back to in-process dispatch. The daemon's very + * first tool call can land while the eager worker's handshake is still in + * flight (a ~40 ms window even on a fast host); going in-process there pays a + * seconds-long lazy module load on the transport thread — far worse than + * waiting the remaining spawn latency out. Well under the in-process cold + * cost, so a genuinely wedged spawn still loses to the in-process path. + */ +const READY_GRACE_MS = 4_000; + /** Shape of a message a worker posts back (ready handshake or a tool result). */ interface WorkerMessage { type?: string; @@ -129,6 +171,11 @@ export interface QueryPoolOptions { idleRetireMs?: number; /** How long destroy() waits on a worker still starting up (tests shorten it). Default 15s. */ startSettleMs?: number; + /** + * Workers trickle-spawned ahead of demand (see DEFAULT_WARM_FLOOR). Capped at + * `size`; 0 disables pre-warming. Default `CODEGRAPH_QUERY_POOL_WARM` or 4. + */ + warmFloor?: number; } /** @@ -197,6 +244,7 @@ export class QueryPool { private readonly idleRetireMs: number; private retireTimer?: ReturnType; private readonly startSettleMs: number; + private readonly warmFloor: number; constructor(opts: QueryPoolOptions) { this.root = opts.root; @@ -210,17 +258,28 @@ export class QueryPool { this.retireTimer.unref?.(); } this.startSettleMs = opts.startSettleMs ?? WORKER_START_SETTLE_MS; + this.warmFloor = Math.min(opts.warmFloor ?? resolveWarmFloor(), this.maxSize); // One eager warm worker, ready for the first call. A pool with no default // project waits for its first call instead: most such sessions never query. if (this.root !== null) this.spawnOne(); } + /** How many workers may be starting at once — stricter while the pool is cold. */ + private spawnCap(): number { + return this.everReady ? MAX_WARM_SPAWN : MAX_CONCURRENT_SPAWN; + } + /** - * Start a worker if none is running. Calls are served in-process until one is - * ready, so the call that triggers this does not wait for it. + * Grow toward the warm floor (and from zero when nothing is running). Called + * per dispatch and from {@link drain} on every worker progress event, so the + * floor fills in the background bounded by the spawn cap. Calls are served + * in-process until a worker is ready, so the call that triggers this does + * not wait for it. */ warm(): void { - if (this.workers.size === 0 && this.healthy) this.spawnOne(); + while (this.workers.size < this.warmFloor && this.pendingWorkers.size < this.spawnCap() && this.healthy) { + this.spawnOne(); + } } /** @@ -285,6 +344,29 @@ export class QueryPool { } private everReady = false; + /** + * Wait for the pool's first worker to post its 'ready' handshake, up to + * {@link READY_GRACE_MS}. Returns whether the pool is ready afterward. + * Covers the cold-window race between the eager spawn and the first client + * call; resolves immediately once a worker is ready, or when nothing is on + * its way up (project-less pools, crashed-out spawns, destroyed pools). + */ + async awaitReady(graceMs = READY_GRACE_MS): Promise { + if (this.ready) return true; + if (this.destroyed || this.pendingWorkers.size === 0) return false; + const pending = [...this.pendingWorkers] + .map((w) => this.starts.get(w)?.settled) + .filter((p): p is Promise => p !== undefined); + if (pending.length === 0) return false; + let cap: NodeJS.Timeout | undefined; + await Promise.race([ + Promise.all(pending), + new Promise((resolve) => { cap = setTimeout(resolve, graceMs); cap.unref?.(); }), + ]); + clearTimeout(cap); + return this.ready; + } + private spawnOne(): void { if (this.destroyed || this.workers.size >= this.maxSize) return; let w: PoolWorker; @@ -355,13 +437,16 @@ export class QueryPool { } private drain(): void { + // Refill toward the warm floor on every progress event, so a burst finds + // idle workers instead of paying the cold-start ladder serially. + this.warm(); // Grow toward maxSize while queued work outstrips workers that are idle OR // already on their way up (pending) — so we never spawn the whole pool for a // single call whose eager worker just hasn't reported ready yet. while ( this.queue.length > this.idle.length + this.pendingWorkers.size && this.workers.size < this.maxSize && - this.pendingWorkers.size < MAX_CONCURRENT_SPAWN && + this.pendingWorkers.size < this.spawnCap() && this.healthy ) { this.spawnOne(); diff --git a/src/mcp/query-worker.ts b/src/mcp/query-worker.ts index 27064df260..153033190f 100644 --- a/src/mcp/query-worker.ts +++ b/src/mcp/query-worker.ts @@ -50,10 +50,14 @@ if (parentPort) { // opened lazily on first cross-project (projectPath) call by the ToolHandler's // own per-handler cache. openSync does not start a watcher — workers are pure // readers; the single watcher/writer stays on the daemon's main thread. + // `readOnly` skips the writer-side open work (migration/heal/WAL probes) that + // only the daemon's connection may run, and `compactConnection` keeps the + // per-worker page cache and mmap small so a grown pool's summed RSS stays + // near a single connection's. let handler: InstanceType | null = null; let initError: string | null = null; try { - const cg = root ? loadCodeGraph().openSync(root) : null; + const cg = root ? loadCodeGraph().openSync(root, { readOnly: true, compactConnection: true }) : null; handler = new (loadToolHandler())(cg); } catch (err) { initError = err instanceof Error ? err.message : String(err); diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 21a7a9f04e..dc71e7085c 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -3344,7 +3344,14 @@ export class ToolHandler { // main thread's current root explicitly; with no root or projectPath, // keep the main handler's workspace-specific not-indexed guidance. if (args.projectPath ?? this.cg) this.queryPool?.warm(); - const pooled = !!(this.queryPool && this.queryPool.healthy && this.queryPool.ready); + // The first call can land while the eager worker's handshake is still in + // flight — wait out the remaining spawn (bounded grace inside) instead of + // paying a seconds-long lazy module load on this thread. + const pooled = !!( + this.queryPool && + this.queryPool.healthy && + (this.queryPool.ready || (await this.queryPool.awaitReady())) + ); const projectPath = pooled ? args.projectPath ?? this.cg?.getProjectRoot() : undefined; const wasDegraded = project.isWatcherDegraded?.(); const raw = (pooled && projectPath)