brainy/src/utils/paramValidation.ts
David Snelling 3e8b9aacc8 feat: COW always-on architecture + cloud storage clear() fix (v5.11.0)
Major architectural improvements and critical bug fixes:

## COW Always-On Architecture
- Removed cowEnabled flag from BaseStorage (COW cannot be disabled)
- Eliminated marker file system (checkClearMarker, createClearMarker)
- Simplified all code paths to assume COW is always enabled
- COW automatically re-initializes after clear() operations

## Critical Bug Fix: Cloud Storage clear()
- Fixed GCS clear() using correct paths (branches/ instead of entities/nouns/)
- Fixed S3Compatible clear() path structure
- Fixed R2 clear() implementation
- Fixed Azure, FileSystem, OPFS, Memory clear() COW flag handling
- clear() now deletes: branches/, _cow/, _system/
- Result: Cloud buckets can now be fully cleared (previously impossible)

## Container Memory Detection
- Auto-detect Docker/K8s/Cloud Run memory limits (cgroup v1/v2)
- Smart memory allocation (75% graph data, 25% query operations)
- Environment variable support (CLOUD_RUN_MEMORY, MEMORY_LIMIT)
- Production-grade containerized deployment support

## CommitLog streamHistory Feature
- Added streamable commit history with pagination
- Efficient memory usage for large commit histories
- Support for branch filtering and time ranges

## Comprehensive Storage Documentation
- Complete v5.11.0 file structure reference
- Detailed path construction algorithms
- 8 common storage scenarios with examples
- Type-first storage, sharding, COW architecture explained
- Public docs: docs/architecture/data-storage-architecture.md (1063 lines)

## Files Modified (14 files)
- All 8 storage adapters (GCS, S3, R2, Azure, FS, OPFS, Memory, Historical)
- BaseStorage core architecture
- CommitLog with streaming
- Brainy memory configuration
- Parameter validation with container detection
- Storage architecture documentation

## Breaking Changes
NONE - COW was already enabled by default. This removes the ability to disable it.

## Migration
No action required. Upgrade and clear() will work correctly on cloud storage.

## Impact
- Users can now clear cloud storage buckets completely
- No more corrupted buckets after clear() operations
- Container deployments automatically optimize memory allocation
- COW is mandatory and always enabled (safer, simpler)

v5.11.0 - Production ready
2025-11-18 13:44:02 -08:00

445 lines
No EOL
13 KiB
TypeScript

