brainy/tests/unit/utils/memoryLimits.test.ts
David Snelling b26d3d42b3 fix(8.0): drive query-cap off MemAvailable + floor auto-detected caps
The auto-detected query-limit cap was computed from os.freemem() (MemFree),
which excludes reclaimable page cache. On hosts that memory-map large index
files the cache holding those pages dominates RAM, so MemFree collapses to a
sliver and the cap cratered to its floor on perfectly healthy machines,
rejecting legitimate find() calls.

- getAvailableMemory() now prefers /proc/meminfo MemAvailable (counts
  reclaimable cache), falling back to os.freemem() off-Linux, then a 2 GB
  constant where no OS module is available.
- Auto-detected caps (container + free-memory tiers) are floored at
  MIN_AUTO_QUERY_LIMIT (10_000); a transient low reading can never throttle
  queries to a near-useless ceiling. Consumer-supplied maxQueryLimit /
  reservedQueryMemory bypass the floor — an explicit caller knows their box.

Also scrubs two stale comments referencing the removed cloud storage
adapters and the retired mmap-vector backend: 8.0's native vector provider
persists its own .dkann file via getBinaryBlobPath, so the old rootDirectory
hook does not apply on this line.

Tests: memoryLimits 26/26 (4 new floor regressions), unit 1398/1398,
find-limits + db-mvcc + api-parameter-validation 37/37.
2026-06-16 09:01:45 -07:00

