GitNexus/gitnexus/test/unit/lbug-config-wal.test.ts
Copilot 87b91c821e
fix(lbug): add WAL checkpoint-threshold control (#1772)
* Initial plan

* fix(analyze): add WAL auto-checkpoint CLI control and default-off behavior

* test(analyze): share lbug auto-checkpoint parsing and align validation

* fix(analyze): always enable lbug auto-checkpoint and expose threshold control

* refactor(lbug): inline always-on auto-checkpoint constructor arg

* fix(analyze): guide checkpoint-threshold on Ladybug WAL checkpoint IO failures

* test(analyze): cover checkpoint IO guidance and add integration guard

* fix(analyze): tighten checkpoint IO detection and remove test hook

* fix(analyze): remove checkpoint test hook and tighten error matching

* fix(analyze): rename to wal-checkpoint-threshold, raise default, add manual checkpoint driver with retry

Address review feedback on PR #1772:

- Rename CLI flag, env var, AnalyzeOptions field, recovery-hint tag, and
  parser/constants from lbug-* to engine-neutral wal-* (matches the existing
  WAL_RECOVERY_SUGGESTION / isWalCorruptionError convention).
- Raise default threshold from -1 (Ladybug stock ~16 MiB) to 64 MiB so users
  on the default config no longer hit the original rename/remove race.
- Align both READMEs to publish 67108864 (64 MiB) instead of 65536 (which
  would have made the crash more frequent).
- Add wal-checkpoint-driver.ts: a periodic manual CHECKPOINT driver wrapped
  in a 3-attempt jittered retry (50/200/500 ms), driven from runFullAnalysis.
  Opt-out via GITNEXUS_WAL_MANUAL_CHECKPOINT=0. Moves the race window into a
  JS-controllable retry surface while keeping native auto-checkpoint on.
- Move LBUG_CHECKPOINT_RENAME_RE / REMOVE_RE plus the predicate (renamed to
  isLbugCheckpointIoError) into lbug-config.ts alongside isWalCorruptionError.
  Predicate is now exported. Add a permissive fallback matcher and pin the
  matched Ladybug version in comments.
- Warn instead of silently defaulting when GITNEXUS_WAL_CHECKPOINT_THRESHOLD
  is set to a non-empty unparseable value (closes the CLI-vs-env asymmetry).
- Add a typed RecoveryHint string-literal union in cli-message.ts so future
  hint tags can't drift.
- Add a real integration test under test/integration/ that triggers a
  Ladybug checkpoint IO failure via a pre-existing directory at the rename
  target (portable across platforms; no test-only injection hook).
- Add small-disk / CI caveat (32 MiB secondary suggestion) to the recovery
  hint and README env-var rows.
- Document CLI/env precedence in the analyze --help block.
- Help placeholder: <value> -> <bytes>.
- Rename analyze-lbug-auto-checkpoint.test.ts to use the new wal-* token.

* chore(lbug): remove dead jitteredDelay helper and apply prettier

- Drop unused `jitteredDelay` function flagged by CodeQL in PR #1772; the
  retry loop already inlines the same calculation with the injectable
  `randomImpl` so the helper was dead. Move the non-cryptographic-by-design
  comment next to the actual jitter site.
- Apply `prettier --write` to wal-checkpoint-driver.ts and the new
  integration test to absorb the PR autofix bot's formatting findings.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Test <test@example.com>
2026-05-22 14:46:49 +01:00

195 lines
6 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import {
createLbugDatabase,
isLbugCheckpointIoError,
isWalCorruptionError,
} from '../../src/core/lbug/lbug-config.js';
import { _captureLogger } from '../../src/core/logger.js';
const DEFAULT_THRESHOLD = 64 * 1024 * 1024;
describe('isWalCorruptionError', () => {
it.each([
[
'Corrupted wal file',
'Runtime exception: Corrupted wal file. Read out invalid WAL record type.',
],
['invalid WAL record', 'Error: invalid WAL record type'],
['WAL checksum', 'Checksum verification failed, the WAL file is corrupted.'],
['WAL + corrupt', 'the WAL file is corrupted'],
])('matches WAL corruption: %s', (_label, msg) => {
expect(isWalCorruptionError(msg)).toBe(true);
expect(isWalCorruptionError(new Error(msg))).toBe(true);
});
it.each([
['lock error', 'Could not set lock on file : /path/to/db'],
['generic', 'Query failed'],
['not found', 'LadybugDB not found at /path'],
['checksum without WAL', 'Checksum verification failed for parquet file'],
['permission path with WAL', "EACCES: permission denied '/path/to/wal'"],
['schema mismatch WAL', 'schema version mismatch in WAL'],
])('does not match non-WAL error: %s', (_label, msg) => {
expect(isWalCorruptionError(msg)).toBe(false);
});
it('handles non-string input', () => {
expect(isWalCorruptionError(undefined)).toBe(false);
expect(isWalCorruptionError(null)).toBe(false);
expect(isWalCorruptionError(42)).toBe(false);
expect(isWalCorruptionError(new Error('ok'))).toBe(false);
});
});
describe('createLbugDatabase WAL replay option', () => {
it('enables auto-checkpoint by default and uses default threshold (64 MiB)', () => {
const Database = vi.fn(function (this: any) {});
const lbugModule = { Database } as any;
createLbugDatabase(lbugModule, '/tmp/lbug-default');
expect(Database).toHaveBeenCalledWith(
'/tmp/lbug-default',
0,
false,
false,
expect.any(Number),
true,
DEFAULT_THRESHOLD,
true,
true,
);
});
it.each([
['0', 0],
['1024', 1024],
['-1', -1],
['invalid', DEFAULT_THRESHOLD],
['', DEFAULT_THRESHOLD],
])('respects GITNEXUS_WAL_CHECKPOINT_THRESHOLD=%s', (raw, expectedCheckpointThreshold) => {
try {
vi.stubEnv('GITNEXUS_WAL_CHECKPOINT_THRESHOLD', raw);
const Database = vi.fn(function (this: any) {});
const lbugModule = { Database } as any;
createLbugDatabase(lbugModule, '/tmp/lbug-env');
expect(Database).toHaveBeenCalledWith(
'/tmp/lbug-env',
0,
false,
false,
expect.any(Number),
true,
expectedCheckpointThreshold,
true,
true,
);
} finally {
vi.unstubAllEnvs();
}
});
it('warns and falls back to default when GITNEXUS_WAL_CHECKPOINT_THRESHOLD is invalid', () => {
const cap = _captureLogger();
try {
vi.stubEnv('GITNEXUS_WAL_CHECKPOINT_THRESHOLD', 'invalid');
const Database = vi.fn(function (this: any) {});
const lbugModule = { Database } as any;
createLbugDatabase(lbugModule, '/tmp/lbug-invalid');
const warn = cap
.records()
.find(
(r) =>
typeof r.msg === 'string' &&
r.msg.includes('Ignoring invalid GITNEXUS_WAL_CHECKPOINT_THRESHOLD'),
);
expect(warn).toBeDefined();
} finally {
vi.unstubAllEnvs();
cap.restore();
}
});
it('does NOT warn when GITNEXUS_WAL_CHECKPOINT_THRESHOLD is empty (treated as unset)', () => {
const cap = _captureLogger();
try {
vi.stubEnv('GITNEXUS_WAL_CHECKPOINT_THRESHOLD', '');
const Database = vi.fn(function (this: any) {});
const lbugModule = { Database } as any;
createLbugDatabase(lbugModule, '/tmp/lbug-empty');
const warn = cap
.records()
.find(
(r) =>
typeof r.msg === 'string' &&
r.msg.includes('Ignoring invalid GITNEXUS_WAL_CHECKPOINT_THRESHOLD'),
);
expect(warn).toBeUndefined();
} finally {
vi.unstubAllEnvs();
cap.restore();
}
});
it('passes throwOnWalReplayFailure and checksum constructor args explicitly', () => {
const Database = vi.fn(function (this: any) {});
const lbugModule = { Database } as any;
createLbugDatabase(lbugModule, '/tmp/lbug', {
readOnly: true,
throwOnWalReplayFailure: false,
});
expect(Database).toHaveBeenCalledWith(
'/tmp/lbug',
0,
false,
true,
expect.any(Number),
true,
DEFAULT_THRESHOLD,
false,
true,
);
});
});
// ─── Finding 8: strict + permissive checkpoint IO matchers ─────────────────
describe('isLbugCheckpointIoError', () => {
it.each([
[
'native rename failure (v0.16.x exact)',
'Runtime exception: IO exception: Error renaming file /repo/.gitnexus/lbug.wal to /repo/.gitnexus/lbug.wal.checkpoint. ErrorMessage: Permission denied',
],
[
'native remove failure (v0.16.x exact)',
'Runtime exception: IO exception: Error removing directory or file /repo/.gitnexus/lbug.wal.checkpoint. Error Message: Permission denied',
],
])('matches strict %s', (_label, msg) => {
expect(isLbugCheckpointIoError(msg)).toBe(true);
expect(isLbugCheckpointIoError(new Error(msg))).toBe(true);
});
it('matches permissive fallback for hypothetical message drift', () => {
// Permissive matcher accepts any IO-exception-shaped message mentioning .wal.checkpoint.
const drift =
'Some new wrapper preamble :: IO exception when finalizing /repo/.gitnexus/lbug.wal.checkpoint';
expect(isLbugCheckpointIoError(drift)).toBe(true);
});
it('does NOT match unrelated IO errors', () => {
expect(
isLbugCheckpointIoError(
'Runtime exception: IO exception: Error renaming file /repo/data.tmp to /repo/data.tmp.bak',
),
).toBe(false);
expect(isLbugCheckpointIoError('Some other error')).toBe(false);
expect(isLbugCheckpointIoError(undefined)).toBe(false);
});
});