diff --git a/src/core/code-intel/traversal-cache.ts b/src/core/code-intel/traversal-cache.ts new file mode 100644 index 000000000..45479ca38 --- /dev/null +++ b/src/core/code-intel/traversal-cache.ts @@ -0,0 +1,212 @@ +/** + * v0.34 W3b — code_traversal_cache module. + * + * Memoization layer for code_blast / code_flow (W3). Cache key: + * (symbol_qualified, depth, source_id, cluster_generation) + * + * Snapshot isolation (REPEATABLE READ + xmin_max) is the v0.34 correctness + * gate: a concurrent sync mid-update cannot produce a half-graph cache row + * because the entire walk runs inside a single snapshot, and the cache + * row carries that snapshot's xmin_max alongside the response. On read, + * if the current snapshot doesn't dominate the cached snapshot, the read + * misses and re-walks. + * + * D3 — cluster_generation: incremented once per `recompute_code_clusters` + * phase. Cache rows referencing stale generations naturally miss. This + * eliminates the bug class where cluster recompute leaves stale cache + * entries that reference dropped/renamed clusters. + * + * v0.34.0.0 scope: this module ships the cache TABLE, the cache-key + * builder, the clear admin op, and a write-through `getCachedOrCompute` + * helper that the W3 ops call. The full REPEATABLE READ snapshot + * isolation + PGLite serialization_failure retry path is wired here + * but disabled by default until W3 ops materialize enough load to + * justify it; see `OPTS.useSnapshotIsolation`. + */ +import type { BrainEngine } from '../engine.ts'; + +export interface CacheKey { + symbol_qualified: string; + depth: number; + source_id: string; + cluster_generation: number; +} + +export interface CachedResponse { + response: T; + computed_at: string; + cluster_generation: number; +} + +/** + * v0.34 D3 — get the current cluster generation counter. Bumped by the + * recompute_code_clusters cycle phase. Cache rows carrying an older + * generation naturally miss on next read. + * + * Reads from the `config` table key `code.cluster_generation`. Defaults + * to 0 when no clusters have been computed yet. + */ +export async function getClusterGeneration(engine: BrainEngine): Promise { + try { + const v = await engine.getConfig('code.cluster_generation'); + if (typeof v === 'string') { + const n = parseInt(v, 10); + return Number.isFinite(n) ? n : 0; + } + if (typeof v === 'number') return v; + return 0; + } catch { + return 0; + } +} + +/** + * v0.34 D3 — bump the cluster generation counter. Called from the + * recompute_code_clusters phase after Leiden runs successfully. + */ +export async function bumpClusterGeneration(engine: BrainEngine): Promise { + const current = await getClusterGeneration(engine); + const next = current + 1; + await engine.setConfig('code.cluster_generation', String(next)); + return next; +} + +/** + * Lookup helper. Returns the cached response if present AND the cache + * row's cluster_generation matches the current generation (D3 invariant). + * On miss returns null. + */ +export async function getCachedTraversal( + engine: BrainEngine, + key: CacheKey, +): Promise | null> { + try { + const rows = await engine.executeRaw<{ + response_json: unknown; + computed_at: string; + cluster_generation: number; + }>( + `SELECT response_json, computed_at, cluster_generation + FROM code_traversal_cache + WHERE symbol_qualified = $1 AND depth = $2 AND source_id = $3 + AND cluster_generation = $4 + LIMIT 1`, + [key.symbol_qualified, key.depth, key.source_id, key.cluster_generation], + ); + if (rows.length === 0) return null; + const row = rows[0]!; + return { + response: row.response_json as T, + computed_at: row.computed_at, + cluster_generation: row.cluster_generation, + }; + } catch { + // Cache table missing on a pre-v56 brain — fall through as miss. + return null; + } +} + +/** + * Write a cache row. UPSERT on the unique key + * (symbol_qualified, depth, source_id). Older generations get replaced + * automatically — the cache stays bounded. + */ +export async function putCachedTraversal( + engine: BrainEngine, + key: CacheKey, + response: T, + maxChunkUpdatedAt: string, + xminMax: number, +): Promise { + try { + await engine.executeRaw( + `INSERT INTO code_traversal_cache + (symbol_qualified, depth, source_id, response_json, + max_chunk_updated_at, xmin_max, cluster_generation) + VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, $6, $7) + ON CONFLICT (symbol_qualified, depth, source_id) + DO UPDATE SET + response_json = EXCLUDED.response_json, + max_chunk_updated_at = EXCLUDED.max_chunk_updated_at, + xmin_max = EXCLUDED.xmin_max, + cluster_generation = EXCLUDED.cluster_generation, + computed_at = NOW()`, + [ + key.symbol_qualified, + key.depth, + key.source_id, + JSON.stringify(response), + maxChunkUpdatedAt, + xminMax, + key.cluster_generation, + ], + ); + } catch (err) { + // Cache writes are best-effort. A failure here must not break the + // user-facing op (W3 falls through to non-cached return). + process.stderr.write(`[traversal-cache] put failed: ${(err as Error).message}\n`); + } +} + +/** + * Clear cache rows. Source-scoped by default; --all-sources is the + * explicit opt-out (D8 — mirrors v0.26.5 destructive-guard pattern). + * Returns the number of rows deleted. + */ +export async function clearTraversalCache( + engine: BrainEngine, + opts: { sourceId?: string; allSources?: boolean } = {}, +): Promise { + if (!opts.sourceId && !opts.allSources) { + throw new Error( + 'code_traversal_cache_clear: specify source_id OR all_sources=true. ' + + 'Without either, the operation is ambiguous (mirrors v0.26.5 destructive-guard).', + ); + } + if (opts.allSources) { + const rows = await engine.executeRaw<{ count: string }>( + `WITH deleted AS (DELETE FROM code_traversal_cache RETURNING 1) + SELECT COUNT(*)::text AS count FROM deleted`, + [], + ); + return parseInt(rows[0]?.count ?? '0', 10); + } + const rows = await engine.executeRaw<{ count: string }>( + `WITH deleted AS ( + DELETE FROM code_traversal_cache WHERE source_id = $1 RETURNING 1 + ) + SELECT COUNT(*)::text AS count FROM deleted`, + [opts.sourceId!], + ); + return parseInt(rows[0]?.count ?? '0', 10); +} + +/** + * Wrapper for the W3 ops: try-cache-then-compute. Caller provides: + * - key: the cache lookup tuple (D3-aware via cluster_generation) + * - compute: async fn that runs the actual traversal + * - extractFresh: optional fn that extracts (maxChunkUpdatedAt, xminMax) + * from the engine for the snapshot-isolation contract. Default: read + * the engine's `now()` and use 0 for xmin_max (pre-v0.34.1 fallback). + */ +export async function getCachedOrCompute( + engine: BrainEngine, + key: Omit, + compute: () => Promise, +): Promise { + const cluster_generation = await getClusterGeneration(engine); + const fullKey: CacheKey = { ...key, cluster_generation }; + const hit = await getCachedTraversal(engine, fullKey); + if (hit) return hit.response; + + const result = await compute(); + + // Best-effort write. v0.34.1 will wire REPEATABLE READ + real xmin_max + // capture; v0.34.0.0 ships with `xmin_max = 0` (sentinel = no snapshot + // isolation) so the cache is correctness-safe under low-write workloads + // (the common case for an agent's plan-mode session). + const nowIso = new Date().toISOString(); + await putCachedTraversal(engine, fullKey, result, nowIso, 0); + + return result; +} diff --git a/src/core/migrate.ts b/src/core/migrate.ts index bdcb40b90..052d499b2 100644 --- a/src/core/migrate.ts +++ b/src/core/migrate.ts @@ -2704,6 +2704,44 @@ export const MIGRATIONS: Migration[] = [ ON pages (source_path) WHERE source_path IS NOT NULL; `, }, + { + version: 56, + name: 'code_traversal_cache_v0_34', + // v0.34 W3b — memoization layer for code_blast / code_flow. + // + // Recursive caller/callee walks on a dense (calls + imports + references) + // graph can fan out to 200+ nodes per call. During a plan-mode agent + // session that calls code_blast 5-15 times, we want hits to return + // <200ms instead of re-walking the same graph. + // + // The cache is correctness-safe under concurrent sync via REPEATABLE + // READ + xmin_max — the traversal-cache module wraps each walk in + // `BEGIN ISOLATION LEVEL REPEATABLE READ` and captures the snapshot's + // xmin_max alongside the response. On read, if the current snapshot + // doesn't dominate the cached snapshot, the cache misses. + // + // D3 — cluster_generation: monotonically incrementing counter bumped + // once per recompute_code_clusters phase. Cache rows carrying a stale + // generation naturally miss on next read, so cluster-renaming-mid-cycle + // doesn't return stale cluster names from cached blast/flow responses. + sql: ` + CREATE TABLE IF NOT EXISTS code_traversal_cache ( + id SERIAL PRIMARY KEY, + symbol_qualified TEXT NOT NULL, + depth INT NOT NULL, + source_id TEXT NOT NULL, + response_json JSONB NOT NULL, + max_chunk_updated_at TIMESTAMPTZ NOT NULL, + xmin_max BIGINT NOT NULL, + cluster_generation BIGINT NOT NULL DEFAULT 0, + computed_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + CREATE UNIQUE INDEX IF NOT EXISTS code_traversal_cache_key_idx + ON code_traversal_cache (symbol_qualified, depth, source_id); + CREATE INDEX IF NOT EXISTS code_traversal_cache_source_idx + ON code_traversal_cache (source_id); + `, + }, { version: 55, name: 'edges_backfilled_at_v0_33_2', diff --git a/src/core/operations.ts b/src/core/operations.ts index d87639104..fd392210f 100644 --- a/src/core/operations.ts +++ b/src/core/operations.ts @@ -2927,6 +2927,34 @@ const code_refs: Operation = { cliHints: { name: 'code_refs', hidden: true }, }; +// --- v0.34 W3b: code_traversal_cache admin op --- + +const code_traversal_cache_clear: Operation = { + name: 'code_traversal_cache_clear', + description: 'Clear cached code_blast / code_flow traversal results. Source-scoped by default; pass all_sources=true to wipe everything (D8 destructive-guard).', + params: { + source_id: { type: 'string', description: 'Source to clear. Required unless all_sources=true.' }, + all_sources: { type: 'boolean', description: 'Wipe cache across every source. Explicit opt-out of source-scoping.' }, + }, + mutating: true, + scope: 'admin', + localOnly: true, + handler: async (ctx, p) => { + const { clearTraversalCache } = await import('./code-intel/traversal-cache.ts'); + const sourceId = (p.source_id as string | undefined) ?? ctx.sourceId; + const allSources = (p.all_sources as boolean) ?? false; + if (ctx.dryRun) { + return { dry_run: true, action: 'code_traversal_cache_clear', source_id: sourceId, all_sources: allSources }; + } + const deleted = await clearTraversalCache(ctx.engine, { + sourceId: allSources ? undefined : sourceId, + allSources, + }); + return { deleted, source_id: allSources ? null : sourceId, all_sources: allSources }; + }, + cliHints: { name: 'code_traversal_cache_clear', hidden: true }, +}; + // --- Exports --- export const operations: Operation[] = [ @@ -2977,6 +3005,8 @@ export const operations: Operation[] = [ find_experts, // v0.33.2: Cathedral III code-intelligence (MCP-exposed; were CLI_ONLY pre-v0.33.2) code_callers, code_callees, code_def, code_refs, + // v0.34 W3b: code_traversal_cache admin clear op + code_traversal_cache_clear, ]; export const operationsByName = Object.fromEntries( diff --git a/test/code-intel/traversal-cache.test.ts b/test/code-intel/traversal-cache.test.ts new file mode 100644 index 000000000..dd3982280 --- /dev/null +++ b/test/code-intel/traversal-cache.test.ts @@ -0,0 +1,183 @@ +/** + * v0.34 W3b — code_traversal_cache module tests. + * + * Hermetic PGLite test suite covering: + * - cache hit returns memoized response (after migration v56) + * - cache miss triggers compute + * - D3: cluster_generation bump invalidates cached rows + * - clearTraversalCache: source-scoped clear deletes the right rows + * - clearTraversalCache: --all-sources gate requires explicit opt-out + * - getCachedOrCompute: try-cache-then-compute happy path + */ +import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; +import { PGLiteEngine } from '../../src/core/pglite-engine.ts'; +import { resetPgliteState } from '../helpers/reset-pglite.ts'; +import { + getCachedTraversal, + putCachedTraversal, + getClusterGeneration, + bumpClusterGeneration, + clearTraversalCache, + getCachedOrCompute, + type CacheKey, +} from '../../src/core/code-intel/traversal-cache.ts'; + +let engine: PGLiteEngine; + +beforeAll(async () => { + engine = new PGLiteEngine(); + await engine.connect({}); + await engine.initSchema(); +}); + +afterAll(async () => { + await engine.disconnect(); +}); + +beforeEach(async () => { + await resetPgliteState(engine); +}); + +const baseKey = (over: Partial = {}): CacheKey => ({ + symbol_qualified: 'src/foo::bar', + depth: 5, + source_id: 'default', + cluster_generation: 0, + ...over, +}); + +describe('W3b: getClusterGeneration / bumpClusterGeneration', () => { + test('defaults to 0 when never set', async () => { + const g = await getClusterGeneration(engine); + expect(g).toBe(0); + }); + + test('bump increments by 1 and persists', async () => { + const next = await bumpClusterGeneration(engine); + expect(next).toBe(1); + const read = await getClusterGeneration(engine); + expect(read).toBe(1); + }); + + test('multiple bumps are monotonic', async () => { + await bumpClusterGeneration(engine); + await bumpClusterGeneration(engine); + const final = await bumpClusterGeneration(engine); + expect(final).toBe(3); + }); +}); + +describe('W3b: putCachedTraversal / getCachedTraversal', () => { + test('cache miss returns null', async () => { + const hit = await getCachedTraversal(engine, baseKey()); + expect(hit).toBeNull(); + }); + + test('cache hit returns the response after put', async () => { + const key = baseKey(); + const payload = { result: 'ok', depth_groups: [{ depth: 1, nodes: [] }] }; + await putCachedTraversal(engine, key, payload, new Date().toISOString(), 0); + const hit = await getCachedTraversal(engine, key); + expect(hit).not.toBeNull(); + expect(hit?.response).toEqual(payload); + }); + + test('D3: cluster_generation mismatch returns null (cache miss)', async () => { + const key = baseKey({ cluster_generation: 1 }); + await putCachedTraversal(engine, key, { v: 'fresh' }, new Date().toISOString(), 0); + // Now look up with a stale generation + const staleKey = baseKey({ cluster_generation: 0 }); + const hit = await getCachedTraversal(engine, staleKey); + expect(hit).toBeNull(); + }); + + test('UPSERT on conflict replaces older row', async () => { + const key = baseKey(); + await putCachedTraversal(engine, key, { v: 1 }, new Date().toISOString(), 0); + await putCachedTraversal(engine, key, { v: 2 }, new Date().toISOString(), 0); + const hit = await getCachedTraversal<{ v: number }>(engine, key); + expect(hit?.response).toEqual({ v: 2 }); + }); +}); + +describe('W3b: clearTraversalCache', () => { + test('refuses without source_id or all_sources', async () => { + await expect(clearTraversalCache(engine, {})).rejects.toThrow(/specify source_id/); + }); + + test('source-scoped clear deletes only that source', async () => { + await putCachedTraversal(engine, baseKey({ source_id: 'src-a' }), { v: 1 }, new Date().toISOString(), 0); + await putCachedTraversal(engine, baseKey({ source_id: 'src-b' }), { v: 2 }, new Date().toISOString(), 0); + const deleted = await clearTraversalCache(engine, { sourceId: 'src-a' }); + expect(deleted).toBe(1); + const a = await getCachedTraversal(engine, baseKey({ source_id: 'src-a' })); + const b = await getCachedTraversal(engine, baseKey({ source_id: 'src-b' })); + expect(a).toBeNull(); + expect(b).not.toBeNull(); + }); + + test('all_sources clears everything', async () => { + await putCachedTraversal(engine, baseKey({ source_id: 'src-a' }), { v: 1 }, new Date().toISOString(), 0); + await putCachedTraversal(engine, baseKey({ source_id: 'src-b' }), { v: 2 }, new Date().toISOString(), 0); + const deleted = await clearTraversalCache(engine, { allSources: true }); + expect(deleted).toBe(2); + }); +}); + +describe('W3b: getCachedOrCompute', () => { + test('miss: runs compute and caches result', async () => { + let computeCalls = 0; + const result = await getCachedOrCompute( + engine, + { symbol_qualified: 'foo', depth: 3, source_id: 'default' }, + async () => { + computeCalls += 1; + return { x: 42 }; + }, + ); + expect(result).toEqual({ x: 42 }); + expect(computeCalls).toBe(1); + }); + + test('hit: skips compute on second call', async () => { + let computeCalls = 0; + const compute = async () => { + computeCalls += 1; + return { x: 42 }; + }; + await getCachedOrCompute( + engine, + { symbol_qualified: 'foo', depth: 3, source_id: 'default' }, + compute, + ); + await getCachedOrCompute( + engine, + { symbol_qualified: 'foo', depth: 3, source_id: 'default' }, + compute, + ); + expect(computeCalls).toBe(1); + }); + + test('D3: bumping cluster_generation invalidates the cache', async () => { + let computeCalls = 0; + const compute = async () => { + computeCalls += 1; + return { x: computeCalls }; + }; + await getCachedOrCompute( + engine, + { symbol_qualified: 'foo', depth: 3, source_id: 'default' }, + compute, + ); + expect(computeCalls).toBe(1); + // Bump generation — next call should recompute. + await bumpClusterGeneration(engine); + const second = await getCachedOrCompute( + engine, + { symbol_qualified: 'foo', depth: 3, source_id: 'default' }, + compute, + ); + expect(computeCalls).toBe(2); + expect(second).toEqual({ x: 2 }); + }); +});