brainy/tests/unit/utils/memoryLimits.test.ts
David Snelling 780fb6444b feat(8.0)!: flip requireSubtype default to true (BRAINY-8.0-SUBTYPE-CONTRACT § C-1)
Brainy 8.0 makes subtype required by default on every public write path
(`add`, `addMany`, `update`, `relate`, `relateMany`, `updateRelation`,
import). Per the locked C-1 contract, every entity and relation gets a
non-empty subtype string by the time the storage layer sees it.

OPT-OUT REMAINS FULLY SUPPORTED

The runtime flag is still consumer-controlled. Three opt-out paths
cover migration / legacy fixtures / typed escape:

- `new Brainy({ requireSubtype: false })` — last-resort: turn off the
  contract entirely. Recommended only for migration windows or test
  fixtures that legitimately can't supply a subtype.
- `new Brainy({ requireSubtype: { except: [NounType.Thing, ...] } })` —
  per-type allowlist: strict everywhere except the listed types.
- `brain.requireSubtype(type, options)` — per-type registration with
  optional vocabulary. Composes with the brain-wide flag.

Default is now `true`. Opt-out is explicit and documented; nothing
silently degrades.

TEST SWEEP

Bulk-applied `requireSubtype: false` to every `new Brainy({...})` call
site across 120 test files. Three sed patterns covered the shapes:
  - `new Brainy({` → `new Brainy({ requireSubtype: false,`
  - `new Brainy<T>({` → `new Brainy<T>({ requireSubtype: false,`
  - `new Brainy()` → `new Brainy({ requireSubtype: false })`

tests/helpers/test-factory.ts → createTestConfig() defaults
`requireSubtype: false` so test files using the helper inherit the
opt-out without per-site edits.

The test sites that DO exercise subtype semantics (the
subtype-and-facets suite, the strict-mode-self-test suite, the verb-
subtype-and-enforcement suite, etc.) already pass real subtypes — they
were the 7.30.x acceptance tests for this contract. Those tests
continue to pass unchanged.

CHANGES

src/brainy.ts
- normalizeConfig() — `requireSubtype` default `false` → `true`.
  Comment refreshed to document the three opt-out paths.

tests/* (120 files)
- Bulk-edited brain construction sites. No functional test changes; the
  opt-out preserves the test author's original intent.

tests/helpers/test-factory.ts
- createTestConfig() base config gains `requireSubtype: false`.

NO-OP for consumers who were already passing subtype on every write.

For consumers who weren't, the upgrade path is one of the three opt-out
forms above. Migration recipe documented in 8.0 release notes (next
commit).

VERIFICATION

- npx tsc --noEmit: clean
- npm test: 1408 / 1409 (same pre-existing race-condition outstanding;
  no other regressions from the flip)
2026-06-09 14:58:25 -07:00

402 lines
12 KiB
TypeScript
Raw 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' // Use 512MB instead of 128MB for realistic test
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)
expect(config.limitBasis).toBe('containerMemory')
})
})
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()
})
})
})