Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 50 additions & 7 deletions __tests__/query-pool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand Down
4 changes: 4 additions & 0 deletions site/src/content/docs/reference/mcp-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
4 changes: 2 additions & 2 deletions src/codegraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OpenOptions, 'readOnly'> & { deferIndexRecovery?: boolean } = {}): CodeGraph {
static openSync(projectRoot: string, options: Pick<OpenOptions, 'readOnly'> & { deferIndexRecovery?: boolean; compactConnection?: boolean } = {}): CodeGraph {
const resolvedRoot = path.resolve(projectRoot);

// Check if initialized
Expand All @@ -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);
Expand Down
22 changes: 16 additions & 6 deletions src/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}`);
}
Expand All @@ -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;
Expand Down
99 changes: 92 additions & 7 deletions src/mcp/query-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -197,6 +244,7 @@ export class QueryPool {
private readonly idleRetireMs: number;
private retireTimer?: ReturnType<typeof setInterval>;
private readonly startSettleMs: number;
private readonly warmFloor: number;

constructor(opts: QueryPoolOptions) {
this.root = opts.root;
Expand All @@ -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();
}
}

/**
Expand Down Expand Up @@ -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<boolean> {
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<void> => p !== undefined);
if (pending.length === 0) return false;
let cap: NodeJS.Timeout | undefined;
await Promise.race([
Promise.all(pending),
new Promise<void>((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;
Expand Down Expand Up @@ -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();
Expand Down
6 changes: 5 additions & 1 deletion src/mcp/query-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('./tools').ToolHandler> | 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);
Expand Down
9 changes: 8 additions & 1 deletion src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading