gitnexus 1.6.13-rc.26 → 1.6.13-rc.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -31,7 +31,7 @@ import { extensionManager, getFtsCapability, resolveAnalyzeInstallPolicy, } from
|
|
|
31
31
|
import { diagnoseExtensionLoad, extractExtensionPath } from './extension-load-error.js';
|
|
32
32
|
import { resolveFtsVersionPair } from './vendored-extension-path.js';
|
|
33
33
|
import { classifyDeleteAllError, closeLbugConnection, HANDLE_RELEASE_PROBE_ATTEMPTS, HANDLE_RELEASE_PROBE_DELAY_MS, isDbBusyError, isOpenRetryExhausted, isStorageVersionMismatchError, isWalCorruptionError, throwIfStorageVersionMismatch, bufferPoolExhaustionRemedy, openLbugConnection, sleep, toNativeSafePath, resolveNativeSafeStorageDir, WAL_RECOVERY_SUGGESTION, waitForWindowsHandleRelease, } from './lbug-config.js';
|
|
34
|
-
import { assertReadOnlyFtsCrashSafe, cleanQuarantinedMissingShadowWals, finalizeLbugSidecarsAfterClose, guardWalQuarantine, isMissingShadowSidecarError, isReadOnlyShadowReplayError, lbugLockRemediation, preflightLbugSidecars, quarantineWalForMissingShadow, renameFailureMessage, shadowSidecarRecoveryMessage, sidecarPreflightDisabled, } from './sidecar-recovery.js';
|
|
34
|
+
import { assertReadOnlyFtsCrashSafe, cleanQuarantinedMissingShadowWals, finalizeLbugSidecarsAfterClose, guardWalQuarantine, isMissingShadowSidecarError, isReadOnlyCheckpointInProgressError, isReadOnlyRecoveryFailure, isReadOnlyShadowReplayError, lbugLockRemediation, preflightLbugSidecars, quarantineWalForMissingShadow, readOnlyRecoveryFailureMessage, renameFailureMessage, shadowSidecarRecoveryMessage, sidecarPreflightDisabled, } from './sidecar-recovery.js';
|
|
35
35
|
import { isProcessAlive } from '../../utils/process-identity.js';
|
|
36
36
|
import { logger } from '../logger.js';
|
|
37
37
|
import { SPRING_AUTO_CONFIGURATION_REASONS, SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX, } from '../ingestion/frameworks/spring/auto-configuration.js';
|
|
@@ -422,6 +422,10 @@ const queryAndDrain = async (targetConn, cypher) => {
|
|
|
422
422
|
// `queryAndDrain`, which drains and discards the rows; the ONLY observable is
|
|
423
423
|
// whether the read-only shadow replay throws, so no row identity is read.
|
|
424
424
|
const READ_ONLY_SHADOW_REPLAY_PROBE = 'MATCH (n) RETURN n LIMIT 1';
|
|
425
|
+
// The durability half of writable recovery: replayed pages only persist when
|
|
426
|
+
// an explicit CHECKPOINT applies them to the main file (see
|
|
427
|
+
// recoverReadOnlyViaWritableOpen).
|
|
428
|
+
const RECOVERY_CHECKPOINT_QUERY = 'CHECKPOINT';
|
|
425
429
|
/**
|
|
426
430
|
* Serve-side entry to the shared WAL-quarantine safety gate. Refuses (throws)
|
|
427
431
|
* when the `.shadow` is present on disk or the orphan WAL is too large to
|
|
@@ -501,13 +505,30 @@ const ensureReadOnlyConnectionUsable = async (dbPath, handle) => {
|
|
|
501
505
|
await closeLbugConnection(handle);
|
|
502
506
|
return await reopenReadOnlyAfterMissingShadow(dbPath, err);
|
|
503
507
|
}
|
|
504
|
-
if (!isReadOnlyShadowReplayError(err)) {
|
|
508
|
+
if (!isReadOnlyShadowReplayError(err) && !isReadOnlyCheckpointInProgressError(err)) {
|
|
505
509
|
await closeLbugConnection(handle);
|
|
506
510
|
throw err;
|
|
507
511
|
}
|
|
508
512
|
shadowReplayErr = err;
|
|
509
513
|
}
|
|
510
514
|
await closeLbugConnection(handle);
|
|
515
|
+
return await recoverReadOnlyViaWritableOpen(dbPath, shadowReplayErr);
|
|
516
|
+
};
|
|
517
|
+
/**
|
|
518
|
+
* Clear an interrupted-checkpoint / pending-shadow-replay state by opening the
|
|
519
|
+
* database WRITABLE once — probe (forces the WAL replay) then an explicit
|
|
520
|
+
* CHECKPOINT (persists it; see the comment at the call site) — and reopening
|
|
521
|
+
* read-only. Recovery for every read-only refusal an interrupted checkpoint
|
|
522
|
+
* produces — `isReadOnlyShadowReplayError` and
|
|
523
|
+
* `isReadOnlyCheckpointInProgressError` — shared by the probe path
|
|
524
|
+
* (`ensureReadOnlyConnectionUsable`) and the open path (`doInitLbug`'s
|
|
525
|
+
* read-only branch, where the native open itself refuses before any probe can
|
|
526
|
+
* run). Homelab repro 2026-09-19: a wiki pod killed mid-CHECKPOINT left the
|
|
527
|
+
* checkpoint sidecars behind, and every read-only open thereafter failed until
|
|
528
|
+
* a writable open (any `gitnexus analyze`) recovered it — this makes the read
|
|
529
|
+
* path self-heal instead.
|
|
530
|
+
*/
|
|
531
|
+
const recoverReadOnlyViaWritableOpen = async (dbPath, triggeringErr) => {
|
|
511
532
|
let writable;
|
|
512
533
|
try {
|
|
513
534
|
writable = await openLbugConnection(lbug, dbPath);
|
|
@@ -515,18 +536,28 @@ const ensureReadOnlyConnectionUsable = async (dbPath, handle) => {
|
|
|
515
536
|
catch (openErr) {
|
|
516
537
|
const code = extractErrnoCode(openErr);
|
|
517
538
|
if (code === 'EROFS' || code === 'EACCES' || code === 'EPERM') {
|
|
518
|
-
throw new Error(
|
|
519
|
-
'\n The workspace appears to be read-only — mount it read-write to perform
|
|
539
|
+
throw new Error(readOnlyRecoveryFailureMessage(dbPath, triggeringErr) +
|
|
540
|
+
'\n The workspace appears to be read-only — mount it read-write to perform WAL recovery,' +
|
|
520
541
|
' or re-run `gitnexus analyze` on a writable filesystem to rebuild the index.');
|
|
521
542
|
}
|
|
522
543
|
throw openErr;
|
|
523
544
|
}
|
|
524
545
|
let missingShadowError;
|
|
546
|
+
let probeSucceeded = false;
|
|
525
547
|
try {
|
|
526
548
|
await queryAndDrain(writable.conn, READ_ONLY_SHADOW_REPLAY_PROBE);
|
|
549
|
+
probeSucceeded = true;
|
|
550
|
+
// Load-bearing durability step (engine 0.19.1 matrix, homelab repro
|
|
551
|
+
// 2026-09-19): the probe replays the WAL in MEMORY only. Without an
|
|
552
|
+
// explicit CHECKPOINT the engine drops those pages at close and the
|
|
553
|
+
// follow-up read-only open silently serves the pre-checkpoint state.
|
|
554
|
+
// CHECKPOINT applies the replay to the main file, consuming the
|
|
555
|
+
// `lbug.wal.checkpoint` / `lbug.shadow` sidecars and clearing the
|
|
556
|
+
// checkpoint locks the interrupted checkpoint left behind.
|
|
557
|
+
await queryAndDrain(writable.conn, RECOVERY_CHECKPOINT_QUERY);
|
|
527
558
|
}
|
|
528
559
|
catch (err) {
|
|
529
|
-
if (isMissingShadowSidecarError(err)) {
|
|
560
|
+
if (!probeSucceeded && isMissingShadowSidecarError(err)) {
|
|
530
561
|
missingShadowError = err;
|
|
531
562
|
}
|
|
532
563
|
else {
|
|
@@ -546,8 +577,10 @@ const ensureReadOnlyConnectionUsable = async (dbPath, handle) => {
|
|
|
546
577
|
}
|
|
547
578
|
catch (err) {
|
|
548
579
|
await closeLbugConnection(reopened);
|
|
549
|
-
if (isMissingShadowSidecarError(err)
|
|
550
|
-
|
|
580
|
+
if (isMissingShadowSidecarError(err) ||
|
|
581
|
+
isReadOnlyShadowReplayError(err) ||
|
|
582
|
+
isReadOnlyCheckpointInProgressError(err)) {
|
|
583
|
+
throw new Error(readOnlyRecoveryFailureMessage(dbPath, err));
|
|
551
584
|
}
|
|
552
585
|
throw err;
|
|
553
586
|
}
|
|
@@ -700,17 +733,27 @@ const doInitLbug = async (dbPath, options = {}) => {
|
|
|
700
733
|
usable = await ensureReadOnlyConnectionUsable(dbPath, opened);
|
|
701
734
|
}
|
|
702
735
|
catch (err) {
|
|
703
|
-
//
|
|
704
|
-
//
|
|
705
|
-
//
|
|
706
|
-
//
|
|
707
|
-
//
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
736
|
+
// An interrupted checkpoint can make the OPEN itself refuse read-only
|
|
737
|
+
// ("Cannot open database in read-only mode while checkpoint is in
|
|
738
|
+
// progress") before any probe runs. Clear it with one writable open,
|
|
739
|
+
// then reopen read-only — the same self-heal the probe path applies.
|
|
740
|
+
// Skip already-wrapped failures so we do not re-enter writable recovery.
|
|
741
|
+
if (isReadOnlyCheckpointInProgressError(err) && !isReadOnlyRecoveryFailure(err)) {
|
|
742
|
+
usable = await recoverReadOnlyViaWritableOpen(dbPath, err);
|
|
743
|
+
}
|
|
744
|
+
else {
|
|
745
|
+
// Not retryable: the on-disk file's storage version doesn't change on
|
|
746
|
+
// its own, so withLbugDb's retry loop (which only handles
|
|
747
|
+
// isDbBusyError) would just repeat the same native exception. Fail
|
|
748
|
+
// immediately with an actionable message instead (review finding on
|
|
749
|
+
// PR #3189 — this became reachable once the pinned engine version can
|
|
750
|
+
// trail behind whatever version last wrote an index, e.g. after
|
|
751
|
+
// downgrading the dependency). Mirrors the pool-adapter.ts check for
|
|
752
|
+
// the same error, on the separate open path /api/graph and /api/query
|
|
753
|
+
// actually use (withLbugDb, not the pool).
|
|
754
|
+
throwIfStorageVersionMismatch(err);
|
|
755
|
+
throw err;
|
|
756
|
+
}
|
|
714
757
|
}
|
|
715
758
|
db = usable.db;
|
|
716
759
|
conn = usable.conn;
|
|
@@ -20,7 +20,7 @@ import { isReadOnlyDbError, loadFTSExtension, loadVectorExtension } from './lbug
|
|
|
20
20
|
import { closeQueryResults } from './query-result-utils.js';
|
|
21
21
|
import { warnIfQueryTextUnbounded } from './query-batch.js';
|
|
22
22
|
import { createLbugDatabase, isWalCorruptionError, sleep, throwIfStorageVersionMismatch, toNativeSafePath, WAL_RECOVERY_SUGGESTION, } from './lbug-config.js';
|
|
23
|
-
import { assertReadOnlyFtsCrashSafe, FtsReaderUnrepairableError, guardWalQuarantine, isMissingFsError, isMissingShadowSidecarError, isReadOnlyShadowReplayError, preflightLbugSidecars, quarantineWalForMissingShadow, renameFailureMessage, statIfExists, } from './sidecar-recovery.js';
|
|
23
|
+
import { assertReadOnlyFtsCrashSafe, FtsReaderUnrepairableError, guardWalQuarantine, isMissingFsError, isMissingShadowSidecarError, isReadOnlyCheckpointInProgressError, isReadOnlyShadowReplayError, preflightLbugSidecars, quarantineWalForMissingShadow, renameFailureMessage, statIfExists, } from './sidecar-recovery.js';
|
|
24
24
|
export async function statDbIdentity(dbPath) {
|
|
25
25
|
try {
|
|
26
26
|
const s = await fs.stat(dbPath);
|
|
@@ -481,13 +481,35 @@ async function probeDatabaseForShadowReplay(db) {
|
|
|
481
481
|
}
|
|
482
482
|
async function replayShadowPagesWithWritableOpen(dbPath) {
|
|
483
483
|
let db;
|
|
484
|
+
// Mirrors the direct adapter's `probeSucceeded` guard: once the probe has
|
|
485
|
+
// replayed, a MISSING-SHADOW error can only come from the CHECKPOINT itself
|
|
486
|
+
// — quarantining the WAL then would park a live sidecar on a db whose main
|
|
487
|
+
// file just changed underneath it. Fail closed instead (review finding:
|
|
488
|
+
// policy drift vs the serve path).
|
|
489
|
+
let replaySucceeded = false;
|
|
484
490
|
try {
|
|
485
491
|
db = createLbugDatabase(lbug, toNativeSafePath(dbPath), { throwOnWalReplayFailure: false });
|
|
486
492
|
await db.init();
|
|
487
493
|
await probeDatabaseForShadowReplay(db);
|
|
494
|
+
replaySucceeded = true;
|
|
495
|
+
// Load-bearing durability step (engine 0.19.1 matrix, homelab repro
|
|
496
|
+
// 2026-09-19): the probe replays the WAL in MEMORY only. Without an
|
|
497
|
+
// explicit CHECKPOINT the engine drops those pages at close and the
|
|
498
|
+
// follow-up read-only open silently serves the pre-checkpoint state.
|
|
499
|
+
const conn = createConnection(db);
|
|
500
|
+
try {
|
|
501
|
+
const checkpointResult = await conn.query('CHECKPOINT');
|
|
502
|
+
const result = Array.isArray(checkpointResult) ? checkpointResult[0] : checkpointResult;
|
|
503
|
+
await result.getAll();
|
|
504
|
+
// Shared best-effort closer (awaits + swallows) — never roll this loop.
|
|
505
|
+
await closeQueryResults(result);
|
|
506
|
+
}
|
|
507
|
+
finally {
|
|
508
|
+
await conn.close().catch(() => { });
|
|
509
|
+
}
|
|
488
510
|
}
|
|
489
511
|
catch (err) {
|
|
490
|
-
if (isMissingShadowSidecarError(err)) {
|
|
512
|
+
if (isMissingShadowSidecarError(err) && !replaySucceeded) {
|
|
491
513
|
await tryQuarantineForMissingShadow(dbPath, {
|
|
492
514
|
reason: 'pool writable replay recovery',
|
|
493
515
|
err,
|
|
@@ -515,8 +537,14 @@ async function openReadOnlyDatabase(dbPath) {
|
|
|
515
537
|
readOnly: true,
|
|
516
538
|
throwOnWalReplayFailure: false,
|
|
517
539
|
});
|
|
518
|
-
|
|
540
|
+
// init() is inside the try: an interrupted checkpoint (pod killed
|
|
541
|
+
// mid-CHECKPOINT leaves `lbug.wal` + `lbug.shadow`) can make the read-only
|
|
542
|
+
// OPEN itself refuse — "Cannot open database in read-only mode while
|
|
543
|
+
// checkpoint is in progress" — before any probe runs (homelab repro
|
|
544
|
+
// 2026-09-19). Both refusal classes recover identically below: one
|
|
545
|
+
// writable open replays the WAL/completes the checkpoint.
|
|
519
546
|
try {
|
|
547
|
+
await db.init();
|
|
520
548
|
await probeDatabaseForShadowReplay(db);
|
|
521
549
|
}
|
|
522
550
|
catch (err) {
|
|
@@ -540,7 +568,7 @@ async function openReadOnlyDatabase(dbPath) {
|
|
|
540
568
|
await probeDatabaseForShadowReplay(db);
|
|
541
569
|
return db;
|
|
542
570
|
}
|
|
543
|
-
if (!isReadOnlyShadowReplayError(err)) {
|
|
571
|
+
if (!isReadOnlyShadowReplayError(err) && !isReadOnlyCheckpointInProgressError(err)) {
|
|
544
572
|
throw err;
|
|
545
573
|
}
|
|
546
574
|
await db.close().catch(() => { });
|
|
@@ -57,6 +57,20 @@ export declare const statIfExists: (filePath: string) => Promise<{
|
|
|
57
57
|
} | null>;
|
|
58
58
|
export declare const isMissingShadowSidecarError: (err: unknown) => boolean;
|
|
59
59
|
export declare const isReadOnlyShadowReplayError: (err: unknown) => boolean;
|
|
60
|
+
export declare const isReadOnlyCheckpointInProgressError: (err: unknown) => boolean;
|
|
61
|
+
/** Prefix of the interrupted-checkpoint wrap — must not rematch the native classifier. */
|
|
62
|
+
export declare const INTERRUPTED_CHECKPOINT_RECOVERY_PREFIX = "LadybugDB could not finish an interrupted checkpoint";
|
|
63
|
+
/** Prefix of the pending-shadow-replay wrap — must not rematch the native classifier. */
|
|
64
|
+
export declare const PENDING_SHADOW_REPLAY_RECOVERY_PREFIX = "LadybugDB could not finish a pending shadow replay";
|
|
65
|
+
/** True when `err` is already a classifier-aware recovery wrap (not a native refusal). */
|
|
66
|
+
export declare const isReadOnlyRecoveryFailure: (err: unknown) => boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Operator-facing wrap for a failed read-only self-heal. Checkpoint-in-progress
|
|
69
|
+
* and pending-shadow-replay must not reuse `shadowSidecarRecoveryMessage` —
|
|
70
|
+
* that copy claims the sidecar is missing and appends the native text, which
|
|
71
|
+
* rematches the classifiers and can re-enter writable CHECKPOINT.
|
|
72
|
+
*/
|
|
73
|
+
export declare const readOnlyRecoveryFailureMessage: (dbPath: string, err: unknown) => string;
|
|
60
74
|
export declare const shadowSidecarRecoveryMessage: (dbPath: string, err: unknown) => string;
|
|
61
75
|
/**
|
|
62
76
|
* Actionable message for the case where LadybugDB reports a "missing shadow"
|
|
@@ -189,6 +189,48 @@ export const isReadOnlyShadowReplayError = (err) => {
|
|
|
189
189
|
const msg = err instanceof Error ? err.message : String(err);
|
|
190
190
|
return /replay shadow pages under read-only mode/i.test(msg);
|
|
191
191
|
};
|
|
192
|
+
// LADYBUGDB-CONTRACT: native error text. When bumping LadybugDB, re-validate
|
|
193
|
+
// this regex against the new error format — `git grep "LADYBUGDB-CONTRACT"`
|
|
194
|
+
// enumerates every version-coupled spot.
|
|
195
|
+
// Version honesty (review finding on the interrupted-checkpoint PR): this
|
|
196
|
+
// string is FIRST OBSERVED on @ladybugdb/core 0.19.1 — live-reproduced by
|
|
197
|
+
// SIGKILLing a writer mid-CHECKPOINT (homelab 2026-09-19, GitNexus image
|
|
198
|
+
// 1.6.10-20260917, built on 0.19.x). The 0.18.3 binary does NOT contain it
|
|
199
|
+
// (checked via `strings`), and 0.18.3 tolerates the same on-disk state, so on
|
|
200
|
+
// 0.18.x this classifier simply never fires. If the engine pin ever moves
|
|
201
|
+
// back to ^0.19, the read-path self-heal is already in place.
|
|
202
|
+
export const isReadOnlyCheckpointInProgressError = (err) => {
|
|
203
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
204
|
+
return /cannot open database in read-only mode while checkpoint is in progress/i.test(msg);
|
|
205
|
+
};
|
|
206
|
+
/** Prefix of the interrupted-checkpoint wrap — must not rematch the native classifier. */
|
|
207
|
+
export const INTERRUPTED_CHECKPOINT_RECOVERY_PREFIX = 'LadybugDB could not finish an interrupted checkpoint';
|
|
208
|
+
/** Prefix of the pending-shadow-replay wrap — must not rematch the native classifier. */
|
|
209
|
+
export const PENDING_SHADOW_REPLAY_RECOVERY_PREFIX = 'LadybugDB could not finish a pending shadow replay';
|
|
210
|
+
const READ_ONLY_RECOVERY_REMOUNT = 'Mount the workspace read-write or re-run `gitnexus analyze` to complete recovery.';
|
|
211
|
+
/** True when `err` is already a classifier-aware recovery wrap (not a native refusal). */
|
|
212
|
+
export const isReadOnlyRecoveryFailure = (err) => {
|
|
213
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
214
|
+
return (msg.startsWith(INTERRUPTED_CHECKPOINT_RECOVERY_PREFIX) ||
|
|
215
|
+
msg.startsWith(PENDING_SHADOW_REPLAY_RECOVERY_PREFIX));
|
|
216
|
+
};
|
|
217
|
+
/**
|
|
218
|
+
* Operator-facing wrap for a failed read-only self-heal. Checkpoint-in-progress
|
|
219
|
+
* and pending-shadow-replay must not reuse `shadowSidecarRecoveryMessage` —
|
|
220
|
+
* that copy claims the sidecar is missing and appends the native text, which
|
|
221
|
+
* rematches the classifiers and can re-enter writable CHECKPOINT.
|
|
222
|
+
*/
|
|
223
|
+
export const readOnlyRecoveryFailureMessage = (dbPath, err) => {
|
|
224
|
+
if (isReadOnlyCheckpointInProgressError(err)) {
|
|
225
|
+
return (`${INTERRUPTED_CHECKPOINT_RECOVERY_PREFIX} for ${dbPath}. ` +
|
|
226
|
+
`${READ_ONLY_RECOVERY_REMOUNT} ` +
|
|
227
|
+
'Retry later if another writer is still checkpointing.');
|
|
228
|
+
}
|
|
229
|
+
if (isReadOnlyShadowReplayError(err)) {
|
|
230
|
+
return `${PENDING_SHADOW_REPLAY_RECOVERY_PREFIX} for ${dbPath}. ${READ_ONLY_RECOVERY_REMOUNT}`;
|
|
231
|
+
}
|
|
232
|
+
return shadowSidecarRecoveryMessage(dbPath, err);
|
|
233
|
+
};
|
|
192
234
|
export const shadowSidecarRecoveryMessage = (dbPath, err) => {
|
|
193
235
|
const msg = err instanceof Error ? err.message : String(err);
|
|
194
236
|
return (`LadybugDB checkpoint sidecar is missing for ${dbPath}. ` +
|
package/package.json
CHANGED
|
@@ -165,6 +165,7 @@ const LBUG_NATIVE = [
|
|
|
165
165
|
'test/integration/lbug-open-retry.test.ts',
|
|
166
166
|
'test/integration/lbug-close-handle-release.test.ts',
|
|
167
167
|
'test/integration/lbug-orphan-sidecar-recovery.test.ts',
|
|
168
|
+
'test/integration/lbug-interrupted-checkpoint-recovery.test.ts',
|
|
168
169
|
'test/integration/lbug-readonly-init.test.ts',
|
|
169
170
|
'test/integration/lbug-non-ascii-path.test.ts',
|
|
170
171
|
// Cross-repo trace e2e: builds two real lbug indexes + a real bridge and
|