/**
* Zero-Config Parameter Validation
*
* Self-configuring validation that adapts to system capabilities
* Only enforces universal truths, learns everything else
*/
import { FindParams, AddParams, UpdateParams, RelateParams } from '../types/brainy.types.js'
import { NounType, VerbType } from '../types/graphTypes.js'
// Dynamic import for Node.js os and fs modules
let os: any = null
let fs: any = null
if (typeof window === 'undefined') {
try {
os = await import('node:os')
fs = await import('node:fs')
} catch (e) {
// OS/FS modules not available
}
}
// Browser-safe memory detection
const getSystemMemory = (): number => {
if (os) {
return os.totalmem()
}
// Browser fallback: assume 4GB
return 4 * 1024 * 1024 * 1024
}
const getAvailableMemory = (): number => {
if (os) {
return os.freemem()
}
// Browser fallback: assume 2GB available
return 2 * 1024 * 1024 * 1024
}
/**
* Detect container memory limit (Docker/Kubernetes/Cloud Run)
*
* Production-grade detection for containerized environments.
* Supports:
* - cgroup v1 (legacy Docker/K8s)
* - cgroup v2 (modern systems)
* - Environment variables (Cloud Run, GCP, AWS, Azure)
*
* @returns Container memory limit in bytes, or null if not containerized
*/
const getContainerMemoryLimit = (): number | null => {
// Not in Node.js environment
if (!fs) {
return null
}
try {
// 1. Check environment variables first (fastest, most reliable for Cloud Run)
// Google Cloud Run
if (process.env.CLOUD_RUN_MEMORY) {
// Format: "512Mi", "1Gi", "2Gi", "4Gi"
const match = process.env.CLOUD_RUN_MEMORY.match(/^(\d+)(Mi|Gi)$/)
if (match) {
const value = parseInt(match[1])
const unit = match[2]
return unit === 'Gi' ? value * 1024 * 1024 * 1024 : value * 1024 * 1024
}
}
// Generic MEMORY_LIMIT env var (bytes)
if (process.env.MEMORY_LIMIT) {
const limit = parseInt(process.env.MEMORY_LIMIT)
if (!isNaN(limit) && limit > 0) {
return limit
}
}
// 2. Check cgroup v2 (modern Docker/K8s)
try {
const cgroupV2Path = '/sys/fs/cgroup/memory.max'
const cgroupV2Content = fs.readFileSync(cgroupV2Path, 'utf8').trim()
// "max" means no limit, otherwise it's bytes
if (cgroupV2Content !== 'max') {
const limit = parseInt(cgroupV2Content)
if (!isNaN(limit) && limit > 0) {
return limit
}
}
} catch (e) {
// cgroup v2 not available, try v1
}
// 3. Check cgroup v1 (legacy Docker/K8s)
try {
const cgroupV1Path = '/sys/fs/cgroup/memory/memory.limit_in_bytes'
const cgroupV1Content = fs.readFileSync(cgroupV1Path, 'utf8').trim()
const limit = parseInt(cgroupV1Content)
// Very large values (> 1 PB) indicate no limit
const ONE_PETABYTE = 1024 * 1024 * 1024 * 1024 * 1024
if (!isNaN(limit) && limit > 0 && limit < ONE_PETABYTE) {
return limit
}
} catch (e) {
// cgroup v1 not available
}
// Not containerized or no limit set
return null
} catch (e) {
// Error reading cgroup files
return null
}
}
/**
* Configuration options for ValidationConfig
*/
export interface ValidationConfigOptions {
/**
* Explicit maximum query limit override
* Bypasses all auto-detection
*/
maxQueryLimit?: number
/**
* Memory reserved for query operations (in bytes)
* Bypasses auto-detection but still applies safety limits
*/
reservedQueryMemory?: number
}
/**
* Auto-configured limits based on system resources
* These adapt to available memory and observed performance
*/
export class ValidationConfig {
private static instance: ValidationConfig
// Dynamic limits based on system
public maxLimit: number
public maxQueryLength: number
public maxVectorDimensions: number
// Tracking for diagnostics
public limitBasis: 'override' | 'reservedMemory' | 'containerMemory' | 'freeMemory'
public detectedContainerLimit: number | null
// Performance observations
private avgQueryTime: number = 0
private queryCount: number = 0
private constructor(options?: ValidationConfigOptions) {
// Vector dimensions (standard for all-MiniLM-L6-v2)
this.maxVectorDimensions = 384
// Detect container memory limit
this.detectedContainerLimit = getContainerMemoryLimit()
// Priority 1: Explicit override (highest priority)
if (options?.maxQueryLimit !== undefined) {
this.maxLimit = Math.min(options.maxQueryLimit, 100000) // Still cap at 100k for safety
this.limitBasis = 'override'
// Scale query length with limit
this.maxQueryLength = Math.min(50000, this.maxLimit * 5)
return
}
// Priority 2: Reserved memory specified
if (options?.reservedQueryMemory !== undefined) {
this.maxLimit = Math.min(
100000,
Math.floor(options.reservedQueryMemory / (1024 * 1024 * 100)) * 1000
)
this.limitBasis = 'reservedMemory'
this.maxQueryLength = Math.min(
50000,
Math.floor(options.reservedQueryMemory / (1024 * 1024 * 10)) * 1000
)
return
}
// Priority 3: Container detected (smart containerized behavior)
if (this.detectedContainerLimit) {
// In containers, assume 75% used by graph data (EXPECTED)
// Reserve 25% for query operations
const queryMemory = this.detectedContainerLimit * 0.25
this.maxLimit = Math.min(
100000,
Math.floor(queryMemory / (1024 * 1024 * 100)) * 1000
)
this.limitBasis = 'containerMemory'
this.maxQueryLength = Math.min(
50000,
Math.floor(queryMemory / (1024 * 1024 * 10)) * 1000
)
return
}
// Priority 4: Free memory (fallback, current behavior)
const availableMemory = getAvailableMemory()
this.maxLimit = Math.min(
100000,
Math.floor(availableMemory / (1024 * 1024 * 100)) * 1000
)
this.limitBasis = 'freeMemory'
this.maxQueryLength = Math.min(
50000,
Math.floor(availableMemory / (1024 * 1024 * 10)) * 1000
)
}
static getInstance(options?: ValidationConfigOptions): ValidationConfig {
if (!ValidationConfig.instance) {
ValidationConfig.instance = new ValidationConfig(options)
}
return ValidationConfig.instance
}
/**
* Reset singleton (for testing or reconfiguration)
*/
static reset(): void {
ValidationConfig.instance = null as any
}
/**
* Reconfigure with new options
*/
static reconfigure(options: ValidationConfigOptions): ValidationConfig {
ValidationConfig.instance = new ValidationConfig(options)
return ValidationConfig.instance
}
/**
* Learn from actual usage to adjust limits
*/
recordQuery(duration: number, resultCount: number) {
this.queryCount++
this.avgQueryTime = (this.avgQueryTime * (this.queryCount - 1) + duration) / this.queryCount
// Only auto-adjust if not using explicit overrides
if (this.limitBasis !== 'override') {
// If queries are consistently fast with large results, increase limits
if (this.avgQueryTime < 100 && resultCount > this.maxLimit * 0.8) {
this.maxLimit = Math.min(this.maxLimit * 1.5, 100000)
}
// If queries are slow, reduce limits
if (this.avgQueryTime > 1000) {
this.maxLimit = Math.max(this.maxLimit * 0.8, 1000)
}
}
}
}
/**
* Universal validations - things that are always invalid
* These are mathematical/logical truths, not configuration
*/
export function validateFindParams(params: FindParams): void {
const config = ValidationConfig.getInstance()
// Universal truth: negative pagination never makes sense
if (params.limit !== undefined) {
if (params.limit < 0) {
throw new Error('limit must be non-negative')
}
if (params.limit > config.maxLimit) {
throw new Error(`limit exceeds auto-configured maximum of ${config.maxLimit} (based on available memory)`)
}
}
if (params.offset !== undefined && params.offset < 0) {
throw new Error('offset must be non-negative')
}
// Universal truth: probability/similarity must be 0-1
if (params.near?.threshold !== undefined) {
const t = params.near.threshold
if (t < 0 || t > 1) {
throw new Error('threshold must be between 0 and 1')
}
}
// Universal truth: can't specify both query and vector (they're alternatives)
if (params.query !== undefined && params.vector !== undefined) {
throw new Error('cannot specify both query and vector - they are mutually exclusive')
}
// Universal truth: can't use both cursor and offset pagination
if (params.cursor !== undefined && params.offset !== undefined) {
throw new Error('cannot use both cursor and offset pagination simultaneously')
}
// Auto-limit query length based on memory
if (params.query && params.query.length > config.maxQueryLength) {
throw new Error(`query exceeds auto-configured maximum length of ${config.maxQueryLength} characters`)
}
// Validate vector dimensions if provided
if (params.vector && params.vector.length !== config.maxVectorDimensions) {
throw new Error(`vector must have exactly ${config.maxVectorDimensions} dimensions`)
}
// Validate enum types if specified
if (params.type) {
const types = Array.isArray(params.type) ? params.type : [params.type]
for (const type of types) {
if (!Object.values(NounType).includes(type)) {
throw new Error(`invalid NounType: ${type}`)
}
}
}
}
/**
* Validate add parameters
*/
export function validateAddParams(params: AddParams): void {
// Universal truth: must have data or vector
if (!params.data && !params.vector) {
throw new Error(
`Invalid add() parameters: Missing required field 'data'\n` +
`\nReceived: ${JSON.stringify({
type: params.type,
hasMetadata: !!params.metadata,
hasId: !!params.id
}, null, 2)}\n` +
`\nExpected one of:\n` +
` { data: 'text to store', type?: 'note', metadata?: {...} }\n` +
` { vector: [0.1, 0.2, ...], type?: 'embedding', metadata?: {...} }\n` +
`\nExamples:\n` +
` await brain.add({ data: 'Machine learning is AI', type: 'concept' })\n` +
` await brain.add({ data: { title: 'Doc', content: '...' }, type: 'document' })`
)
}
// Validate noun type
if (!Object.values(NounType).includes(params.type)) {
throw new Error(
`Invalid NounType: '${params.type}'\n` +
`\nValid types: ${Object.values(NounType).join(', ')}\n` +
`\nExample: await brain.add({ data: 'text', type: NounType.Note })`
)
}
// Validate vector dimensions if provided
if (params.vector) {
const config = ValidationConfig.getInstance()
if (params.vector.length !== config.maxVectorDimensions) {
throw new Error(`vector must have exactly ${config.maxVectorDimensions} dimensions`)
}
}
}
/**
* Validate update parameters
*/
export function validateUpdateParams(params: UpdateParams): void {
// Universal truth: must have an ID
if (!params.id) {
throw new Error('id is required for update')
}
// Universal truth: must update something
if (!params.data && !params.metadata && !params.type && !params.vector) {
throw new Error('must specify at least one field to update')
}
// Validate type if changing
if (params.type && !Object.values(NounType).includes(params.type)) {
throw new Error(`invalid NounType: ${params.type}`)
}
// Validate vector dimensions if provided
if (params.vector) {
const config = ValidationConfig.getInstance()
if (params.vector.length !== config.maxVectorDimensions) {
throw new Error(`vector must have exactly ${config.maxVectorDimensions} dimensions`)
}
}
}
/**
* Validate relate parameters
*/
export function validateRelateParams(params: RelateParams): void {
// Universal truths
if (!params.from) {
throw new Error('from entity ID is required')
}
if (!params.to) {
throw new Error('to entity ID is required')
}
// Allow self-referential relationships - they're valid in graph systems
// (e.g., a person can be related to themselves, a file can reference itself, etc.)
// Validate verb type - default to RelatedTo if not specified
if (params.type === undefined) {
params.type = VerbType.RelatedTo
} else if (!Object.values(VerbType).includes(params.type)) {
throw new Error(`invalid VerbType: ${params.type}`)
}
// Universal truth: weight must be 0-1
if (params.weight !== undefined) {
if (params.weight < 0 || params.weight > 1) {
throw new Error('weight must be between 0 and 1')
}
}
}
/**
* Get current validation configuration
* Useful for debugging and monitoring
*/
export function getValidationConfig() {
const config = ValidationConfig.getInstance()
return {
maxLimit: config.maxLimit,
maxQueryLength: config.maxQueryLength,
maxVectorDimensions: config.maxVectorDimensions,
systemMemory: getSystemMemory(),
availableMemory: getAvailableMemory()
}
}
/**
* Record query performance for auto-tuning
*/
export function recordQueryPerformance(duration: number, resultCount: number) {
ValidationConfig.getInstance().recordQuery(duration, resultCount)
}