GitNexus/gitnexus/test/unit/incremental-write-integrity.test.ts

267 lines
11 KiB
TypeScript

import { appendFile } from 'node:fs/promises';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { setupMiniRepo } from '../helpers/mini-repo.js';
import { getStoragePaths, loadMeta, readRegistry } from '../../src/storage/repo-manager.js';
import * as adapter from '../../src/core/lbug/lbug-adapter.js';
import * as fts from '../../src/core/search/fts-indexes.js';
import * as analyzerIdentity from '../../src/core/analyzer-identity.js';
import * as checkpoints from '../../src/core/lbug/wal-checkpoint-driver.js';
import { commitAll, initGitRepo } from '../helpers/temp-git-repo.js';
import { runFullAnalysis } from '../../src/core/run-analyze.js';
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
import { reconcileGraphNodeIdentities } from '../../src/core/incremental/write-reconciliation.js';
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
});
const options = { skipAgentsMd: true, skipSkills: true };
const callbacks = { onProgress: () => {} };
async function touchHandler(repoPath: string): Promise<void> {
initGitRepo(repoPath);
await appendFile(path.join(repoPath, 'src/handler.ts'), '\n// integrity probe\n');
commitAll(repoPath, 'touch handler');
}
describe('incremental graph identity before publication', () => {
it('chooses a full write before selective mutation when manual checkpoints are disabled', async () => {
const repo = await setupMiniRepo('gitnexus-test-checkpoint-opt-out-');
const { storagePath } = getStoragePaths(repo.dbPath);
try {
await runFullAnalysis(repo.dbPath, options, callbacks);
await touchHandler(repo.dbPath);
vi.stubEnv('GITNEXUS_WAL_MANUAL_CHECKPOINT', '0');
const deleteNodes = vi.spyOn(adapter, 'deleteNodesForFiles');
const flush = vi.spyOn(adapter, 'tryFlushWAL');
const logs: string[] = [];
const result = await runFullAnalysis(repo.dbPath, options, {
...callbacks,
onLog: (line) => logs.push(line),
});
expect(deleteNodes).not.toHaveBeenCalled();
expect(flush).not.toHaveBeenCalled();
expect(result.incrementalStats).toBeUndefined();
expect(logs).toContain(
'Manual WAL checkpoints are disabled; switching to a full DB write before mutation.',
);
expect((await loadMeta(storagePath))?.incrementalInProgress).toBeUndefined();
} finally {
await adapter.closeLbug();
await repo.cleanup();
}
});
it('retries a transient checkpoint I/O failure at the final publication gate', async () => {
const repo = await setupMiniRepo('gitnexus-test-final-checkpoint-retry-');
const { storagePath } = getStoragePaths(repo.dbPath);
try {
await runFullAnalysis(repo.dbPath, options, callbacks);
await touchHandler(repo.dbPath);
// Isolate the final gate from the periodic driver's independent cadence.
vi.spyOn(checkpoints, 'startWalCheckpointDriver').mockReturnValue({ stop: async () => {} });
const flush = adapter.tryFlushWAL;
const build = fts.buildSearchIndexesOrDegrade;
let attempts = 0;
vi.spyOn(fts, 'buildSearchIndexesOrDegrade').mockImplementation(async (...args) => {
const result = await build(...args);
vi.spyOn(adapter, 'tryFlushWAL').mockImplementation(async () => {
if (++attempts === 1) {
throw new Error(
'Runtime exception: IO exception: Error renaming file db.wal to db.wal.checkpoint',
);
}
return flush();
});
return result;
});
const result = await runFullAnalysis(repo.dbPath, options, callbacks);
expect(attempts).toBe(2);
expect(result.incrementalStats?.writeMode).toBe('incremental');
expect((await loadMeta(storagePath))?.incrementalInProgress).toBeUndefined();
} finally {
await adapter.closeLbug();
await repo.cleanup();
}
});
it('matches native CSV normalization for nullable, absent-range and Unicode identity fields', async () => {
const repo = await setupMiniRepo('gitnexus-test-identity-parity-');
const { storagePath, lbugPath } = getStoragePaths(repo.dbPath);
const graph = createKnowledgeGraph();
graph.addNode({
id: 'File:src/Å.ts',
label: 'File',
properties: { name: 'Å.ts', filePath: 'src/Å.ts' },
});
graph.addNode({
id: 'Route:/王',
label: 'Route',
properties: { name: '/王', filePath: 'src/Å.ts' },
});
graph.addNode({
id: 'Tool:王',
label: 'Tool',
properties: { name: '王', filePath: 'src/Å.ts' },
});
graph.addNode({ id: 'Destination:topic', label: 'Destination', properties: { name: 'topic' } });
graph.addNode({
id: 'Function:src/Å.ts:王\u0000\uD800',
label: 'Function',
properties: { name: '王\r\n\uD800', filePath: 'src/Å.ts' },
});
try {
await adapter.initLbug(lbugPath, { skipFts: true });
await adapter.loadGraphToLbug(
graph,
repo.dbPath,
storagePath,
undefined,
undefined,
undefined,
'none',
);
await adapter.tryFlushWAL();
await expect(
reconcileGraphNodeIdentities(graph, adapter.executeQuery, 'native CSV parity'),
).resolves.toMatchObject({ nodes: 5 });
} finally {
await adapter.closeLbug();
await repo.cleanup();
}
});
it.each([
{ phase: 'after-copy', atomicIncremental: false },
{ phase: 'after-fts', atomicIncremental: false },
{ phase: 'after-fts', atomicIncremental: true },
{ phase: 'after-fts-finalization', atomicIncremental: false },
{ phase: 'checkpoint-false', atomicIncremental: false },
{ phase: 'checkpoint-throw', atomicIncremental: true },
])(
'refuses freshness after $phase failure (atomic=$atomicIncremental)',
async ({ phase, atomicIncremental }) => {
const repo = await setupMiniRepo('gitnexus-test-write-integrity-');
const { storagePath, lbugPath } = getStoragePaths(repo.dbPath);
try {
const healthy = await runFullAnalysis(repo.dbPath, options, callbacks);
const before = await loadMeta(storagePath);
const registeredBefore = (await readRegistry()).find((entry) => entry.path === repo.dbPath);
const logs: string[] = [];
const victim = [...healthy.pipelineResult.graph.iterNodes()].find(
(node) => node.label === 'Function' && node.properties.filePath === 'src/validator.ts',
);
expect(victim?.id).toBeTruthy();
await touchHandler(repo.dbPath);
const damage = async () => {
await adapter.executePrepared('MATCH (n:Function {id: $id}) DETACH DELETE n', {
id: victim.id,
});
};
if (phase === 'after-copy') {
const load = adapter.loadGraphToLbug;
vi.spyOn(adapter, 'loadGraphToLbug').mockImplementation(async (...args) => {
const result = await load(...args);
await damage();
return result;
});
} else {
const build = fts.buildSearchIndexesOrDegrade;
vi.spyOn(fts, 'buildSearchIndexesOrDegrade').mockImplementation(async (...args) => {
const result = await build(...args);
if (phase === 'checkpoint-false') {
vi.spyOn(adapter, 'tryFlushWAL').mockResolvedValue(false);
} else if (phase === 'checkpoint-throw') {
vi.spyOn(adapter, 'tryFlushWAL').mockRejectedValue(
new Error('injected final checkpoint failure'),
);
} else {
await damage();
if (phase === 'after-fts-finalization') {
vi.spyOn(analyzerIdentity, 'finalizeAnalyzerRunnerIdentity').mockImplementation(
() => {
throw new Error('injected analyzer finalization failure');
},
);
}
}
return result;
});
}
await expect(
runFullAnalysis(
repo.dbPath,
{ ...options, atomicIncremental },
{
...callbacks,
onLog: (line) => logs.push(line),
},
),
).rejects.toThrow(
phase.startsWith('checkpoint')
? /checkpoint/i
: phase === 'after-fts-finalization'
? /injected analyzer finalization failure/
: /graph.*reconciliation/i,
);
const after = await loadMeta(storagePath);
expect(after?.lastCommit).toBe(before?.lastCommit);
expect(after?.indexedAt).toBe(before?.indexedAt);
expect(logs.filter((line) => line.includes('atomic-incremental'))).toEqual(
atomicIncremental ? [expect.stringContaining('staged')] : [],
);
expect(Boolean(after?.incrementalInProgress)).toBe(!atomicIncremental);
const registeredAfter = (await readRegistry()).find((entry) => entry.path === repo.dbPath);
expect(registeredAfter?.indexedAt).toBe(registeredBefore?.indexedAt);
expect(registeredAfter?.lastCommit).toBe(registeredBefore?.lastCommit);
vi.restoreAllMocks();
if (atomicIncremental) {
await adapter.initLbug(lbugPath);
const retained = await adapter.executePrepared(
'MATCH (n:Function {id: $id}) RETURN n.id AS id',
{
id: victim.id,
},
);
expect(retained).toEqual([{ id: victim.id }]);
await adapter.closeLbug();
} else {
expect(after?.incrementalInProgress).toMatchObject({
phase: 'graph-reconciliation',
checkpointSucceeded: false,
});
await expect(
runFullAnalysis(repo.dbPath, { ...options, repairFts: true }, callbacks),
).rejects.toThrow(/mid-incremental-recovery/);
expect((await loadMeta(storagePath))?.incrementalInProgress?.phase).toBe(
'graph-reconciliation',
);
// The dirty marker drives recovery without a manual --force, and a
// no-op rerun after recovery must stop rebuilding.
const recovered = await runFullAnalysis(repo.dbPath, options, callbacks);
expect(recovered.incrementalStats).toBeUndefined();
expect((await loadMeta(storagePath))?.incrementalInProgress).toBeUndefined();
await adapter.initLbug(lbugPath, { readOnly: true });
await expect(
reconcileGraphNodeIdentities(
recovered.pipelineResult.graph,
adapter.executeQuery,
'recovered/reopened',
),
).resolves.toMatchObject({ nodes: expect.any(Number) });
await adapter.closeLbug();
expect((await runFullAnalysis(repo.dbPath, options, callbacks)).alreadyUpToDate).toBe(
true,
);
}
} finally {
await adapter.closeLbug();
await repo.cleanup();
}
},
120_000,
);
});