fix(lbug): reclaim missing-shadow WAL quarantine files on write-path init (#2638)
Some checks are pending
Gitleaks / gitleaks (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run

This commit is contained in:
Gergő Magyar 2026-07-22 21:30:52 +01:00 committed by GitHub
parent 9538be957d
commit cdbdf219dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 173 additions and 1 deletions

View file

@ -46,6 +46,7 @@ import {
type LbugConnectionHandle,
} from './lbug-config.js';
import {
cleanQuarantinedMissingShadowWals,
finalizeLbugSidecarsAfterClose,
guardWalQuarantine,
isMissingShadowSidecarError,
@ -55,6 +56,7 @@ import {
quarantineWalForMissingShadow,
renameFailureMessage,
shadowSidecarRecoveryMessage,
sidecarPreflightDisabled,
} from './sidecar-recovery.js';
import { logger } from '../logger.js';
@ -822,6 +824,30 @@ const doInitLbug = async (dbPath: string, readOnly: boolean = false) => {
// -------------------------------------------------------------------------
const releaseInitLock = await acquireInitLock(dbPath);
try {
// Reclaim missing-shadow WAL quarantines from a PRIOR crash (#2637).
// LadybugDB renames an unrecoverable WAL aside as
// `${dbPath}.wal.missing-shadow.<ts>-<rand>` (quarantineWalForMissingShadow)
// instead of deleting it. Once quarantined it is permanently detached from
// the live store and never reopened, so reclaiming it is safe regardless of
// whether the main DB file exists this run — unlike the orphan-sidecar
// cleanup below, this must NOT be gated on "main DB missing": a quarantine
// event and a healthy main DB are independent facts. Never let a reclaim
// failure (e.g. a transient EBUSY from an antivirus scan) block DB startup.
if (!sidecarPreflightDisabled()) {
try {
const reclaimed = await cleanQuarantinedMissingShadowWals(dbPath);
for (const file of reclaimed) {
logger.warn(
`GitNexus: reclaimed quarantined WAL ${path.basename(file)} from a prior crash`,
);
}
} catch (err) {
logger.warn(
`GitNexus: failed to reclaim missing-shadow WAL quarantines: ${summarizeError(err)}`,
);
}
}
// Crash-recovery cleanup: if the main DB file is missing, stale sidecars
// from an interrupted run can block fresh opens indefinitely.
try {

View file

@ -60,7 +60,7 @@ export const isMissingFsError = (err: unknown): boolean =>
const missing = isMissingFsError;
const sidecarPreflightDisabled = (): boolean =>
export const sidecarPreflightDisabled = (): boolean =>
/^(1|true|yes|on)$/i.test(process.env.GITNEXUS_DISABLE_LBUG_SIDECAR_PREFLIGHT ?? '');
export const statIfExists = async (filePath: string): Promise<{ size: number } | null> => {

View file

@ -328,3 +328,140 @@ describe('init lock — single-process ownership contract', () => {
}
});
});
// ---------------------------------------------------------------------------
// Missing-shadow WAL quarantine reclaim (issue #2637)
// ---------------------------------------------------------------------------
const plantMissingShadowQuarantine = async (dbPath: string): Promise<string> => {
const quarantinePath = `${dbPath}.wal.missing-shadow.${Date.now()}-${Math.random()
.toString(36)
.slice(2)}`;
await fs.writeFile(quarantinePath, 'stale-quarantined-wal-bytes');
return quarantinePath;
};
describe('missing-shadow quarantine reclaim — native integration (issue #2637)', () => {
itLbugReopen(
'reclaims a pre-existing missing-shadow WAL quarantine file on write-path init when the main DB is present',
async () => {
const tmp = await createTempDir('gitnexus-lbug-quarantine-');
const dbPath = path.join(tmp.dbPath, 'lbug');
try {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
// Create a real DB first, then close it.
await adapter.initLbug(dbPath);
await adapter.closeLbug();
// Plant a quarantine file left over from an earlier crash.
const quarantinePath = await plantMissingShadowQuarantine(dbPath);
await expect(fs.access(quarantinePath)).resolves.toBeUndefined();
// Re-init with the main DB present — reclaim must fire unconditionally.
await adapter.initLbug(dbPath);
const rows = await adapter.executeQuery('RETURN 1 AS ok');
expect(rows).toEqual([{ ok: 1 }]);
await expect(fs.access(quarantinePath)).rejects.toThrow();
await adapter.closeLbug();
} finally {
await tmp.cleanup();
}
},
);
itLbugReopen(
'reclaims a pre-existing missing-shadow WAL quarantine file when the main DB is ALSO missing (crash-recovery path)',
async () => {
const tmp = await createTempDir('gitnexus-lbug-quarantine-');
const dbPath = path.join(tmp.dbPath, 'lbug');
const shadowPath = `${dbPath}.shadow`;
const walCheckpointPath = `${dbPath}.wal.checkpoint`;
try {
// No main DB file — plant the quarantine file alongside the #1618
// orphan sidecars to prove both cleanup blocks coexist correctly.
const quarantinePath = await plantMissingShadowQuarantine(dbPath);
await fs.writeFile(shadowPath, 'stale-shadow-data');
await fs.writeFile(walCheckpointPath, 'stale-wal-checkpoint-data');
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
await adapter.initLbug(dbPath);
const rows = await adapter.executeQuery('RETURN 1 AS ok');
expect(rows).toEqual([{ ok: 1 }]);
await expect(fs.access(quarantinePath)).rejects.toThrow();
await expect(fs.access(shadowPath)).rejects.toThrow();
await expect(fs.access(walCheckpointPath)).rejects.toThrow();
await adapter.closeLbug();
} finally {
await tmp.cleanup();
}
},
);
itLbugReopen(
'leaves a missing-shadow quarantine file untouched when GITNEXUS_DISABLE_LBUG_SIDECAR_PREFLIGHT=1',
async () => {
const tmp = await createTempDir('gitnexus-lbug-quarantine-');
const dbPath = path.join(tmp.dbPath, 'lbug');
const previousEnv = process.env.GITNEXUS_DISABLE_LBUG_SIDECAR_PREFLIGHT;
try {
const quarantinePath = await plantMissingShadowQuarantine(dbPath);
process.env.GITNEXUS_DISABLE_LBUG_SIDECAR_PREFLIGHT = '1';
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
await adapter.initLbug(dbPath);
const rows = await adapter.executeQuery('RETURN 1 AS ok');
expect(rows).toEqual([{ ok: 1 }]);
// Reclaim was suppressed — the quarantine file survives.
await expect(fs.access(quarantinePath)).resolves.toBeUndefined();
await adapter.closeLbug();
} finally {
if (previousEnv === undefined) {
delete process.env.GITNEXUS_DISABLE_LBUG_SIDECAR_PREFLIGHT;
} else {
process.env.GITNEXUS_DISABLE_LBUG_SIDECAR_PREFLIGHT = previousEnv;
}
await tmp.cleanup();
}
},
);
itLbugReopen(
'does not touch a .dirty-recovery parked sidecar (isolation from the missing-shadow family)',
async () => {
const tmp = await createTempDir('gitnexus-lbug-quarantine-');
const dbPath = path.join(tmp.dbPath, 'lbug');
const dirtyRecoveryPath = `${dbPath}.wal.dirty-recovery`;
try {
await fs.writeFile(dirtyRecoveryPath, 'parked-from-an-interrupted-dirty-recovery-rebuild');
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
await adapter.initLbug(dbPath);
const rows = await adapter.executeQuery('RETURN 1 AS ok');
expect(rows).toEqual([{ ok: 1 }]);
// Different sidecar family, different lifecycle — must survive.
await expect(fs.access(dirtyRecoveryPath)).resolves.toBeUndefined();
await adapter.closeLbug();
} finally {
await tmp.cleanup();
}
},
);
});

View file

@ -44,6 +44,7 @@ function makeFsMock(dbPath: string) {
rename: vi.fn(async () => {}),
mkdir: vi.fn(async () => {}),
open: makeOpenMock(),
readdir: vi.fn(async () => []),
},
};
}
@ -586,6 +587,7 @@ function makeFsMockWithWalSize(
rename: vi.fn(async () => {}),
mkdir: vi.fn(async () => {}),
open: makeOpenMock(),
readdir: vi.fn(async () => []),
},
};
}

