Skip to content

Commit dfef438

Browse files
authored
fix(review): cache impact-map's per-file vector query results (#4544)
* fix(review): cache impact-map's per-file vector query results computeImpactMap issues one retrieveContextWithMetrics call per changed- symbol file (up to MAX_IMPACT_MAP_INPUT_FILES=20), each a real embedding call plus a live vector-index query with no result cache -- only a 60-second cold-index existence check is memoized. Impact-map is a dynamic feature (bypasses the durable ai_review cache), so only the 30-minute non-cacheable retry cooldown throttled repeat computation; once that lapsed, the full up-to-20x embed+query loop re-fired for an unchanged PR. Adds impact_map_query_cache, keyed by (project, repo, fingerprint) where the fingerprint hashes every input that affects the result (queryText, excludePaths, topK, minScore, reranker) -- excludePaths in particular varies per changed file, since each excludes itself. Unlike the grounding file-content cache, this needs a TTL: the underlying vector index can change as new commits get embedded, so an identical query issued later could legitimately have a different correct answer. Matches the same 30-minute cooldown that already throttles how often this computation is re-attempted. Closes #4500. * fix(review): close impact-map query-cache coverage gaps Restructure the cache-hit branch to a proper if/else (a braceless single-statement if with no else was mis-instrumented by v8's coverage collector) and add a fail-safe test for a throwing cache read.
1 parent afd731e commit dfef438

4 files changed

Lines changed: 291 additions & 10 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
-- Impact-map query cache (#4500): computeImpactMap issues one retrieveContextWithMetrics call per
2+
-- changed-symbol file (up to MAX_IMPACT_MAP_INPUT_FILES=20), and each call does a REAL embedding-model
3+
-- inference call plus a live vector-index query with no result cache -- only a 60-second cold-index
4+
-- existence check is memoized (rag.ts's chunkCountCache), not query results. Unlike grounding_file_content_cache
5+
-- (migration 0130), this DOES need a TTL: the underlying vector index can change as new commits get embedded,
6+
-- so an identical query issued later could legitimately have a different correct answer. query_fingerprint
7+
-- hashes every input that affects the result (queryText, excludePaths, topK, minScore, reranker) since all of
8+
-- them vary meaningfully -- excludePaths in particular varies per changed file (each excludes itself).
9+
CREATE TABLE IF NOT EXISTS impact_map_query_cache (
10+
project TEXT NOT NULL,
11+
repo TEXT NOT NULL,
12+
query_fingerprint TEXT NOT NULL,
13+
context TEXT NOT NULL,
14+
metrics_json TEXT NOT NULL,
15+
fetched_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
16+
PRIMARY KEY (project, repo, query_fingerprint)
17+
);

src/db/schema.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1476,3 +1476,28 @@ export const groundingFileContentCache = sqliteTable(
14761476
primary: primaryKey({ columns: [table.repoFullName, table.path, table.headSha] }),
14771477
}),
14781478
);
1479+
1480+
// Impact-map query cache (#4500): computeImpactMap issues one retrieveContextWithMetrics call per
1481+
// changed-symbol file with no result cache -- only a 60-second cold-index existence check is memoized. Unlike
1482+
// linkedIssueSatisfactionCache, this DOES need a TTL (checked at the read site, not a schema constraint): the
1483+
// underlying vector index can change as new commits get embedded, so an identical query issued later could
1484+
// legitimately have a different correct answer.
1485+
export const impactMapQueryCache = sqliteTable(
1486+
"impact_map_query_cache",
1487+
{
1488+
project: text("project").notNull(),
1489+
repo: text("repo").notNull(),
1490+
// Hashes every input that affects the result (queryText, excludePaths, topK, minScore, reranker) -- all of
1491+
// them vary meaningfully; excludePaths in particular varies per changed file (each excludes itself).
1492+
queryFingerprint: text("query_fingerprint").notNull(),
1493+
context: text("context").notNull(),
1494+
metricsJson: text("metrics_json").notNull(),
1495+
/* v8 ignore next -- this default only fires for a Drizzle query-builder insert omitting fetchedAt;
1496+
* putCachedImpactMapQuery always writes via raw SQL with an explicit fetched_at value, so this callback
1497+
* is never actually invoked by the real code path (defensive schema-level default only). */
1498+
fetchedAt: text("fetched_at").notNull().$defaultFn(() => nowIso()),
1499+
},
1500+
(table) => ({
1501+
primary: primaryKey({ columns: [table.project, table.repo, table.queryFingerprint] }),
1502+
}),
1503+
);