451 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Unit tests for memory limit calculation and container detection (v5.11.0)
*
* Tests verify:
* - Container memory detection (cgroup v1/v2, env vars)
* - Smart memory limit calculation
* - Configuration overrides
* - Memory stats API
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { Brainy } from '../../../src/brainy.js'
import { ValidationConfig } from '../../../src/utils/paramValidation.js'
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
describe('Memory Limits - Container Detection & Smart Calculation', () => {
let testDir: string
let originalEnv: Record<string, string | undefined>
beforeEach(() => {
testDir = mkdtempSync(join(tmpdir(), 'brainy-memory-test-'))
// Save original environment variables
originalEnv = {
CLOUD_RUN_MEMORY: process.env.CLOUD_RUN_MEMORY,
MEMORY_LIMIT: process.env.MEMORY_LIMIT
}
// Reset ValidationConfig singleton before each test
ValidationConfig.reset()
})
afterEach(() => {
rmSync(testDir, { recursive: true, force: true })
// Restore original environment
Object.keys(originalEnv).forEach(key => {
if (originalEnv[key] === undefined) {
delete process.env[key]
} else {
process.env[key] = originalEnv[key]
}
})
// Reset ValidationConfig after each test
ValidationConfig.reset()
})
describe('Container Memory Detection', () => {
it('should detect Cloud Run memory limit from env var', () => {
process.env.CLOUD_RUN_MEMORY = '4Gi'
const config = ValidationConfig.getInstance()
expect(config.detectedContainerLimit).toBe(4 * 1024 * 1024 * 1024)
expect(config.limitBasis).toBe('containerMemory')
// 4GB * 0.25 = 1GB query memory = 10k limit
expect(config.maxLimit).toBeGreaterThan(5000)
})
it('should detect Cloud Run memory limit in Mi units', () => {
process.env.CLOUD_RUN_MEMORY = '512Mi'
const config = ValidationConfig.getInstance()
expect(config.detectedContainerLimit).toBe(512 * 1024 * 1024)
expect(config.limitBasis).toBe('containerMemory')
// 512MB * 0.25 = 128MB query memory
expect(config.maxLimit).toBeGreaterThan(0)
})
it('should detect generic MEMORY_LIMIT env var', () => {
process.env.MEMORY_LIMIT = String(2 * 1024 * 1024 * 1024) // 2GB
const config = ValidationConfig.getInstance()
expect(config.detectedContainerLimit).toBe(2 * 1024 * 1024 * 1024)
expect(config.limitBasis).toBe('containerMemory')
})
it('should fall back to free memory if no container detected', () => {
// No environment variables set
const config = ValidationConfig.getInstance()
expect(config.limitBasis).toBe('freeMemory')
expect(config.maxLimit).toBeGreaterThan(0)
})
})
describe('Smart Memory Limit Calculation', () => {
it('should allocate 25% of container memory for queries', () => {
process.env.CLOUD_RUN_MEMORY = '4Gi'
const config = ValidationConfig.getInstance()
// 4 GB × 0.25 = 1 GB for queries; at 25 KB / result (7.30.2 calibration)
// → 1 GB / 25 KB = ~40_960 → floor to 40 × 1000 = 40_000.
// Pre-7.30.2 used 100 KB / result and returned 10_000 here.
expect(config.maxLimit).toBe(40000)
})
it('should respect absolute maximum of 100k', () => {
// Simulate huge container
process.env.MEMORY_LIMIT = String(100 * 1024 * 1024 * 1024) // 100GB
const config = ValidationConfig.getInstance()
// Should cap at 100,000 even with huge memory
expect(config.maxLimit).toBe(100000)
})
it('should handle small containers gracefully', () => {
process.env.CLOUD_RUN_MEMORY = '512Mi'
const config = ValidationConfig.getInstance()
// 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 })
expect(config.maxLimit).toBe(50000)
expect(config.limitBasis).toBe('override')
})
it('should respect reservedQueryMemory override', () => {
// Reserve 1 GB for queries; at 25 KB / result (7.30.2) → 1 GB / 25 KB
// = ~40_960 → floor to 40 × 1000 = 40_000. Pre-7.30.2 used 100 KB /
// result and this returned 10_000.
const config = ValidationConfig.getInstance({
reservedQueryMemory: 1 * 1024 * 1024 * 1024
})
expect(config.maxLimit).toBe(40000)
expect(config.limitBasis).toBe('reservedMemory')
})
it('should prioritize maxQueryLimit over reservedQueryMemory', () => {
const config = ValidationConfig.getInstance({
maxQueryLimit: 25000,
reservedQueryMemory: 1 * 1024 * 1024 * 1024
})
expect(config.maxLimit).toBe(25000)
expect(config.limitBasis).toBe('override')
})
it('should cap explicit overrides at 100k for safety', () => {
const config = ValidationConfig.getInstance({
maxQueryLimit: 200000 // Try to set above max
})
expect(config.maxLimit).toBe(100000) // Capped
expect(config.limitBasis).toBe('override')
})
})
describe('ValidationConfig Reconfiguration', () => {
it('should reconfigure singleton with new options', () => {
const config1 = ValidationConfig.getInstance()
const originalLimit = config1.maxLimit
// Reconfigure
const config2 = ValidationConfig.reconfigure({ maxQueryLimit: 30000 })
expect(config2.maxLimit).toBe(30000)
expect(config2.limitBasis).toBe('override')
// Verify singleton updated
const config3 = ValidationConfig.getInstance()
expect(config3.maxLimit).toBe(30000)
})
it('should reset singleton', () => {
const config1 = ValidationConfig.getInstance({ maxQueryLimit: 10000 })
expect(config1.maxLimit).toBe(10000)
ValidationConfig.reset()
const config2 = ValidationConfig.getInstance()
// Should recalculate based on system memory
expect(config2.maxLimit).not.toBe(10000)
})
})
describe('Brain Integration', () => {
it('should configure memory limits via Brain constructor', async () => {
const brain = new Brainy({ requireSubtype: false,
storage: { type: 'memory' },
maxQueryLimit: 15000,
silent: true
})
await brain.init()
const stats = brain.getMemoryStats()
expect(stats.limits.maxQueryLimit).toBe(15000)
expect(stats.limits.basis).toBe('override')
expect(stats.config.maxQueryLimit).toBe(15000)
await brain.close()
})
it('should configure reserved memory via Brain constructor', async () => {
const brain = new Brainy({ requireSubtype: false,
storage: { type: 'memory' },
reservedQueryMemory: 500 * 1024 * 1024, // 500 MB
silent: true
})
await brain.init()
const stats = brain.getMemoryStats()
// 500 MB / 25 KB per result (7.30.2 calibration) = ~20_000.
// Pre-7.30.2 used 100 KB / result and this returned 5000.
expect(stats.limits.maxQueryLimit).toBe(20000)
expect(stats.limits.basis).toBe('reservedMemory')
expect(stats.config.reservedQueryMemory).toBe(500 * 1024 * 1024)
await brain.close()
})
it('should auto-detect container limits when no config provided', async () => {
process.env.CLOUD_RUN_MEMORY = '2Gi'
const brain = new Brainy({ requireSubtype: false,
storage: { type: 'memory' },
silent: true
})
await brain.init()
const stats = brain.getMemoryStats()
expect(stats.memory.containerLimit).toBe(2 * 1024 * 1024 * 1024)
expect(stats.limits.basis).toBe('containerMemory')
// 2 GB × 0.25 = 512 MB query budget; at 25 KB per result (7.30.2) →
// 512 MB / 25 KB = ~20_971 → floor to 20 × 1000 = 20_000. Pre-7.30.2
// used 100 KB per result and this returned 5_000.
expect(stats.limits.maxQueryLimit).toBe(20000)
await brain.close()
})
})
describe('getMemoryStats() API', () => {
it('should return complete memory statistics', async () => {
process.env.CLOUD_RUN_MEMORY = '4Gi'
const brain = new Brainy({ requireSubtype: false,
storage: { type: 'memory' },
maxQueryLimit: 20000,
silent: true
})
await brain.init()
const stats = brain.getMemoryStats()
// Memory stats
expect(stats.memory).toHaveProperty('heapUsed')
expect(stats.memory).toHaveProperty('heapTotal')
expect(stats.memory).toHaveProperty('external')
expect(stats.memory).toHaveProperty('rss')
expect(stats.memory).toHaveProperty('free')
expect(stats.memory).toHaveProperty('total')
expect(stats.memory).toHaveProperty('containerLimit')
expect(stats.memory.containerLimit).toBe(4 * 1024 * 1024 * 1024)
// Limits
expect(stats.limits.maxQueryLimit).toBe(20000)
expect(stats.limits.basis).toBe('override')
expect(stats.limits.maxQueryLength).toBeGreaterThan(0)
expect(stats.limits.maxVectorDimensions).toBe(384)
// Config
expect(stats.config.maxQueryLimit).toBe(20000)
// Recommendations
expect(Array.isArray(stats.recommendations)).toBe(true)
await brain.close()
})
it('should provide recommendations when appropriate', async () => {
// Large container but using free memory basis
process.env.CLOUD_RUN_MEMORY = '4Gi'
// Don't set overrides, let it use containerMemory
const brain = new Brainy({ requireSubtype: false,
storage: { type: 'memory' },
silent: true
})
await brain.init()
const stats = brain.getMemoryStats()
expect(stats.recommendations).toBeDefined()
expect(stats.recommendations!.length).toBeGreaterThanOrEqual(0)
await brain.close()
})
it('should handle browser environment gracefully', async () => {
const brain = new Brainy({ requireSubtype: false,
storage: { type: 'memory' },
silent: true
})
await brain.init()
const stats = brain.getMemoryStats()
// Should not crash in any environment
expect(stats).toBeDefined()
expect(stats.limits).toBeDefined()
await brain.close()
})
})
describe('Production Scenarios', () => {
it('should handle 4GB Cloud Run container optimally', async () => {
process.env.CLOUD_RUN_MEMORY = '4Gi'
const brain = new Brainy({ requireSubtype: false,
storage: { type: 'memory' },
silent: true
})
await brain.init()
const stats = brain.getMemoryStats()
// Allocates 1 GB (25% of 4 GB) for queries; at 25 KB per result
// (7.30.2 calibration) → 1 GB / 25 KB = ~40_960 → floor to 40 × 1000 =
// 40_000. Pre-7.30.2 used 100 KB per result and this returned 10_000.
expect(stats.memory.containerLimit).toBe(4 * 1024 * 1024 * 1024)
expect(stats.limits.maxQueryLimit).toBe(40000)
expect(stats.limits.basis).toBe('containerMemory')
await brain.close()
})
it('should allow manual override for power users', async () => {
process.env.CLOUD_RUN_MEMORY = '4Gi'
const brain = new Brainy({ requireSubtype: false,
storage: { type: 'memory' },
maxQueryLimit: 50000, // Power user wants higher limit
silent: true
})
await brain.init()
const stats = brain.getMemoryStats()
expect(stats.limits.maxQueryLimit).toBe(50000)
expect(stats.limits.basis).toBe('override')
// Should note override in recommendations
const overrideNote = stats.recommendations?.find(r => r.includes('override'))
expect(overrideNote).toBeDefined()
await brain.close()
})
it('should handle bare metal deployment (no container)', async () => {
// No container env vars
const brain = new Brainy({ requireSubtype: false,
storage: { type: 'memory' },
silent: true
})
await brain.init()
const stats = brain.getMemoryStats()
expect(stats.memory.containerLimit).toBeNull()
expect(stats.limits.basis).toBe('freeMemory')
expect(stats.limits.maxQueryLimit).toBeGreaterThan(0)
await brain.close()
})
})
})