diff --git a/README.md b/README.md index 495483cf8..a1eef4304 100644 --- a/README.md +++ b/README.md @@ -491,7 +491,7 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max | `GITNEXUS_WORKER_READY_TIMEOUT_MS` | `5000` | Startup budget in milliseconds for a parse worker to load its grammar bindings and report `{type:'ready'}`. Slots that miss it are treated as startup crashes. | Slow or heavily loaded hosts where a full pool cold-starting concurrently needs more than 5s, and analyze aborts with "did not report ready within 5000ms". | | `GITNEXUS_FTS_STEMMER` | `porter` | Stemmer used when rebuilding BM25/FTS indexes. Use `none` for CJK-heavy repositories, or a language stemmer such as `german`, `french`, or `spanish` for matching repository comments. Re-run `gitnexus analyze --repair-fts` after changing it. | Keyword search quality is poor for non-English comments or identifiers under English stemming. | | `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold in bytes. Equivalent to `--wal-checkpoint-threshold `. `-1` keeps LadybugDB's stock threshold (~16 MiB). Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. | You need a larger or smaller WAL auto-checkpoint threshold for your analyze workload. | -| `GITNEXUS_LBUG_BUFFER_POOL_SIZE` | min(2 GiB, 80% RAM) | LadybugDB buffer-pool ceiling in bytes for every GitNexus database (analyze, MCP server, serve, group bridges). `0` restores LadybugDB's native unbounded default of 80% of system RAM; invalid values warn and fall back to the default (#2557). | A long-lived `gitnexus mcp` or a big incremental `analyze` uses too much memory, or a huge repo's working set genuinely needs a pool larger than 2 GiB. | +| `GITNEXUS_LBUG_BUFFER_POOL_SIZE` | min(2 GiB, 80% RAM) | LadybugDB buffer-pool ceiling in bytes for every GitNexus database (analyze, MCP server, serve, group bridges). `0` restores LadybugDB's native unbounded default of 80% of system RAM; invalid values warn and fall back to the default (#2557). During `analyze` the pool is right-sized to the graph, scaled on non-4 KiB-page hosts by the page-size granule ratio up to min(2 GiB × pageSize/4 KiB, 80% RAM) (#2631); this env var overrides all of that as an absolute value. | A long-lived `gitnexus mcp` or a big incremental `analyze` uses too much memory, or a huge repo's working set genuinely needs a pool larger than 2 GiB. | | `GITNEXUS_LBUG_MAX_DB_SIZE` | `17179869184` (16 GiB) | Maximum size in bytes of a single LadybugDB database file — an mmap/disk-address-space ceiling, not a memory limit (it does not constrain the buffer pool). Invalid values silently fall back to the default. | Indexing a genuinely huge monorepo whose on-disk graph index approaches 16 GiB. | | `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` | `8388608` (8 MB) | Per-job byte budget the pool will send to a worker in one `postMessage`. | Very large individual files; mostly diagnostic — bumping past 8 MB risks structured-clone memory pressure. | | `GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT` | `3` | Max replacement spawns per worker slot before the slot is dropped from the active rotation. Bounds respawn loops on a chronically-crashing slot. | Hosts where a flaky worker should retry more (raise) or fail-fast (lower) before the slot is dropped. | diff --git a/gitnexus/README.md b/gitnexus/README.md index edee4da57..6e92c69d8 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -484,7 +484,7 @@ Configure the behavior with these environment variables: | `GITNEXUS_FTS_CJK_SEGMENTATION` | `none`, `bigram` | `none` | `bigram` inserts overlapping character-bigram boundaries into Chinese/Japanese Han-ideograph spans in `content`/`description` before FTS indexing, so LadybugDB's space-only tokenizer can see sub-phrase word boundaries. Scoped to CJK Unified Ideographs only — Japanese Hiragana/Katakana and Korean Hangul are not currently segmented. Unlike `GITNEXUS_FTS_STEMMER`, this rewrites stored text — enabling it on an already-indexed repo requires a full `gitnexus analyze --force`; neither `--repair-fts` nor a plain incremental `analyze` applies it to previously-indexed files. Set the same value wherever `analyze` and search-serving processes (CLI query, MCP server, web server) run. | | `GITNEXUS_COMMUNITY_ENGINE` | `graphology`, `icebug`, `auto` | `graphology` | Community-detection engine used during analyze. `graphology` uses the bundled default path. `icebug` and `auto` currently behave identically: both try the experimental Icebug CSR path and fall back to Graphology if the optional native module is unavailable or incompatible. | | `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | integer `>= -1` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold during analyze (bytes). Auto-checkpoint remains enabled; `-1` keeps Ladybug's stock ~16 MiB. Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. | -| `GITNEXUS_LBUG_BUFFER_POOL_SIZE` | integer `>= 0` (bytes) | min(2 GiB, 80% RAM) | LadybugDB buffer-pool ceiling for every GitNexus database (analyze, MCP server, serve, group bridges). Bounded so a long-lived `gitnexus mcp` process or a large incremental `analyze` cannot grow toward LadybugDB's native 80%-of-RAM default and OOM the host (#2557). `0` restores that native unbounded default; invalid values warn and fall back to the default. | +| `GITNEXUS_LBUG_BUFFER_POOL_SIZE` | integer `>= 0` (bytes) | min(2 GiB, 80% RAM) | LadybugDB buffer-pool ceiling for every GitNexus database (analyze, MCP server, serve, group bridges). Bounded so a long-lived `gitnexus mcp` process or a large incremental `analyze` cannot grow toward LadybugDB's native 80%-of-RAM default and OOM the host (#2557). `0` restores that native unbounded default; invalid values warn and fall back to the default. During `analyze` the pool is right-sized to the graph and, on non-4 KiB-page hosts (Apple Silicon 16 KiB, Ascend/aarch64 64 KiB), scaled by the page-size granule ratio up to min(2 GiB × pageSize/4 KiB, 80% RAM) (#2631); this env var overrides all of that as an absolute value. | | `GITNEXUS_LBUG_MAX_DB_SIZE` | positive integer (bytes) | `17179869184` (16 GiB) | Upper bound for a single LadybugDB database file. This is an mmap/disk-address-space ceiling, not a memory limit — it does not constrain the buffer pool (use `GITNEXUS_LBUG_BUFFER_POOL_SIZE` for that). Raise it when indexing genuinely huge monorepos; invalid values silently fall back to the default. | ```bash diff --git a/gitnexus/src/cli/doctor.ts b/gitnexus/src/cli/doctor.ts index 7ec8f30f5..6f7fe40c9 100644 --- a/gitnexus/src/cli/doctor.ts +++ b/gitnexus/src/cli/doctor.ts @@ -17,7 +17,11 @@ import { probeFtsExtensionLoad, probeVectorExtensionLoad, } from '../core/lbug/native-check.js'; -import { getOsPageSize, isPageSizeAwareLadybug } from '../core/lbug/lbug-config.js'; +import { + getEffectiveBufferPoolSize, + getOsPageSize, + isPageSizeAwareLadybug, +} from '../core/lbug/lbug-config.js'; import { diagnoseExtensionLoad } from '../core/lbug/extension-load-error.js'; import { getExtensionInstallPolicy } from '../core/lbug/extension-loader.js'; import { t } from './i18n/index.js'; @@ -150,6 +154,22 @@ export function pageSizeDoctorLines( return lines; } +/** + * The hintless buffer-pool doctor line (#2631) — the pool the next Database + * open in THIS process would get. Same plain-params testable-helper shape as + * pageSizeDoctorLines above. `pool` is getEffectiveBufferPoolSize(): `0` is + * the pass-through sentinel for LadybugDB's native 80%-of-RAM default, never + * printed as "0 MiB". `envRaw` (the raw GITNEXUS_LBUG_BUFFER_POOL_SIZE value) + * marks operator-supplied absolute values as "(env override)" — no scaling + * suffix: the hintless default is deliberately unscaled (#2557), and an env + * value is absolute, so a "×N" note would misdescribe both. + */ +export function poolSizeDoctorLine(pool: number, envRaw: string | undefined): string { + const value = pool === 0 ? 'native 80% of RAM' : `${Math.round(pool / (1024 * 1024))} MiB`; + const envNote = envRaw !== undefined && envRaw.trim().length > 0 ? ' (env override)' : ''; + return ` ${padDisplayEnd('pool size', 10)}${value}${envNote}`; +} + export const doctorCommand = async () => { const fingerprint = getRuntimeFingerprint(); const capabilities = getRuntimeCapabilities(); @@ -168,6 +188,11 @@ export const doctorCommand = async () => { for (const line of pageSizeDoctorLines(getOsPageSize(), fingerprint.ladybugdb)) { console.log(line); } + // Hintless buffer pool for the next DB open (#2631). Literal label like + // the page size line above (no i18n key). + console.log( + poolSizeDoctorLine(getEffectiveBufferPoolSize(), process.env.GITNEXUS_LBUG_BUFFER_POOL_SIZE), + ); const nativeCheck = checkLbugNative(); if (nativeCheck.ok) { console.log(` ${padDisplayEnd('native', 10)}✓ lbugjs.node loaded`); diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index 2319507ea..7a153a7de 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -36,6 +36,7 @@ import { isDbBusyError, isOpenRetryExhausted, isWalCorruptionError, + bufferPoolExhaustionRemedy, openLbugConnection, sleep, toNativeSafePath, @@ -952,7 +953,14 @@ const copyNodeCSVs = async ( const copyQuery = getCopyQuery(table, normalizeCopyPath(csvPath)); await copyCsvWithRetry(targetConn, copyQuery, (retryErr) => { const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr); - throw new Error(`COPY failed for ${table}: ${retryMsg.slice(0, 200)}`); + // Pool exhaustion gets a remedy (#2631): the raw binder text gives the + // operator nothing to act on, and on non-4K-page hosts (Ascend aarch64, + // Apple Silicon) the pool bills up to pageSize/4KiB x faster than the + // sizing was calibrated for — name the knob and the mechanism. + const remedy = bufferPoolExhaustionRemedy(retryMsg); + throw new Error( + `COPY failed for ${table}: ${retryMsg.slice(0, 200)}${remedy ? ` ${remedy}` : ''}`, + ); }); } }; @@ -1124,6 +1132,7 @@ export const loadGraphToLbug = async ( const insertedRels = totalValidRels; const warnings: string[] = []; + let poolRemedyIssued = false; if (insertedRels > 0) { log(`Loading edges: ${insertedRels.toLocaleString()} across ${relsByPair.size} types`); @@ -1150,6 +1159,17 @@ export const loadGraphToLbug = async ( await copyCsvWithRetry(writeConn, copyQuery, (retryErr) => { const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr); warnings.push(`${fromLabel}->${toLabel} (${rows} edges): ${retryMsg.slice(0, 80)}`); + // One remedy per bulk load, not per pair (#2631): pool exhaustion + // repeats for every remaining pair once it starts. logger.warn, not + // just warnings.push — the returned warnings array has no consumer at + // any call site, so a push alone would leave the remedy invisible + // while the row-by-row fallback quietly degrades the load. + const remedy = poolRemedyIssued ? undefined : bufferPoolExhaustionRemedy(retryMsg); + if (remedy) { + poolRemedyIssued = true; + warnings.push(remedy); + logger.warn(remedy); + } failedPairEdges += rows; failedPairCsvPaths.add(pairCsvPath); }); diff --git a/gitnexus/src/core/lbug/lbug-config.ts b/gitnexus/src/core/lbug/lbug-config.ts index d5de2161e..cc88878a5 100644 --- a/gitnexus/src/core/lbug/lbug-config.ts +++ b/gitnexus/src/core/lbug/lbug-config.ts @@ -340,18 +340,84 @@ const parseBufferPoolSize = (raw: string | undefined): number | undefined => { return Math.floor(parsed); }; +/** + * The buffer-manager frame size compiled into every shipped `@ladybugdb/core` + * binary (`LBUG_PAGE_SIZE_LOG2 = 12` in the engine's CMake) — frames are 4 KiB + * on every platform, independent of the OS page size. + */ +const LBUG_ASSUMED_FRAME_SIZE = 4096; + +/** + * How much the OS page size amplifies buffer-pool consumption (#2631). + * + * LadybugDB's VM region charges pool budget per DISCARD GRANULE, not per + * frame: `discardGranuleSize = max(frameSize, osPageSize)` (vm_region.cpp), + * `claimFrame` bills the whole granule when its first 4 KiB frame becomes + * resident, and `releaseFrame` refunds only when the granule's LAST frame + * leaves. On a 64 KiB-page kernel (aarch64 openEuler — Ascend hosts) that is + * 16 frames per granule: scattered access is billed up to 16× its real bytes, + * and whole eviction passes can evict frames yet refund nothing — which is + * exactly the engine's "buffer pool is full and no memory could be freed" + * throw. Apple Silicon macOS (16 KiB pages) is the same mechanism at 4×. + * + * So the ANALYZE-path pool sizes (the per-element estimate, the COPY-safety + * floor, and the cap the hint is clamped against) are scaled by this ratio: + * the budget must cover worst-case granule charging or COPY dies on non-4K + * hosts with a pool that would be ample on x86. The hintless default + * (defaultBufferPoolSize — MCP serve, doctor, native-check) is deliberately + * NOT scaled: the pool is a native eager allocation committed at DB open + * (measured — see POOL_BYTES_PER_ELEMENT below), so scaling the global + * default would revert the #2557 OOM cap on every 16 KiB/64 KiB host. If the + * engine ever charges per-frame (or ships page-size-matched frames), this + * collapses back to 1 and the scaling disappears. + * + * Fail-safe: an undetectable page size (win32 — where the granule mechanism + * is absent anyway — or a failed `getconf`) means ratio 1, i.e. today's + * behavior. + */ +export const granuleRatio = (pageSize: number | undefined = getOsPageSize()): number => { + if (pageSize === undefined || !Number.isFinite(pageSize)) return 1; + return Math.max(1, Math.floor(pageSize / LBUG_ASSUMED_FRAME_SIZE)); +}; + +/** + * Hintless pool default — MCP serve, doctor, native-check, any open without a + * per-run hint. Deliberately UNSCALED (#2557): the pool is an eager native + * allocation at DB open, so a page-size-scaled default would hand a + * long-lived `gitnexus mcp` on a 16 KiB/64 KiB host up to 80% of RAM — the + * exact OOM exposure the 2 GiB cap was added to remove. + */ const defaultBufferPoolSize = (): number => Math.min(DEFAULT_BUFFER_POOL_CAP, Math.max(BUFFER_POOL_FLOOR, Math.floor(os.totalmem() * 0.8))); /** - * Clamp an adaptive pool request to [ADAPTIVE_POOL_FLOOR, default]. The lower - * bound keeps LadybugDB's COPY viable; the upper bound (defaultBufferPoolSize) - * means the hint can only shrink the pool from today's default and can never - * exceed the 2 GiB / 80%-RAM cap — and on a machine whose default is below the - * COPY floor, the default wins, so the pool is never over-committed. + * Upper bound for the ANALYZE-path (hinted) pool: the #2557 cap scaled by the + * granule ratio, still bounded by 80% of RAM. Scaling only this bound — and + * not defaultBufferPoolSize — is what lets the #2631 fix take effect during + * the bulk COPY without touching hintless opens: with an unscaled cap the + * min() below would clamp the scaled COPY floor straight back to 2 GiB. */ -const clampBufferPool = (bytes: number): number => - Math.min(defaultBufferPoolSize(), Math.max(ADAPTIVE_POOL_FLOOR, Math.floor(bytes))); +const scaledAnalyzePoolCap = (pageSize: number | undefined): number => + Math.min( + DEFAULT_BUFFER_POOL_CAP * granuleRatio(pageSize), + Math.max(BUFFER_POOL_FLOOR, Math.floor(os.totalmem() * 0.8)), + ); + +/** + * Clamp an adaptive pool request to [ADAPTIVE_POOL_FLOOR × granuleRatio, + * scaledAnalyzePoolCap]. The lower bound keeps LadybugDB's COPY viable + * (scaled because the granule accounting inflates consumption on non-4K + * hosts, see granuleRatio); the upper bound means the hint can never exceed + * the page-size-scaled #2557 cap or 80% of RAM — and on a machine whose cap + * is below the COPY floor, the cap wins, so the pool is never over-committed. + * On 4 KiB hosts (ratio 1) this is byte-identical to clamping against the + * hintless default. + */ +const clampBufferPool = (bytes: number, pageSize: number | undefined = getOsPageSize()): number => + Math.min( + scaledAnalyzePoolCap(pageSize), + Math.max(ADAPTIVE_POOL_FLOOR * granuleRatio(pageSize), Math.floor(bytes)), + ); /** * Buffer-pool bytes to provision per graph element (node + relationship). @@ -372,13 +438,22 @@ const POOL_BYTES_PER_ELEMENT = 4 * 1024; /** * Size the buffer pool to an estimated graph size (node + relationship count), - * clamped to [ADAPTIVE_POOL_FLOOR, defaultBufferPoolSize()]. The estimate can - * only *shrink* the pool from the default — never above the 2 GiB / 80%-RAM cap, - * never below the COPY-safety floor — so no repo is under-sized or gets more - * than the default it would have today. + * clamped to [ADAPTIVE_POOL_FLOOR, scaledAnalyzePoolCap], with every term + * scaled by granuleRatio (#2631): on non-4K hosts the engine bills pool + * budget per OS-page-sized granule, so the same graph consumes up to + * pageSize/4096 × the budget it needs on x86. On 4 KiB hosts the ratio is 1 + * and this is byte-identical to the pre-#2631 behavior. The estimate is never + * above the page-size-scaled #2557 cap bounded by 80% of RAM, never below the + * scaled COPY-safety floor; the hintless default stays unscaled. + * + * `pageSize` is a test seam (the pageSizeDoctorLines convention); production + * callers omit it and get the memoized real OS page size. */ -export const estimateBufferPool = (graphElementCount: number): number => - clampBufferPool(graphElementCount * POOL_BYTES_PER_ELEMENT); +export const estimateBufferPool = ( + graphElementCount: number, + pageSize: number | undefined = getOsPageSize(), +): number => + clampBufferPool(graphElementCount * POOL_BYTES_PER_ELEMENT * granuleRatio(pageSize), pageSize); /** * Optional per-run buffer-pool size hint (bytes). The analyze orchestrator sets @@ -417,12 +492,64 @@ const resolveBufferManagerSize = (): number => { if (raw.trim().length > 0) { logger.warn( { rawValue: raw, fallback: defaultBufferPoolSize() }, - `Ignoring invalid GITNEXUS_LBUG_BUFFER_POOL_SIZE=${raw}; expected integer >= 0 (bytes; 0 restores the native 80%-of-RAM default); falling back to min(2 GiB, 80% of RAM).`, + `Ignoring invalid GITNEXUS_LBUG_BUFFER_POOL_SIZE=${raw}; expected integer >= 0 (bytes; 0 restores the native 80%-of-RAM default); falling back to the platform default pool size.`, ); } return defaultBufferPoolSize(); }; +/** + * Doctor-facing view of the pool size the next Database open would get + * (#2631): env override > clamped hint > unscaled hintless default. Read-only; + * doctor prints it next to the page-size lines so support triage sees the + * sizing inputs at a glance. `0` is the pass-through sentinel for LadybugDB's + * native 80%-of-RAM default — callers must label it, not print "0 MiB". + */ +export const getEffectiveBufferPoolSize = (): number => resolveBufferManagerSize(); + +/** + * Matches the engine's buffer-pool exhaustion throw (buffer_manager.cpp: + * "Unable to allocate memory! The buffer pool is full and no memory could be + * freed!"). Distinct from isLbugPageSizeFrameError above, which matches the + * madvise/frame-release failure class. + */ +const BUFFER_POOL_EXHAUSTION_RE = /buffer pool is full|unable to allocate memory/i; + +const formatMiB = (bytes: number): string => `${Math.round(bytes / (1024 * 1024))} MiB`; + +/** + * Actionable remedy for a buffer-pool exhaustion error (#2631), or undefined + * when `message` is not that class. Cause → consequence → remedy, the + * diagnoseExtensionLoad convention: names the effective pool, the override + * knob, and — on non-4K hosts — the granule amplification that makes the + * budget exhaust early (the reporter's Ascend/aarch64 64 KiB kernel billed a + * pool up to 16× faster than the same analyze on x86). + */ +export const bufferPoolExhaustionRemedy = ( + message: string, + pageSize: number | undefined = getOsPageSize(), +): string | undefined => { + if (!BUFFER_POOL_EXHAUSTION_RE.test(message)) return undefined; + const ratio = granuleRatio(pageSize); + const pool = resolveBufferManagerSize(); + // 0 is the pass-through sentinel (GITNEXUS_LBUG_BUFFER_POOL_SIZE=0 → + // LadybugDB's native 80%-of-RAM default) — "0 MiB" would be nonsense in the + // very triage text this remedy exists to provide. + const poolLabel = pool === 0 ? "LadybugDB's native 80%-of-RAM default" : formatMiB(pool); + const pageNote = + ratio > 1 + ? ` This host's ${(pageSize ?? 0) / 1024} KiB OS page size makes the engine bill pool ` + + `memory in ${(pageSize ?? 0) / 1024} KiB granules — up to ${ratio}× faster budget use ` + + `than a 4 KiB-page host running the same analyze.` + : ''; + return ( + `The LadybugDB buffer pool (${poolLabel}) was exhausted during the bulk COPY.` + + pageNote + + ` Set GITNEXUS_LBUG_BUFFER_POOL_SIZE= to raise it (e.g. ${4 * 1024 * 1024 * 1024}` + + ` for 4 GiB); 0 restores LadybugDB's native 80%-of-RAM default.` + ); +}; + /** Matches WAL corruption errors from the LadybugDB engine. */ const WAL_CORRUPTION_RE = /corrupt(ed)?\s+wal|invalid\s+wal\s+record|wal.*corrupt|checksum.*wal/i; @@ -509,8 +636,12 @@ const LBUG_PAGE_COMBO_RE = /unsupported page size combination/i; * True when `err` looks like the LadybugDB buffer manager failing to release * frame memory — the failure mode of a 4 KiB page-size assumption on a * 16 KiB/64 KiB-page kernel (#1231). Deliberately does NOT match the - * generic "buffer pool is full" exhaustion error, which is a sizing - * problem, not a page-size one. + * generic "buffer pool is full" exhaustion error: that one is handled as a + * SIZING problem — though since #2631 we know page size drives sizing too + * (the engine bills pool budget per OS-page-sized discard granule, so non-4K + * hosts exhaust the same budget up to pageSize/4096× earlier; see + * granuleRatio, which scales the pool accordingly, and + * bufferPoolExhaustionRemedy, which explains it to the operator). */ export const isLbugPageSizeFrameError = (err: unknown): boolean => { if (!err) return false; @@ -537,6 +668,16 @@ export const isPageSizeAwareLadybug = (version: string | undefined): boolean => // because analyze error paths and doctor may both ask, and getconf forks. let cachedOsPageSize: number | null | undefined; +/** + * Test seam (the `_captureLogger` convention): pin the memoized OS page size + * so sizing tests are host-independent — without this they would silently + * drift on 16 KiB-page Apple Silicon runners. `number` pins a value, `null` + * pins "undetectable", `undefined` clears the memo so the next call re-probes. + */ +export const _setOsPageSizeForTests = (pageSize: number | null | undefined): void => { + cachedOsPageSize = pageSize; +}; + /** * OS memory page size in bytes, or `undefined` when it cannot be determined * (Windows, missing getconf, sandboxed exec). Node exposes no page-size API, @@ -575,11 +716,6 @@ export const getOsPageSize = (): number | undefined => { return cachedOsPageSize ?? undefined; }; -/** Exported only for unit tests — clears the getconf probe cache. */ -export const _resetOsPageSizeCacheForTest = (): void => { - cachedOsPageSize = undefined; -}; - type LbugModule = typeof lbug; export interface LbugDatabaseOptions { diff --git a/gitnexus/test/unit/doctor-format.test.ts b/gitnexus/test/unit/doctor-format.test.ts index 2d06f5124..416544ed8 100644 --- a/gitnexus/test/unit/doctor-format.test.ts +++ b/gitnexus/test/unit/doctor-format.test.ts @@ -5,6 +5,7 @@ import { localEmbeddingDoctorStatus, padDisplayEnd, pageSizeDoctorLines, + poolSizeDoctorLine, } from '../../src/cli/doctor.js'; describe('doctor output formatting', () => { @@ -164,6 +165,28 @@ describe('doctor page-size lines (#1231, #2424 review)', () => { }); }); +describe('doctor pool-size line (#2631)', () => { + const MiB = 1024 * 1024; + + it('prints the hintless pool in MiB with no env note when the env var is unset', () => { + expect(poolSizeDoctorLine(2048 * MiB, undefined)).toBe( + ` ${padDisplayEnd('pool size', 10)}2048 MiB`, + ); + }); + + it('marks an operator-supplied absolute value as an env override, with no scaling suffix', () => { + expect(poolSizeDoctorLine(4096 * MiB, String(4096 * MiB))).toBe( + ` ${padDisplayEnd('pool size', 10)}4096 MiB (env override)`, + ); + }); + + it('labels the 0 sentinel as the native default instead of "0 MiB"', () => { + expect(poolSizeDoctorLine(0, '0')).toBe( + ` ${padDisplayEnd('pool size', 10)}native 80% of RAM (env override)`, + ); + }); +}); + describe('doctor survives a malformed GITNEXUS_EMBEDDING_DIMS (#2385)', () => { const ENV_KEYS = [ 'GITNEXUS_EMBEDDING_URL', diff --git a/gitnexus/test/unit/lbug-config-pagesize.test.ts b/gitnexus/test/unit/lbug-config-pagesize.test.ts index f2108d5f8..0acd9e990 100644 --- a/gitnexus/test/unit/lbug-config-pagesize.test.ts +++ b/gitnexus/test/unit/lbug-config-pagesize.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { execFileSync } from 'child_process'; import { - _resetOsPageSizeCacheForTest, + _setOsPageSizeForTests, getOsPageSize, isLbugPageSizeFrameError, isPageSizeAwareLadybug, @@ -92,7 +92,7 @@ describe('isPageSizeAwareLadybug', () => { describe('getOsPageSize', () => { afterEach(() => { - _resetOsPageSizeCacheForTest(); + _setOsPageSizeForTests(undefined); execFileSyncSpy.mockClear(); }); @@ -155,7 +155,7 @@ describe('getOsPageSize', () => { it.skipIf(onWindows)('probes at most once per process (cached)', () => { expect(getOsPageSize()).toBe(getOsPageSize()); expect(execFileSyncSpy).toHaveBeenCalledTimes(1); - _resetOsPageSizeCacheForTest(); + _setOsPageSizeForTests(undefined); getOsPageSize(); expect(execFileSyncSpy).toHaveBeenCalledTimes(2); }); diff --git a/gitnexus/test/unit/lbug-config-wal.test.ts b/gitnexus/test/unit/lbug-config-wal.test.ts index 94395541a..1150a0da7 100644 --- a/gitnexus/test/unit/lbug-config-wal.test.ts +++ b/gitnexus/test/unit/lbug-config-wal.test.ts @@ -1,11 +1,13 @@ import os from 'os'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createLbugDatabase, estimateBufferPool, isLbugCheckpointIoError, isWalCorruptionError, setBufferPoolSizeHint, + _setOsPageSizeForTests, + bufferPoolExhaustionRemedy, } from '../../src/core/lbug/lbug-config.js'; import { _captureLogger } from '../../src/core/logger.js'; @@ -166,6 +168,12 @@ describe('createLbugDatabase WAL replay option', () => { describe('createLbugDatabase buffer pool size (#2557)', () => { const GiB = 1024 * 1024 * 1024; + // Pin a 4 KiB page so every expectation below is host-independent — on a + // 16 KiB-page Apple Silicon runner the #2631 granule scaling would + // otherwise multiply them by 4. + beforeEach(() => _setOsPageSizeForTests(4096)); + afterEach(() => _setOsPageSizeForTests(undefined)); + const bufferPoolArg = (Database: ReturnType): unknown => Database.mock.calls[0][1]; it.each([ @@ -259,7 +267,11 @@ describe('adaptive buffer pool hint', () => { const MiB = 1024 * 1024; const bufferPoolArg = (Database: ReturnType): unknown => Database.mock.calls[0][1]; - afterEach(() => setBufferPoolSizeHint(undefined)); + beforeEach(() => _setOsPageSizeForTests(4096)); + afterEach(() => { + setBufferPoolSizeHint(undefined); + _setOsPageSizeForTests(undefined); + }); describe('estimateBufferPool', () => { it.each([ @@ -355,3 +367,138 @@ describe('isLbugCheckpointIoError', () => { expect(isLbugCheckpointIoError(undefined)).toBe(false); }); }); + +// ─── #2631: page-size-scaled pool sizing (granule accounting) ─────────────── +describe('page-size-scaled buffer pool sizing (#2631)', () => { + const MiB = 1024 * 1024; + const GiB = 1024 * MiB; + const bufferPoolArg = (Database: ReturnType): unknown => Database.mock.calls[0][1]; + + afterEach(() => { + setBufferPoolSizeHint(undefined); + _setOsPageSizeForTests(undefined); + vi.unstubAllEnvs(); + }); + + it.each([ + ['64 KiB pages scale the floor ×16', 65536, 41, 16 * 256 * MiB], + [ + '64 KiB pages scale the estimate ×16 (100k × 4 KiB × 16 = 6.4 GB)', + 65536, + 100_000, + 100_000 * 4 * 1024 * 16, + ], + ['16 KiB pages (Apple Silicon) scale the floor ×4', 16384, 41, 4 * 256 * MiB], + ['4 KiB pages are byte-identical to the unscaled behavior', 4096, 100_000, 100_000 * 4 * 1024], + ])('%s', (_label, pageSize, elements, expected) => { + const totalmemSpy = vi.spyOn(os, 'totalmem').mockReturnValue(32 * GiB); + try { + _setOsPageSizeForTests(pageSize); + expect(estimateBufferPool(elements)).toBe(expected); + } finally { + totalmemSpy.mockRestore(); + } + }); + + it('the scaled cap is still bounded by 80% of RAM (64 KiB pages, huge graph)', () => { + const totalmemSpy = vi.spyOn(os, 'totalmem').mockReturnValue(32 * GiB); + try { + _setOsPageSizeForTests(65536); + // min(2 GiB × 16, 0.8 × 32 GiB) = min(32 GiB, 25.6 GiB) = 25.6 GiB + expect(estimateBufferPool(100_000_000)).toBe(Math.floor(0.8 * 32 * GiB)); + } finally { + totalmemSpy.mockRestore(); + } + }); + + it('an undetectable page size behaves exactly like 4 KiB (ratio 1)', () => { + const totalmemSpy = vi.spyOn(os, 'totalmem').mockReturnValue(32 * GiB); + try { + _setOsPageSizeForTests(null); + expect(estimateBufferPool(100_000)).toBe(100_000 * 4 * 1024); + } finally { + totalmemSpy.mockRestore(); + } + }); + + it('the hintless default passed to the Database ctor stays at the unscaled #2557 cap on 64 KiB hosts', () => { + // The guard for the #2557 OOM protection: MCP serve / doctor / any open + // without a per-run hint must NOT inherit the page-size-scaled budget — + // the pool is an eager allocation at DB open. + const totalmemSpy = vi.spyOn(os, 'totalmem').mockReturnValue(32 * GiB); + try { + _setOsPageSizeForTests(65536); + const Database = vi.fn(function (this: any) {}); + createLbugDatabase({ Database } as any, '/tmp/lbug-pool-64k'); + expect(bufferPoolArg(Database)).toBe(2 * GiB); + } finally { + totalmemSpy.mockRestore(); + } + }); + + it('the analyze hint path DOES scale on 64 KiB hosts (scaled floor, bounded by 80% RAM)', () => { + const totalmemSpy = vi.spyOn(os, 'totalmem').mockReturnValue(32 * GiB); + try { + _setOsPageSizeForTests(65536); + setBufferPoolSizeHint(estimateBufferPool(41)); + const Database = vi.fn(function (this: any) {}); + createLbugDatabase({ Database } as any, '/tmp/lbug-pool-64k-hint'); + // 41 elements → below the scaled COPY floor → 16 × 256 MiB = 4 GiB + expect(bufferPoolArg(Database)).toBe(16 * 256 * MiB); + } finally { + totalmemSpy.mockRestore(); + } + }); + + it('GITNEXUS_LBUG_BUFFER_POOL_SIZE stays absolute on 64 KiB hosts (incl. 0 = native default)', () => { + _setOsPageSizeForTests(65536); + vi.stubEnv('GITNEXUS_LBUG_BUFFER_POOL_SIZE', String(512 * MiB)); + const Database = vi.fn(function (this: any) {}); + createLbugDatabase({ Database } as any, '/tmp/lbug-pool-64k-env'); + expect(bufferPoolArg(Database)).toBe(512 * MiB); + }); +}); + +// ─── #2631: actionable pool-exhaustion remedy ─────────────────────────────── +describe('bufferPoolExhaustionRemedy (#2631)', () => { + afterEach(() => _setOsPageSizeForTests(undefined)); + + const EXHAUSTION = + 'Buffer manager exception: Unable to allocate memory! The buffer pool is full and no memory could be freed!'; + + it('names the override knob for the exhaustion error', () => { + _setOsPageSizeForTests(4096); + const remedy = bufferPoolExhaustionRemedy(EXHAUSTION); + expect(remedy).toContain('GITNEXUS_LBUG_BUFFER_POOL_SIZE'); + expect(remedy).toContain('buffer pool'); + // ratio 1 → no page-size amplification note + expect(remedy).not.toContain('OS page size'); + }); + + it('explains the granule amplification on a 64 KiB-page host', () => { + _setOsPageSizeForTests(65536); + const remedy = bufferPoolExhaustionRemedy(EXHAUSTION); + expect(remedy).toContain('64 KiB OS page size'); + expect(remedy).toContain('16×'); + expect(remedy).toContain('GITNEXUS_LBUG_BUFFER_POOL_SIZE'); + }); + + it('is silent for non-exhaustion errors', () => { + _setOsPageSizeForTests(65536); + expect( + bufferPoolExhaustionRemedy('Binder exception: Table CodeEmbedding does not exist.'), + ).toBeUndefined(); + }); + + it('labels the 0 sentinel as the native default instead of "0 MiB"', () => { + _setOsPageSizeForTests(4096); + vi.stubEnv('GITNEXUS_LBUG_BUFFER_POOL_SIZE', '0'); + try { + const remedy = bufferPoolExhaustionRemedy(EXHAUSTION); + expect(remedy).toContain('native 80%-of-RAM default'); + expect(remedy).not.toContain('(0 MiB)'); + } finally { + vi.unstubAllEnvs(); + } + }); +});