mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-06 02:49:56 +00:00
* fix(storage): load VECTOR only when needed * test(storage): verify VECTOR reopen lifecycle --------- Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
252 lines
9.2 KiB
TypeScript
252 lines
9.2 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
const { createLbugDatabaseMock, loadFTSExtensionMock, loadVectorExtensionMock } = vi.hoisted(
|
|
() => ({
|
|
createLbugDatabaseMock: vi.fn(),
|
|
loadFTSExtensionMock: vi.fn(),
|
|
loadVectorExtensionMock: vi.fn(),
|
|
}),
|
|
);
|
|
|
|
vi.mock('@ladybugdb/core', () => ({
|
|
default: {
|
|
Database: vi.fn(),
|
|
Connection: vi.fn(function (this: any) {
|
|
this.query = vi.fn(async () => ({ getAll: async () => [], close: vi.fn() }));
|
|
this.prepare = vi.fn(async () => ({
|
|
isSuccess: () => true,
|
|
getErrorMessage: async () => '',
|
|
}));
|
|
this.execute = vi.fn(async () => ({
|
|
getAll: async () => [],
|
|
close: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
this.close = vi.fn().mockResolvedValue(undefined);
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
|
|
isReadOnlyDbError: vi.fn(() => false),
|
|
loadFTSExtension: loadFTSExtensionMock,
|
|
loadVectorExtension: loadVectorExtensionMock,
|
|
}));
|
|
|
|
vi.mock('../../src/core/lbug/lbug-config.js', () => ({
|
|
createLbugDatabase: createLbugDatabaseMock,
|
|
toNativeSafePath: vi.fn((p: string) => p),
|
|
isWalCorruptionError: vi.fn(() => false),
|
|
WAL_RECOVERY_SUGGESTION: '',
|
|
}));
|
|
|
|
const { closeLbug, ensureVectorExtension, executeParameterized, initLbug, initLbugWithDb } =
|
|
await import('../../src/core/lbug/pool-adapter.js');
|
|
|
|
describe('read-pool FTS loading', () => {
|
|
const tempDirs: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
await closeLbug().catch(() => {});
|
|
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
|
createLbugDatabaseMock.mockReset();
|
|
loadFTSExtensionMock.mockReset();
|
|
loadVectorExtensionMock.mockReset();
|
|
loadVectorExtensionMock.mockResolvedValue(false);
|
|
});
|
|
|
|
it('loads FTS with load-only policy and caches a successful load', async () => {
|
|
loadFTSExtensionMock.mockResolvedValue(true);
|
|
const db = {} as any;
|
|
|
|
await initLbugWithDb('repo-a', db, '/tmp/shared-fts-db');
|
|
await initLbugWithDb('repo-b', db, '/tmp/shared-fts-db');
|
|
|
|
expect(loadFTSExtensionMock).toHaveBeenCalledTimes(1);
|
|
expect(loadFTSExtensionMock).toHaveBeenCalledWith(expect.anything(), { policy: 'load-only' });
|
|
});
|
|
|
|
it('does not fake a successful load when FTS is unavailable', async () => {
|
|
loadFTSExtensionMock.mockResolvedValue(false);
|
|
const db = {} as any;
|
|
|
|
await initLbugWithDb('repo-a', db, '/tmp/shared-fts-db');
|
|
await initLbugWithDb('repo-b', db, '/tmp/shared-fts-db');
|
|
|
|
expect(loadFTSExtensionMock).toHaveBeenCalledTimes(2);
|
|
expect(loadFTSExtensionMock).toHaveBeenNthCalledWith(1, expect.anything(), {
|
|
policy: 'load-only',
|
|
});
|
|
expect(loadFTSExtensionMock).toHaveBeenNthCalledWith(2, expect.anything(), {
|
|
policy: 'load-only',
|
|
});
|
|
});
|
|
|
|
it('does not probe VECTOR while initializing exact-read pools (#3021)', async () => {
|
|
loadFTSExtensionMock.mockResolvedValue(true);
|
|
loadVectorExtensionMock.mockResolvedValue(true);
|
|
const db = {} as any;
|
|
|
|
await initLbugWithDb('repo-a', db, '/tmp/shared-vec-db');
|
|
await initLbugWithDb('repo-b', db, '/tmp/shared-vec-db');
|
|
|
|
expect(loadVectorExtensionMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('loads VECTOR lazily once for concurrent semantic reads on a shared Database', async () => {
|
|
loadFTSExtensionMock.mockResolvedValue(true);
|
|
loadVectorExtensionMock.mockResolvedValue(true);
|
|
const db = {} as any;
|
|
|
|
await initLbugWithDb('repo-a', db, '/tmp/shared-vec-db');
|
|
await initLbugWithDb('repo-b', db, '/tmp/shared-vec-db');
|
|
|
|
await expect(
|
|
Promise.all([ensureVectorExtension('repo-a'), ensureVectorExtension('repo-b')]),
|
|
).resolves.toEqual([true, true]);
|
|
|
|
expect(loadVectorExtensionMock).toHaveBeenCalledTimes(1);
|
|
expect(loadVectorExtensionMock).toHaveBeenCalledWith(expect.anything(), {
|
|
policy: 'load-only',
|
|
});
|
|
});
|
|
|
|
it('caches an unavailable VECTOR result until a non-external Database is reopened', async () => {
|
|
loadFTSExtensionMock.mockResolvedValue(true);
|
|
loadVectorExtensionMock.mockResolvedValue(false);
|
|
const dir = await mkdtemp(path.join(tmpdir(), 'gitnexus-vector-reopen-'));
|
|
tempDirs.push(dir);
|
|
const dbPath = path.join(dir, 'index.lbug');
|
|
await writeFile(dbPath, 'fixture');
|
|
const firstDb = { init: vi.fn(), close: vi.fn().mockResolvedValue(undefined) };
|
|
const secondDb = { init: vi.fn(), close: vi.fn().mockResolvedValue(undefined) };
|
|
createLbugDatabaseMock.mockReturnValueOnce(firstDb).mockReturnValueOnce(secondDb);
|
|
|
|
await initLbug('repo-a', dbPath);
|
|
await expect(ensureVectorExtension('repo-a')).resolves.toBe(false);
|
|
await expect(ensureVectorExtension('repo-a')).resolves.toBe(false);
|
|
|
|
expect(loadVectorExtensionMock).toHaveBeenCalledTimes(1);
|
|
|
|
await closeLbug('repo-a');
|
|
await initLbug('repo-b', dbPath);
|
|
await expect(ensureVectorExtension('repo-b')).resolves.toBe(false);
|
|
|
|
expect(createLbugDatabaseMock).toHaveBeenCalledTimes(2);
|
|
expect(firstDb.close).toHaveBeenCalledTimes(1);
|
|
expect(loadVectorExtensionMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('retries VECTOR after a rejected lazy load', async () => {
|
|
loadFTSExtensionMock.mockResolvedValue(true);
|
|
loadVectorExtensionMock.mockRejectedValueOnce(new Error('transient load failure'));
|
|
loadVectorExtensionMock.mockResolvedValueOnce(true);
|
|
const db = {} as any;
|
|
|
|
await initLbugWithDb('repo-a', db, '/tmp/shared-vec-retry-db');
|
|
await expect(ensureVectorExtension('repo-a')).rejects.toThrow('transient load failure');
|
|
await expect(ensureVectorExtension('repo-a')).resolves.toBe(true);
|
|
|
|
expect(loadVectorExtensionMock).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('preflights VECTOR only for executable QUERY_VECTOR_INDEX calls', async () => {
|
|
loadFTSExtensionMock.mockResolvedValue(true);
|
|
loadVectorExtensionMock.mockResolvedValue(false);
|
|
await initLbugWithDb('repo-a', {} as any, '/tmp/vector-call-detection-db');
|
|
|
|
const exactReads = [
|
|
"RETURN 'CALL QUERY_VECTOR_INDEX(' AS sourceText",
|
|
'RETURN "CALL QUERY_VECTOR_INDEX(" AS sourceText',
|
|
'RETURN `CALL QUERY_VECTOR_INDEX(` AS propertyName',
|
|
'RETURN `QUERY_VECTOR_INDEX` AS propertyName',
|
|
"RETURN 'CALL `QUERY_VECTOR_INDEX`(' AS sourceText",
|
|
'// CALL QUERY_VECTOR_INDEX(\nRETURN 1 AS value',
|
|
'// CALL `QUERY_VECTOR_INDEX`(\nRETURN 1 AS value',
|
|
'/* CALL QUERY_VECTOR_INDEX( */ RETURN 1 AS value',
|
|
'/* CALL `QUERY_VECTOR_INDEX`( */ RETURN 1 AS value',
|
|
];
|
|
for (const cypher of exactReads) {
|
|
await expect(executeParameterized('repo-a', cypher, {})).resolves.toEqual([]);
|
|
}
|
|
expect(loadVectorExtensionMock).not.toHaveBeenCalled();
|
|
|
|
await expect(
|
|
executeParameterized(
|
|
'repo-a',
|
|
"call query_vector_index\n('CodeEmbedding', 'embedding_idx', [0.1], 1)",
|
|
{},
|
|
),
|
|
).resolves.toEqual([]);
|
|
expect(loadVectorExtensionMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('preflights VECTOR for a backtick-escaped procedure identifier', async () => {
|
|
loadFTSExtensionMock.mockResolvedValue(true);
|
|
loadVectorExtensionMock.mockResolvedValue(false);
|
|
await initLbugWithDb('repo-a', {} as any, '/tmp/vector-quoted-call-detection-db');
|
|
|
|
await expect(
|
|
executeParameterized(
|
|
'repo-a',
|
|
"CALL /* legal comment */ `QUERY_VECTOR_INDEX`('CodeEmbedding', 'embedding_idx', [0.1], 1)",
|
|
{},
|
|
),
|
|
).resolves.toEqual([]);
|
|
expect(loadVectorExtensionMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not hold query connections while a saturated VECTOR preflight loads', async () => {
|
|
loadFTSExtensionMock.mockResolvedValue(true);
|
|
let releaseVectorLoad: ((loaded: boolean) => void) | undefined;
|
|
loadVectorExtensionMock.mockImplementation(
|
|
() =>
|
|
new Promise<boolean>((resolve) => {
|
|
releaseVectorLoad = resolve;
|
|
}),
|
|
);
|
|
await initLbugWithDb('repo-a', {} as any, '/tmp/vector-saturation-db');
|
|
|
|
const calls = Array.from({ length: 8 }, () =>
|
|
executeParameterized(
|
|
'repo-a',
|
|
"CALL QUERY_VECTOR_INDEX('CodeEmbedding', 'embedding_idx', [0.1], 1)",
|
|
{},
|
|
),
|
|
);
|
|
|
|
try {
|
|
await vi.waitFor(() => expect(loadVectorExtensionMock).toHaveBeenCalledTimes(1), {
|
|
timeout: 500,
|
|
});
|
|
releaseVectorLoad?.(true);
|
|
await expect(Promise.all(calls)).resolves.toHaveLength(8);
|
|
} finally {
|
|
if (releaseVectorLoad) {
|
|
releaseVectorLoad(true);
|
|
} else {
|
|
// Allows the old hold-one/wait-for-one ordering to unwind promptly
|
|
// instead of leaving its pool waiter alive until the 30-second timeout.
|
|
await closeLbug('repo-a');
|
|
}
|
|
await Promise.allSettled(calls);
|
|
}
|
|
});
|
|
|
|
it('lets a direct vector query report its own error when preflight rejects', async () => {
|
|
loadFTSExtensionMock.mockResolvedValue(true);
|
|
loadVectorExtensionMock.mockRejectedValueOnce(new Error('transient load failure'));
|
|
await initLbugWithDb('repo-a', {} as any, '/tmp/vector-preflight-rejection-db');
|
|
|
|
await expect(
|
|
executeParameterized(
|
|
'repo-a',
|
|
"CALL QUERY_VECTOR_INDEX('CodeEmbedding', 'embedding_idx', [0.1], 1)",
|
|
{},
|
|
),
|
|
).resolves.toEqual([]);
|
|
});
|
|
});
|