feat: implement always-adaptive caching with getCacheStats monitoring

Replaces lazy mode concept with always-adaptive caching strategy:

- Rename getLazyModeStats() → getCacheStats() with enhanced metrics
- Change lazyModeEnabled boolean → cachingStrategy enum ('preloaded' | 'on-demand')
- Update preloading threshold from 30% to 80% for better cache utilization
- Add comprehensive production monitoring and diagnostics
- Add memory detection for containers (Docker/K8s cgroups v1/v2)
- Add adaptive memory sizing from 2GB to 128GB+ systems

Breaking changes: None (backward compatible, deprecated lazy option ignored)

New APIs:
- getCacheStats(): Comprehensive cache performance statistics
- cachingStrategy field: Transparent strategy reporting
- Enhanced fairness metrics and memory pressure monitoring

Documentation:
- Add migration guide for v3.36.0
- Add operations/capacity-planning.md for enterprise deployments
- Update all examples and troubleshooting guides
- Rename monitor-lazy-mode.ts → monitor-cache-performance.ts
This commit is contained in:
David Snelling 2025-10-10 14:09:30 -07:00
parent 6037db3d85
commit 46c6af3f21
17 changed files with 2737 additions and 127 deletions

View file

@ -1,9 +1,22 @@
/**
* UnifiedCache - Single cache for both HNSW and MetadataIndex
* Prevents resource competition with cost-aware eviction
*
* Features (v3.36.0+):
* - Adaptive sizing: Automatically scales from 2GB to 128GB+ based on available memory
* - Container-aware: Detects Docker/K8s limits (cgroups v1/v2)
* - Environment detection: Production vs development allocation strategies
* - Memory pressure monitoring: Warns when approaching limits
*/
import { prodLog } from './logger.js'
import {
getRecommendedCacheConfig,
formatBytes,
checkMemoryPressure,
type MemoryInfo,
type CacheAllocationStrategy
} from './memoryDetection.js'
export interface CacheItem {
key: string
@ -16,11 +29,32 @@ export interface CacheItem {
}
export interface UnifiedCacheConfig {
maxSize?: number // bytes
/** Maximum cache size in bytes (auto-detected if not specified) */
maxSize?: number
/** Minimum cache size in bytes (default 256MB) */
minSize?: number
/** Force development mode allocation (25% instead of 40-50%) */
developmentMode?: boolean
/** Enable request coalescing to prevent duplicate loads */
enableRequestCoalescing?: boolean
/** Enable fairness monitoring to prevent cache starvation */
enableFairnessCheck?: boolean
fairnessCheckInterval?: number // ms
/** Fairness check interval in milliseconds */
fairnessCheckInterval?: number
/** Enable access pattern persistence for warm starts */
persistPatterns?: boolean
/** Enable memory pressure monitoring (default true) */
enableMemoryMonitoring?: boolean
/** Memory pressure check interval in milliseconds (default 30s) */
memoryCheckInterval?: number
}
export class UnifiedCache {
@ -33,19 +67,67 @@ export class UnifiedCache {
private readonly maxSize: number
private readonly config: UnifiedCacheConfig
// Memory management (v3.36.0+)
private readonly memoryInfo: MemoryInfo
private readonly allocationStrategy: CacheAllocationStrategy
private memoryPressureCheckTimer: NodeJS.Timeout | null = null
private lastMemoryWarning = 0
constructor(config: UnifiedCacheConfig = {}) {
this.maxSize = config.maxSize || 2 * 1024 * 1024 * 1024 // 2GB default
// Adaptive cache sizing (v3.36.0+)
const recommendation = getRecommendedCacheConfig({
manualSize: config.maxSize,
minSize: config.minSize,
developmentMode: config.developmentMode
})
this.memoryInfo = recommendation.memoryInfo
this.allocationStrategy = recommendation.allocation
this.maxSize = recommendation.allocation.cacheSize
// Log allocation decision (v3.36.0+: includes model memory)
prodLog.info(
`UnifiedCache initialized: ${formatBytes(this.maxSize)} ` +
`(${this.allocationStrategy.environment} mode, ` +
`${(this.allocationStrategy.ratio * 100).toFixed(0)}% of ${formatBytes(this.allocationStrategy.availableForCache)} ` +
`after ${formatBytes(this.allocationStrategy.modelMemory)} ${this.allocationStrategy.modelPrecision.toUpperCase()} model)`
)
// Log memory detection details
prodLog.debug(
`Memory detection: source=${this.memoryInfo.source}, ` +
`container=${this.memoryInfo.isContainer}, ` +
`system=${formatBytes(this.memoryInfo.systemTotal)}, ` +
`free=${formatBytes(this.memoryInfo.free)}, ` +
`totalAvailable=${formatBytes(this.memoryInfo.available)}, ` +
`modelReserved=${formatBytes(this.allocationStrategy.modelMemory)}, ` +
`availableForCache=${formatBytes(this.allocationStrategy.availableForCache)}`
)
// Log warnings if any
for (const warning of recommendation.warnings) {
prodLog.warn(`UnifiedCache: ${warning}`)
}
// Finalize configuration
this.config = {
enableRequestCoalescing: true,
enableFairnessCheck: true,
fairnessCheckInterval: 60000, // Check fairness every minute
persistPatterns: true,
enableMemoryMonitoring: true,
memoryCheckInterval: 30000, // Check memory every 30s
...config
}
// Start monitoring
if (this.config.enableFairnessCheck) {
this.startFairnessMonitor()
}
if (this.config.enableMemoryMonitoring) {
this.startMemoryPressureMonitor()
}
}
/**
@ -92,6 +174,27 @@ export class UnifiedCache {
}
}
/**
* Synchronous cache lookup (v3.36.0+)
* Returns cached data immediately or undefined if not cached
* Use for sync fast path optimization - zero async overhead
*/
getSync(key: string): any | undefined {
// Check if in cache
const item = this.cache.get(key)
if (item) {
// Update access tracking synchronously
this.access.set(key, (this.access.get(key) || 0) + 1)
this.totalAccessCount++
item.lastAccess = Date.now()
item.accessCount++
this.typeAccessCounts[item.type]++
return item.data
}
return undefined
}
/**
* Set item in cache with cost-aware eviction
*/
@ -300,7 +403,55 @@ export class UnifiedCache {
}
/**
* Get cache statistics
* Start memory pressure monitoring
* Periodically checks if we're approaching memory limits
*/
private startMemoryPressureMonitor(): void {
const checkInterval = this.config.memoryCheckInterval || 30000
this.memoryPressureCheckTimer = setInterval(() => {
this.checkMemoryPressure()
}, checkInterval)
// Unref so it doesn't keep process alive
if (this.memoryPressureCheckTimer.unref) {
this.memoryPressureCheckTimer.unref()
}
}
/**
* Check current memory pressure and warn if needed
*/
private checkMemoryPressure(): void {
const pressure = checkMemoryPressure(this.currentSize, this.memoryInfo)
// Only log warnings every 5 minutes to avoid spam
const now = Date.now()
const fiveMinutes = 5 * 60 * 1000
if (pressure.warnings.length > 0 && now - this.lastMemoryWarning > fiveMinutes) {
for (const warning of pressure.warnings) {
prodLog.warn(`UnifiedCache: ${warning}`)
}
this.lastMemoryWarning = now
}
// If critical, force aggressive eviction
if (pressure.pressure === 'critical') {
const targetSize = Math.floor(this.maxSize * 0.7) // Evict to 70%
const bytesToFree = this.currentSize - targetSize
if (bytesToFree > 0) {
prodLog.warn(
`UnifiedCache: Critical memory pressure - forcing eviction of ${formatBytes(bytesToFree)}`
)
this.evictForSize(bytesToFree)
}
}
}
/**
* Get cache statistics with memory information
*/
getStats() {
const typeSizes = { hnsw: 0, metadata: 0, embedding: 0, other: 0 }
@ -311,7 +462,11 @@ export class UnifiedCache {
typeCounts[item.type]++
}
const hitRate = this.cache.size > 0 ?
Array.from(this.cache.values()).reduce((sum, item) => sum + item.accessCount, 0) / this.totalAccessCount : 0
return {
// Cache statistics
totalSize: this.currentSize,
maxSize: this.maxSize,
utilization: this.currentSize / this.maxSize,
@ -320,8 +475,28 @@ export class UnifiedCache {
typeCounts,
typeAccessCounts: this.typeAccessCounts,
totalAccessCount: this.totalAccessCount,
hitRate: this.cache.size > 0 ?
Array.from(this.cache.values()).reduce((sum, item) => sum + item.accessCount, 0) / this.totalAccessCount : 0
hitRate,
// Memory management (v3.36.0+)
memory: {
available: this.memoryInfo.available,
source: this.memoryInfo.source,
isContainer: this.memoryInfo.isContainer,
systemTotal: this.memoryInfo.systemTotal,
allocationRatio: this.allocationStrategy.ratio,
environment: this.allocationStrategy.environment
}
}
}
/**
* Get detailed memory information
*/
getMemoryInfo() {
return {
memoryInfo: { ...this.memoryInfo },
allocationStrategy: { ...this.allocationStrategy },
currentPressure: checkMemoryPressure(this.currentSize, this.memoryInfo)
}
}