GitNexus/gitnexus/test/unit/incremental-dirty-recovery.test.ts
Gergő Magyar a2e1710ac0
fix(embeddings): stop Caching embeddings OOM on large incremental analyze (#3310)
* fix(embeddings): spill cached vectors to a Float32 temp file

Keep restore metadata in RAM and write embeddings once the in-memory
row limit is exceeded so incremental analyze can survive large caches
without a full-table number[] heap (#3306).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(lbug): stream CodeEmbedding cache under the connection lock

Spill vectors once the in-memory limit is crossed and fail the load
instead of adopting an empty snapshot, so incremental analyze cannot
OOM or quietly drop the restore cache (#3306).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(analyze): restore cached embeddings from a streamed spill snapshot

Hold row metadata across wipe, materialize 200-row batches, and treat
cache-load failures as warn-and-continue so incremental analyze can
preserve vectors without a full-table heap (#3306).

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3310)

Loop spill writes until the full vector lands, keep materialize failures out of the insert catch and the Phase 4 hash skip-set, and assert spilled restore subsets by node id instead of scan order.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3310)

Discard only this analyze run's embedding spills so a concurrent analyze on another index keeps its restore file, and isolate the default in-memory limit test from inherited env.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3310)

Mark a node stale when any restore batch fails so leftover chunks are deleted and rembedded, and exercise a full-length bad-magic spill header.

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 16:18:29 +01:00

143 lines
6.4 KiB
TypeScript

/**
* #2409 defect 2 — dirty-flag recovery must park the crashed run's
* WAL/shadow sidecars BEFORE any DB open.
*
* The recovery rebuild used to open the crashed DB (embedding-cache
* preservation) before the rebuild wipe — replaying whatever WAL the
* crashed writeback left behind. A poisoned WAL kills that open natively,
* so recovery never ran and only a manual rename-aside of the index dir
* escaped the loop.
*
* Split out of incremental-orchestration.test.ts so the cross-platform CI
* matrix (scripts/cross-platform-tests.ts) can run it on windows-latest
* without paying for the whole orchestration suite: the behaviors under
* test — sidecar renames next to a live native DB, rename-onto-existing
* (rm-first) parking, and the wipe of the sidecar family — are exactly the
* ones with Windows-specific filesystem semantics (file-lock lag, rename
* over existing targets), and the reporting environment for #2409 is
* Windows.
*/
import { writeFile, readFile } from 'fs/promises';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
getStoragePaths,
saveMeta,
loadMeta,
type RepoMeta,
} from '../../src/storage/repo-manager.js';
import { setupMiniRepo as setupSharedMiniRepo } from '../helpers/mini-repo.js';
// Shared embedding-seed helper (this shipping review, FIX 8) — the KTD9
// zero-vector seeding pattern previously lived here as a divergent copy of
// incremental-orchestration.test.ts's (a helper module has no
// describe-registration problem, unlike importing a sibling test file).
import { seedEmbeddingsForFiles } from '../helpers/embedding-seed.js';
const setupMiniRepo = () => setupSharedMiniRepo('gitnexus-incr-dirty-rec-');
async function parkCrashSidecarsAndRebuild(options?: { alwaysSpill?: boolean }) {
vi.stubEnv('GITNEXUS_WORKER_READY_TIMEOUT_MS', '60000');
if (options?.alwaysSpill) {
vi.stubEnv('GITNEXUS_EMBEDDING_CACHE_IN_MEMORY_LIMIT', '0');
}
const repo = await setupMiniRepo();
try {
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
// Seed real embeddings BEFORE the tamper (tri-review 4669518496 / U5):
// with meta.stats.embeddings = 0 the recovery run derived
// shouldLoadCache=false and never opened the DB pre-wipe — this test
// was vacuous about the exact open the parking protects. Seeded rows +
// a stats stamp route the recovery (which runs force:true internally,
// so forceRegenerate → shouldLoadCache) through the REAL
// embedding-cache preservation open on the just-parked DB.
const { storagePath, lbugPath } = getStoragePaths(repo.dbPath);
const seededIdsByFile = await seedEmbeddingsForFiles(
repo.dbPath,
['src/handler.ts', 'src/logger.ts'],
1,
);
const seededNodeIds = [...seededIdsByFile.values()].flat();
expect(seededNodeIds.length).toBeGreaterThan(0);
// Simulate a crashed incremental writeback: dirty flag in meta plus
// leftover sidecars whose bytes must never be replayed. 8KB puts the
// WAL above the tiny-orphan threshold — the state the sidecar
// preflight deliberately leaves in place for engine replay.
const meta = await loadMeta(storagePath);
const tampered: RepoMeta = {
...meta!,
stats: { ...meta!.stats, embeddings: seededNodeIds.length },
incrementalInProgress: {
startedAt: Date.now() - 60_000,
toWriteCount: 12,
phase: 'load-graph',
},
};
await saveMeta(storagePath, tampered);
const walGarbage = Buffer.alloc(8192, 0xab);
const shadowGarbage = Buffer.alloc(4096, 0xcd);
await writeFile(`${lbugPath}.wal`, walGarbage);
await writeFile(`${lbugPath}.shadow`, shadowGarbage);
const logs: string[] = [];
// embeddingsNodeLimit: 1 (KTD9): the recovery runs force:true
// internally, and the seeded stats would otherwise route Phase 4 into
// a real embedder in CI — the 1-node cap suppresses generation while
// leaving the preserve/restore path fully live. On linux the
// wipe-and-restore vector-index seam then fires for real (statically
// linked VECTOR): a CREATE_VECTOR_INDEX over the restored rows is
// expected and harmless here.
const recovered = await runFullAnalysis(
repo.dbPath,
{ skipAgentsMd: true, embeddingsNodeLimit: 1 },
{ onProgress: () => {}, onLog: (m) => logs.push(m) },
);
expect(recovered.alreadyUpToDate).toBeUndefined();
// Both sidecars were parked verbatim (renamed, never deleted) before
// any open could replay them…
expect(Buffer.compare(await readFile(`${lbugPath}.wal.dirty-recovery`), walGarbage)).toBe(0);
expect(Buffer.compare(await readFile(`${lbugPath}.shadow.dirty-recovery`), shadowGarbage)).toBe(
0,
);
const joinedLogs = logs.join('\n');
expect(joinedLogs).toContain('Parked lbug.wal.dirty-recovery, lbug.shadow.dirty-recovery');
// …the run traversed the REAL pre-wipe preservation open — recovery's
// internal force on an embedded repo upgrades to regenerate mode, whose
// banner only prints when existingEmbeddingCount was read from the
// seeded stats and the cache-load path engaged…
expect(joinedLogs).toContain(
`--force on a repo with ${seededNodeIds.length} existing embeddings`,
);
// …with generation itself cap-suppressed (no embedder in CI):
expect(joinedLogs).toContain('exceeds the 1-node safety cap');
// …and the rebuild completed into a clean index: dirty flag cleared,
// and the seeded embeddings survived the park → open → wipe → restore
// round-trip (the strongest signal the preservation open really ran:
// the DB was wiped, so these rows can only come from the cache load).
const after = await loadMeta(storagePath);
expect(after!.incrementalInProgress).toBeUndefined();
expect(after!.stats?.embeddings).toBe(seededNodeIds.length);
} finally {
vi.unstubAllEnvs();
await repo.cleanup();
}
}
describe('runFullAnalysis — dirty-flag recovery sidecar parking (#2409)', () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it('parks the crashed run WAL/shadow sidecars before reopening, then rebuilds clean', async () => {
await parkCrashSidecarsAndRebuild();
}, 300_000);
it('restores seeded embeddings from a spill after wipe when the in-memory limit is 0', async () => {
await parkCrashSidecarsAndRebuild({ alwaysSpill: true });
}, 300_000);
});