GitNexus/gitnexus/test/integration/analyze-wal-checkpoint-failure.test.ts

157 lines
7.1 KiB
TypeScript

/**
* Integration test: WAL auto-checkpoint rename failure (#1741 / #1772).
*
* Drives the real `analyzeCommand` against a real LadybugDB instance and
* provokes a genuine Ladybug-engine `IO exception: Error renaming file
* <db>.wal to <db>.wal.checkpoint` by pre-planting a *directory* at the
* `<db>.wal.checkpoint` rename target. `fs.rename` (which Ladybug's native
* `LocalFileSystem` ultimately invokes) cannot overwrite a non-empty
* directory with a file on either POSIX or Windows, and Ladybug's
* `doInitLbug` orphan-cleanup uses `fs.unlink` which fails on a directory
* — so the blocker survives initialization and the next auto-checkpoint
* fires the natural rename failure that motivated PR #1772.
*
* No test-only hooks, no env-var fault toggles in production code: we use
* the same `GITNEXUS_WAL_CHECKPOINT_THRESHOLD=1` knob that real users have
* available to force checkpointing on every write, then arrange a real
* filesystem state that makes the rename impossible.
*
* Verifies that:
* 1. The CLI exits non-zero.
* 2. stderr contains the actionable recovery hint pointing at
* `--wal-checkpoint-threshold 67108864` (the
* `RECOMMENDED_WAL_CHECKPOINT_THRESHOLD` constant in `analyze.ts`).
* 3. The recovery message references the
* `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` env var as a parallel route.
*
* Empirically confirmed portable on Windows; the same mechanism is
* expected to work on POSIX (`rename(2)` fails with `EISDIR`/`ENOTEMPTY`
* when the target is a non-empty directory). If a future Ladybug release
* changes the rename ordering, the loose match on the recovery hint
* (rather than the exact engine error wording) keeps this test stable.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { CLI_SPAWN_PREFIX } from '../helpers/cli-entry.js';
import { spawnSync } from 'child_process';
import path from 'path';
import fs from 'fs';
import os from 'os';
import { fileURLToPath } from 'url';
import { cleanupTempDirSync } from '../helpers/test-db.js';
const testDir = path.dirname(fileURLToPath(import.meta.url));
const FIXTURE_SRC = path.resolve(testDir, '..', 'fixtures', 'mini-repo');
let tmpParent: string;
let suiteGitnexusHome: string;
let repoPath: string;
beforeAll(() => {
tmpParent = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-wal-checkpoint-e2e-'));
suiteGitnexusHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-wal-checkpoint-home-'));
repoPath = path.join(tmpParent, 'mini-repo');
fs.cpSync(FIXTURE_SRC, repoPath, { recursive: true });
spawnSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' });
spawnSync('git', ['add', '-A'], { cwd: repoPath, stdio: 'pipe' });
spawnSync('git', ['commit', '-m', 'initial commit'], {
cwd: repoPath,
stdio: 'pipe',
env: {
...process.env,
GIT_AUTHOR_NAME: 'test',
GIT_AUTHOR_EMAIL: 'test@test',
GIT_COMMITTER_NAME: 'test',
GIT_COMMITTER_EMAIL: 'test@test',
},
});
});
afterAll(() => {
if (tmpParent) cleanupTempDirSync(tmpParent);
if (suiteGitnexusHome) cleanupTempDirSync(suiteGitnexusHome);
});
const runAnalyze = () =>
spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, 'analyze', '--skip-skills'], {
cwd: repoPath,
encoding: 'utf8',
// Generous timeout: the test does real CSV/COPY work before the
// first failing checkpoint, and CI runners are slow.
timeout: process.env.CI ? 120_000 : 60_000,
stdio: ['pipe', 'pipe', 'pipe'],
env: {
...process.env,
GITNEXUS_HOME: suiteGitnexusHome,
// Skip ensureHeap re-exec (which drops the tsx loader).
NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(),
// Tiny threshold forces auto-checkpoint on every write so the
// first write into the WAL trips the planted rename blocker.
GITNEXUS_WAL_CHECKPOINT_THRESHOLD: '1',
CI: '1',
},
});
describe('analyze WAL auto-checkpoint rename failure (real lbug, no mocks)', () => {
it('surfaces the --wal-checkpoint-threshold recovery hint when the rename target is blocked', () => {
// The checkpoint rename target must be a PREDICTABLE path so the blocker
// can be pre-planted. A full rebuild builds into a per-run
// `lbug.staging.<uuid>` and checkpoints `lbug.staging.<uuid>.wal.checkpoint`
// (#2658) — an unknowable name. An INCREMENTAL run instead writes the live
// index in place, so its auto-checkpoint targets the fixed
// `lbug.wal.checkpoint`. So: first do a clean full analyze to create the
// index, then plant the blocker and drive an incremental analyze into it.
const storageDir = path.join(repoPath, '.gitnexus');
// 1) Clean full analyze (no blocker) — builds the index into staging and
// swaps it in. Must succeed; the staging checkpoint name is unblocked.
const first = runAnalyze();
expect(first.status === null ? 'timeout' : first.status).toBe(0);
// 2) Change a tracked source file and commit, so the next analyze is an
// incremental writeback (in-place), not a full rebuild.
const churnFile = path.join(repoPath, 'src', 'logger.ts');
fs.appendFileSync(churnFile, `\nexport const walChurnMarker = ${Date.now()};\n`);
const gitEnv = {
...process.env,
GIT_AUTHOR_NAME: 'test',
GIT_AUTHOR_EMAIL: 'test@test',
GIT_COMMITTER_NAME: 'test',
GIT_COMMITTER_EMAIL: 'test@test',
};
spawnSync('git', ['add', '-A'], { cwd: repoPath, stdio: 'pipe' });
spawnSync('git', ['commit', '-m', 'churn for incremental'], {
cwd: repoPath,
stdio: 'pipe',
env: gitEnv,
});
// 3) Plant a non-empty directory at `lbug.wal.checkpoint`, the fixed rename
// target of the in-place checkpoint. `fs.rename` cannot overwrite a
// non-empty directory, and the adapter's orphan-sidecar cleanup uses
// `fs.unlink` (which fails on a directory) — so the blocker persists through
// `doInitLbug` and trips the auto-checkpoint the incremental writeback
// forces at `GITNEXUS_WAL_CHECKPOINT_THRESHOLD=1`.
const blockerDir = path.join(storageDir, 'lbug.wal.checkpoint');
fs.rmSync(blockerDir, { recursive: true, force: true });
fs.mkdirSync(blockerDir, { recursive: true });
fs.writeFileSync(path.join(blockerDir, 'blocker'), 'cannot-be-renamed-over');
// 4) Incremental analyze into the blocked checkpoint target.
const result = runAnalyze();
const combined = `${result.stderr}\n${result.stdout}`;
// The CLI must exit non-zero. status === null means the timeout fired
// without a clean exit — also a failure for this assertion.
expect(result.status === null ? 'timeout' : result.status).not.toBe(0);
// Recovery hint must reference the CLI flag and the recommended
// 64 MiB threshold (67_108_864 bytes). Both come from the
// RECOMMENDED_WAL_CHECKPOINT_THRESHOLD constant in analyze.ts; keep
// those values in sync with this assertion if the constant changes.
expect(combined).toContain('gitnexus analyze --wal-checkpoint-threshold');
expect(combined).toContain('67108864');
// The env-var route should be advertised alongside the flag.
expect(combined).toContain('GITNEXUS_WAL_CHECKPOINT_THRESHOLD');
}, 180_000);
});