From ac0b9e09f32ab63fa10a2f264ac22edcc7e37c7f Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Tue, 22 Sep 2026 23:05:34 +0300 Subject: [PATCH 1/4] perf(context): compute the dominant file once per index state (#1864) getDominantFile() backs context ranking's core-directory boost. Its answer depends only on the graph, but the whole-graph aggregation behind it (edges joined to both endpoints' nodes, grouped by file) ran on every generic codegraph_explore - seconds per call on a large index. Memoize it in QueryBuilder against a database change stamp: total_changes() (rows this connection wrote) plus PRAGMA data_version (commits by any other connection or process). Both are O(1), so no write path has to remember to invalidate, and a sync made by another process is seen on the next call. Any write forces a recompute; the heuristic's result is unchanged. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/dominant-file-cache.test.ts | 93 +++++++++++++++++++++++++++ src/db/queries.ts | 41 ++++++++++++ 3 files changed, 135 insertions(+) create mode 100644 __tests__/dominant-file-cache.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b20e237c84..3e78f20323 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -174,6 +174,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - On Windows, the shared MCP daemon now waits longer for another program — an antivirus scan, an indexer, or another session reading its lock file — to let go of that file, so it starts instead of leaving the session to fall back to a slower in-process server. (#1773) - A file over the size limit is no longer read before it is skipped. Large committed video and blob fixtures used to be loaded into memory in full, once per indexing pass, only to be discarded; indexing, change detection and the viewer now go by the file's size instead. Thanks @danusha2345. (#1910) - An MPEG transport stream video that happens to be named `.ts` (golden fixtures under `testdata/`, e2e clips) is now recognised from its first bytes and skipped instead of being fed to the TypeScript parser, which spent a long time on each clip for no symbols. Real TypeScript files are still indexed. Thanks @danusha2345. (#1910) +- Repeated `codegraph_explore` calls from a running MCP server no longer re-rank the whole graph each time to find the project's core file; the answer is reused until the index changes. (#1864) - File watching no longer drops the full re-scan a removed directory asks for when that sync fails, so the deleted files leave the index instead of lingering. (#1964) - Running `codegraph index` while the MCP server is running no longer leaves live auto-sync writing into the old, replaced index: the server now switches to the rebuilt index before its next update and catches up on everything it would otherwise have missed. Thanks @danusha2345. (#1902) - Daemon startup and cleanup now preserve live legacy PID-only locks while still reclaiming dead or identity-disproved records, preventing two writers from serving the same project. diff --git a/__tests__/dominant-file-cache.test.ts b/__tests__/dominant-file-cache.test.ts new file mode 100644 index 0000000000..72da6df9c8 --- /dev/null +++ b/__tests__/dominant-file-cache.test.ts @@ -0,0 +1,93 @@ +/** + * getDominantFile() is computed once per database state (#1864). + * + * The dominant-file heuristic (context ranking's core-directory boost) is a + * whole-graph aggregation whose answer does not depend on the query, yet it + * ran on every generic explore — seconds per call on a large index. It is now + * memoized against a database change stamp. These tests pin both halves: + * repeated explores reuse the answer, and any write — by this connection or + * by another process's sync — makes the next call see the new graph. + */ + +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 type { QueryBuilder } from '../src/db/queries'; + +/** A file whose functions call each other in a chain: `n` in-file call edges. */ +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; +} + +function spyCompute(cg: CodeGraph) { + return vi.spyOn(queriesOf(cg) as unknown as { computeDominantFile: () => unknown }, 'computeDominantFile'); +} + +describe('dominant file — computed once per index state (#1864)', () => { + 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-dominant-')); + 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; + } + + it('reuses the answer across explores while the database is unchanged', async () => { + const cg = await setup(); + const compute = spyCompute(cg); + + for (const q of ['engine step', 'plugin step', 'how does the engine run']) { + await cg.findRelevantContext(q); + } + expect(compute).toHaveBeenCalledTimes(1); + expect(queriesOf(cg).getDominantFile()?.filePath).toBe('core/engine.ts'); + expect(compute).toHaveBeenCalledTimes(1); + }); + + it('sees a new dominant file after a sync changes the graph', async () => { + const cg = await setup(); + expect(queriesOf(cg).getDominantFile()?.filePath).toBe('core/engine.ts'); + + fs.writeFileSync(path.join(dir, 'ext', 'plugin.ts'), chain('pluginStep', 120)); + await cg.sync(); + + const compute = spyCompute(cg); + expect(queriesOf(cg).getDominantFile()?.filePath).toBe('ext/plugin.ts'); + expect(compute).toHaveBeenCalledTimes(1); + }); + + it('sees a sync made through another connection (another process)', async () => { + const writer = await setup(); + const reader = await CodeGraph.open(dir); + open.push(reader); + expect(queriesOf(reader).getDominantFile()?.filePath).toBe('core/engine.ts'); + + fs.writeFileSync(path.join(dir, 'ext', 'plugin.ts'), chain('pluginStep', 120)); + await writer.sync(); + + expect(queriesOf(reader).getDominantFile()?.filePath).toBe('ext/plugin.ts'); + }); +}); diff --git a/src/db/queries.ts b/src/db/queries.ts index 9666a256f5..c24dda772e 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -251,6 +251,13 @@ export class QueryBuilder { private nodeCache: Map = new Map(); private readonly maxCacheSize = 1000; + // getDominantFile()'s answer, tagged with the database change stamp it was + // computed under (see getChangeStamp). Query-independent, so one value + // serves every explore until the database changes (#1864). + private dominantFileMemo: + | { stamp: string; value: { filePath: string; edgeCount: number; nextEdgeCount: number } | null } + | undefined; + // Prepared statements (lazily initialized) private stmts: { insertNode?: SqliteStatement; @@ -294,6 +301,7 @@ export class QueryBuilder { getAllFilePaths?: SqliteStatement; getAllNodeNames?: SqliteStatement; getDominantFile?: SqliteStatement; + getChangeStamp?: SqliteStatement; getTopRouteFile?: SqliteStatement; getRoutingManifest?: SqliteStatement; insertNameSegment?: SqliteStatement; @@ -1055,8 +1063,41 @@ export class QueryBuilder { * Excludes test/spec files from candidacy via path-pattern. The agent's * typical question is "how does X work", not "how is X tested", so * boosting a test file's directory would be a misfire. + * + * The answer depends only on the graph, never on the query, and the + * aggregation behind it scans every edge — seconds on a large index, paid + * by every generic explore (#1864). So it is memoized per database change + * stamp: recomputed only after something wrote to the database. */ getDominantFile(): { filePath: string; edgeCount: number; nextEdgeCount: number } | null { + const stamp = this.getChangeStamp(); + if (this.dominantFileMemo?.stamp === stamp) return this.dominantFileMemo.value; + const value = this.computeDominantFile(); + this.dominantFileMemo = { stamp, 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 + * rows this connection inserted, updated or deleted, and + * `PRAGMA data_version` moves when any OTHER connection — another process's + * sync, a CLI `codegraph index` beside a running MCP server — commits. Both + * are O(1), so no write path has to remember to invalidate anything. + * Coarse on purpose: any write, not just one to nodes/edges, forces a + * recompute, which only costs time, never a stale answer. + */ + private getChangeStamp(): string { + if (!this.stmts.getChangeStamp) { + this.stmts.getChangeStamp = this.db.prepare( + 'SELECT total_changes() AS changes, (SELECT data_version FROM pragma_data_version) AS version' + ); + } + const row = this.stmts.getChangeStamp.get() as { changes: number; version: number }; + return `${row.changes}:${row.version}`; + } + + private computeDominantFile(): { filePath: string; edgeCount: number; nextEdgeCount: number } | null { if (!this.stmts.getDominantFile) { // Pull top 20 candidates; we then filter out test/generated files // in code (regex-grade matching that SQL LIKE can't express). The From 95e9f4f5d31810511ca73b292b5d6434cfb65fc3 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Thu, 24 Sep 2026 11:03:42 +0300 Subject: [PATCH 2/4] fix(context): never keep a dominant file read inside a transaction (#1864) total_changes() counts writes that are later rolled back, so a result computed inside a transaction could survive the ROLLBACK under an unchanged stamp and describe edges that no longer exist. getDominantFile now skips the memo while a transaction is open (and on a runtime without isTransaction). Ported from the maintainer's hardening of this fix in #1919. Co-Authored-By: Claude Opus 5.5 --- __tests__/dominant-file-cache.test.ts | 25 +++++++++++++++++++++++++ src/db/queries.ts | 8 ++++++++ src/db/sqlite-adapter.ts | 6 ++++++ 3 files changed, 39 insertions(+) diff --git a/__tests__/dominant-file-cache.test.ts b/__tests__/dominant-file-cache.test.ts index 72da6df9c8..7e4f3a1f35 100644 --- a/__tests__/dominant-file-cache.test.ts +++ b/__tests__/dominant-file-cache.test.ts @@ -90,4 +90,29 @@ describe('dominant file — computed once per index state (#1864)', () => { expect(queriesOf(reader).getDominantFile()?.filePath).toBe('ext/plugin.ts'); }); + + it('does not keep a result read inside a transaction that is rolled back', async () => { + const cg = await setup(); + const q = queriesOf(cg); + const before = q.getDominantFile(); + const db = (cg as any).db.getDb(); + db.exec('BEGIN'); + try { + db.exec('DELETE FROM edges'); + expect(q.getDominantFile()).toBeNull(); + } finally { + db.exec('ROLLBACK'); + } + expect(q.getDominantFile()).toEqual(before); + }); + + it.runIf(process.platform !== 'win32')('sees a database rebuilt and reopened under it', async () => { + const reader = await setup(); + expect(queriesOf(reader).getDominantFile()?.filePath).toBe('core/engine.ts'); + fs.writeFileSync(path.join(dir, 'ext', 'plugin.ts'), chain('pluginStep', 120)); + const rebuilt = await CodeGraph.recreate(dir); + try { await rebuilt.indexAll(); } finally { rebuilt.close(); } + expect(reader.reopenIfReplaced()).toBe(true); + expect(queriesOf(reader).getDominantFile()?.filePath).toBe('ext/plugin.ts'); + }); }); diff --git a/src/db/queries.ts b/src/db/queries.ts index c24dda772e..eac7d96c20 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -1070,6 +1070,14 @@ export class QueryBuilder { * stamp: recomputed only after something wrote to the database. */ getDominantFile(): { filePath: string; edgeCount: number; nextEdgeCount: number } | null { + // total_changes() counts writes that are later rolled back, so a result + // read inside a transaction could outlive a ROLLBACK under an unchanged + // stamp. Never keep one; `undefined` (a runtime without the getter) is + // treated the same way. + if (this.db.inTransaction !== false) { + this.dominantFileMemo = undefined; + return this.computeDominantFile(); + } const stamp = this.getChangeStamp(); if (this.dominantFileMemo?.stamp === stamp) return this.dominantFileMemo.value; const value = this.computeDominantFile(); diff --git a/src/db/sqlite-adapter.ts b/src/db/sqlite-adapter.ts index e94b587b95..39b2d8ba0f 100644 --- a/src/db/sqlite-adapter.ts +++ b/src/db/sqlite-adapter.ts @@ -32,6 +32,8 @@ export interface SqliteDatabase { transaction(fn: (...args: any[]) => T): (...args: any[]) => T; close(): void; readonly open: boolean; + /** Undefined on a runtime without `isTransaction`; callers must then not memoize. */ + readonly inTransaction?: boolean; } /** @@ -73,6 +75,10 @@ class NodeSqliteAdapter implements SqliteDatabase { return toWslSharedIndexError(err, this._dbPath) ?? err; } + get inTransaction(): boolean | undefined { + return this._db.isTransaction; + } + get open(): boolean { return this._db.isOpen; } From 93c2d35a50bae966da4e2f6b9e7d8cf12e44803f Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Mon, 28 Sep 2026 23:30:01 -0500 Subject: [PATCH 3/4] fix(db): drop the dominant-file memo when a QueryBuilder is rebound (#1864) The memo's change stamp (total_changes + data_version) is per connection, and fresh read-only connections to two different databases report the same one ("0:2"). A pool worker rebound onto a rebuilt index kept serving the old database's dominant file until something wrote through the new connection. A test rebinds onto a second project and fails without the reset. Co-Authored-By: Claude Opus 5.5 --- __tests__/dominant-file-cache.test.ts | 24 +++++++++++++++++++++++- src/db/queries.ts | 4 ++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/__tests__/dominant-file-cache.test.ts b/__tests__/dominant-file-cache.test.ts index 7e4f3a1f35..f9ba57eef5 100644 --- a/__tests__/dominant-file-cache.test.ts +++ b/__tests__/dominant-file-cache.test.ts @@ -14,7 +14,8 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import CodeGraph from '../src'; -import type { QueryBuilder } from '../src/db/queries'; +import { QueryBuilder } from '../src/db/queries'; +import { DatabaseConnection, getDatabasePath } from '../src/db'; /** A file whose functions call each other in a chain: `n` in-file call edges. */ function chain(prefix: string, n: number): string { @@ -106,6 +107,27 @@ describe('dominant file — computed once per index state (#1864)', () => { expect(q.getDominantFile()).toEqual(before); }); + it('forgets the answer when rebound to another connection (a pool worker following a rebuilt index)', async () => { + await setup(); + const other = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-dominant-other-')); + const conns: DatabaseConnection[] = []; + try { + fs.mkdirSync(path.join(other, 'ext')); + fs.writeFileSync(path.join(other, 'ext', 'plugin.ts'), chain('pluginStep', 120)); + (await CodeGraph.init(other, { index: true })).close(); + conns.push(DatabaseConnection.open(getDatabasePath(dir)), DatabaseConnection.open(getDatabasePath(other))); + const q = new QueryBuilder(conns[0]!.getDb()); + expect(q.getDominantFile()?.filePath).toBe('core/engine.ts'); + // Two fresh connections to different databases can report the same + // change stamp, so only dropping the memo on rebind keeps this honest. + q.rebind(conns[1]!.getDb()); + expect(q.getDominantFile()?.filePath).toBe('ext/plugin.ts'); + } finally { + for (const c of conns) c.close(); + fs.rmSync(other, { recursive: true, force: true }); + } + }); + it.runIf(process.platform !== 'win32')('sees a database rebuilt and reopened under it', async () => { const reader = await setup(); expect(queriesOf(reader).getDominantFile()?.filePath).toBe('core/engine.ts'); diff --git a/src/db/queries.ts b/src/db/queries.ts index eac7d96c20..86215d061b 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -387,6 +387,10 @@ export class QueryBuilder { this.stmts = {}; this.batchStmts.clear(); this.edgeKindStmts.clear(); + // The change stamp is per connection, and fresh connections to two + // different databases report the same one — the memo goes with the old + // connection, or a worker following a rebuilt index keeps its answer (#1864). + this.dominantFileMemo = undefined; } private edgeKindStmt(sql: string): SqliteStatement { From fb4ddf6b3ad792b968d00fa28434af2dbbccc13f Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Mon, 28 Sep 2026 23:31:47 -0500 Subject: [PATCH 4/4] docs(changelog): credit @danusha2345 for #1864 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e78f20323..c7eedc92c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -174,7 +174,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - On Windows, the shared MCP daemon now waits longer for another program — an antivirus scan, an indexer, or another session reading its lock file — to let go of that file, so it starts instead of leaving the session to fall back to a slower in-process server. (#1773) - A file over the size limit is no longer read before it is skipped. Large committed video and blob fixtures used to be loaded into memory in full, once per indexing pass, only to be discarded; indexing, change detection and the viewer now go by the file's size instead. Thanks @danusha2345. (#1910) - An MPEG transport stream video that happens to be named `.ts` (golden fixtures under `testdata/`, e2e clips) is now recognised from its first bytes and skipped instead of being fed to the TypeScript parser, which spent a long time on each clip for no symbols. Real TypeScript files are still indexed. Thanks @danusha2345. (#1910) -- Repeated `codegraph_explore` calls from a running MCP server no longer re-rank the whole graph each time to find the project's core file; the answer is reused until the index changes. (#1864) +- Repeated `codegraph_explore` calls from a running MCP server no longer re-rank the whole graph each time to find the project's core file; the answer is reused until the index changes. Thanks @danusha2345. (#1864) - File watching no longer drops the full re-scan a removed directory asks for when that sync fails, so the deleted files leave the index instead of lingering. (#1964) - Running `codegraph index` while the MCP server is running no longer leaves live auto-sync writing into the old, replaced index: the server now switches to the rebuilt index before its next update and catches up on everything it would otherwise have missed. Thanks @danusha2345. (#1902) - Daemon startup and cleanup now preserve live legacy PID-only locks while still reclaiming dead or identity-disproved records, preventing two writers from serving the same project.