diff --git a/CHANGELOG.md b/CHANGELOG.md index 68d7cc062b..29f1e05cff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### New Features + +- Claude Code prompts that get CodeGraph context start faster on large projects, because project-wide summaries are now kept between prompts and refreshed when the index changes. + ### Fixes - In VB.NET, every member of a `Structure` is now indexed, including its fields, properties, methods, constructors and nested enums. Before, only the first member was, so the rest could not be found and their callers looked empty. diff --git a/__tests__/dominant-file-cache.test.ts b/__tests__/dominant-file-cache.test.ts index f9ba57eef5..f898605611 100644 --- a/__tests__/dominant-file-cache.test.ts +++ b/__tests__/dominant-file-cache.test.ts @@ -57,7 +57,12 @@ describe('dominant file — computed once per index state (#1864)', () => { } it('reuses the answer across explores while the database is unchanged', async () => { - const cg = await setup(); + // indexAll warmed the memo in memory and in the sidecar; start from neither. + (await setup()).close(); + open.splice(0); + fs.rmSync(getDatabasePath(dir) + '.memo.json', { force: true }); + const cg = await CodeGraph.open(dir); + open.push(cg); const compute = spyCompute(cg); for (const q of ['engine step', 'plugin step', 'how does the engine run']) { diff --git a/__tests__/persisted-memo.test.ts b/__tests__/persisted-memo.test.ts new file mode 100644 index 0000000000..fe8e01d368 --- /dev/null +++ b/__tests__/persisted-memo.test.ts @@ -0,0 +1,121 @@ +/** + * The dominant-file and stats aggregates are shared across processes through a + * sidecar next to the database, valid only under the current `graph_epoch`. + * A stale or unreadable sidecar must recompute; every graph writer must make a + * fresh process see its changes; and a sync warms the sidecar after a delay. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import CodeGraph from '../src'; +import { QueryBuilder } from '../src/db/queries'; +import { getDatabasePath } from '../src/db'; + +const WARM_DELAY_FLOOR_MS = 10_000; + +function chain(prefix: string, n: number): string { + const lines: string[] = []; + for (let i = 0; i < n; i++) { + const next = i + 1 < n ? `${prefix}${i + 1}();` : ''; + lines.push(`export function ${prefix}${i}(): void { ${next} }`); + } + return lines.join('\n') + '\n'; +} + +function queriesOf(cg: CodeGraph): QueryBuilder { + return (cg as unknown as { queries: QueryBuilder }).queries; +} + +describe('persisted aggregate memo', () => { + let dir: string; + const open: CodeGraph[] = []; + + afterEach(() => { + for (const cg of open.splice(0)) cg.close(); + vi.restoreAllMocks(); + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + }); + + async function setup(): Promise { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-memo-')); + fs.mkdirSync(path.join(dir, 'core')); + fs.mkdirSync(path.join(dir, 'ext')); + fs.writeFileSync(path.join(dir, 'core', 'engine.ts'), chain('engineStep', 40)); + fs.writeFileSync(path.join(dir, 'ext', 'plugin.ts'), chain('pluginStep', 5)); + const cg = await CodeGraph.init(dir, { index: true }); + open.push(cg); + return cg; + } + + async function freshProcess(): Promise { + const cg = await CodeGraph.open(dir); + open.push(cg); + return cg; + } + + const sidecar = () => getDatabasePath(dir) + '.memo.json'; + + it.each([ + ['a stale stamp', () => JSON.stringify({ stamp: 'old', entries: { dominantFile: { filePath: 'bogus.ts', edgeCount: 99, nextEdgeCount: 0 } } })], + ['a corrupt sidecar', () => '{not json'], + ])('recomputes and repairs %s', async (_name, contents) => { + const writer = await setup(); + writer.close(); + open.splice(0); + fs.writeFileSync(sidecar(), contents()); + + const reader = await freshProcess(); + expect(reader.getStats().fileCount).toBe(2); + expect(queriesOf(reader).getDominantFile()?.filePath).toBe('core/engine.ts'); + + const repaired = JSON.parse(fs.readFileSync(sidecar(), 'utf8')) as { entries: { dominantFile: { filePath: string } } }; + expect(repaired.entries.dominantFile.filePath).toBe('core/engine.ts'); + }); + + it('shows a fresh process the graph after indexFiles', async () => { + const writer = await setup(); + expect(queriesOf(await freshProcess()).getDominantFile()?.filePath).toBe('core/engine.ts'); + + fs.writeFileSync(path.join(dir, 'ext', 'plugin.ts'), chain('pluginStep', 120)); + await writer.indexFiles(['ext/plugin.ts']); + + const reader = await freshProcess(); + expect(queriesOf(reader).getDominantFile()?.filePath).toBe('ext/plugin.ts'); + }); + + it('warms the sidecar after a sync once the delay elapses', async () => { + const writer = await setup(); + fs.writeFileSync(path.join(dir, 'ext', 'plugin.ts'), chain('pluginStep', 120)); + + const realSetTimeout = global.setTimeout; + const warmTimers: Array<() => void> = []; + vi.spyOn(global, 'setTimeout').mockImplementation(((fn: () => void, ms?: number, ...args: unknown[]) => { + if (typeof ms === 'number' && ms >= WARM_DELAY_FLOOR_MS) { + warmTimers.push(fn); + return { unref() {} } as unknown as NodeJS.Timeout; + } + return realSetTimeout(fn, ms, ...args); + }) as typeof setTimeout); + await writer.sync(); + vi.restoreAllMocks(); + + expect(warmTimers).toHaveLength(1); + warmTimers[0]!(); + + const reader = await freshProcess(); + const compute = vi.spyOn(queriesOf(reader) as unknown as { computeDominantFile: () => unknown }, 'computeDominantFile'); + expect(queriesOf(reader).getDominantFile()?.filePath).toBe('ext/plugin.ts'); + expect(compute).not.toHaveBeenCalled(); + }); + + it('shows a fresh process an empty graph after clear', async () => { + const writer = await setup(); + expect(writer.getStats().nodeCount).toBeGreaterThan(0); + + writer.clear(); + + expect((await freshProcess()).getStats().nodeCount).toBe(0); + }); +}); diff --git a/src/db/persisted-memo.ts b/src/db/persisted-memo.ts new file mode 100644 index 0000000000..3daf886bcc --- /dev/null +++ b/src/db/persisted-memo.ts @@ -0,0 +1,60 @@ +/** + * Cross-process memo for query-independent whole-graph aggregates. + * + * `getDominantFile()` and `getStats()` scan every edge/node, and a fresh + * process (the prompt hook) cannot reuse the in-memory memo. Values are kept + * in a sidecar file next to the database, tagged with `project_metadata.graph_epoch`, + * which `QueryBuilder.bumpGraphEpoch` replaces whenever an index or sync run + * changes the graph. File mtimes are deliberately not used: WAL checkpoints + * and no-op opens touch them without changing content. The sidecar is a + * separate file so storing a value never changes the epoch and a read-only + * connection can still use it. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +const SIDECAR_SUFFIX = '.memo.json'; + +type MemoEntries = Record; + +function readEntries(dbPath: string, stamp: string): MemoEntries { + try { + const parsed = JSON.parse(fs.readFileSync(dbPath + SIDECAR_SUFFIX, 'utf8')) as { + stamp?: unknown; + entries?: MemoEntries; + }; + if (parsed.stamp !== stamp || !parsed.entries) return {}; + return parsed.entries; + } catch { + return {}; + } +} + +/** Look up `key` under `stamp`; undefined when absent, stale, or unreadable. */ +export function readPersistedMemo(dbPath: string, stamp: string, key: string): T | undefined { + return readEntries(dbPath, stamp)[key] as T | undefined; +} + +/** Store `value` under `stamp`; failures (read-only dir, races) are ignored. */ +export function writePersistedMemo(dbPath: string, stamp: string, key: string, value: unknown): void { + const target = dbPath + SIDECAR_SUFFIX; + const tmp = `${target}.${process.pid}.tmp`; + try { + const entries = { ...readEntries(dbPath, stamp), [key]: value }; + fs.writeFileSync(tmp, JSON.stringify({ stamp, entries })); + fs.renameSync(tmp, target); + } catch { + try { + fs.rmSync(tmp, { force: true }); + } catch { + // best effort + } + } +} + +export function memoDbPath(db: { prepare(sql: string): { all(): unknown[] } }): string | null { + const rows = db.prepare('PRAGMA database_list').all() as Array<{ name: string; file: string }>; + const main = rows.find(r => r.name === 'main'); + return main?.file ? path.resolve(main.file) : null; +} diff --git a/src/db/queries.ts b/src/db/queries.ts index 704cfd5bcd..5b87f9ac9e 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -18,11 +18,14 @@ import { SearchResult, } from '../types'; import { safeJsonParse } from '../utils'; +import { memoDbPath, readPersistedMemo, writePersistedMemo } from './persisted-memo'; import { kindBonus, nameMatchBonus, scorePathRelevance } from '../search/query-utils'; import { parseQuery, boundedEditDistance } from '../search/query-parser'; import { isGeneratedFile } from '../extraction/generated-detection'; import { splitIdentifierSegments } from '../search/identifier-segments'; +const GRAPH_EPOCH_KEY = 'graph_epoch'; + /** * Files that should not be candidates for "dominant file" detection: test/spec * files and tool-generated files. Generated files (`*.pb.go`, `*.pulsar.go`, @@ -1129,11 +1132,48 @@ export class QueryBuilder { } const stamp = this.getChangeStamp(); if (this.dominantFileMemo?.stamp === stamp) return this.dominantFileMemo.value; - const value = this.computeDominantFile(); + const value = this.persistedMemo('dominantFile', () => this.computeDominantFile()); this.dominantFileMemo = { stamp, value }; return value; } + hasGraphEpoch(): boolean { + return this.getMetadata(GRAPH_EPOCH_KEY) !== null; + } + + /** Replace `graph_epoch`: every persisted memo computed before this is stale. */ + bumpGraphEpoch(): void { + this.setMetadata(GRAPH_EPOCH_KEY, `${Date.now()}.${Math.random().toString(36).slice(2)}`); + } + + /** Recompute and store the persisted aggregates under the current epoch. */ + warmPersistedMemo(): void { + this.dominantFileMemo = undefined; + this.getDominantFile(); + this.getStats(); + } + + /** + * Cross-process memo for a query-independent aggregate, so a fresh process + * (the prompt hook) reuses what another process computed. It lives in a + * sidecar file, not a `project_metadata` row: a reader must never need the + * database write lock the daemon's writer holds, and storing a row would + * make the hook's own connection a writer. The stamp is read before + * computing, so a write racing the compute leaves a stale stamp that the + * next call recomputes. Skipped inside a transaction (uncommitted state) + * and when the index has no `graph_epoch` yet. + */ + private persistedMemo(key: string, compute: () => T): T { + const dbPath = this.db.inTransaction === false ? memoDbPath(this.db) : null; + const stamp = dbPath ? this.getMetadata(GRAPH_EPOCH_KEY) : null; + if (!dbPath || !stamp) return compute(); + const hit = readPersistedMemo(dbPath, stamp, key); + if (hit !== undefined) return hit; + const value = compute(); + writePersistedMemo(dbPath, stamp, key, value); + return value; + } + /** * A value that differs whenever the database content may have changed * since the last call, whoever changed it: `total_changes()` counts the @@ -3949,6 +3989,11 @@ export class QueryBuilder { * Get graph statistics */ getStats(): GraphStats { + const stats = this.persistedMemo('stats', () => this.computeStats()); + return { ...stats, lastUpdated: Date.now() }; + } + + private computeStats(): GraphStats { // Single query for all three aggregate counts const counts = this.db.prepare(` SELECT @@ -4037,6 +4082,7 @@ export class QueryBuilder { this.db.exec('DELETE FROM edges'); this.db.exec('DELETE FROM nodes'); this.db.exec('DELETE FROM files'); + this.bumpGraphEpoch(); })(); } } diff --git a/src/index.ts b/src/index.ts index c1e5d25e58..4f45ff2305 100644 --- a/src/index.ts +++ b/src/index.ts @@ -65,6 +65,8 @@ import { extractSegmentSearchWords, segmentLookupVariants, splitIdentifierSegmen import { createYielder } from './resolution/cooperative-yield'; import { minRefsForPool } from './resolution/resolver-pool'; +const MEMO_WARM_DELAY_MS = 15_000; + // Re-export types for consumers export * from './types'; // Storage building blocks for embedded/SDK consumers that drive the graph @@ -158,6 +160,7 @@ export class CodeGraph { // Mutex for preventing concurrent indexing operations (in-process) private indexMutex = new Mutex(); + private memoWarmTimer: ReturnType | undefined; // File lock for preventing concurrent writes across processes (CLI, MCP, git hooks) private fileLock: FileLock; @@ -490,6 +493,7 @@ export class CodeGraph { */ close(): void { this.unwatch(); + if (this.memoWarmTimer) clearTimeout(this.memoWarmTimer); // Release file lock if held this.fileLock.release(); this.db.close(); @@ -793,6 +797,7 @@ export class CodeGraph { this.db.getDb().pragma('journal_mode = WAL'); } catch { /* connection may be closing */ } } + this.markGraphChanged(true); this.fileLock.release(); } }); @@ -811,8 +816,9 @@ export class CodeGraph { return { success: false, filesIndexed: 0, filesSkipped: 0, filesErrored: 0, nodesCreated: 0, edgesCreated: 0, errors: [{ message: 'Could not acquire file lock - another process may be indexing', severity: 'error' as const }], durationMs: 0 }; } try { - return this.orchestrator.indexFiles(filePaths); + return await this.orchestrator.indexFiles(filePaths); } finally { + this.markGraphChanged(false); this.fileLock.release(); } }); @@ -891,6 +897,9 @@ export class CodeGraph { ); walValve.start(); } + // A run that ends without reaching the no-change branch (including a + // throw after partial writes) is treated as having changed the graph. + let graphUnchanged = false; try { // Captured BEFORE the sync runs: the sync's own incremental writes // populate vocab rows for the files it touches, so an end-of-sync @@ -1121,6 +1130,8 @@ export class CodeGraph { // Off-thread — see indexAll's call site. if (filesChanged || result.filesRemoved > 0 || orphanCount > 0 || refreshSynthesis) { await this.db.runMaintenance(); + } else { + graphUnchanged = true; } // Heal the segment vocabulary on indexes built before the table @@ -1155,11 +1166,37 @@ export class CodeGraph { if (deferWal) { try { this.db.setWalAutocheckpoint(priorAutocheckpoint); } catch { /* connection may be closing */ } } + // An index that predates graph_epoch gets its first epoch from any sync. + if (!graphUnchanged || !this.queries.hasGraphEpoch()) this.markGraphChanged(false); this.fileLock.release(); } }); } + /** + * Invalidate persisted query memos after the graph changed, then recompute + * them so the prompt hook (a fresh process per prompt) reads a hit. A bulk + * index warms inline; other writers defer to a quiet period so the + * multi-second aggregate never lands on every edit. + */ + private markGraphChanged(warmNow: boolean): void { + try { + this.queries.bumpGraphEpoch(); + } catch { /* memo is advisory — never fail a write over it */ return; } + // A pending deferred warm is superseded by this bump (and by an inline warm). + if (this.memoWarmTimer) clearTimeout(this.memoWarmTimer); + this.memoWarmTimer = undefined; + if (warmNow) { + try { this.queries.warmPersistedMemo(); } catch { /* advisory */ } + return; + } + this.memoWarmTimer = setTimeout(() => { + this.memoWarmTimer = undefined; + try { this.queries.warmPersistedMemo(); } catch { /* closed or busy: next reader recomputes */ } + }, MEMO_WARM_DELAY_MS); + this.memoWarmTimer.unref(); + } + /** * Check if an indexing operation is currently in progress */ @@ -1410,7 +1447,11 @@ export class CodeGraph { resolveReferences(onProgress?: (current: number, total: number) => void): ResolutionResult { // Get all unresolved references from the database const unresolvedRefs = this.queries.getUnresolvedReferences(); - return this.resolver.resolveAndPersist(unresolvedRefs, onProgress); + try { + return this.resolver.resolveAndPersist(unresolvedRefs, onProgress); + } finally { + this.markGraphChanged(false); + } } /** @@ -1445,7 +1486,7 @@ export class CodeGraph { end: () => this.db.endBulkRefLoad(), }, backpressure, - }, synthesize); + }, synthesize).finally(() => this.markGraphChanged(false)); } /**