src/review/impact-map.ts

Lines changed: 89 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
// FAIL-SAFE (mirrors rag.ts's own guarantee): a missing/cold RAG index, no changed symbols, or any retrieval
1010
// error degrades to an EMPTY impact map — this computation can never break or block a review.
1111

12+
import { sha256Hex } from "../utils/crypto";
13+
import { nowIso } from "../utils/json";
1214
import type { FileChangedSymbols } from "./impact-symbols";
13-
import { retrieveContextWithMetrics, type RagInfra } from "./rag";
15+
import { retrieveContextWithMetrics, type RagInfra, type RagRetrievalResult } from "./rag";
1416

1517
export type ImpactMapEntry = {
1618
/** The file whose changed exported symbol(s) triggered this entry. */
@@ -53,6 +55,71 @@ function buildSymbolQueryText(file: FileChangedSymbols): string {
5355
return `Changed symbols: ${file.symbols.join(", ")}\nFile: ${file.path}`;
5456
}
5557

58+
// #4500: a query-result cache, distinct from grounding_file_content_cache -- the underlying vector index can
59+
// change as new commits get embedded, so (unlike file content at an immutable head SHA) an identical query
60+
// issued later could legitimately have a different correct answer. Matches
61+
// AI_REVIEW_NON_CACHEABLE_RETRY_COOLDOWN_MS (processors.ts), the SAME cooldown that throttles how often this
62+
// whole computation is even re-attempted -- a cache TTL any shorter would never actually prevent a redundant
63+
// re-embed within that window, and any longer would risk masking a real index update for no added benefit.
64+
const IMPACT_MAP_QUERY_CACHE_MAX_AGE_MS = 30 * 60 * 1000;
65+
66+
/** One query's cache key: every input that affects retrieveContextWithMetrics' result. topK/minScore/reranker
67+
* are constants for this module's own calls, but are still hashed (not assumed) so this function stays
68+
* correct if a future caller ever varies them. excludePaths is sorted before hashing so argument order never
69+
* causes a spurious cache miss. */
70+
async function impactMapQueryFingerprint(input: {
71+
queryText: string;
72+
excludePaths: string[];
73+
topK: number;
74+
minScore: number;
75+
reranker: string;
76+
}): Promise<string> {
77+
const payload = [input.queryText, [...input.excludePaths].sort().join(","), String(input.topK), String(input.minScore), input.reranker].join("|");
78+
return sha256Hex(payload);
79+
}
80+
81+
async function getCachedImpactMapQuery(
82+
storage: RagInfra["storage"],
83+
project: string,
84+
repo: string,
85+
fingerprint: string,
86+
): Promise<RagRetrievalResult | null> {
87+
try {
88+
const row = await storage
89+
.prepare("SELECT context, metrics_json AS metricsJson, fetched_at AS fetchedAt FROM impact_map_query_cache WHERE project = ? AND repo = ? AND query_fingerprint = ?")
90+
.bind(project, repo, fingerprint)
91+
.first<{ context: string; metricsJson: string; fetchedAt: string }>();
92+
if (!row) return null;
93+
const ageMs = Date.now() - Date.parse(row.fetchedAt);
94+
if (!Number.isFinite(ageMs) || ageMs >= IMPACT_MAP_QUERY_CACHE_MAX_AGE_MS) return null;
95+
return { context: row.context, metrics: JSON.parse(row.metricsJson) as RagRetrievalResult["metrics"] };
96+
} catch {
97+
return null; // fail-safe: a storage error degrades to "no cache", never blocks the query
98+
}
99+
}
100+
101+
async function putCachedImpactMapQuery(
102+
storage: RagInfra["storage"],
103+
project: string,
104+
repo: string,
105+
fingerprint: string,
106+
result: RagRetrievalResult,
107+
): Promise<void> {
108+
try {
109+
await storage
110+
.prepare(
111+
`INSERT INTO impact_map_query_cache (project, repo, query_fingerprint, context, metrics_json, fetched_at)
112+
VALUES (?, ?, ?, ?, ?, ?)
113+
ON CONFLICT(project, repo, query_fingerprint) DO UPDATE SET
114+
context = excluded.context, metrics_json = excluded.metrics_json, fetched_at = excluded.fetched_at`,
115+
)
116+
.bind(project, repo, fingerprint, result.context, JSON.stringify(result.metrics), nowIso())
117+
.run();
118+
} catch {
119+
// fail-safe: a write failure only means this ONE result isn't cached -- never blocks the review
120+
}
121+
}
122+
56123
/**
57124
* Compute the deterministic impact map for a PR's changed symbols. One entry per changed file that has at
58125
* least one extracted symbol (files with none contribute no entry — there's nothing symbol-driven to query
@@ -71,17 +138,29 @@ export async function computeImpactMap(
71138
const queryableFiles = symbols.filter((file) => file.symbols.length > 0).slice(0, MAX_IMPACT_MAP_INPUT_FILES);
72139
for (const file of queryableFiles) {
73140
const queryText = buildSymbolQueryText(file);
141+
const excludePaths = [file.path];
74142
let affectedModules: string[];
75143
try {
76-
const result = await retrieveContextWithMetrics(ragContext.infra, {
77-
project: ragContext.project,
78-
repo: ragContext.repo,
79-
queryText,
80-
topK: IMPACT_MAP_TOP_K,
81-
minScore: IMPACT_MAP_MIN_SCORE,
82-
excludePaths: [file.path],
83-
reranker: "bm25",
84-
});
144+
// #4500: reuse a still-fresh prior result for the IDENTICAL query instead of re-embedding + re-querying
145+
// the vector index -- a real cost this loop pays up to MAX_IMPACT_MAP_INPUT_FILES times per pass, with
146+
// nothing else memoizing it (impact-map is a dynamic feature that bypasses the durable ai_review cache).
147+
const fingerprint = await impactMapQueryFingerprint({ queryText, excludePaths, topK: IMPACT_MAP_TOP_K, minScore: IMPACT_MAP_MIN_SCORE, reranker: "bm25" });
148+
const cached = await getCachedImpactMapQuery(ragContext.infra.storage, ragContext.project, ragContext.repo, fingerprint);
149+
let result: RagRetrievalResult;
150+
if (cached !== null) {
151+
result = cached;
152+
} else {
153+
result = await retrieveContextWithMetrics(ragContext.infra, {
154+
project: ragContext.project,
155+
repo: ragContext.repo,
156+
queryText,
157+
topK: IMPACT_MAP_TOP_K,
158+
minScore: IMPACT_MAP_MIN_SCORE,
159+
excludePaths,
160+
reranker: "bm25",
161+
});
162+
await putCachedImpactMapQuery(ragContext.infra.storage, ragContext.project, ragContext.repo, fingerprint, result);
163+
}
85164
affectedModules = result.metrics.paths.slice(0, MAX_AFFECTED_MODULES_PER_ENTRY);
86165
// Defense in depth: retrieveContextWithMetrics is itself fail-safe (its own try/catch degrades a
87166
// throwing vector/inference adapter to an empty result internally — never throws out to us), but this

test/unit/impact-map.test.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,38 @@ function storageStubWithText(count: number): StorageAdapter {
3030
} as unknown as StorageAdapter;
3131
}
3232

33+
/** A storage stub that actually backs impact_map_query_cache's INSERT/SELECT/ON CONFLICT semantics in an
34+
* in-memory Map (keyed by "project|repo|fingerprint"), while still answering repo_chunks' COUNT/chunk-text
35+
* queries like storageStubWithText above -- lets the invariant/regression tests below assert genuine cache
36+
* hit/miss/expiry behavior instead of the other stubs' fixed canned responses (which always read as a miss). */
37+
function cachingStorageStub(count: number, fetchedAtOverride?: string): { storage: StorageAdapter; rows: Map<string, { context: string; metricsJson: string; fetchedAt: string }> } {
38+
const rows = new Map<string, { context: string; metricsJson: string; fetchedAt: string }>();
39+
const storage: StorageAdapter = {
40+
prepare: (sql: string) => ({
41+
bind: (...args: unknown[]) => ({
42+
first: async () => {
43+
if (/FROM impact_map_query_cache/i.test(sql)) {
44+
const [project, repo, fingerprint] = args as [string, string, string];
45+
return rows.get(`${project}|${repo}|${fingerprint}`) ?? null;
46+
}
47+
return { n: count };
48+
},
49+
all: async () =>
50+
/SELECT id, text/i.test(sql) ? { results: args.map((id) => ({ id: String(id), text: `body for ${String(id)}` })) } : { results: [] },
51+
run: async () => {
52+
if (/INSERT INTO impact_map_query_cache/i.test(sql)) {
53+
const [project, repo, fingerprint, context, metricsJson, fetchedAt] = args as [string, string, string, string, string, string];
54+
rows.set(`${project}|${repo}|${fingerprint}`, { context, metricsJson, fetchedAt: fetchedAtOverride ?? fetchedAt });
55+
}
56+
return undefined;
57+
},
58+
}),
59+
}),
60+
batch: async () => undefined,
61+
} as unknown as StorageAdapter;
62+
return { storage, rows };
63+
}
64+
3365
function vectorStub(matches: Array<{ id: string; score: number; metadata: { path: string } }>): VectorAdapter {
3466
return {
3567
query: async () => ({ matches }),
@@ -215,4 +247,132 @@ describe("computeImpactMap", () => {
215247
{ changedModule: "src/review/impact-map.ts", affectedModules: ["src/review/y.ts"], callers: ["computeImpactMap"] },
216248
]);
217249
});
250+
251+
it("INVARIANT (#4500): a second computeImpactMap call with the IDENTICAL changed-symbol set makes ZERO additional embed/vector-query calls", async () => {
252+
let embedCalls = 0;
253+
let queryCalls = 0;
254+
const countingInference: InferenceAdapter = {
255+
run: async () => {
256+
embedCalls += 1;
257+
return { data: [Array(1024).fill(0.1)] };
258+
},
259+
};
260+
const countingVector: VectorAdapter = {
261+
query: async () => {
262+
queryCalls += 1;
263+
return { matches: [{ id: "src/review/caller.ts::0", score: 0.9, metadata: { path: "src/review/caller.ts" } }] };
264+
},
265+
upsert: async () => undefined,
266+
deleteByIds: async () => undefined,
267+
} as unknown as VectorAdapter;
268+
const { storage } = cachingStorageStub(5);
269+
const infra: RagInfra = { storage, vector: countingVector, inference: countingInference };
270+
const symbols: FileChangedSymbols[] = [{ path: "src/review/impact-map.ts", symbols: ["computeImpactMap"] }];
271+
272+
const first = await computeImpactMap(symbols, { infra, project: "acme", repo: "widgets" });
273+
const second = await computeImpactMap(symbols, { infra, project: "acme", repo: "widgets" });
274+
275+
expect(first).toEqual(second);
276+
expect(embedCalls).toBe(1);
277+
expect(queryCalls).toBe(1);
278+
});
279+
280+
it("REGRESSION (#4500, impact-map-refetch incident): repeated cooldown-driven computeImpactMap calls on an unchanged head only embed/query once per file, not once per call", async () => {
281+
let embedCalls = 0;
282+
let queryCalls = 0;
283+
const countingInference: InferenceAdapter = {
284+
run: async () => {
285+
embedCalls += 1;
286+
return { data: [Array(1024).fill(0.1)] };
287+
},
288+
};
289+
const countingVector: VectorAdapter = {
290+
query: async () => {
291+
queryCalls += 1;
292+
return { matches: [{ id: "src/review/caller.ts::0", score: 0.9, metadata: { path: "src/review/caller.ts" } }] };
293+
},
294+
upsert: async () => undefined,
295+
deleteByIds: async () => undefined,
296+
} as unknown as VectorAdapter;
297+
const { storage } = cachingStorageStub(5);
298+
const infra: RagInfra = { storage, vector: countingVector, inference: countingInference };
299+
const symbols: FileChangedSymbols[] = [{ path: "src/review/impact-map.ts", symbols: ["computeImpactMap"] }];
300+
301+
// Simulates 5 separate scheduled-sweep-tick passes for the SAME unchanged PR head past the 30-minute
302+
// non-cacheable cooldown -- previously each one re-embedded and re-queried the vector index from scratch.
303+
for (let i = 0; i < 5; i += 1) {
304+
// eslint-disable-next-line no-await-in-loop -- sequential passes, mirroring separate review invocations
305+
await computeImpactMap(symbols, { infra, project: "acme", repo: "widgets" });
306+
}
307+
308+
expect(embedCalls).toBe(1);
309+
expect(queryCalls).toBe(1);
310+
});
311+
312+
it("a throwing cache READ degrades to a fresh embed+query (fail-safe, never blocks the impact-map computation)", async () => {
313+
const throwingStorage: StorageAdapter = {
314+
prepare: () => ({
315+
bind: () => ({
316+
first: async () => {
317+
throw new Error("cache read boom");
318+
},
319+
all: async () => ({ results: [] }),
320+
run: async () => undefined,
321+
}),
322+
}),
323+
batch: async () => undefined,
324+
} as unknown as StorageAdapter;
325+
const infra: RagInfra = {
326+
storage: throwingStorage,
327+
vector: vectorStub([{ id: "src/review/caller.ts::0", score: 0.9, metadata: { path: "src/review/caller.ts" } }]),
328+
inference: ai1024,
329+
};
330+
const symbols: FileChangedSymbols[] = [{ path: "src/review/impact-map.ts", symbols: ["computeImpactMap"] }];
331+
// The chunk-text lookup (repo_chunks) ALSO throws via this stub, which retrieveContextWithMetrics itself
332+
// already degrades fail-safe -- the cache-read throw specifically is what this test targets, so the
333+
// resulting entry is empty (no chunk text survives), but the computation must complete, not throw.
334+
await expect(computeImpactMap(symbols, { infra, project: "acme", repo: "widgets" })).resolves.toEqual([]);
335+
});
336+
337+
it("a genuinely different query (different changed symbols) still triggers a fresh embed+query, never masked by another file's cached entry", async () => {
338+
let queryCalls = 0;
339+
const countingVector: VectorAdapter = {
340+
query: async () => {
341+
queryCalls += 1;
342+
return { matches: [{ id: "src/review/caller.ts::0", score: 0.9, metadata: { path: "src/review/caller.ts" } }] };
343+
},
344+
upsert: async () => undefined,
345+
deleteByIds: async () => undefined,
346+
} as unknown as VectorAdapter;
347+
const { storage } = cachingStorageStub(5);
348+
const infra: RagInfra = { storage, vector: countingVector, inference: ai1024 };
349+
350+
await computeImpactMap([{ path: "src/review/impact-map.ts", symbols: ["computeImpactMap"] }], { infra, project: "acme", repo: "widgets" });
351+
// A different changed file with different symbols -- a genuinely different query, even though it shares
352+
// the same project/repo as the first call.
353+
await computeImpactMap([{ path: "src/review/rag.ts", symbols: ["retrieveContextWithMetrics"] }], { infra, project: "acme", repo: "widgets" });
354+
355+
expect(queryCalls).toBe(2);
356+
});
357+
358+
it("a cache entry older than the TTL is treated as a miss, re-embedding and re-querying instead of serving a possibly-stale answer", async () => {
359+
let queryCalls = 0;
360+
const countingVector: VectorAdapter = {
361+
query: async () => {
362+
queryCalls += 1;
363+
return { matches: [{ id: "src/review/caller.ts::0", score: 0.9, metadata: { path: "src/review/caller.ts" } }] };
364+
},
365+
upsert: async () => undefined,
366+
deleteByIds: async () => undefined,
367+
} as unknown as VectorAdapter;
368+
// Every row this stub returns is stamped an hour old -- past the 30-minute TTL.
369+
const { storage } = cachingStorageStub(5, new Date(Date.now() - 60 * 60 * 1000).toISOString());
370+
const infra: RagInfra = { storage, vector: countingVector, inference: ai1024 };
371+
const symbols: FileChangedSymbols[] = [{ path: "src/review/impact-map.ts", symbols: ["computeImpactMap"] }];
372+
373+
await computeImpactMap(symbols, { infra, project: "acme", repo: "widgets" });
374+
await computeImpactMap(symbols, { infra, project: "acme", repo: "widgets" });
375+
376+
expect(queryCalls).toBe(2);
377+
});
218378
});

0 commit comments

Comments
 (0)