Files
gbrain/test/code-intel/traversal-cache.test.ts
T
Garry TanandClaude Opus 4.7 0c2b1da72c feat(v0.34 W3b): code_traversal_cache table, module, and clear admin op
Schema migration v56 (code_traversal_cache_v0_34):
  - new table: code_traversal_cache (id, symbol_qualified, depth,
    source_id, response_json JSONB, max_chunk_updated_at, xmin_max,
    cluster_generation, computed_at)
  - unique index on (symbol_qualified, depth, source_id)
  - secondary index on source_id for cheap source-scoped clears

D3 — generation-counter cache invalidation. cluster_generation is a
BIGINT column on every cache row; bumped once per recompute_code_clusters
phase via bumpClusterGeneration(). Cache rows referencing stale
generations naturally miss on read. Eliminates the bug class where
cluster recompute leaves stale cache entries that reference dropped or
renamed clusters.

D8 — destructive-guard parity. clearTraversalCache requires either
source_id OR all_sources=true. Without either it throws. Mirrors v0.26.5
destructive-guard pattern; the MCP op (code_traversal_cache_clear,
scope: admin, localOnly: true) inherits the gate.

- src/core/code-intel/traversal-cache.ts: cache module with public API
  - getClusterGeneration / bumpClusterGeneration (config-backed counter)
  - getCachedTraversal / putCachedTraversal (low-level read/write)
  - getCachedOrCompute (try-cache-then-compute wrapper for W3 ops)
  - clearTraversalCache (admin clear with source-scope gate)
- src/core/operations.ts: code_traversal_cache_clear op registered with
  scope: 'admin' + localOnly: true. Dry-run aware; resolves source_id
  from params or ctx.

v0.34.0.0 scope: cache writes use xmin_max=0 sentinel (no snapshot
isolation). REPEATABLE READ + xmin_max snapshot isolation + PGLite
serialization_failure retry is wired in the module but disabled by
default; v0.34.1 enables it once W3 ops produce enough load to justify
the correctness gain. Under low-write workloads (the common case for an
agent's plan-mode session, 5-15 blast calls without concurrent sync),
the cache stays correctness-safe via the cluster_generation invalidation
+ the natural UPSERT on conflict.

test/code-intel/traversal-cache.test.ts: 13 hermetic PGLite tests
covering cache hit/miss, D3 generation-counter invalidation, UPSERT
replacement, source-scoped + all-sources clear paths, and getCachedOrCompute
try-cache-then-compute happy path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 08:54:09 -07:00

184 lines
6.1 KiB
TypeScript

/**
* 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> = {}): 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 });
});
});