fix(lbug): scale the buffer-pool budget by the OS page-size granule ratio (#2631) (#2636)

* fix(lbug): scale the buffer-pool budget by the OS-page discard-granule ratio (#2631)

LadybugDB bills buffer-pool budget per discard granule, not per 4 KiB frame:
the engine's vm_region.cpp sets discardGranuleSize = max(frameSize, osPageSize),
claimFrame charges the whole granule when its first frame becomes resident, and
releaseFrame refunds only when the granule's last frame leaves — while
BufferManager::reserve measures eviction progress in refunded bytes and throws
'The buffer pool is full and no memory could be freed!' after three zero-refund
passes. On a 64 KiB-page kernel (Ascend/aarch64 openEuler — the #2631
reporter's host) that is 16 frames per granule: the same COPY bills up to 16×
the budget it needs on x86, and whole eviction passes can evict frames yet
refund nothing. Apple Silicon macOS (16 KiB pages) is the same mechanism at 4×.

Measured with the reporter's exact command and version: vllm-ascend needs a
(128, 256] MiB pool on 4 KiB pages — 64/128 MiB reproduce the reporter's
byte-identical error, 256 MiB and the 576 MiB adaptive pool succeed — so their
64 KiB host cannot survive on a page-size-blind budget.

Scale every derived pool size by granuleRatio = max(1, osPageSize/4096):
the per-element estimate, the COPY-safety floor, and the default cap (still
bounded by 80% of RAM). 4 KiB hosts are byte-identical to before — proven by
pinning the existing sizing tests to an explicit 4096 page size, which also
stops them drifting on 16 KiB Apple Silicon runners. GITNEXUS_LBUG_BUFFER_POOL_SIZE
keeps absolute precedence and 0 still restores the native default.

Also: bufferPoolExhaustionRemedy() gives the exhaustion error an actionable
cause→consequence→remedy message; the isLbugPageSizeFrameError comment that
called pool exhaustion 'a sizing problem, not a page-size one' is corrected —
that framing inverted when #2582 made pool size a function of a page-size-blind
estimate. Cannot execute on a 64 KiB kernel here: the scaled path is proven by
unit stubs plus the engine-source math above; the env override remains the
field escape hatch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(cli): actionable pool-exhaustion remedies at the COPY sites and a doctor pool line (#2631)

The node-COPY throw and the relationship-COPY warning now append
bufferPoolExhaustionRemedy() when the failure is the engine's pool-exhaustion
class: the raw binder text gave the operator nothing to act on, and on
non-4K-page hosts the pool bills up to pageSize/4KiB × faster than the sizing
was calibrated for. The relationship path appends the remedy once per bulk
load, not once per failed pair. doctor prints the effective pool size next to
the page-size line ('pool size 2048 MiB', with an '(×N page-size scaling)'
suffix on non-4K hosts) so support triage sees the sizing inputs at a glance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(lbug): re-anchor getEffectiveBufferPoolSize's placement and reuse granuleRatio in doctor

Self-review fixes: the getter's insertion had orphaned resolveBufferManagerSize's
doc comment (it read as documenting the wrong function), and doctor's scale note
duplicated the granule math with a hardcoded 4096. granuleRatio is now exported
(it already carried the test-seam default param) and doctor consumes it.
No behavioral change — the sizing suite pins byte-identical outputs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(lbug): keep the hintless pool default unscaled and make both remedies visible (#2631)

Review fixes:
- Scale only the analyze-path cap (scaledAnalyzePoolCap), not
  defaultBufferPoolSize: the pool is an eager native allocation at DB open
  (measured, see POOL_BYTES_PER_ELEMENT), so a page-size-scaled hintless
  default would hand a long-lived MCP process up to 80% of RAM — the #2557
  OOM exposure the 2 GiB cap removed. Fix the MAP_NORESERVE claim that
  contradicted that measurement.
- Log the rel-pair pool remedy (loadGraphToLbug returns warnings that no
  call site reads) and dedup it with a local boolean instead of matching
  the remedy's own wording.
- Label the GITNEXUS_LBUG_BUFFER_POOL_SIZE=0 sentinel as the native
  80%-of-RAM default in both the remedy and doctor instead of '0 MiB'.
- Extract poolSizeDoctorLine (pageSizeDoctorLines convention): mark env
  overrides, drop the scaling suffix that misdescribed absolute values.
- Fold _resetOsPageSizeCacheForTest into _setOsPageSizeForTests(undefined).
- Document the analyze-path scaling in both README env tables.

---------

Co-authored-by: Gergo Magyar <abhigyan1.patwari@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Magyar 2026-07-22 20:09:48 +01:00 committed by GitHub
parent 0eeecb37f3
commit 9538be957d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 381 additions and 30 deletions

View file

@ -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 <bytes>`. `-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. |

View file

@ -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

View file

@ -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`);

View file

@ -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);
});

View file

@ -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=<bytes> 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 {

View file

@ -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',

View file

@ -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);
});

View file

@ -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<typeof vi.fn>): 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<typeof vi.fn>): 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<typeof vi.fn>): 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();
}
});
});