diff --git a/src/brainy.ts b/src/brainy.ts index 691d25d7..f98f3e2c 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -770,10 +770,11 @@ export class Brainy implements BrainyInterface { // Wire the connections codec (2.4.0 #3). When the graph:compression // provider is registered AND the metadata index exposes a stable - // idMapper, inject a codec that encodes HNSW connections as - // delta-varint blobs at save time and decodes on load. The blob - // primitive itself works on every brainy 7.25.0 adapter, so unlike the - // mmap-vector backend this layer engages even on cloud adapters. + // idMapper, inject a codec that encodes JS HNSW connections as + // delta-varint blobs at save time and decodes on load. It rides the + // storage adapter's generic binary-blob primitive (both the filesystem + // and memory adapters implement it), so it is independent of any native + // vector provider's single-file on-disk format. this.wireConnectionsCodec() // 8.0 generational MVCC: if crash recovery rolled back an uncommitted @@ -10533,9 +10534,11 @@ export class Brainy implements BrainyInterface { * metadata index exposes a stable idMapper. Failures are non-fatal: HNSW * keeps working via the legacy JSON-array path. * - * Unlike the mmap-vector backend, this layer does NOT require a real local - * path — the binary-blob primitive saves the compressed bytes through the - * storage adapter directly, so the codec is engaged on cloud adapters too. + * This layer does NOT require a real local path — the binary-blob primitive + * saves the compressed bytes through the storage adapter directly, so the + * codec works on the memory adapter as well as filesystem. (A native vector + * provider, by contrast, persists its own single `.dkann` file and has no + * per-node connection lists, so it is skipped — see the feature-detect below.) * Format convergence is lazy: pre-2.4.0 nodes load via the legacy path, * then the next dirty save writes the compressed form (and the legacy * field of saveVectorIndexData becomes empty), so all reads converge over time diff --git a/src/utils/paramValidation.ts b/src/utils/paramValidation.ts index c9036455..9cb3dd24 100644 --- a/src/utils/paramValidation.ts +++ b/src/utils/paramValidation.ts @@ -27,7 +27,37 @@ const getSystemMemory = (): number => { return 4 * 1024 * 1024 * 1024 } +/** + * Best-effort "memory we can actually use right now", in bytes. + * + * Prefers `/proc/meminfo` **MemAvailable**, which the kernel computes as the + * memory obtainable for a new workload *including reclaimable page cache*. This + * matters on hosts that memory-map large index files: the cache holding those + * mmap'd pages is instantly reclaimable, yet `os.freemem()` reports only + * **MemFree** (the unused sliver), which collapses to near-zero on a warm box. + * Driving the auto query-cap off MemFree made the cap crater to its floor on + * perfectly healthy machines (BRAINY-QUERYCAP-MISREAD); MemAvailable fixes that. + * + * Falls back to `os.freemem()` where `/proc/meminfo` is absent (non-Linux), then + * to a conservative 2 GB when no OS module is available at all (browser/edge). + * + * @returns Usable memory in bytes. + */ const getAvailableMemory = (): number => { + if (fs) { + try { + const meminfo = fs.readFileSync('/proc/meminfo', 'utf8') as string + const match = meminfo.match(/^MemAvailable:\s+(\d+)\s*kB/m) + if (match) { + const bytes = parseInt(match[1], 10) * 1024 + if (bytes > 0) { + return bytes + } + } + } catch { + // /proc/meminfo unreadable (non-Linux, sandboxed) — fall through. + } + } if (os) { return os.freemem() } @@ -134,6 +164,18 @@ const getContainerMemoryLimit = (): number | null => { */ const MAX_LIMIT_KB_PER_RESULT = 25 +/** + * Floor for *auto-detected* query caps (the container- and free-memory tiers). + * + * Memory probing is a heuristic; a transiently low reading must never silently + * throttle legitimate queries to a near-useless ceiling. 10 000 is a generous + * working limit that still sits far below any OOM danger (10 000 × 25 KB ≈ + * 250 MB worst case). It is a floor, not an override: consumer-supplied + * `maxQueryLimit` / `reservedQueryMemory` bypass it entirely (Priorities 1–2), + * because an explicit caller knows their box better than the probe does. + */ +const MIN_AUTO_QUERY_LIMIT = 10000 + /** * One-time-per-call-site warning dedup. Keyed on the caller location returned * by `findCallerLocation()` plus the exceeding limit value so the warning fires @@ -226,9 +268,12 @@ export class ValidationConfig { // Reserve 25% for query operations const queryMemory = this.detectedContainerLimit * 0.25 - this.maxLimit = Math.min( - 100000, - Math.floor(queryMemory / (1024 * 1024 * MAX_LIMIT_KB_PER_RESULT)) * 1000 + this.maxLimit = Math.max( + MIN_AUTO_QUERY_LIMIT, + Math.min( + 100000, + Math.floor(queryMemory / (1024 * 1024 * MAX_LIMIT_KB_PER_RESULT)) * 1000 + ) ) this.limitBasis = 'containerMemory' @@ -242,9 +287,12 @@ export class ValidationConfig { // Priority 4: Free memory (fallback, current behavior) const availableMemory = getAvailableMemory() - this.maxLimit = Math.min( - 100000, - Math.floor(availableMemory / (1024 * 1024 * MAX_LIMIT_KB_PER_RESULT)) * 1000 + this.maxLimit = Math.max( + MIN_AUTO_QUERY_LIMIT, + Math.min( + 100000, + Math.floor(availableMemory / (1024 * 1024 * MAX_LIMIT_KB_PER_RESULT)) * 1000 + ) ) this.limitBasis = 'freeMemory' diff --git a/tests/unit/utils/memoryLimits.test.ts b/tests/unit/utils/memoryLimits.test.ts index d3be70a2..f11e3eda 100644 --- a/tests/unit/utils/memoryLimits.test.ts +++ b/tests/unit/utils/memoryLimits.test.ts @@ -115,17 +115,66 @@ describe('Memory Limits - Container Detection & Smart Calculation', () => { }) it('should handle small containers gracefully', () => { - process.env.CLOUD_RUN_MEMORY = '512Mi' // Use 512MB instead of 128MB for realistic test + process.env.CLOUD_RUN_MEMORY = '512Mi' const config = ValidationConfig.getInstance() - // 512MB * 0.25 = 128MB for queries - // 128MB / 100MB = 1.28 floor to 1 * 1000 = 1000 limit - expect(config.maxLimit).toBeGreaterThan(0) + // 512 MB × 0.25 = 128 MB for queries; at 25 KB / result that raw figure + // is ~5_242 → floor to 5 × 1000 = 5_000, which the MIN_AUTO_QUERY_LIMIT + // floor lifts to 10_000. A small container must never throttle queries to + // a near-useless ceiling (BRAINY-QUERYCAP-MISREAD). + expect(config.maxLimit).toBe(10000) expect(config.limitBasis).toBe('containerMemory') }) }) + describe('Auto-cap floor (BRAINY-QUERYCAP-MISREAD regression)', () => { + // A transiently low memory reading once collapsed the auto-detected query + // cap to a near-useless ceiling on healthy hosts (the cap was driven off + // os.freemem()/MemFree, which excludes reclaimable page cache). The fix: + // drive detection off /proc/meminfo MemAvailable AND floor every + // *auto-detected* tier at MIN_AUTO_QUERY_LIMIT (10_000). Consumer-supplied + // limits bypass the floor — an explicit caller knows their box best. + + it('floors a tiny container to 10_000, never below', () => { + // 50 MB × 0.25 = 12.5 MB for queries → raw cap rounds to 0; the floor + // must lift it to 10_000 rather than let it throttle to nothing. + process.env.MEMORY_LIMIT = String(50 * 1024 * 1024) + + const config = ValidationConfig.getInstance() + + expect(config.limitBasis).toBe('containerMemory') + expect(config.maxLimit).toBe(10000) + }) + + it('floors the free-memory tier at 10_000', () => { + // No container env → free-memory tier (now MemAvailable-based). Whatever + // the host reports, the auto cap is guaranteed never to fall below 10_000. + const config = ValidationConfig.getInstance() + + expect(config.limitBasis).toBe('freeMemory') + expect(config.maxLimit).toBeGreaterThanOrEqual(10000) + }) + + it('does NOT floor an explicit maxQueryLimit below 10_000', () => { + // A consumer asking for 500 means 500 — the floor is for auto-detection. + const config = ValidationConfig.getInstance({ maxQueryLimit: 500 }) + + expect(config.limitBasis).toBe('override') + expect(config.maxLimit).toBe(500) + }) + + it('does NOT floor an explicit reservedQueryMemory below 10_000', () => { + // 100 MB / 25 KB = ~4_000 → kept as-is (no floor on the explicit tier). + const config = ValidationConfig.getInstance({ + reservedQueryMemory: 100 * 1024 * 1024 + }) + + expect(config.limitBasis).toBe('reservedMemory') + expect(config.maxLimit).toBe(4000) + }) + }) + describe('Configuration Overrides', () => { it('should respect maxQueryLimit override', () => { const config = ValidationConfig.getInstance({ maxQueryLimit: 50000 })