From cdbdf219dce797e51cdeb8cfa386e77ab2d35628 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Wed, 22 Jul 2026 21:30:52 +0100 Subject: [PATCH] fix(lbug): reclaim missing-shadow WAL quarantine files on write-path init (#2638) --- gitnexus/src/core/lbug/lbug-adapter.ts | 26 ++++ gitnexus/src/core/lbug/sidecar-recovery.ts | 2 +- .../lbug-orphan-sidecar-recovery.test.ts | 137 ++++++++++++++++++ .../test/unit/lbug-adapter-wal-schema.test.ts | 2 + .../unit/lbug-checkpoint-lifecycle.test.ts | 7 + 5 files changed, 173 insertions(+), 1 deletion(-) diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index 7a153a7de..75e8dccbe 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -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.-` (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 { diff --git a/gitnexus/src/core/lbug/sidecar-recovery.ts b/gitnexus/src/core/lbug/sidecar-recovery.ts index c00c1aa7d..882041747 100644 --- a/gitnexus/src/core/lbug/sidecar-recovery.ts +++ b/gitnexus/src/core/lbug/sidecar-recovery.ts @@ -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> => { diff --git a/gitnexus/test/integration/lbug-orphan-sidecar-recovery.test.ts b/gitnexus/test/integration/lbug-orphan-sidecar-recovery.test.ts index e74cd5cf7..97e71cfb2 100644 --- a/gitnexus/test/integration/lbug-orphan-sidecar-recovery.test.ts +++ b/gitnexus/test/integration/lbug-orphan-sidecar-recovery.test.ts @@ -328,3 +328,140 @@ describe('init lock — single-process ownership contract', () => { } }); }); + +// --------------------------------------------------------------------------- +// Missing-shadow WAL quarantine reclaim (issue #2637) +// --------------------------------------------------------------------------- + +const plantMissingShadowQuarantine = async (dbPath: string): Promise => { + 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(); + } + }, + ); +}); diff --git a/gitnexus/test/unit/lbug-adapter-wal-schema.test.ts b/gitnexus/test/unit/lbug-adapter-wal-schema.test.ts index ebd956228..3243c1cfd 100644 --- a/gitnexus/test/unit/lbug-adapter-wal-schema.test.ts +++ b/gitnexus/test/unit/lbug-adapter-wal-schema.test.ts @@ -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 () => []), }, }; } diff --git a/gitnexus/test/unit/lbug-checkpoint-lifecycle.test.ts b/gitnexus/test/unit/lbug-checkpoint-lifecycle.test.ts index 286c30a99..e06e7445c 100644 --- a/gitnexus/test/unit/lbug-checkpoint-lifecycle.test.ts +++ b/gitnexus/test/unit/lbug-checkpoint-lifecycle.test.ts @@ -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 }));