Files
codegraph/__tests__/dominant-file-cache.test.ts
Colby Mchenry 09b9646b7e perf(context): compute the dominant file once per index state (#1864) (#2086)
* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* docs(changelog): credit @danusha2345 for #1864

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: danusha2345 <danusha2345@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 04:37:41 +00:00

141 lines
5.6 KiB
TypeScript

/**
* 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 { 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 {
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<CodeGraph> {
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');
});
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('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');
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');
});
});