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(shadowSidecarRecoveryMessage(dbPath, shadowReplayErr) +
519
- '\n The workspace appears to be read-only — mount it read-write to perform shadow replay recovery,' +
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
- throw new Error(shadowSidecarRecoveryMessage(dbPath, err));
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
- // Not retryable: the on-disk file's storage version doesn't change on
704
- // its own, so withLbugDb's retry loop (which only handles
705
- // isDbBusyError) would just repeat the same native exception. Fail
706
- // immediately with an actionable message instead (review finding on
707
- // PR #3189 — this became reachable once the pinned engine version can
708
- // trail behind whatever version last wrote an index, e.g. after
709
- // downgrading the dependency). Mirrors the pool-adapter.ts check for
710
- // the same error, on the separate open path /api/graph and /api/query
711
- // actually use (withLbugDb, not the pool).
712
- throwIfStorageVersionMismatch(err);
713
- throw err;
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
- await db.init();
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.13-rc.26",
3
+ "version": "1.6.13-rc.28",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",
@@ -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