gitnexus 1.6.4-rc.92 → 1.6.4-rc.94
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.
- package/dist/core/augmentation/engine.js +1 -1
- package/dist/core/lbug/lbug-adapter.d.ts +0 -6
- package/dist/core/lbug/lbug-adapter.js +34 -15
- package/dist/core/lbug/lbug-config.d.ts +53 -0
- package/dist/core/lbug/lbug-config.js +230 -1
- package/dist/core/search/bm25-index.d.ts +6 -1
- package/dist/core/search/bm25-index.js +27 -10
- package/dist/core/search/hybrid-search.d.ts +4 -3
- package/dist/core/search/hybrid-search.js +6 -5
- package/dist/mcp/local/local-backend.js +5 -4
- package/dist/server/api.js +16 -7
- package/package.json +1 -1
|
@@ -90,7 +90,7 @@ export async function augment(pattern, cwd) {
|
|
|
90
90
|
await initLbug(repoId, repo.lbugPath);
|
|
91
91
|
}
|
|
92
92
|
// Step 1: BM25 search (fast, no embeddings)
|
|
93
|
-
const bm25Results = await searchFTSFromLbug(pattern, 10, repoId);
|
|
93
|
+
const { results: bm25Results } = await searchFTSFromLbug(pattern, 10, repoId);
|
|
94
94
|
if (bm25Results.length === 0)
|
|
95
95
|
return '';
|
|
96
96
|
// Step 2: Map BM25 file results to symbols
|
|
@@ -32,12 +32,6 @@ export interface RelCsvSplitResult {
|
|
|
32
32
|
export declare const splitRelCsvByLabelPair: (csvPath: string, csvDir: string, validTables: Set<string>, getNodeLabel: (id: string) => string, wsFactory?: WriteStreamFactory) => Promise<RelCsvSplitResult>;
|
|
33
33
|
/** Expose the current Database for pool adapter reuse in tests. */
|
|
34
34
|
export declare const getDatabase: () => lbug.Database | null;
|
|
35
|
-
/**
|
|
36
|
-
* Return true when the error message indicates that another process holds
|
|
37
|
-
* an exclusive lock on the LadybugDB file (e.g. `gitnexus analyze` or
|
|
38
|
-
* `gitnexus serve` running at the same time).
|
|
39
|
-
*/
|
|
40
|
-
export declare const isDbBusyError: (err: unknown) => boolean;
|
|
41
35
|
/**
|
|
42
36
|
* Return true when the error message indicates a write was attempted against
|
|
43
37
|
* a read-only LadybugDB connection. The MCP query pool opens DBs read-only,
|
|
@@ -8,7 +8,7 @@ import lbug from '@ladybugdb/core';
|
|
|
8
8
|
import { NODE_TABLES, REL_TABLE_NAME, SCHEMA_QUERIES, EMBEDDING_TABLE_NAME, STALE_HASH_SENTINEL, } from './schema.js';
|
|
9
9
|
import { streamAllCSVsToDisk } from './csv-generator.js';
|
|
10
10
|
import { extensionManager } from './extension-loader.js';
|
|
11
|
-
import { closeLbugConnection, openLbugConnection, } from './lbug-config.js';
|
|
11
|
+
import { closeLbugConnection, isDbBusyError, isOpenRetryExhausted, openLbugConnection, waitForWindowsHandleRelease, } from './lbug-config.js';
|
|
12
12
|
import { isVectorExtensionSupportedByPlatform } from '../platform/capabilities.js';
|
|
13
13
|
import { logger } from '../logger.js';
|
|
14
14
|
/**
|
|
@@ -140,18 +140,6 @@ let sessionLock = Promise.resolve();
|
|
|
140
140
|
const DB_LOCK_RETRY_ATTEMPTS = 3;
|
|
141
141
|
/** Base back-off in ms between BUSY retries (multiplied by attempt number). */
|
|
142
142
|
const DB_LOCK_RETRY_DELAY_MS = 500;
|
|
143
|
-
/**
|
|
144
|
-
* Return true when the error message indicates that another process holds
|
|
145
|
-
* an exclusive lock on the LadybugDB file (e.g. `gitnexus analyze` or
|
|
146
|
-
* `gitnexus serve` running at the same time).
|
|
147
|
-
*/
|
|
148
|
-
export const isDbBusyError = (err) => {
|
|
149
|
-
const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
|
|
150
|
-
return (msg.includes('busy') ||
|
|
151
|
-
msg.includes('lock') ||
|
|
152
|
-
msg.includes('already in use') ||
|
|
153
|
-
msg.includes('could not set lock'));
|
|
154
|
-
};
|
|
155
143
|
/**
|
|
156
144
|
* Return true when the error message indicates a write was attempted against
|
|
157
145
|
* a read-only LadybugDB connection. The MCP query pool opens DBs read-only,
|
|
@@ -201,7 +189,11 @@ export const withLbugDb = async (dbPath, operation) => {
|
|
|
201
189
|
}
|
|
202
190
|
catch (err) {
|
|
203
191
|
lastError = err;
|
|
204
|
-
|
|
192
|
+
// Skip outer retry when the inner open-retry already exhausted: the
|
|
193
|
+
// ~1.5s open-time budget was just spent, repeating the full reset+
|
|
194
|
+
// reopen cycle would only add 4-5s of tail latency without changing
|
|
195
|
+
// the outcome (both layers consult the same isDbBusyError matcher).
|
|
196
|
+
if (!isDbBusyError(err) || isOpenRetryExhausted(err) || attempt === DB_LOCK_RETRY_ATTEMPTS) {
|
|
205
197
|
throw err;
|
|
206
198
|
}
|
|
207
199
|
// Close stale connection inside the session lock to prevent race conditions
|
|
@@ -274,7 +266,16 @@ const doInitLbug = async (dbPath) => {
|
|
|
274
266
|
}
|
|
275
267
|
catch (err) {
|
|
276
268
|
const msg = err instanceof Error ? err.message : String(err);
|
|
277
|
-
|
|
269
|
+
// Suppression list:
|
|
270
|
+
// - "already exists": expected idempotent re-create on existing DBs
|
|
271
|
+
// - "could not set lock on file": LadybugDB v0.16.1 emits this on
|
|
272
|
+
// Windows when CREATE NODE TABLE runs against a path that was
|
|
273
|
+
// just opened (the WAL handle from a fresh Database briefly
|
|
274
|
+
// contests the table's first-write lock). The table is created
|
|
275
|
+
// anyway and any genuine cross-process lock contention surfaces
|
|
276
|
+
// on the next operation via withLbugDb's retry. Logging it here
|
|
277
|
+
// would just be noise in CI.
|
|
278
|
+
if (!msg.includes('already exists') && !isDbBusyError(err)) {
|
|
278
279
|
logger.warn(`⚠️ Schema creation warning: ${msg.slice(0, 120)}`);
|
|
279
280
|
}
|
|
280
281
|
}
|
|
@@ -940,6 +941,9 @@ export const flushWAL = async () => {
|
|
|
940
941
|
*/
|
|
941
942
|
export const safeClose = async () => {
|
|
942
943
|
await flushWAL();
|
|
944
|
+
// Capture before close — currentDbPath stays set so the Windows post-close
|
|
945
|
+
// probe below knows which file to wait on.
|
|
946
|
+
const closingDbPath = currentDbPath;
|
|
943
947
|
if (conn) {
|
|
944
948
|
try {
|
|
945
949
|
// eslint-disable-next-line no-restricted-syntax -- sole authorised close site
|
|
@@ -960,6 +964,21 @@ export const safeClose = async () => {
|
|
|
960
964
|
}
|
|
961
965
|
db = null;
|
|
962
966
|
}
|
|
967
|
+
// Windows: libuv reports `db.close()` resolved before the kernel has
|
|
968
|
+
// released the file handle. A subsequent `new Database(samePath)` in
|
|
969
|
+
// the same process can race the release. The probe (lbug-config.ts)
|
|
970
|
+
// forces any residual lock to surface as EBUSY/EPERM/EACCES so the
|
|
971
|
+
// open-time retry absorbs the lag.
|
|
972
|
+
if (process.platform === 'win32' && closingDbPath) {
|
|
973
|
+
const released = await waitForWindowsHandleRelease(closingDbPath);
|
|
974
|
+
if (!released) {
|
|
975
|
+
// Probe exhausted with a lock code still in flight. The next
|
|
976
|
+
// openLbugConnection will absorb whatever residual lag remains, but
|
|
977
|
+
// a chronic warning helps operators spot AV interference (Windows
|
|
978
|
+
// Defender holding the file far past the 250ms budget).
|
|
979
|
+
logger.warn({ dbPath: closingDbPath }, '⚠️ LadybugDB file handle still locked after close (Windows). If this repeats, check antivirus/Defender exclusions for the GitNexus storage directory.');
|
|
980
|
+
}
|
|
981
|
+
}
|
|
963
982
|
};
|
|
964
983
|
export const closeLbug = async () => {
|
|
965
984
|
await safeClose();
|
|
@@ -43,7 +43,60 @@ export interface LbugConnectionHandle {
|
|
|
43
43
|
db: lbug.Database;
|
|
44
44
|
conn: lbug.Connection;
|
|
45
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Return true when the error message indicates that a LadybugDB file lock
|
|
48
|
+
* could not be acquired — either at construction time
|
|
49
|
+
* (`new lbug.Database(...)` raises from `local_file_system.cpp`) or during
|
|
50
|
+
* a query (another writer holds the exclusive lock).
|
|
51
|
+
*
|
|
52
|
+
* Lives here (not in `lbug-adapter.ts`) so both the construction-time
|
|
53
|
+
* retry (`openWithLockRetry` in this file) and the query-time retry
|
|
54
|
+
* (`withLbugDb` in `lbug-adapter.ts`) consult the same matcher. Callers
|
|
55
|
+
* import directly from this module — no re-export to keep in sync.
|
|
56
|
+
*/
|
|
57
|
+
export declare const isDbBusyError: (err: unknown) => boolean;
|
|
46
58
|
export declare function createLbugDatabase(lbugModule: LbugModule, databasePath: string, options?: LbugDatabaseOptions): lbug.Database;
|
|
59
|
+
/**
|
|
60
|
+
* Marker symbol attached to lock errors after `openWithLockRetry` exhausts
|
|
61
|
+
* its budget. `withLbugDb`'s outer query-time retry consults this so it
|
|
62
|
+
* does not re-retry a path that just spent up to ~1.5s in the open-time
|
|
63
|
+
* loop — preventing 6s tail latencies (3× outer × 5× inner attempts).
|
|
64
|
+
*
|
|
65
|
+
* The symbol is internal to GitNexus; consumers should treat the underlying
|
|
66
|
+
* error message as the user-visible signal.
|
|
67
|
+
*/
|
|
68
|
+
export declare const LBUG_OPEN_RETRY_EXHAUSTED: unique symbol;
|
|
69
|
+
export declare const isOpenRetryExhausted: (err: unknown) => boolean;
|
|
70
|
+
/** Exported only for direct unit testing — production callers use `openWithLockRetry`. */
|
|
71
|
+
export declare const _isTestFixturePathForTest: (dbPath: string) => boolean;
|
|
47
72
|
export declare function openLbugConnection(lbugModule: LbugModule, databasePath: string, options?: LbugDatabaseOptions): Promise<LbugConnectionHandle>;
|
|
48
73
|
export declare function closeLbugConnection(handle: LbugConnectionHandle): Promise<void>;
|
|
74
|
+
/**
|
|
75
|
+
* Probe `dbPath` AND its `.wal` sidecar after `db.close()` so any
|
|
76
|
+
* residual native file handle surfaces as EBUSY/EPERM/EACCES and the
|
|
77
|
+
* bounded retry absorbs the release lag. Windows-only — Linux/macOS do
|
|
78
|
+
* not exhibit this race.
|
|
79
|
+
*
|
|
80
|
+
* Both files matter. Empirically, on rapid open→close→reopen cycles the
|
|
81
|
+
* main `dbPath` handle releases first; the `.wal` handle from the
|
|
82
|
+
* previous Database lingers and the new Database's first write (CREATE
|
|
83
|
+
* NODE TABLE during schema init) fails with "Could not set lock on
|
|
84
|
+
* file". Probing both makes safeClose actually return when the kernel
|
|
85
|
+
* is fully done with the path.
|
|
86
|
+
*
|
|
87
|
+
* Returns `true` when both probes succeeded (or skipped on non-lock
|
|
88
|
+
* errors / missing files). Returns `false` when either probe exhausted
|
|
89
|
+
* its budget with a lock code still in flight.
|
|
90
|
+
*
|
|
91
|
+
* Defensive shape:
|
|
92
|
+
* - Opens read+write (`'r+'`) so the probe actually surfaces exclusive
|
|
93
|
+
* locks held by the previous Database. A read-only probe (`'r'`) is
|
|
94
|
+
* insufficient — Windows will grant read access while the previous
|
|
95
|
+
* handle's exclusive write lock is still in flight, which lets
|
|
96
|
+
* `safeClose` return before the next CREATE NODE TABLE can lock the
|
|
97
|
+
* file.
|
|
98
|
+
* - `try/finally` around `handle.close()` guarantees no fd leak even
|
|
99
|
+
* if close itself throws.
|
|
100
|
+
*/
|
|
101
|
+
export declare const waitForWindowsHandleRelease: (dbPath: string) => Promise<boolean>;
|
|
49
102
|
export {};
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
1
4
|
/**
|
|
2
5
|
* Shared configuration for `@ladybugdb/core` `Database` construction.
|
|
3
6
|
*
|
|
@@ -48,6 +51,27 @@ export function isWalCorruptionError(err) {
|
|
|
48
51
|
const msg = err instanceof Error ? err.message : String(err);
|
|
49
52
|
return WAL_CORRUPTION_RE.test(msg);
|
|
50
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Return true when the error message indicates that a LadybugDB file lock
|
|
56
|
+
* could not be acquired — either at construction time
|
|
57
|
+
* (`new lbug.Database(...)` raises from `local_file_system.cpp`) or during
|
|
58
|
+
* a query (another writer holds the exclusive lock).
|
|
59
|
+
*
|
|
60
|
+
* Lives here (not in `lbug-adapter.ts`) so both the construction-time
|
|
61
|
+
* retry (`openWithLockRetry` in this file) and the query-time retry
|
|
62
|
+
* (`withLbugDb` in `lbug-adapter.ts`) consult the same matcher. Callers
|
|
63
|
+
* import directly from this module — no re-export to keep in sync.
|
|
64
|
+
*/
|
|
65
|
+
export const isDbBusyError = (err) => {
|
|
66
|
+
const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
|
|
67
|
+
// `lock` already subsumes `could not set lock`; the broader term is kept
|
|
68
|
+
// because graph-DB transient errors include "deadlock", "lock contention",
|
|
69
|
+
// and the LadybugDB native module's "could not set lock on file" — all of
|
|
70
|
+
// which deserve a retry. If a non-transient lock-shaped error ever
|
|
71
|
+
// surfaces (e.g., "lock file missing" during recovery), tighten this
|
|
72
|
+
// matcher rather than raising the retry budget.
|
|
73
|
+
return msg.includes('busy') || msg.includes('lock') || msg.includes('already in use');
|
|
74
|
+
};
|
|
51
75
|
export function createLbugDatabase(lbugModule, databasePath, options = {}) {
|
|
52
76
|
// .d.ts declares fewer args than the native constructor accepts.
|
|
53
77
|
return new lbugModule.Database(databasePath, 0, // bufferManagerSize
|
|
@@ -56,10 +80,155 @@ export function createLbugDatabase(lbugModule, databasePath, options = {}) {
|
|
|
56
80
|
-1, // checkpointThreshold
|
|
57
81
|
options.throwOnWalReplayFailure ?? true, true);
|
|
58
82
|
}
|
|
83
|
+
// ─── Lock-busy retry tuning knobs ───────────────────────────────────────────
|
|
84
|
+
//
|
|
85
|
+
// All four GitNexus retry pairs that touch native LadybugDB locks live with
|
|
86
|
+
// a comment cross-reference here so an SRE tuning Windows flakes finds them
|
|
87
|
+
// in one grep:
|
|
88
|
+
//
|
|
89
|
+
// 1. OPEN_LOCK_RETRY_ATTEMPTS / OPEN_LOCK_RETRY_DELAY_MS (this file)
|
|
90
|
+
// → `new lbug.Database()` constructor lock failures
|
|
91
|
+
// 2. HANDLE_RELEASE_PROBE_ATTEMPTS / HANDLE_RELEASE_PROBE_DELAY_MS (this file)
|
|
92
|
+
// → post-close fs.open probe to absorb Windows handle-release lag
|
|
93
|
+
// 3. DB_LOCK_RETRY_ATTEMPTS / DB_LOCK_RETRY_DELAY_MS (lbug-adapter.ts withLbugDb)
|
|
94
|
+
// → query-time busy/lock retry around already-open connections
|
|
95
|
+
//
|
|
96
|
+
// `new lbug.Database()` calls into the native module which performs an
|
|
97
|
+
// OS-level exclusive lock on `<dbPath>`. On Windows that lock can fail
|
|
98
|
+
// for reasons specific to the OS (Defender briefly opens new files,
|
|
99
|
+
// libuv handle release lags the JS-side close). 5 attempts × 100ms
|
|
100
|
+
// linear back-off (max sleep 100+200+300+400 = 1s, plus 5 ctor RTTs
|
|
101
|
+
// of 10–50ms each = ~1.0–1.2s worst case) clears the typical
|
|
102
|
+
// AV-scanner hold without masking real cross-process conflicts.
|
|
103
|
+
//
|
|
104
|
+
// Source: https://github.com/LadybugDB/ladybug/blob/v0.16.1/src/common/file_system/local_file_system.cpp#L126
|
|
105
|
+
const OPEN_LOCK_RETRY_ATTEMPTS = 5;
|
|
106
|
+
const OPEN_LOCK_RETRY_DELAY_MS = 100;
|
|
107
|
+
const HANDLE_RELEASE_PROBE_ATTEMPTS = 5;
|
|
108
|
+
const HANDLE_RELEASE_PROBE_DELAY_MS = 50;
|
|
109
|
+
const HANDLE_RELEASE_LOCK_CODES = new Set(['EBUSY', 'EPERM', 'EACCES']);
|
|
110
|
+
/**
|
|
111
|
+
* Test-fixture directory prefixes recognized by `isTestFixturePath`.
|
|
112
|
+
*
|
|
113
|
+
* IMPORTANT: this list must stay in sync with the prefixes passed to
|
|
114
|
+
* `createTempDir` in `gitnexus/test/helpers/test-db.ts` and the prefixes
|
|
115
|
+
* used by `withTestLbugDB` (`gitnexus/test/helpers/test-indexed-db.ts`).
|
|
116
|
+
* If you add a new test that passes a custom prefix to `createTempDir`,
|
|
117
|
+
* add it here too — otherwise the stale-sidecar sweep silently won't
|
|
118
|
+
* fire for that fixture and CI flakes return.
|
|
119
|
+
*
|
|
120
|
+
* The default `createTempDir('gitnexus-test-')` and the lbug variant
|
|
121
|
+
* `'gitnexus-lbug-'` cover today's call sites.
|
|
122
|
+
*/
|
|
123
|
+
const TEST_FIXTURE_PREFIXES = ['gitnexus-lbug-', 'gitnexus-test-'];
|
|
124
|
+
/**
|
|
125
|
+
* Marker symbol attached to lock errors after `openWithLockRetry` exhausts
|
|
126
|
+
* its budget. `withLbugDb`'s outer query-time retry consults this so it
|
|
127
|
+
* does not re-retry a path that just spent up to ~1.5s in the open-time
|
|
128
|
+
* loop — preventing 6s tail latencies (3× outer × 5× inner attempts).
|
|
129
|
+
*
|
|
130
|
+
* The symbol is internal to GitNexus; consumers should treat the underlying
|
|
131
|
+
* error message as the user-visible signal.
|
|
132
|
+
*/
|
|
133
|
+
export const LBUG_OPEN_RETRY_EXHAUSTED = Symbol.for('gitnexus.lbug.openRetryExhausted');
|
|
134
|
+
export const isOpenRetryExhausted = (err) => {
|
|
135
|
+
if (err === null || err === undefined || typeof err !== 'object')
|
|
136
|
+
return false;
|
|
137
|
+
return err[LBUG_OPEN_RETRY_EXHAUSTED] === true;
|
|
138
|
+
};
|
|
139
|
+
const tagOpenRetryExhausted = (err) => {
|
|
140
|
+
if (err && typeof err === 'object') {
|
|
141
|
+
err[LBUG_OPEN_RETRY_EXHAUSTED] = true;
|
|
142
|
+
}
|
|
143
|
+
return err;
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* True when `dbPath` resolves to a recognized test fixture under the OS
|
|
147
|
+
* temp directory. Used to gate the stale-sidecar sweep so production
|
|
148
|
+
* paths never have their `.wal` / `.lock` files deleted.
|
|
149
|
+
*
|
|
150
|
+
* Defensive shape:
|
|
151
|
+
* - `path.resolve` normalizes `..` segments before the prefix check, so
|
|
152
|
+
* `<tmp>/gitnexus-lbug-x/../../etc/passwd` is rejected.
|
|
153
|
+
* - The tmpRoot check trims any trailing separator returned by some
|
|
154
|
+
* Windows TMP configurations (`C:\Users\X\Temp\`) so the startsWith
|
|
155
|
+
* comparison stays correct.
|
|
156
|
+
* - Only the IMMEDIATE parent directory is matched against the prefix
|
|
157
|
+
* list. An ancestor walk would let a tmpdir whose own basename starts
|
|
158
|
+
* with `gitnexus-lbug-` accept arbitrary nested paths under it.
|
|
159
|
+
*/
|
|
160
|
+
const isTestFixturePath = (dbPath) => {
|
|
161
|
+
const tmpRoot = os.tmpdir().replace(new RegExp(`${path.sep === '\\' ? '\\\\' : path.sep}+$`), '');
|
|
162
|
+
const resolved = path.resolve(dbPath);
|
|
163
|
+
if (!resolved.startsWith(tmpRoot + path.sep) && resolved !== tmpRoot)
|
|
164
|
+
return false;
|
|
165
|
+
const parentBase = path.basename(path.dirname(resolved));
|
|
166
|
+
return TEST_FIXTURE_PREFIXES.some((p) => parentBase.startsWith(p));
|
|
167
|
+
};
|
|
168
|
+
/** Exported only for direct unit testing — production callers use `openWithLockRetry`. */
|
|
169
|
+
export const _isTestFixturePathForTest = isTestFixturePath;
|
|
170
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
171
|
+
/**
|
|
172
|
+
* Attempt to remove stale `.wal` / `.lock` sidecars that a previous aborted
|
|
173
|
+
* test run may have left behind. Best-effort: ENOENT is normal, anything
|
|
174
|
+
* else is swallowed so the caller's retry can surface the original error.
|
|
175
|
+
*/
|
|
176
|
+
const sweepStaleSidecars = async (dbPath) => {
|
|
177
|
+
for (const suffix of ['.wal', '.lock']) {
|
|
178
|
+
try {
|
|
179
|
+
await fs.unlink(dbPath + suffix);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
/* missing sidecar or permission error — let the open retry surface it */
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
/**
|
|
187
|
+
* Run `construct` with bounded retries when `new lbug.Database(...)` throws
|
|
188
|
+
* a busy/lock error. The original (loop-captured) error is preferred over
|
|
189
|
+
* any post-sweep error so triage sees the real LadybugDB lock message.
|
|
190
|
+
* On exhaustion the rethrown error is tagged via
|
|
191
|
+
* `LBUG_OPEN_RETRY_EXHAUSTED` so the outer query-time retry in
|
|
192
|
+
* `withLbugDb` skips re-retrying a freshly-exhausted path.
|
|
193
|
+
*/
|
|
194
|
+
const openWithLockRetry = async (construct, dbPath) => {
|
|
195
|
+
let originalLockError;
|
|
196
|
+
for (let attempt = 1; attempt <= OPEN_LOCK_RETRY_ATTEMPTS; attempt++) {
|
|
197
|
+
try {
|
|
198
|
+
return construct();
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
if (!isDbBusyError(err))
|
|
202
|
+
throw err;
|
|
203
|
+
originalLockError = err;
|
|
204
|
+
if (attempt === OPEN_LOCK_RETRY_ATTEMPTS)
|
|
205
|
+
break;
|
|
206
|
+
await sleep(OPEN_LOCK_RETRY_DELAY_MS * attempt);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// Final defense: only for recognized test fixtures, sweep stale sidecars
|
|
210
|
+
// (a prior aborted test run can leave a `.wal` lock that survives the
|
|
211
|
+
// tmp dir cleanup). Production paths never reach this branch — the guard
|
|
212
|
+
// requires the immediate parent dir to match a test prefix AND the
|
|
213
|
+
// resolved path to live under the OS temp directory.
|
|
214
|
+
if (isTestFixturePath(dbPath)) {
|
|
215
|
+
await sweepStaleSidecars(dbPath);
|
|
216
|
+
try {
|
|
217
|
+
return construct();
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// Intentionally do NOT overwrite originalLockError. The user-actionable
|
|
221
|
+
// signal is "we exhausted lock retries" — a different error from the
|
|
222
|
+
// post-sweep attempt is less useful than the lock failure that drove
|
|
223
|
+
// the sweep in the first place.
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
throw tagOpenRetryExhausted(originalLockError);
|
|
227
|
+
};
|
|
59
228
|
export async function openLbugConnection(lbugModule, databasePath, options = {}) {
|
|
60
229
|
let db;
|
|
61
230
|
try {
|
|
62
|
-
db = createLbugDatabase(lbugModule, databasePath, options);
|
|
231
|
+
db = await openWithLockRetry(() => createLbugDatabase(lbugModule, databasePath, options), databasePath);
|
|
63
232
|
return { db, conn: new lbugModule.Connection(db) };
|
|
64
233
|
}
|
|
65
234
|
catch (err) {
|
|
@@ -72,3 +241,63 @@ export async function closeLbugConnection(handle) {
|
|
|
72
241
|
await handle.conn.close().catch(() => { });
|
|
73
242
|
await handle.db.close().catch(() => { });
|
|
74
243
|
}
|
|
244
|
+
/**
|
|
245
|
+
* Probe `dbPath` AND its `.wal` sidecar after `db.close()` so any
|
|
246
|
+
* residual native file handle surfaces as EBUSY/EPERM/EACCES and the
|
|
247
|
+
* bounded retry absorbs the release lag. Windows-only — Linux/macOS do
|
|
248
|
+
* not exhibit this race.
|
|
249
|
+
*
|
|
250
|
+
* Both files matter. Empirically, on rapid open→close→reopen cycles the
|
|
251
|
+
* main `dbPath` handle releases first; the `.wal` handle from the
|
|
252
|
+
* previous Database lingers and the new Database's first write (CREATE
|
|
253
|
+
* NODE TABLE during schema init) fails with "Could not set lock on
|
|
254
|
+
* file". Probing both makes safeClose actually return when the kernel
|
|
255
|
+
* is fully done with the path.
|
|
256
|
+
*
|
|
257
|
+
* Returns `true` when both probes succeeded (or skipped on non-lock
|
|
258
|
+
* errors / missing files). Returns `false` when either probe exhausted
|
|
259
|
+
* its budget with a lock code still in flight.
|
|
260
|
+
*
|
|
261
|
+
* Defensive shape:
|
|
262
|
+
* - Opens read+write (`'r+'`) so the probe actually surfaces exclusive
|
|
263
|
+
* locks held by the previous Database. A read-only probe (`'r'`) is
|
|
264
|
+
* insufficient — Windows will grant read access while the previous
|
|
265
|
+
* handle's exclusive write lock is still in flight, which lets
|
|
266
|
+
* `safeClose` return before the next CREATE NODE TABLE can lock the
|
|
267
|
+
* file.
|
|
268
|
+
* - `try/finally` around `handle.close()` guarantees no fd leak even
|
|
269
|
+
* if close itself throws.
|
|
270
|
+
*/
|
|
271
|
+
export const waitForWindowsHandleRelease = async (dbPath) => {
|
|
272
|
+
const mainReleased = await probeSinglePath(dbPath);
|
|
273
|
+
const walReleased = await probeSinglePath(dbPath + '.wal');
|
|
274
|
+
return mainReleased && walReleased;
|
|
275
|
+
};
|
|
276
|
+
const probeSinglePath = async (filePath) => {
|
|
277
|
+
for (let attempt = 1; attempt <= HANDLE_RELEASE_PROBE_ATTEMPTS; attempt++) {
|
|
278
|
+
let handle;
|
|
279
|
+
try {
|
|
280
|
+
handle = await fs.open(filePath, 'r+');
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
catch (err) {
|
|
284
|
+
const code = err?.code;
|
|
285
|
+
if (!code || !HANDLE_RELEASE_LOCK_CODES.has(code))
|
|
286
|
+
return true; // ENOENT / unrelated → not our problem
|
|
287
|
+
if (attempt === HANDLE_RELEASE_PROBE_ATTEMPTS)
|
|
288
|
+
return false;
|
|
289
|
+
await sleep(HANDLE_RELEASE_PROBE_DELAY_MS * attempt);
|
|
290
|
+
}
|
|
291
|
+
finally {
|
|
292
|
+
if (handle) {
|
|
293
|
+
try {
|
|
294
|
+
await handle.close();
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
/* swallow — caller cannot do anything useful with a probe-close failure */
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return false;
|
|
303
|
+
};
|
|
@@ -10,6 +10,11 @@ export interface BM25SearchResult {
|
|
|
10
10
|
rank: number;
|
|
11
11
|
nodeIds?: string[];
|
|
12
12
|
}
|
|
13
|
+
export interface FTSSearchResponse {
|
|
14
|
+
results: BM25SearchResult[];
|
|
15
|
+
/** True when at least one FTS index query succeeded (index exists). */
|
|
16
|
+
ftsAvailable: boolean;
|
|
17
|
+
}
|
|
13
18
|
/**
|
|
14
19
|
* Search using LadybugDB's built-in FTS (always fresh, reads from disk)
|
|
15
20
|
*
|
|
@@ -21,4 +26,4 @@ export interface BM25SearchResult {
|
|
|
21
26
|
* @param repoId - If provided, queries will be routed via the MCP connection pool
|
|
22
27
|
* @returns Ranked search results from FTS indexes
|
|
23
28
|
*/
|
|
24
|
-
export declare const searchFTSFromLbug: (query: string, limit?: number, repoId?: string) => Promise<
|
|
29
|
+
export declare const searchFTSFromLbug: (query: string, limit?: number, repoId?: string) => Promise<FTSSearchResponse>;
|
|
@@ -8,7 +8,8 @@ import { queryFTS } from '../lbug/lbug-adapter.js';
|
|
|
8
8
|
import { FTS_INDEXES } from './fts-schema.js';
|
|
9
9
|
/**
|
|
10
10
|
* Execute a single FTS query via a custom executor (for MCP connection pool).
|
|
11
|
-
* Returns the
|
|
11
|
+
* Returns `null` when the query fails (e.g. FTS index does not exist) so the
|
|
12
|
+
* caller can distinguish "zero matches" from "index missing".
|
|
12
13
|
*/
|
|
13
14
|
async function queryFTSViaExecutor(executor, tableName, indexName, query, limit) {
|
|
14
15
|
// Escape single quotes and backslashes to prevent Cypher injection
|
|
@@ -32,7 +33,7 @@ async function queryFTSViaExecutor(executor, tableName, indexName, query, limit)
|
|
|
32
33
|
});
|
|
33
34
|
}
|
|
34
35
|
catch {
|
|
35
|
-
return
|
|
36
|
+
return null;
|
|
36
37
|
}
|
|
37
38
|
}
|
|
38
39
|
/**
|
|
@@ -48,6 +49,7 @@ async function queryFTSViaExecutor(executor, tableName, indexName, query, limit)
|
|
|
48
49
|
*/
|
|
49
50
|
export const searchFTSFromLbug = async (query, limit = 20, repoId) => {
|
|
50
51
|
const resultsByIndex = [];
|
|
52
|
+
let queriesSucceeded = 0;
|
|
51
53
|
if (repoId) {
|
|
52
54
|
// Use MCP connection pool via dynamic import
|
|
53
55
|
// IMPORTANT: FTS queries run sequentially to avoid connection contention.
|
|
@@ -56,15 +58,27 @@ export const searchFTSFromLbug = async (query, limit = 20, repoId) => {
|
|
|
56
58
|
const { executeQuery } = poolMod;
|
|
57
59
|
const executor = (cypher) => executeQuery(repoId, cypher);
|
|
58
60
|
for (const { table, indexName } of FTS_INDEXES) {
|
|
59
|
-
|
|
61
|
+
const result = await queryFTSViaExecutor(executor, table, indexName, query, limit);
|
|
62
|
+
if (result !== null) {
|
|
63
|
+
queriesSucceeded++;
|
|
64
|
+
resultsByIndex.push(result);
|
|
65
|
+
}
|
|
60
66
|
}
|
|
61
67
|
}
|
|
62
68
|
else {
|
|
63
69
|
// Use core lbug adapter (CLI / pipeline context) — also sequential for safety.
|
|
64
70
|
for (const { table, indexName } of FTS_INDEXES) {
|
|
65
|
-
|
|
71
|
+
try {
|
|
72
|
+
const result = await queryFTS(table, indexName, query, limit, false);
|
|
73
|
+
queriesSucceeded++;
|
|
74
|
+
resultsByIndex.push(result);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// FTS index may not exist — count as failed
|
|
78
|
+
}
|
|
66
79
|
}
|
|
67
80
|
}
|
|
81
|
+
const ftsAvailable = queriesSucceeded > 0;
|
|
68
82
|
// Collect all node scores per filePath to track which nodes actually matched
|
|
69
83
|
const fileNodeScores = new Map();
|
|
70
84
|
const addResults = (results) => {
|
|
@@ -92,10 +106,13 @@ export const searchFTSFromLbug = async (query, limit = 20, repoId) => {
|
|
|
92
106
|
const sorted = Array.from(merged.values())
|
|
93
107
|
.sort((a, b) => b.score - a.score)
|
|
94
108
|
.slice(0, limit);
|
|
95
|
-
return
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
109
|
+
return {
|
|
110
|
+
results: sorted.map((r, index) => ({
|
|
111
|
+
filePath: r.filePath,
|
|
112
|
+
score: r.score,
|
|
113
|
+
rank: index + 1,
|
|
114
|
+
nodeIds: r.nodeIds,
|
|
115
|
+
})),
|
|
116
|
+
ftsAvailable,
|
|
117
|
+
};
|
|
101
118
|
};
|
|
@@ -32,9 +32,10 @@ export interface HybridSearchResult {
|
|
|
32
32
|
*/
|
|
33
33
|
export declare const mergeWithRRF: (bm25Results: BM25SearchResult[], semanticResults: SemanticSearchResult[], limit?: number) => HybridSearchResult[];
|
|
34
34
|
/**
|
|
35
|
-
* Check if hybrid search is available
|
|
36
|
-
*
|
|
37
|
-
*
|
|
35
|
+
* Check if hybrid search is available.
|
|
36
|
+
* FTS indexes may be missing on read-only MCP connections (see #1403);
|
|
37
|
+
* callers should inspect `ftsAvailable` from searchFTSFromLbug for
|
|
38
|
+
* per-query availability. This helper is a coarse gate only.
|
|
38
39
|
*/
|
|
39
40
|
export declare const isHybridSearchReady: () => boolean;
|
|
40
41
|
/**
|
|
@@ -79,12 +79,13 @@ export const mergeWithRRF = (bm25Results, semanticResults, limit = 10) => {
|
|
|
79
79
|
return sorted;
|
|
80
80
|
};
|
|
81
81
|
/**
|
|
82
|
-
* Check if hybrid search is available
|
|
83
|
-
*
|
|
84
|
-
*
|
|
82
|
+
* Check if hybrid search is available.
|
|
83
|
+
* FTS indexes may be missing on read-only MCP connections (see #1403);
|
|
84
|
+
* callers should inspect `ftsAvailable` from searchFTSFromLbug for
|
|
85
|
+
* per-query availability. This helper is a coarse gate only.
|
|
85
86
|
*/
|
|
86
87
|
export const isHybridSearchReady = () => {
|
|
87
|
-
return true; // FTS is
|
|
88
|
+
return true; // FTS is attempted on every query; ftsAvailable signals actual availability
|
|
88
89
|
};
|
|
89
90
|
/**
|
|
90
91
|
* Format hybrid results for LLM consumption
|
|
@@ -112,7 +113,7 @@ export const formatHybridResults = (results) => {
|
|
|
112
113
|
*/
|
|
113
114
|
export const hybridSearch = async (query, limit, executeQuery, semanticSearch) => {
|
|
114
115
|
// Use LadybugDB FTS for always-fresh BM25 results
|
|
115
|
-
const bm25Results = await searchFTSFromLbug(query, limit);
|
|
116
|
+
const { results: bm25Results } = await searchFTSFromLbug(query, limit);
|
|
116
117
|
const semanticResults = await semanticSearch(executeQuery, query, limit);
|
|
117
118
|
return mergeWithRRF(bm25Results, semanticResults, limit);
|
|
118
119
|
};
|
|
@@ -818,7 +818,7 @@ export class LocalBackend {
|
|
|
818
818
|
definitions: definitions.slice(0, 20), // cap standalone definitions
|
|
819
819
|
timing,
|
|
820
820
|
...(!ftsUsed && {
|
|
821
|
-
warning: 'FTS
|
|
821
|
+
warning: 'FTS indexes missing — keyword search degraded. Run: gitnexus analyze --force to rebuild indexes.',
|
|
822
822
|
}),
|
|
823
823
|
};
|
|
824
824
|
}
|
|
@@ -827,15 +827,16 @@ export class LocalBackend {
|
|
|
827
827
|
*/
|
|
828
828
|
async bm25Search(repo, query, limit) {
|
|
829
829
|
const { searchFTSFromLbug } = await import('../../core/search/bm25-index.js');
|
|
830
|
-
let
|
|
830
|
+
let ftsResponse;
|
|
831
831
|
try {
|
|
832
|
-
|
|
832
|
+
ftsResponse = await searchFTSFromLbug(query, limit, repo.id);
|
|
833
833
|
}
|
|
834
834
|
catch (err) {
|
|
835
835
|
logger.error({ err: err.message }, 'GitNexus: BM25/FTS search failed (FTS indexes may not exist) -');
|
|
836
836
|
return { results: [], ftsUsed: false };
|
|
837
837
|
}
|
|
838
|
-
const
|
|
838
|
+
const bm25Results = ftsResponse.results;
|
|
839
|
+
const ftsUsed = ftsResponse.ftsAvailable;
|
|
839
840
|
const results = [];
|
|
840
841
|
for (const bm25Result of bm25Results) {
|
|
841
842
|
const fullPath = bm25Result.filePath;
|
package/dist/server/api.js
CHANGED
|
@@ -932,10 +932,11 @@ export const createServer = async (port, host = '127.0.0.1') => {
|
|
|
932
932
|
const enrich = req.body.enrich !== false; // default true
|
|
933
933
|
const results = await withLbugDb(lbugPath, async () => {
|
|
934
934
|
let searchResults;
|
|
935
|
+
let ftsAvailable;
|
|
935
936
|
if (mode === 'semantic') {
|
|
936
937
|
const { isEmbedderReady } = await import('../core/embeddings/embedder.js');
|
|
937
938
|
if (!isEmbedderReady()) {
|
|
938
|
-
return [];
|
|
939
|
+
return { searchResults: [], ftsAvailable: undefined };
|
|
939
940
|
}
|
|
940
941
|
const { semanticSearch: semSearch } = await import('../core/embeddings/embedding-pipeline.js');
|
|
941
942
|
searchResults = await semSearch(executeQuery, query, limit);
|
|
@@ -948,8 +949,9 @@ export const createServer = async (port, host = '127.0.0.1') => {
|
|
|
948
949
|
}));
|
|
949
950
|
}
|
|
950
951
|
else if (mode === 'bm25') {
|
|
951
|
-
|
|
952
|
-
|
|
952
|
+
const ftsResponse = await searchFTSFromLbug(query, limit);
|
|
953
|
+
ftsAvailable = ftsResponse.ftsAvailable;
|
|
954
|
+
searchResults = ftsResponse.results.map((r, i) => ({
|
|
953
955
|
...r,
|
|
954
956
|
rank: i + 1,
|
|
955
957
|
sources: ['bm25'],
|
|
@@ -963,11 +965,13 @@ export const createServer = async (port, host = '127.0.0.1') => {
|
|
|
963
965
|
searchResults = await hybridSearch(query, limit, executeQuery, semSearch);
|
|
964
966
|
}
|
|
965
967
|
else {
|
|
966
|
-
|
|
968
|
+
const ftsResponse = await searchFTSFromLbug(query, limit);
|
|
969
|
+
ftsAvailable = ftsResponse.ftsAvailable;
|
|
970
|
+
searchResults = ftsResponse.results;
|
|
967
971
|
}
|
|
968
972
|
}
|
|
969
973
|
if (!enrich)
|
|
970
|
-
return searchResults;
|
|
974
|
+
return { searchResults, ftsAvailable };
|
|
971
975
|
// Server-side enrichment: add connections, cluster, processes per result
|
|
972
976
|
// Uses parameterized queries to prevent Cypher injection via nodeId
|
|
973
977
|
const validLabel = (label) => NODE_TABLES.includes(label);
|
|
@@ -1029,9 +1033,14 @@ export const createServer = async (port, host = '127.0.0.1') => {
|
|
|
1029
1033
|
}
|
|
1030
1034
|
return { ...r, ...enrichment };
|
|
1031
1035
|
}));
|
|
1032
|
-
return enriched;
|
|
1036
|
+
return { searchResults: enriched, ftsAvailable };
|
|
1033
1037
|
});
|
|
1034
|
-
|
|
1038
|
+
const response = { results: results.searchResults ?? results };
|
|
1039
|
+
if (results.ftsAvailable === false) {
|
|
1040
|
+
response.warning =
|
|
1041
|
+
'FTS indexes missing — keyword search degraded. Run: gitnexus analyze --force to rebuild indexes.';
|
|
1042
|
+
}
|
|
1043
|
+
res.json(response);
|
|
1035
1044
|
}
|
|
1036
1045
|
catch (err) {
|
|
1037
1046
|
res.status(500).json({ error: err.message || 'Search failed' });
|
package/package.json
CHANGED