mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-11-10 08:27:27 +00:00
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (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
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
* fix(mcp): reject corrupt query identities and refresh index metadata * fix(mcp): preserve reader and search integrity Keep replaced readers unknown until matching metadata is published. Defer shared-handle refresh until other consumers release the old graph to avoid rebuilding connections against the same stale generation. Propagate identity corruption after both hybrid search lanes settle, so the sibling cannot query after its database session is released. Select the regressions for the OS matrix and retain Python coverage without optional Swift grammars. Validation: 393 focused tests, nine native generation tests and ten race executions across five fresh processes pass. Build and source/test typechecks pass. The early full run passed 26,096 tests and failed eight: seven reproduce on baseline; the mixed-source publication case passes in clean reruns. Windows/macOS native execution remains for CI. Graph analysis maps all edited production symbols; coverage remains partial for the anonymous FTS test callback, manually reviewed. Refs #3571 * fix(lbug): bump @ladybugdb/core to 0.21.2 to stop doubled rows after delete and re-copy LadybugDB 0.21.0 and 0.21.1 return every row of a table that spans more than one native vector twice once the incremental writeback has DETACH DELETEd it and a COPY into an earlier table has run. NodeGroup::scan let a morsel that ended before the group's first live row wrap an unsigned subtraction and rescan to the end of the group (LadybugDB #1085, fixed in 0.21.2). No primary key error fires. The identity guard from #3442 stops the run with "unexpected or duplicate ID", so a Swift edit that pulls every importer into the write set failed on repos with more than 2048 structs. Only unreleased main was affected: 1.6.12 ships 0.18.3. Pin 0.21.2 in package.json, update the six @ladybugdb/core entries in the lockfile, and move coreVersion in the vendored FTS manifest. The FTS extension stays at 0.21.0 because no v0.21.1 or v0.21.2 artifact is published; the pairing assertion and its unit tests pass. Add a regression to the delete-nodes-for-files suite: 2049 structs with CONTAINS edges are loaded, every file is deleted, the graph is loaded again, and each id and edge must appear exactly once. Verification (Linux, Node 22.18): - lbug-delete-nodes-for-files: 11/11 on 0.21.2. With the 0.21.1 native packages swapped in, the new test fails with 4098 rows instead of 2049. - Reporter recipe end to end on the built CLI: incremental analyze exits 0 with no duplicate ids on a Swift-heavy fixture and on a 70-file Python fixture, with and without FTS. On 0.21.1 the Swift fixture exits 1. - A 0.21.1-written database opens and reads under 0.21.2. - prettier, eslint and typecheck:tests are clean on the changed files. - The full lbug-db project had not finished when this was committed. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * test(fts): say what the Windows warm-session branch covers On Windows the repair step calls poolClose, which removes the pool entry before the in-place writer opens. The step therefore checks the backend's cached capability state, not a live pool connection. The comment now says so instead of implying the pool stays warm. No behavior change. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * revert: take the @ladybugdb/core 0.21.2 bump out of this PR Reverts0cdc3dfe3. The bump fixes LadybugDB 0.21.0 and 0.21.1 returning every row of a large table twice after a delete and re-copy, but on Windows it crashes the vendored FTS extension, which is still the v0.21.0 build: four of eight Windows shards failed with exit code 3221225477 (0xC0000005) in tests that load libfts and build an FTS index. All eight passed on9d79092abwith core 0.21.1. A Windows extension is only published for v0.21.0, not for v0.21.1 or v0.21.2. The bump moves to its own draft PR (#3578) until a matching Windows extension exists. This PR returns to its original query-integrity and index-freshness scope. Restores gitnexus/package.json, the six @ladybugdb/core entries in package-lock.json, the coreVersion in vendor/lbug-fts/manifest.json, and removes the regression test that belongs with the bump. All four files are identical to origin/main (1b4e3f0a8) after this commit. Verification: - git diff origin/main for the four files is empty. - fts-artifact-coverage and vendored-extension-path unit tests pass (31), and assert-publish-fts-coverage reports core 0.21.1 paired with extension 0.21.0. - The graph check on the staged revert was partial, because it only deletes test code. The forward change was gated clean: 4 files, 6 symbols, all in that test, no affected processes. - Windows CI has not run on this commit. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
363 lines
14 KiB
TypeScript
363 lines
14 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
import lbug from '@ladybugdb/core';
|
|
|
|
// Drive the REAL initLbug path (which calls evictLRU) rather than
|
|
// initLbugWithDb (which bypasses eviction entirely). The native LadybugDB
|
|
// open/connect/FTS stack is mocked exactly as in lbug-pool-fts-load.test.ts,
|
|
// plus sidecar-recovery so openReadOnlyDatabase's preflight is a no-op. fs is
|
|
// NOT mocked — each repo uses a real temp file so the fs.stat existence check
|
|
// in doInitLbug succeeds naturally.
|
|
//
|
|
// Covers issue #2189: a group sync larger than MAX_POOL_SIZE must keep every
|
|
// repo resident through deferred manifest/workspace resolution. Pinning makes
|
|
// that resident set survive automatic (LRU + idle) eviction.
|
|
|
|
const { loadFTSExtensionMock, loadVectorExtensionMock } = vi.hoisted(() => ({
|
|
loadFTSExtensionMock: vi.fn(),
|
|
loadVectorExtensionMock: vi.fn().mockResolvedValue(false),
|
|
}));
|
|
|
|
vi.mock('@ladybugdb/core', () => ({
|
|
default: {
|
|
Database: vi.fn(),
|
|
Connection: vi.fn(function (this: any) {
|
|
// probeDatabaseForShadowReplay() runs a probe query during the
|
|
// read-only open; the result must expose getAll()/close().
|
|
this.query = vi.fn().mockResolvedValue({
|
|
getAll: vi.fn().mockResolvedValue([]),
|
|
close: vi.fn(),
|
|
});
|
|
this.close = vi.fn().mockResolvedValue(undefined);
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
|
|
isReadOnlyDbError: vi.fn(() => false),
|
|
loadFTSExtension: loadFTSExtensionMock,
|
|
loadVectorExtension: loadVectorExtensionMock,
|
|
}));
|
|
|
|
vi.mock('../../src/core/lbug/lbug-config.js', () => ({
|
|
// A fresh fake Database per call so distinct dbPaths get distinct entries
|
|
// and closeOne's db.close() resolves per repo.
|
|
createLbugDatabase: vi.fn(() => ({
|
|
init: vi.fn().mockResolvedValue(undefined),
|
|
close: vi.fn().mockResolvedValue(undefined),
|
|
})),
|
|
toNativeSafePath: vi.fn((p: string) => p),
|
|
isWalCorruptionError: vi.fn(() => false),
|
|
WAL_RECOVERY_SUGGESTION: '',
|
|
isStorageVersionMismatchError: vi.fn(() => false),
|
|
throwIfStorageVersionMismatch: vi.fn(),
|
|
sleep: vi.fn(async () => {}),
|
|
STORAGE_VERSION_MISMATCH_SUGGESTION: '',
|
|
}));
|
|
|
|
vi.mock('../../src/core/lbug/sidecar-recovery.js', () => ({
|
|
preflightLbugSidecars: vi.fn().mockResolvedValue(undefined),
|
|
guardWalQuarantine: vi.fn().mockResolvedValue(undefined),
|
|
isMissingFsError: vi.fn(() => false),
|
|
isMissingShadowSidecarError: vi.fn(() => false),
|
|
isReadOnlyShadowReplayError: vi.fn(() => false),
|
|
quarantineWalForMissingShadow: vi.fn().mockResolvedValue(''),
|
|
// Not consumed by pool-adapter today; listed so this wholesale mock can't
|
|
// become a TypeError trap if the pool ever routes through dirty recovery
|
|
// (#2409, tri-review 4669518496 mock-hygiene sweep).
|
|
quarantineSidecarsForDirtyRecovery: vi
|
|
.fn()
|
|
.mockResolvedValue({ moved: [], removed: [], failed: [] }),
|
|
renameFailureMessage: vi.fn((p: string) => `rename failed for ${p}`),
|
|
statIfExists: vi.fn().mockResolvedValue(null),
|
|
assertReadOnlyFtsCrashSafe: vi.fn().mockResolvedValue(undefined),
|
|
FtsReaderUnrepairableError: class FtsReaderUnrepairableError extends Error {
|
|
readonly code = 'FTS_READER_UNREPAIRABLE' as const;
|
|
constructor(dbPath = '') {
|
|
super(dbPath);
|
|
this.name = 'FtsReaderUnrepairableError';
|
|
}
|
|
},
|
|
}));
|
|
|
|
const { initLbug, initLbugWithDb, closeLbug, getLbugDbIdentity, isLbugReady, pinRepo, unpinRepo } =
|
|
await import('../../src/core/lbug/pool-adapter.js');
|
|
const { createLbugDatabase } = await import('../../src/core/lbug/lbug-config.js');
|
|
const { initWikiDb, closeWikiDb, pinWikiDb } = await import('../../src/core/wiki/graph-queries.js');
|
|
|
|
describe('pool-adapter repo pinning (issue #2189)', () => {
|
|
let tmpDir: string;
|
|
// Track every repoId touched so afterEach can fully reset module-global state.
|
|
const touched = new Set<string>();
|
|
|
|
const dbPathFor = (repoId: string): string => {
|
|
const p = path.join(tmpDir, `${repoId}.lbug`);
|
|
writeFileSync(p, ''); // real file so fs.stat() in doInitLbug succeeds
|
|
return p;
|
|
};
|
|
|
|
const init = async (repoId: string): Promise<void> => {
|
|
touched.add(repoId);
|
|
await initLbug(repoId, dbPathFor(repoId));
|
|
};
|
|
|
|
beforeEach(() => {
|
|
tmpDir = mkdtempSync(path.join(os.tmpdir(), 'gn-pin-test-'));
|
|
loadFTSExtensionMock.mockResolvedValue(true);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.useRealTimers();
|
|
await closeLbug().catch(() => {});
|
|
for (const id of touched) unpinRepo(id);
|
|
touched.clear();
|
|
loadFTSExtensionMock.mockReset();
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
// MAX_POOL_SIZE is 5; the 6th init triggers evictLRU.
|
|
it('characterization: WITHOUT pinning, the earliest repo is LRU-evicted past the cap', async () => {
|
|
for (let i = 1; i <= 6; i++) await init(`repo-${i}`);
|
|
|
|
// repo-1 had the oldest lastUsed and nothing was checked out, so it is the
|
|
// eviction victim — exactly the stale-executor scenario from #2189.
|
|
expect(isLbugReady('repo-1')).toBe(false);
|
|
// The most recently initialized repo survives.
|
|
expect(isLbugReady('repo-6')).toBe(true);
|
|
});
|
|
|
|
it('FIX: pinning each repo keeps all of them resident past the cap', async () => {
|
|
for (let i = 1; i <= 6; i++) {
|
|
await init(`repo-${i}`);
|
|
pinRepo(`repo-${i}`);
|
|
}
|
|
|
|
for (let i = 1; i <= 6; i++) {
|
|
expect(isLbugReady(`repo-${i}`)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('explicit close beats the pin AND clears it (no cross-operation leak)', async () => {
|
|
pinRepo('repo-x');
|
|
await init('repo-x');
|
|
expect(isLbugReady('repo-x')).toBe(true);
|
|
|
|
// Explicit teardown closes a pinned repo without needing an unpin first.
|
|
await closeLbug('repo-x');
|
|
expect(isLbugReady('repo-x')).toBe(false);
|
|
|
|
// The pin must have been cleared on close: re-init repo-x FIRST (oldest
|
|
// lastUsed) and fill past the cap. If the pin had leaked, repo-x would be
|
|
// un-evictable; instead it is evicted as the LRU victim.
|
|
await init('repo-x');
|
|
for (let i = 1; i <= 5; i++) await init(`fresh-${i}`);
|
|
expect(isLbugReady('repo-x')).toBe(false);
|
|
});
|
|
|
|
it('idle-timeout sweep skips pinned repos but still evicts idle unpinned ones', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
await init('pinned-idle');
|
|
pinRepo('pinned-idle');
|
|
await init('unpinned-idle');
|
|
|
|
expect(isLbugReady('pinned-idle')).toBe(true);
|
|
expect(isLbugReady('unpinned-idle')).toBe(true);
|
|
|
|
// The idle timer runs every 60s and closes entries idle past
|
|
// IDLE_TIMEOUT_MS (5 min) with no checked-out connections. advance past
|
|
// both thresholds, flushing microtasks between fires.
|
|
await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 60 * 1000);
|
|
|
|
expect(isLbugReady('pinned-idle')).toBe(true);
|
|
expect(isLbugReady('unpinned-idle')).toBe(false);
|
|
});
|
|
|
|
it('wiki DB pin wrapper keeps __wiki__ resident past idle cleanup', async () => {
|
|
vi.useFakeTimers();
|
|
|
|
const releaseWikiPin = pinWikiDb();
|
|
await initWikiDb(dbPathFor('wiki-wrapper'));
|
|
expect(isLbugReady('__wiki__')).toBe(true);
|
|
|
|
await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 60 * 1000);
|
|
expect(isLbugReady('__wiki__')).toBe(true);
|
|
|
|
releaseWikiPin();
|
|
await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 60 * 1000);
|
|
expect(isLbugReady('__wiki__')).toBe(false);
|
|
|
|
await closeWikiDb();
|
|
});
|
|
|
|
it('unpinRepo re-enables eviction for that repo', async () => {
|
|
// Pin five repos and fill the pool; a sixth init evicts nothing (all pinned).
|
|
for (let i = 1; i <= 5; i++) {
|
|
await init(`p-${i}`);
|
|
pinRepo(`p-${i}`);
|
|
}
|
|
await init('p-6'); // unpinned; pool now holds 6 (soft-cap exceeded)
|
|
for (let i = 1; i <= 6; i++) expect(isLbugReady(`p-${i}`)).toBe(true);
|
|
|
|
// Unpin the oldest, then init a 7th repo — the now-unpinned p-1 is the LRU
|
|
// victim.
|
|
unpinRepo('p-1');
|
|
await init('p-7');
|
|
expect(isLbugReady('p-1')).toBe(false);
|
|
expect(isLbugReady('p-7')).toBe(true);
|
|
});
|
|
|
|
it('reference-counts leases: two pins need two unpins before eviction (Finding 1)', async () => {
|
|
// Fill the pool to capacity, all leased.
|
|
for (let i = 1; i <= 4; i++) {
|
|
await init(`rc-${i}`);
|
|
pinRepo(`rc-${i}`);
|
|
}
|
|
await init('rc-shared');
|
|
pinRepo('rc-shared'); // lease 1
|
|
pinRepo('rc-shared'); // lease 2 (two holders)
|
|
|
|
// Release ONE lease — a holder remains, so rc-shared stays exempt even
|
|
// under eviction pressure.
|
|
unpinRepo('rc-shared');
|
|
await init('rc-extra'); // evictLRU finds no unpinned victim → pool grows
|
|
expect(isLbugReady('rc-shared')).toBe(true);
|
|
|
|
// Release the LAST lease — now rc-shared (oldest unpinned) is evictable.
|
|
unpinRepo('rc-shared');
|
|
await init('rc-extra2');
|
|
expect(isLbugReady('rc-shared')).toBe(false);
|
|
});
|
|
|
|
it('unpinRepo floors at zero and tolerates unknown repoIds', () => {
|
|
expect(() => {
|
|
unpinRepo('never-touched'); // unknown repoId → no-op
|
|
pinRepo('floor-x');
|
|
unpinRepo('floor-x'); // count 0 → key deleted
|
|
unpinRepo('floor-x'); // already gone → no-op, never a negative count
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it('pinRepo returns a disposer that releases exactly once and composes with refcount', async () => {
|
|
for (let i = 1; i <= 4; i++) {
|
|
await init(`d-${i}`);
|
|
pinRepo(`d-${i}`);
|
|
}
|
|
await init('d-shared');
|
|
const release1 = pinRepo('d-shared'); // lease 1
|
|
const release2 = pinRepo('d-shared'); // lease 2
|
|
|
|
release1();
|
|
release1(); // double-call is a no-op — must NOT decrement lease 2
|
|
|
|
// lease 2 still held → d-shared survives eviction pressure.
|
|
await init('d-extra');
|
|
expect(isLbugReady('d-shared')).toBe(true);
|
|
|
|
// Release the last lease via its own disposer → now evictable.
|
|
release2();
|
|
await init('d-extra2');
|
|
expect(isLbugReady('d-shared')).toBe(false);
|
|
});
|
|
|
|
it('a pin acquired while closeOne awaits db.close() does not survive teardown', async () => {
|
|
vi.mocked(createLbugDatabase).mockImplementationOnce(() => ({
|
|
init: vi.fn().mockResolvedValue(undefined),
|
|
initSync: vi.fn(),
|
|
closeSync: vi.fn(),
|
|
close: vi.fn().mockImplementation(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
}),
|
|
}));
|
|
|
|
await init('late-pin');
|
|
const closing = closeLbug('late-pin');
|
|
await Promise.resolve();
|
|
pinRepo('late-pin');
|
|
await closing;
|
|
expect(isLbugReady('late-pin')).toBe(false);
|
|
|
|
await init('late-pin');
|
|
for (let i = 1; i <= 5; i++) await init(`late-fresh-${i}`);
|
|
expect(isLbugReady('late-pin')).toBe(false);
|
|
});
|
|
|
|
it('initLbugWithDb waits for an in-flight closeOne before registering', async () => {
|
|
vi.mocked(createLbugDatabase).mockImplementationOnce(() => ({
|
|
init: vi.fn().mockResolvedValue(undefined),
|
|
initSync: vi.fn(),
|
|
closeSync: vi.fn(),
|
|
close: vi.fn().mockImplementation(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
}),
|
|
}));
|
|
|
|
await init('injected-overlap');
|
|
const closing = closeLbug('injected-overlap');
|
|
await Promise.resolve();
|
|
const injected = {
|
|
init: vi.fn().mockResolvedValue(undefined),
|
|
initSync: vi.fn(),
|
|
closeSync: vi.fn(),
|
|
close: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
const injecting = initLbugWithDb(
|
|
'injected-overlap',
|
|
injected as never,
|
|
dbPathFor('injected-overlap'),
|
|
);
|
|
await closing;
|
|
await injecting;
|
|
expect(isLbugReady('injected-overlap')).toBe(true);
|
|
});
|
|
|
|
it('defers shared-handle refresh without connection churn until the other consumer releases it', async () => {
|
|
const dbPath = dbPathFor('shared-refresh');
|
|
await initLbug('refresh-reader', dbPath);
|
|
await initLbug('refresh-holder', dbPath);
|
|
const openedIdentity = getLbugDbIdentity('refresh-reader');
|
|
const database = vi.mocked(createLbugDatabase).mock.results.at(-1)?.value;
|
|
expect(database).toBeDefined();
|
|
const connectionCount = vi.mocked(lbug.Connection).mock.calls.length;
|
|
const connections = vi.mocked(lbug.Connection).mock.results.map(({ value }) => value);
|
|
const closeCounts = connections.map((conn) => conn.close.mock.calls.length);
|
|
|
|
// Real file stat changes on every OS; the mocked native handle remains on
|
|
// the opened generation without relying on POSIX replacement semantics.
|
|
writeFileSync(dbPath, 'new generation');
|
|
for (let i = 0; i < 3; i++) {
|
|
expect(await initLbug('refresh-reader', dbPath)).toBe(false);
|
|
expect(getLbugDbIdentity('refresh-reader')).toEqual(openedIdentity);
|
|
expect(isLbugReady('refresh-holder')).toBe(true);
|
|
}
|
|
expect(vi.mocked(lbug.Connection).mock.calls).toHaveLength(connectionCount);
|
|
expect(connections.map((conn) => conn.close.mock.calls.length)).toEqual(closeCounts);
|
|
expect(database.close).not.toHaveBeenCalled();
|
|
|
|
await closeLbug('refresh-holder');
|
|
expect(database.close).not.toHaveBeenCalled();
|
|
expect(await initLbug('refresh-reader', dbPath)).toBe(true);
|
|
expect(database.close).toHaveBeenCalledTimes(1);
|
|
expect(getLbugDbIdentity('refresh-reader')).not.toEqual(openedIdentity);
|
|
expect(await initLbug('refresh-reader', dbPath)).toBe(false);
|
|
});
|
|
|
|
it('keeps externally owned databases untracked and open across file changes', async () => {
|
|
const dbPath = dbPathFor('external-refresh');
|
|
const externalDb = {
|
|
init: vi.fn(),
|
|
close: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
await initLbugWithDb('external-reader', externalDb as never, dbPath);
|
|
await initLbug('external-holder', dbPath);
|
|
writeFileSync(dbPath, 'new generation');
|
|
expect(await initLbug('external-reader', dbPath)).toBe(false);
|
|
expect(getLbugDbIdentity('external-reader')).toBeNull();
|
|
expect(getLbugDbIdentity('external-holder')).toBeNull();
|
|
await closeLbug('external-holder');
|
|
await closeLbug('external-reader');
|
|
expect(externalDb.close).not.toHaveBeenCalled();
|
|
});
|
|
});
|