mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
157 lines
7.1 KiB
TypeScript
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);
|
|
});
|