2025-08-26 12:32:21 -07:00
/ * *
* UnifiedCache - Single cache for both HNSW and MetadataIndex
* Prevents resource competition with cost - aware eviction
2025-10-10 14:09:30 -07:00
*
2026-01-27 15:38:21 -08:00
* Features :
2025-10-10 14:09:30 -07:00
* - Adaptive sizing : Automatically scales from 2 GB to 128 GB + 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
2025-08-26 12:32:21 -07:00
* /
import { prodLog } from './logger.js'
2025-10-10 14:09:30 -07:00
import {
getRecommendedCacheConfig ,
formatBytes ,
checkMemoryPressure ,
type MemoryInfo ,
type CacheAllocationStrategy
} from './memoryDetection.js'
2025-08-26 12:32:21 -07:00
export interface CacheItem {
key : string
type : 'hnsw' | 'metadata' | 'embedding' | 'other'
data : any
size : number
rebuildCost : number // milliseconds to rebuild
lastAccess : number
accessCount : number
}
export interface UnifiedCacheConfig {
2025-10-10 14:09:30 -07:00
/** 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 */
2025-08-26 12:32:21 -07:00
enableRequestCoalescing? : boolean
2025-10-10 14:09:30 -07:00
/** Enable fairness monitoring to prevent cache starvation */
2025-08-26 12:32:21 -07:00
enableFairnessCheck? : boolean
2025-10-10 14:09:30 -07:00
/** Fairness check interval in milliseconds */
fairnessCheckInterval? : number
/** Enable access pattern persistence for warm starts */
2025-08-26 12:32:21 -07:00
persistPatterns? : boolean
2025-10-10 14:09:30 -07:00
/** Enable memory pressure monitoring (default true) */
enableMemoryMonitoring? : boolean
/** Memory pressure check interval in milliseconds (default 30s) */
memoryCheckInterval? : number
2025-08-26 12:32:21 -07:00
}
export class UnifiedCache {
private cache = new Map < string , CacheItem > ( )
private access = new Map < string , number > ( ) // Access counts
private loadingPromises = new Map < string , Promise < any > > ( )
private typeAccessCounts = { hnsw : 0 , metadata : 0 , embedding : 0 , other : 0 }
private totalAccessCount = 0
private currentSize = 0
private readonly maxSize : number
private readonly config : UnifiedCacheConfig
2026-01-27 15:38:21 -08:00
// Memory management
2025-10-10 14:09:30 -07:00
private readonly memoryInfo : MemoryInfo
private readonly allocationStrategy : CacheAllocationStrategy
private memoryPressureCheckTimer : NodeJS.Timeout | null = null
private lastMemoryWarning = 0
2025-08-26 12:32:21 -07:00
constructor ( config : UnifiedCacheConfig = { } ) {
2026-01-27 15:38:21 -08:00
// Adaptive cache sizing
2025-10-10 14:09:30 -07:00
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
2026-01-27 15:38:21 -08:00
// Log allocation decision (includes model memory)
2025-10-10 14:09:30 -07:00
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
2025-08-26 12:32:21 -07:00
this . config = {
enableRequestCoalescing : true ,
enableFairnessCheck : true ,
2026-01-27 15:38:21 -08:00
fairnessCheckInterval : 30000 , // Check fairness every 30 seconds (was 60s)
2025-08-26 12:32:21 -07:00
persistPatterns : true ,
2025-10-10 14:09:30 -07:00
enableMemoryMonitoring : true ,
memoryCheckInterval : 30000 , // Check memory every 30s
2025-08-26 12:32:21 -07:00
. . . config
}
2025-10-10 14:09:30 -07:00
// Start monitoring
2025-08-26 12:32:21 -07:00
if ( this . config . enableFairnessCheck ) {
this . startFairnessMonitor ( )
}
2025-10-10 14:09:30 -07:00
if ( this . config . enableMemoryMonitoring ) {
this . startMemoryPressureMonitor ( )
}
2025-08-26 12:32:21 -07:00
}
/ * *
* Get item from cache with request coalescing
* /
async get ( key : string , loadFn ? : ( ) = > Promise < any > ) : Promise < any > {
// Update access tracking
this . access . set ( key , ( this . access . get ( key ) || 0 ) + 1 )
this . totalAccessCount ++
// Check if in cache
const item = this . cache . get ( key )
if ( item ) {
item . lastAccess = Date . now ( )
item . accessCount ++
this . typeAccessCounts [ item . type ] ++
return item . data
}
// If no load function, return undefined
if ( ! loadFn ) {
return undefined
}
// Request coalescing - prevent stampede
if ( this . config . enableRequestCoalescing && this . loadingPromises . has ( key ) ) {
prodLog . debug ( 'Request coalescing for key:' , key )
return this . loadingPromises . get ( key )
}
// Load data
const loadPromise = loadFn ( )
if ( this . config . enableRequestCoalescing ) {
this . loadingPromises . set ( key , loadPromise )
}
try {
const data = await loadPromise
return data
} finally {
if ( this . config . enableRequestCoalescing ) {
this . loadingPromises . delete ( key )
}
}
}
2025-10-10 14:09:30 -07:00
/ * *
2026-01-27 15:38:21 -08:00
* Synchronous cache lookup
2025-10-10 14:09:30 -07:00
* 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
}
2025-08-26 12:32:21 -07:00
/ * *
* Set item in cache with cost - aware eviction
* /
set (
key : string ,
data : any ,
type : 'hnsw' | 'metadata' | 'embedding' | 'other' ,
size : number ,
rebuildCost : number = 1
) : void {
// Make room if needed
while ( this . currentSize + size > this . maxSize && this . cache . size > 0 ) {
this . evictLowestValue ( )
}
// Add to cache
const item : CacheItem = {
key ,
type ,
data ,
size ,
rebuildCost ,
lastAccess : Date.now ( ) ,
accessCount : 1
}
// Update or add
const existing = this . cache . get ( key )
if ( existing ) {
this . currentSize -= existing . size
}
this . cache . set ( key , item )
this . currentSize += size
this . typeAccessCounts [ type ] ++
this . totalAccessCount ++
2025-10-13 11:50:53 -07:00
2026-01-27 15:38:21 -08:00
// Proactive fairness check: Check immediately if adding to a dominant type
2025-10-13 11:50:53 -07:00
// This prevents imbalance formation instead of reacting to it
if ( this . config . enableFairnessCheck && this . cache . size > 10 ) {
this . checkProactiveFairness ( type )
}
2025-08-26 12:32:21 -07:00
}
/ * *
* Evict item with lowest value ( access count / rebuild cost )
* /
private evictLowestValue ( ) : void {
let victim : string | null = null
let lowestScore = Infinity
for ( const [ key , item ] of this . cache ) {
2025-10-13 11:21:19 -07:00
// Calculate value score: access frequency * rebuild cost (higher is better)
2025-08-26 12:32:21 -07:00
const accessScore = ( this . access . get ( key ) || 1 )
2025-10-13 11:21:19 -07:00
const score = accessScore * item . rebuildCost
2025-08-26 12:32:21 -07:00
if ( score < lowestScore ) {
lowestScore = score
victim = key
}
}
if ( victim ) {
const item = this . cache . get ( victim ) !
prodLog . debug ( ` Evicting ${ victim } (type: ${ item . type } , score: ${ lowestScore } ) ` )
this . currentSize -= item . size
this . cache . delete ( victim )
// Keep access count for a while to prevent re-caching cold items
// this.access.delete(victim) // Don't delete immediately
}
}
/ * *
* Size - aware eviction - try to match needed size
* /
evictForSize ( bytesNeeded : number ) : boolean {
const candidates : Array < [ string , number , CacheItem ] > = [ ]
2025-10-13 11:21:19 -07:00
2025-08-26 12:32:21 -07:00
for ( const [ key , item ] of this . cache ) {
2025-10-13 11:21:19 -07:00
const score = ( this . access . get ( key ) || 1 ) * item . rebuildCost
2025-08-26 12:32:21 -07:00
candidates . push ( [ key , score , item ] )
}
// Sort by score (lower is worse)
candidates . sort ( ( a , b ) = > a [ 1 ] - b [ 1 ] )
let freedBytes = 0
const toEvict : string [ ] = [ ]
// Try to free exactly what we need
for ( const [ key , , item ] of candidates ) {
toEvict . push ( key )
freedBytes += item . size
if ( freedBytes >= bytesNeeded ) {
break
}
}
// Evict selected items
for ( const key of toEvict ) {
const item = this . cache . get ( key ) !
this . currentSize -= item . size
this . cache . delete ( key )
}
return freedBytes >= bytesNeeded
}
/ * *
* Fairness monitoring - prevent one type from hogging cache
* /
private startFairnessMonitor ( ) : void {
setInterval ( ( ) = > {
this . checkFairness ( )
} , this . config . fairnessCheckInterval ! )
}
private checkFairness ( ) : void {
// Calculate type ratios in cache
const typeSizes = { hnsw : 0 , metadata : 0 , embedding : 0 , other : 0 }
const typeCounts = { hnsw : 0 , metadata : 0 , embedding : 0 , other : 0 }
for ( const item of this . cache . values ( ) ) {
typeSizes [ item . type ] += item . size
typeCounts [ item . type ] ++
}
// Calculate access ratios
const totalAccess = this . totalAccessCount || 1
const accessRatios = {
hnsw : this.typeAccessCounts.hnsw / totalAccess ,
metadata : this.typeAccessCounts.metadata / totalAccess ,
embedding : this.typeAccessCounts.embedding / totalAccess ,
other : this.typeAccessCounts.other / totalAccess
}
// Calculate size ratios
const totalSize = this . currentSize || 1
const sizeRatios = {
hnsw : typeSizes.hnsw / totalSize ,
metadata : typeSizes.metadata / totalSize ,
embedding : typeSizes.embedding / totalSize ,
other : typeSizes.other / totalSize
}
2026-01-27 15:38:21 -08:00
// Check for starvation (more aggressive - 70% cache with <15% accesses)
2025-10-13 11:50:53 -07:00
// Previous: 90% cache, <10% access (too lenient, caused thrashing)
2025-08-26 12:32:21 -07:00
for ( const type of [ 'hnsw' , 'metadata' , 'embedding' , 'other' ] as const ) {
2025-10-13 11:50:53 -07:00
if ( sizeRatios [ type ] > 0.7 && accessRatios [ type ] < 0.15 ) {
2025-08-26 12:32:21 -07:00
prodLog . warn ( ` Type ${ type } is hogging cache ( ${ ( sizeRatios [ type ] * 100 ) . toFixed ( 1 ) } % size, ${ ( accessRatios [ type ] * 100 ) . toFixed ( 1 ) } % access) ` )
this . evictType ( type )
}
}
}
2025-10-13 11:50:53 -07:00
/ * *
2026-01-27 15:38:21 -08:00
* Proactive fairness check
2025-10-13 11:50:53 -07:00
* Called immediately when adding items to prevent imbalance formation
* Uses same thresholds as periodic check but runs on - demand
* /
private checkProactiveFairness ( addedType : 'hnsw' | 'metadata' | 'embedding' | 'other' ) : void {
// Quick check: only evaluate the type being added
let typeSize = 0
for ( const item of this . cache . values ( ) ) {
if ( item . type === addedType ) {
typeSize += item . size
}
}
const sizeRatio = typeSize / ( this . currentSize || 1 )
const accessRatio = this . typeAccessCounts [ addedType ] / ( this . totalAccessCount || 1 )
// Same threshold as periodic check: 70% size, <15% access
if ( sizeRatio > 0.7 && accessRatio < 0.15 ) {
prodLog . debug ( ` Proactive fairness: ${ addedType } reaching dominance ( ${ ( sizeRatio * 100 ) . toFixed ( 1 ) } % size, ${ ( accessRatio * 100 ) . toFixed ( 1 ) } % access) ` )
this . evictType ( addedType )
}
}
2025-08-26 12:32:21 -07:00
/ * *
* Force evict items of a specific type
* /
private evictType ( type : 'hnsw' | 'metadata' | 'embedding' | 'other' ) : void {
const candidates : Array < [ string , number , CacheItem ] > = [ ]
2025-10-13 11:21:19 -07:00
2025-08-26 12:32:21 -07:00
for ( const [ key , item ] of this . cache ) {
if ( item . type === type ) {
2025-10-13 11:21:19 -07:00
const score = ( this . access . get ( key ) || 1 ) * item . rebuildCost
2025-08-26 12:32:21 -07:00
candidates . push ( [ key , score , item ] )
}
}
// Sort by score (lower is worse)
candidates . sort ( ( a , b ) = > a [ 1 ] - b [ 1 ] )
2026-01-27 15:38:21 -08:00
// Evict bottom 50% of this type (was 20%, too slow to prevent thrashing)
2025-10-13 11:50:53 -07:00
const evictCount = Math . max ( 1 , Math . floor ( candidates . length * 0.5 ) )
2025-08-26 12:32:21 -07:00
for ( let i = 0 ; i < evictCount && i < candidates . length ; i ++ ) {
const [ key , , item ] = candidates [ i ]
this . currentSize -= item . size
this . cache . delete ( key )
prodLog . debug ( ` Fairness eviction: ${ key } (type: ${ type } ) ` )
}
}
/ * *
* Delete specific item from cache
* /
delete ( key : string ) : boolean {
const item = this . cache . get ( key )
if ( item ) {
this . currentSize -= item . size
this . cache . delete ( key )
return true
}
return false
}
2025-12-04 11:22:30 -08:00
/ * *
* Delete all items with keys starting with the given prefix
2026-01-27 15:38:21 -08:00
* Added for VFS cache invalidation ( fixes stale parent ID bug )
2025-12-04 11:22:30 -08:00
* @param prefix - The key prefix to match
* @returns Number of items deleted
* /
deleteByPrefix ( prefix : string ) : number {
let deleted = 0
for ( const [ key , item ] of this . cache ) {
if ( key . startsWith ( prefix ) ) {
this . currentSize -= item . size
this . cache . delete ( key )
deleted ++
}
}
return deleted
}
2025-08-26 12:32:21 -07:00
/ * *
* Clear cache or specific type
* /
clear ( type ? : 'hnsw' | 'metadata' | 'embedding' | 'other' ) : void {
if ( ! type ) {
this . cache . clear ( )
this . currentSize = 0
return
}
for ( const [ key , item ] of this . cache ) {
if ( item . type === type ) {
this . currentSize -= item . size
this . cache . delete ( key )
}
}
}
/ * *
2025-10-10 14:09:30 -07:00
* 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
2025-08-26 12:32:21 -07:00
* /
getStats() {
const typeSizes = { hnsw : 0 , metadata : 0 , embedding : 0 , other : 0 }
const typeCounts = { hnsw : 0 , metadata : 0 , embedding : 0 , other : 0 }
for ( const item of this . cache . values ( ) ) {
typeSizes [ item . type ] += item . size
typeCounts [ item . type ] ++
}
2025-10-10 14:09:30 -07:00
const hitRate = this . cache . size > 0 ?
Array . from ( this . cache . values ( ) ) . reduce ( ( sum , item ) = > sum + item . accessCount , 0 ) / this . totalAccessCount : 0
2025-08-26 12:32:21 -07:00
return {
2025-10-10 14:09:30 -07:00
// Cache statistics
2025-08-26 12:32:21 -07:00
totalSize : this.currentSize ,
maxSize : this.maxSize ,
utilization : this.currentSize / this . maxSize ,
itemCount : this.cache.size ,
typeSizes ,
typeCounts ,
typeAccessCounts : this.typeAccessCounts ,
totalAccessCount : this.totalAccessCount ,
2025-10-10 14:09:30 -07:00
hitRate ,
2026-01-27 15:38:21 -08:00
// Memory management
2025-10-10 14:09:30 -07:00
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 )
2025-08-26 12:32:21 -07:00
}
}
/ * *
* Save access patterns for cold start optimization
* /
async saveAccessPatterns ( ) : Promise < any > {
if ( ! this . config . persistPatterns ) return
const patterns = Array . from ( this . cache . entries ( ) )
. map ( ( [ key , item ] ) = > ( {
key ,
type : item . type ,
accessCount : this.access.get ( key ) || 0 ,
size : item.size ,
rebuildCost : item.rebuildCost
} ) )
. sort ( ( a , b ) = > b . accessCount - a . accessCount )
return {
patterns ,
typeAccessCounts : this.typeAccessCounts ,
timestamp : Date.now ( )
}
}
/ * *
* Load access patterns for warm start
* /
async loadAccessPatterns ( patterns : any ) : Promise < void > {
if ( ! patterns ? . patterns ) return
// Pre-populate access counts
for ( const pattern of patterns . patterns ) {
this . access . set ( pattern . key , pattern . accessCount )
}
// Restore type access counts
if ( patterns . typeAccessCounts ) {
this . typeAccessCounts = patterns . typeAccessCounts
}
prodLog . debug ( 'Loaded access patterns:' , patterns . patterns . length , 'items' )
}
}
// Export singleton for global coordination
let globalCache : UnifiedCache | null = null
export function getGlobalCache ( config? : UnifiedCacheConfig ) : UnifiedCache {
if ( ! globalCache ) {
globalCache = new UnifiedCache ( config )
}
return globalCache
}
feat: wire plugin system with provider resolution, storage factories, and browser deprecation
- Wire PluginRegistry into Brainy init() with provider resolution for distance,
metadataIndex, graphIndex, embeddings, roaring, msgpack, and storage adapters
- Add setupStorage() factory that resolves storage:* providers from plugins before
falling back to built-in createStorage()
- Export internals API (setGlobalCache, UnifiedCache, EntityIdMapper, etc.) for
cortex plugin consumption
- Add plugin.test.ts verifying registration, activation, and provider resolution
- Deprecate browser support (OPFS, Web Workers, WASM embeddings) with warnings
in preparation for v8.0 server-only release
- FileSystemStorage: fix setupStorage resolution for mmap-filesystem provider
2026-01-31 12:02:13 -08:00
export function setGlobalCache ( cache : UnifiedCache ) : void {
globalCache = cache
}
2025-08-26 12:32:21 -07:00
export function clearGlobalCache ( ) : void {
if ( globalCache ) {
globalCache . clear ( )
globalCache = null
}
}