Files
gbrain/test/fts-language-cache-isolation.serial.test.ts
paul-0320 b4a9c7683d fix(search): fold the FTS configuration name into knobs_hash — stop stale rows surviving a reindex-search-vector language switch (#3677)
Adversarial review: survived a hostile reviewer plus two independent refuters, each told to assume the PR was broken and to default to refuting when uncertain.

`GBRAIN_FTS_LANGUAGE` was absent from the query-cache key, so a language switch served stale pre-switch rows. The hash now folds it (v14→15) with all five pin sites updated; reverting the fix fails 4 of 15 tests at the exact claimed step. Landing first in the knobs_hash cluster — the constant is single-writer, so #3617 rebases onto this and takes 16.

Verified before merge: the PR's own tests fail when the production change is reverted (11 of the previous 32 PRs failed exactly there — one had 7 of 8 new tests passing on master); typecheck clean; MERGEABLE/CLEAN with 22/22 checks green on the current base, not a stale one.
2026-08-01 03:11:42 +08:00

174 lines
7.5 KiB
TypeScript

/**
* GBRAIN_FTS_LANGUAGE must isolate query_cache rows.
*
* getFtsLanguage() reaches both sides of the lexical arms — the
* `update_page_search_vector` / `update_chunk_search_vector` triggers that
* build the tsvector, and the `websearch_to_tsquery(<lang>, …)` inside
* searchKeyword / searchTitles / searchKeywordChunks on BOTH engines — but it
* only applied at DB-query build time, i.e. on a cache MISS. So the
* documented language-switch procedure
* (`GBRAIN_FTS_LANGUAGE=… gbrain reindex-search-vector --yes`) left every
* pre-switch cache row reachable: the freshly retokenized index was silently
* bypassed for up to cache.ttl_seconds.
*
* This drives the real production path (`hybridSearchCached` over a PGLite
* brain) rather than the hash in isolation: a run under `english` populates a
* row from an english-stemmed index, then the same query under a different
* configuration must NOT be served that row. The fixture makes the difference
* observable — "builders" stems to "builder" under english and matches the
* seeded pages; under `simple` it stays "builders" and matches nothing, so a
* stale hit is a visibly wrong answer, not just a wrong key.
*
* Serial: mock.module + process.env mutation (isolation guards R1 + R2).
*/
import { afterAll, beforeAll, describe, expect, mock, test } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import * as realEmbedding from '../src/core/embedding.ts';
/** Deterministic 1536d unit vector — identical for every call, so a cache
* consult matches a prior write at cosine 1.0 whenever the knobs hash
* agrees. That isolates the assertion to the key, not the similarity gate. */
function fixedEmbedding(): Float32Array {
const arr = new Float32Array(1536);
for (let i = 0; i < 1536; i++) arr[i] = Math.sin(1 + i * 0.001);
let norm = 0;
for (let i = 0; i < 1536; i++) norm += arr[i] * arr[i];
norm = Math.sqrt(norm);
if (norm > 0) for (let i = 0; i < 1536; i++) arr[i] /= norm;
return arr;
}
// Mock BEFORE importing hybrid.ts (spread keeps every other export live).
mock.module('../src/core/embedding.ts', () => ({
...realEmbedding,
embed: async () => fixedEmbedding(),
embedQuery: async () => fixedEmbedding(),
}));
// Import AFTER mocking.
const { hybridSearchCached, awaitPendingSearchCacheWrites } =
await import('../src/core/search/hybrid.ts');
const { configureGateway, resetGateway } = await import('../src/core/ai/gateway.ts');
const { PGLiteEngine } = await import('../src/core/pglite-engine.ts');
const { resetFtsLanguageCache } = await import('../src/core/fts-language.ts');
type Meta = import('../src/core/types.ts').HybridSearchMeta;
let engine: InstanceType<typeof PGLiteEngine>;
let tmpHome: string;
const savedGbrainHome = process.env.GBRAIN_HOME;
const savedFtsLanguage = process.env.GBRAIN_FTS_LANGUAGE;
/** Pin the process FTS language (undefined = unset → the 'english' default).
* getFtsLanguage() memoizes, so the cache is reset on every change. */
function setFtsLanguage(language: string | undefined): void {
if (language === undefined) delete process.env.GBRAIN_FTS_LANGUAGE;
else process.env.GBRAIN_FTS_LANGUAGE = language;
resetFtsLanguageCache();
}
/** One cached search; returns the results plus the published cache status. */
async function search(query: string): Promise<{
results: Awaited<ReturnType<typeof hybridSearchCached>>;
status: NonNullable<Meta['cache']>['status'] | undefined;
}> {
let meta: Meta | undefined;
const results = await hybridSearchCached(engine, query, {
limit: 10,
onMeta: (m) => { meta = m; },
});
await awaitPendingSearchCacheWrites();
return { results, status: meta?.cache?.status };
}
beforeAll(async () => {
// Hermetic config home so a developer's real ~/.gbrain/config.json can't
// leak an embedding_model that flips the consult to 'disabled' via
// isCacheSafe (same rationale as hybrid-cached-hit-budget-meta).
tmpHome = mkdtempSync(join(tmpdir(), 'gbrain-fts-cache-key-'));
process.env.GBRAIN_HOME = tmpHome;
// The brain is built and indexed under the default language, exactly like
// an install that has not run a language switch yet.
setFtsLanguage(undefined);
resetGateway();
configureGateway({
embedding_model: 'openai:text-embedding-3-large',
embedding_dimensions: 1536,
env: { OPENAI_API_KEY: 'sk-fake' },
});
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
// Keyword-findable pages. putPage never chunks and searchKeyword joins
// content_chunks, so the chunks are explicit. The chunk trigger stamps
// search_vector with the language in force at initSchema time (english).
const fixtures: Array<[string, string, string]> = [
['alice-foo', 'Alice Foo', 'person'],
['bob-bar', 'Bob Bar', 'company'],
['carol-baz', 'Carol Baz', 'note'],
];
for (const [slug, title, type] of fixtures) {
const truth = `${title} is a builder. ${'x'.repeat(400)}`;
await engine.putPage(slug, { type, title, compiled_truth: truth });
await engine.upsertChunks(slug, [
{ chunk_index: 0, chunk_text: truth, chunk_source: 'compiled_truth' },
]);
}
});
afterAll(async () => {
if (savedGbrainHome === undefined) delete process.env.GBRAIN_HOME;
else process.env.GBRAIN_HOME = savedGbrainHome;
if (savedFtsLanguage === undefined) delete process.env.GBRAIN_FTS_LANGUAGE;
else process.env.GBRAIN_FTS_LANGUAGE = savedFtsLanguage;
resetFtsLanguageCache();
try { await engine.disconnect(); } catch { /* ignore */ }
resetGateway();
try { rmSync(tmpHome, { recursive: true, force: true }); } catch { /* ignore */ }
});
describe('query_cache isolation across an FTS language switch', () => {
test('an english-tokenized row is not served to a differently configured process', async () => {
// 1. Default (english) install: 'builders' stems to 'builder' and finds
// the seeded pages. The row lands in query_cache.
setFtsLanguage(undefined);
const englishRun = await search('builders');
expect(englishRun.status).toBe('miss');
expect(englishRun.results.length).toBeGreaterThan(0);
// Same process, same language → the row is reachable (the cache still
// works; this pins that the fix isolates rather than disables).
const englishRepeat = await search('builders');
expect(englishRepeat.status).toBe('hit');
expect(englishRepeat.results.length).toBe(englishRun.results.length);
// 2. Operator switches the language. Only the query side moves here (the
// seeded search_vector stays english-stemmed), which is what makes the
// divergence observable in-process: 'builders' no longer reaches
// 'builder', so this process cannot reproduce the english answer from
// the index it is now querying. A real switch also retokenizes the
// write side via `reindex-search-vector`; either way the cached rows
// describe a corpus view the new configuration does not have.
setFtsLanguage('simple');
const switched = await search('builders');
// Pre-fix this was a HIT serving englishRun.results (3 pages).
expect(switched.status).toBe('miss');
expect(switched.results.length).toBeLessThan(englishRun.results.length);
// 3. Switching back reaches the original row, not a rebuilt one — the
// two languages occupy distinct rows rather than overwriting.
setFtsLanguage(undefined);
const back = await search('builders');
expect(back.status).toBe('hit');
expect(back.results.map((r) => r.slug)).toEqual(englishRun.results.map((r) => r.slug));
});
});