View file

@ -45,6 +45,7 @@ const mockFsForInit = (dbPath: string) => {
unlink: vi.fn(async () => {}),
mkdir: vi.fn(async () => {}),
open: makeOpenMock(),
readdir: vi.fn(async () => []),
},
}));
};
@ -85,6 +86,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => {
unlink: unlinkMock,
mkdir: vi.fn(async () => {}),
open: makeOpenMock(),
readdir: vi.fn(async () => []),
},
}));
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
@ -156,6 +158,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => {
unlink: unlinkMock,
mkdir: vi.fn(async () => {}),
open: makeOpenMock(),
readdir: vi.fn(async () => []),
},
}));
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
@ -220,6 +223,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => {
unlink: unlinkMock,
mkdir: vi.fn(async () => {}),
open: makeOpenMock(),
readdir: vi.fn(async () => []),
},
}));
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
@ -285,6 +289,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => {
unlink: unlinkMock,
mkdir: vi.fn(async () => {}),
open: makeOpenMock(),
readdir: vi.fn(async () => []),
},
}));
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
@ -345,6 +350,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => {
unlink: unlinkMock,
mkdir: vi.fn(async () => {}),
open: makeOpenMock(),
readdir: vi.fn(async () => []),
},
}));
vi.doMock('../../src/core/lbug/lbug-config.js', () => ({
@ -415,6 +421,7 @@ describe('lbug adapter CHECKPOINT lifecycle', () => {
unlink: unlinkMock,
mkdir: vi.fn(async () => {}),
open: makeOpenMock(),
readdir: vi.fn(async () => []),
},
}));
const openLbugConnectionMock = vi.fn(async () => ({ db, conn }));