2025-08-26 12:32:21 -07:00
|
|
|
/**
|
|
|
|
|
* Multi-level Cache Manager
|
|
|
|
|
*
|
|
|
|
|
* Implements a three-level caching strategy:
|
|
|
|
|
* - Level 1: Hot cache (most accessed nodes) - RAM (automatically detecting and adjusting in each environment)
|
|
|
|
|
* - Level 2: Warm cache (recent nodes) - OPFS, Filesystem or S3 depending on environment
|
|
|
|
|
* - Level 3: Cold storage (all nodes) - OPFS, Filesystem or S3 depending on environment
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { HNSWNoun, GraphVerb, HNSWVerb } from '../coreTypes.js'
|
|
|
|
|
import { BrainyError } from '../errors/brainyError.js'
|
|
|
|
|
|
|
|
|
|
// Extend Navigator interface to include deviceMemory property
|
|
|
|
|
// and WorkerGlobalScope to include storage property
|
|
|
|
|
declare global {
|
|
|
|
|
interface Navigator {
|
|
|
|
|
deviceMemory?: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface WorkerGlobalScope {
|
|
|
|
|
storage?: {
|
|
|
|
|
getDirectory?: () => Promise<any>;
|
|
|
|
|
[key: string]: any;
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Type aliases for better readability
|
|
|
|
|
type HNSWNode = HNSWNoun
|
|
|
|
|
type Edge = GraphVerb
|
|
|
|
|
|
|
|
|
|
// Cache entry with metadata for LRU and TTL management
|
|
|
|
|
interface CacheEntry<T> {
|
|
|
|
|
data: T
|
|
|
|
|
lastAccessed: number
|
|
|
|
|
accessCount: number
|
|
|
|
|
expiresAt: number | null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Cache statistics for monitoring and tuning
|
|
|
|
|
interface CacheStats {
|
|
|
|
|
hits: number
|
|
|
|
|
misses: number
|
|
|
|
|
evictions: number
|
|
|
|
|
size: number
|
|
|
|
|
maxSize: number
|
|
|
|
|
hotCacheSize: number
|
|
|
|
|
warmCacheSize: number
|
|
|
|
|
hotCacheHits: number
|
|
|
|
|
hotCacheMisses: number
|
|
|
|
|
warmCacheHits: number
|
|
|
|
|
warmCacheMisses: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Environment detection for storage selection
|
|
|
|
|
enum Environment {
|
|
|
|
|
BROWSER,
|
|
|
|
|
NODE,
|
|
|
|
|
WORKER
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Storage type for warm and cold caches
|
|
|
|
|
enum StorageType {
|
|
|
|
|
MEMORY,
|
|
|
|
|
OPFS,
|
|
|
|
|
FILESYSTEM,
|
|
|
|
|
S3,
|
|
|
|
|
REMOTE_API
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Multi-level cache manager for efficient data access
|
|
|
|
|
*/
|
|
|
|
|
export class CacheManager<T extends HNSWNode | Edge | HNSWVerb> {
|
|
|
|
|
// Hot cache (RAM)
|
|
|
|
|
private hotCache = new Map<string, CacheEntry<T>>()
|
|
|
|
|
|
|
|
|
|
// Cache statistics
|
|
|
|
|
private stats: CacheStats = {
|
|
|
|
|
hits: 0,
|
|
|
|
|
misses: 0,
|
|
|
|
|
evictions: 0,
|
|
|
|
|
size: 0,
|
|
|
|
|
maxSize: 0,
|
|
|
|
|
hotCacheSize: 0,
|
|
|
|
|
warmCacheSize: 0,
|
|
|
|
|
hotCacheHits: 0,
|
|
|
|
|
hotCacheMisses: 0,
|
|
|
|
|
warmCacheHits: 0,
|
|
|
|
|
warmCacheMisses: 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Environment and storage configuration
|
|
|
|
|
private environment: Environment
|
|
|
|
|
private warmStorageType: StorageType
|
|
|
|
|
private coldStorageType: StorageType
|
|
|
|
|
|
|
|
|
|
// Cache configuration
|
|
|
|
|
private hotCacheMaxSize: number
|
|
|
|
|
private hotCacheEvictionThreshold: number
|
|
|
|
|
private warmCacheTTL: number
|
|
|
|
|
private batchSize: number
|
|
|
|
|
|
|
|
|
|
// Auto-tuning configuration
|
|
|
|
|
private autoTune: boolean
|
|
|
|
|
private lastAutoTuneTime: number = 0
|
|
|
|
|
private autoTuneInterval: number = 5 * 60 * 1000 // 5 minutes
|
|
|
|
|
private storageStatistics: any = null
|
|
|
|
|
|
|
|
|
|
// Storage adapters for warm and cold caches
|
|
|
|
|
private warmStorage: any
|
|
|
|
|
private coldStorage: any
|
|
|
|
|
|
|
|
|
|
// Store options for later reference
|
|
|
|
|
private options: {
|
|
|
|
|
hotCacheMaxSize?: number
|
|
|
|
|
hotCacheEvictionThreshold?: number
|
|
|
|
|
warmCacheTTL?: number
|
|
|
|
|
batchSize?: number
|
|
|
|
|
autoTune?: boolean
|
|
|
|
|
warmStorage?: any
|
|
|
|
|
coldStorage?: any
|
|
|
|
|
readOnly?: boolean
|
|
|
|
|
environmentConfig?: {
|
|
|
|
|
node?: {
|
|
|
|
|
hotCacheMaxSize?: number
|
|
|
|
|
hotCacheEvictionThreshold?: number
|
|
|
|
|
warmCacheTTL?: number
|
|
|
|
|
batchSize?: number
|
|
|
|
|
},
|
|
|
|
|
browser?: {
|
|
|
|
|
hotCacheMaxSize?: number
|
|
|
|
|
hotCacheEvictionThreshold?: number
|
|
|
|
|
warmCacheTTL?: number
|
|
|
|
|
batchSize?: number
|
|
|
|
|
},
|
|
|
|
|
worker?: {
|
|
|
|
|
hotCacheMaxSize?: number
|
|
|
|
|
hotCacheEvictionThreshold?: number
|
|
|
|
|
warmCacheTTL?: number
|
|
|
|
|
batchSize?: number
|
|
|
|
|
},
|
|
|
|
|
[key: string]: {
|
|
|
|
|
hotCacheMaxSize?: number
|
|
|
|
|
hotCacheEvictionThreshold?: number
|
|
|
|
|
warmCacheTTL?: number
|
|
|
|
|
batchSize?: number
|
|
|
|
|
} | undefined
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Initialize the cache manager
|
|
|
|
|
* @param options Configuration options
|
|
|
|
|
*/
|
|
|
|
|
constructor(options: {
|
|
|
|
|
hotCacheMaxSize?: number
|
|
|
|
|
hotCacheEvictionThreshold?: number
|
|
|
|
|
warmCacheTTL?: number
|
|
|
|
|
batchSize?: number
|
|
|
|
|
autoTune?: boolean
|
|
|
|
|
warmStorage?: any
|
|
|
|
|
coldStorage?: any
|
|
|
|
|
readOnly?: boolean
|
|
|
|
|
environmentConfig?: {
|
|
|
|
|
node?: {
|
|
|
|
|
hotCacheMaxSize?: number
|
|
|
|
|
hotCacheEvictionThreshold?: number
|
|
|
|
|
warmCacheTTL?: number
|
|
|
|
|
batchSize?: number
|
|
|
|
|
},
|
|
|
|
|
browser?: {
|
|
|
|
|
hotCacheMaxSize?: number
|
|
|
|
|
hotCacheEvictionThreshold?: number
|
|
|
|
|
warmCacheTTL?: number
|
|
|
|
|
batchSize?: number
|
|
|
|
|
},
|
|
|
|
|
worker?: {
|
|
|
|
|
hotCacheMaxSize?: number
|
|
|
|
|
hotCacheEvictionThreshold?: number
|
|
|
|
|
warmCacheTTL?: number
|
|
|
|
|
batchSize?: number
|
|
|
|
|
},
|
|
|
|
|
[key: string]: {
|
|
|
|
|
hotCacheMaxSize?: number
|
|
|
|
|
hotCacheEvictionThreshold?: number
|
|
|
|
|
warmCacheTTL?: number
|
|
|
|
|
batchSize?: number
|
|
|
|
|
} | undefined
|
|
|
|
|
}
|
|
|
|
|
} = {}) {
|
|
|
|
|
// Store options for later reference
|
|
|
|
|
this.options = options
|
|
|
|
|
|
|
|
|
|
// Detect environment
|
|
|
|
|
this.environment = this.detectEnvironment()
|
|
|
|
|
|
|
|
|
|
// Set storage types based on environment
|
|
|
|
|
this.warmStorageType = this.detectWarmStorageType()
|
|
|
|
|
this.coldStorageType = this.detectColdStorageType()
|
|
|
|
|
|
|
|
|
|
// Initialize storage adapters
|
|
|
|
|
this.warmStorage = options.warmStorage || this.initializeWarmStorage()
|
|
|
|
|
this.coldStorage = options.coldStorage || this.initializeColdStorage()
|
|
|
|
|
|
|
|
|
|
// Set auto-tuning flag
|
|
|
|
|
this.autoTune = options.autoTune !== undefined ? options.autoTune : true
|
|
|
|
|
|
|
|
|
|
// Get environment-specific configuration if available
|
|
|
|
|
const envConfig = options.environmentConfig?.[Environment[this.environment].toLowerCase()]
|
|
|
|
|
|
|
|
|
|
// Set default values or use environment-specific values or global values
|
|
|
|
|
this.hotCacheMaxSize = envConfig?.hotCacheMaxSize || options.hotCacheMaxSize || this.detectOptimalCacheSize()
|
|
|
|
|
this.hotCacheEvictionThreshold = envConfig?.hotCacheEvictionThreshold || options.hotCacheEvictionThreshold || 0.8
|
|
|
|
|
this.warmCacheTTL = envConfig?.warmCacheTTL || options.warmCacheTTL || 24 * 60 * 60 * 1000 // 24 hours
|
|
|
|
|
this.batchSize = envConfig?.batchSize || options.batchSize || 10
|
|
|
|
|
|
|
|
|
|
// If auto-tuning is enabled, perform initial tuning
|
|
|
|
|
if (this.autoTune) {
|
|
|
|
|
this.tuneParameters()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Log configuration
|
|
|
|
|
if (process.env.DEBUG) {
|
|
|
|
|
console.log('Cache Manager initialized with configuration:', {
|
|
|
|
|
environment: Environment[this.environment],
|
|
|
|
|
hotCacheMaxSize: this.hotCacheMaxSize,
|
|
|
|
|
hotCacheEvictionThreshold: this.hotCacheEvictionThreshold,
|
|
|
|
|
warmCacheTTL: this.warmCacheTTL,
|
|
|
|
|
batchSize: this.batchSize,
|
|
|
|
|
autoTune: this.autoTune,
|
|
|
|
|
warmStorageType: StorageType[this.warmStorageType],
|
|
|
|
|
coldStorageType: StorageType[this.coldStorageType]
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Detect the current environment
|
|
|
|
|
*/
|
|
|
|
|
private detectEnvironment(): Environment {
|
|
|
|
|
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
|
|
|
|
return Environment.BROWSER
|
|
|
|
|
} else if (typeof self !== 'undefined' && typeof window === 'undefined') {
|
|
|
|
|
// In a worker environment, self is defined but window is not
|
|
|
|
|
return Environment.WORKER
|
|
|
|
|
} else {
|
|
|
|
|
return Environment.NODE
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Detect the optimal cache size based on available memory and operating mode
|
|
|
|
|
*
|
|
|
|
|
* Enhanced to better handle large datasets in S3 or other storage:
|
|
|
|
|
* - Increases cache size for read-only mode
|
|
|
|
|
* - Adjusts based on total dataset size when available
|
|
|
|
|
* - Provides more aggressive caching for large datasets
|
|
|
|
|
* - Optimizes memory usage based on environment
|
|
|
|
|
*/
|
|
|
|
|
private detectOptimalCacheSize(): number {
|
|
|
|
|
try {
|
|
|
|
|
// Default to a conservative value
|
|
|
|
|
const defaultSize = 1000
|
|
|
|
|
|
|
|
|
|
// Get the total dataset size if available
|
|
|
|
|
const totalItems = this.storageStatistics ?
|
|
|
|
|
(this.storageStatistics.totalNodes || 0) + (this.storageStatistics.totalEdges || 0) : 0
|
|
|
|
|
|
|
|
|
|
// Determine if we're dealing with a large dataset (>100K items)
|
|
|
|
|
const isLargeDataset = totalItems > 100000
|
|
|
|
|
|
2025-09-11 16:23:32 -07:00
|
|
|
// Check if we're in read-only mode (from parent Brainy instance)
|
2025-08-26 12:32:21 -07:00
|
|
|
const isReadOnly = this.options?.readOnly || false
|
|
|
|
|
|
|
|
|
|
// In Node.js, use available system memory with enhanced allocation
|
|
|
|
|
if (this.environment === Environment.NODE) {
|
|
|
|
|
try {
|
|
|
|
|
// For ES module compatibility, we'll use a fixed default value
|
|
|
|
|
// since we can't use dynamic imports in a synchronous function
|
|
|
|
|
|
|
|
|
|
// Use conservative defaults that don't require OS module
|
|
|
|
|
// These values are reasonable for most systems
|
|
|
|
|
const estimatedTotalMemory = 8 * 1024 * 1024 * 1024 // Assume 8GB total
|
|
|
|
|
const estimatedFreeMemory = 4 * 1024 * 1024 * 1024 // Assume 4GB free
|
|
|
|
|
|
|
|
|
|
// Estimate average entry size (in bytes)
|
|
|
|
|
// This is a conservative estimate for complex objects with vectors
|
|
|
|
|
const ESTIMATED_BYTES_PER_ENTRY = 1024 // 1KB per entry
|
|
|
|
|
|
|
|
|
|
// Base memory percentage - 10% by default
|
|
|
|
|
let memoryPercentage = 0.1
|
|
|
|
|
|
|
|
|
|
// Adjust based on operating mode and dataset size
|
|
|
|
|
if (isReadOnly) {
|
|
|
|
|
// In read-only mode, we can use more memory for caching
|
|
|
|
|
memoryPercentage = 0.25 // 25% of free memory
|
|
|
|
|
|
|
|
|
|
// For large datasets in read-only mode, be even more aggressive
|
|
|
|
|
if (isLargeDataset) {
|
|
|
|
|
memoryPercentage = 0.4 // 40% of free memory
|
|
|
|
|
}
|
|
|
|
|
} else if (isLargeDataset) {
|
|
|
|
|
// For large datasets in normal mode, increase slightly
|
|
|
|
|
memoryPercentage = 0.15 // 15% of free memory
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Calculate optimal size based on adjusted percentage
|
|
|
|
|
const optimalSize = Math.max(
|
|
|
|
|
Math.floor(estimatedFreeMemory * memoryPercentage / ESTIMATED_BYTES_PER_ENTRY),
|
|
|
|
|
1000
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// If we know the total dataset size, cap at a reasonable percentage
|
|
|
|
|
if (totalItems > 0) {
|
|
|
|
|
// In read-only mode, we can cache a larger percentage
|
|
|
|
|
const maxPercentage = isReadOnly ? 0.5 : 0.3
|
|
|
|
|
const maxItems = Math.ceil(totalItems * maxPercentage)
|
|
|
|
|
|
|
|
|
|
// Return the smaller of the two to avoid excessive memory usage
|
|
|
|
|
return Math.min(optimalSize, maxItems)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return optimalSize
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('Failed to detect optimal cache size:', error)
|
|
|
|
|
return defaultSize
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// In browser, use navigator.deviceMemory with enhanced allocation
|
|
|
|
|
if (this.environment === Environment.BROWSER && navigator.deviceMemory) {
|
|
|
|
|
// Base entries per GB
|
|
|
|
|
let entriesPerGB = 500
|
|
|
|
|
|
|
|
|
|
// Adjust based on operating mode and dataset size
|
|
|
|
|
if (isReadOnly) {
|
|
|
|
|
entriesPerGB = 800 // More aggressive caching in read-only mode
|
|
|
|
|
|
|
|
|
|
if (isLargeDataset) {
|
|
|
|
|
entriesPerGB = 1000 // Even more aggressive for large datasets
|
|
|
|
|
}
|
|
|
|
|
} else if (isLargeDataset) {
|
|
|
|
|
entriesPerGB = 600 // Slightly more aggressive for large datasets
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Calculate based on device memory
|
|
|
|
|
const browserCacheSize = Math.max(navigator.deviceMemory * entriesPerGB, 1000)
|
|
|
|
|
|
|
|
|
|
// If we know the total dataset size, cap at a reasonable percentage
|
|
|
|
|
if (totalItems > 0) {
|
|
|
|
|
// In read-only mode, we can cache a larger percentage
|
|
|
|
|
const maxPercentage = isReadOnly ? 0.4 : 0.25
|
|
|
|
|
const maxItems = Math.ceil(totalItems * maxPercentage)
|
|
|
|
|
|
|
|
|
|
// Return the smaller of the two to avoid excessive memory usage
|
|
|
|
|
return Math.min(browserCacheSize, maxItems)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return browserCacheSize
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// For worker environments or when memory detection fails
|
|
|
|
|
if (this.environment === Environment.WORKER) {
|
|
|
|
|
// Workers typically have limited memory, be conservative
|
|
|
|
|
return isReadOnly ? 2000 : 1000
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return defaultSize
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('Error detecting optimal cache size:', error)
|
|
|
|
|
return 1000 // Conservative default
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Async version of detectOptimalCacheSize that uses dynamic imports
|
|
|
|
|
* to access system information in Node.js environments
|
|
|
|
|
*
|
|
|
|
|
* This method provides more accurate memory detection by using
|
|
|
|
|
* the OS module's dynamic import in Node.js environments
|
|
|
|
|
*/
|
|
|
|
|
private async detectOptimalCacheSizeAsync(): Promise<number> {
|
|
|
|
|
try {
|
|
|
|
|
// Default to a conservative value
|
|
|
|
|
const defaultSize = 1000
|
|
|
|
|
|
|
|
|
|
// Get the total dataset size if available
|
|
|
|
|
const totalItems = this.storageStatistics ?
|
|
|
|
|
(this.storageStatistics.totalNodes || 0) + (this.storageStatistics.totalEdges || 0) : 0
|
|
|
|
|
|
|
|
|
|
// Determine if we're dealing with a large dataset (>100K items)
|
|
|
|
|
const isLargeDataset = totalItems > 100000
|
|
|
|
|
|
2025-09-11 16:23:32 -07:00
|
|
|
// Check if we're in read-only mode (from parent Brainy instance)
|
2025-08-26 12:32:21 -07:00
|
|
|
const isReadOnly = this.options?.readOnly || false
|
|
|
|
|
|
|
|
|
|
// Get memory information based on environment
|
|
|
|
|
const memoryInfo = await this.detectAvailableMemory()
|
|
|
|
|
|
|
|
|
|
// If memory detection failed, use the synchronous method
|
|
|
|
|
if (!memoryInfo) {
|
|
|
|
|
return this.detectOptimalCacheSize()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Estimate average entry size (in bytes)
|
|
|
|
|
// This is a conservative estimate for complex objects with vectors
|
|
|
|
|
const ESTIMATED_BYTES_PER_ENTRY = 1024 // 1KB per entry
|
|
|
|
|
|
|
|
|
|
// Base memory percentage - 10% by default
|
|
|
|
|
let memoryPercentage = 0.1
|
|
|
|
|
|
|
|
|
|
// Adjust based on operating mode and dataset size
|
|
|
|
|
if (isReadOnly) {
|
|
|
|
|
// In read-only mode, we can use more memory for caching
|
|
|
|
|
memoryPercentage = 0.25 // 25% of free memory
|
|
|
|
|
|
|
|
|
|
// For large datasets in read-only mode, be even more aggressive
|
|
|
|
|
if (isLargeDataset) {
|
|
|
|
|
memoryPercentage = 0.4 // 40% of free memory
|
|
|
|
|
}
|
|
|
|
|
} else if (isLargeDataset) {
|
|
|
|
|
// For large datasets in normal mode, increase slightly
|
|
|
|
|
memoryPercentage = 0.15 // 15% of free memory
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Calculate optimal size based on adjusted percentage
|
|
|
|
|
const optimalSize = Math.max(
|
|
|
|
|
Math.floor(memoryInfo.freeMemory * memoryPercentage / ESTIMATED_BYTES_PER_ENTRY),
|
|
|
|
|
1000
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// If we know the total dataset size, cap at a reasonable percentage
|
|
|
|
|
if (totalItems > 0) {
|
|
|
|
|
// In read-only mode, we can cache a larger percentage
|
|
|
|
|
const maxPercentage = isReadOnly ? 0.5 : 0.3
|
|
|
|
|
const maxItems = Math.ceil(totalItems * maxPercentage)
|
|
|
|
|
|
|
|
|
|
// Return the smaller of the two to avoid excessive memory usage
|
|
|
|
|
return Math.min(optimalSize, maxItems)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return optimalSize
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('Error detecting optimal cache size asynchronously:', error)
|
|
|
|
|
return 1000 // Conservative default
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Detects available memory across different environments
|
|
|
|
|
*
|
|
|
|
|
* This method uses different techniques to detect memory in:
|
|
|
|
|
* - Node.js: Uses the OS module with dynamic import
|
|
|
|
|
* - Browser: Uses performance.memory or navigator.deviceMemory
|
|
|
|
|
* - Worker: Uses performance.memory if available
|
|
|
|
|
*
|
|
|
|
|
* @returns An object with totalMemory and freeMemory in bytes, or null if detection fails
|
|
|
|
|
*/
|
|
|
|
|
private async detectAvailableMemory(): Promise<{ totalMemory: number, freeMemory: number } | null> {
|
|
|
|
|
try {
|
|
|
|
|
// Node.js environment
|
|
|
|
|
if (this.environment === Environment.NODE) {
|
|
|
|
|
try {
|
|
|
|
|
// Use dynamic import for OS module
|
feat: add node: protocol to all Node.js built-in imports for bundler compatibility
- Updated all fs, path, crypto, os, url, util, events, http, https, net, child_process, stream, and zlib imports
- Changed both static imports and dynamic imports to use node: protocol
- This makes Brainy more bundler-friendly by explicitly marking Node.js built-ins
- Prevents bundlers from attempting to polyfill or bundle these modules
- Reduces bundle size for web applications using Brainy
- Improves tree-shaking and dead code elimination
Benefits for external bundlers:
- Clear distinction between Node.js built-ins and external dependencies
- No ambiguity about what needs polyfilling
- Smaller bundles for browser builds
- Better compatibility with modern bundlers (Webpack 5, Vite, Rollup, esbuild)
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 14:20:21 -07:00
|
|
|
const os = await import('node:os')
|
2025-08-26 12:32:21 -07:00
|
|
|
|
|
|
|
|
// Get actual system memory information
|
|
|
|
|
const totalMemory = os.totalmem()
|
|
|
|
|
const freeMemory = os.freemem()
|
|
|
|
|
|
|
|
|
|
return { totalMemory, freeMemory }
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('Failed to detect memory in Node.js environment:', error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Browser environment
|
|
|
|
|
if (this.environment === Environment.BROWSER) {
|
|
|
|
|
// Try using performance.memory (Chrome only)
|
|
|
|
|
if (performance && (performance as any).memory) {
|
|
|
|
|
const memoryInfo = (performance as any).memory
|
|
|
|
|
|
|
|
|
|
// jsHeapSizeLimit is the maximum size of the heap
|
|
|
|
|
// totalJSHeapSize is the currently allocated heap size
|
|
|
|
|
// usedJSHeapSize is the amount of heap currently being used
|
|
|
|
|
const totalMemory = memoryInfo.jsHeapSizeLimit || 0
|
|
|
|
|
const usedMemory = memoryInfo.usedJSHeapSize || 0
|
|
|
|
|
const freeMemory = Math.max(totalMemory - usedMemory, 0)
|
|
|
|
|
|
|
|
|
|
return { totalMemory, freeMemory }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Try using navigator.deviceMemory as fallback
|
|
|
|
|
if (navigator.deviceMemory) {
|
|
|
|
|
// deviceMemory is in GB, convert to bytes
|
|
|
|
|
const totalMemory = navigator.deviceMemory * 1024 * 1024 * 1024
|
|
|
|
|
// Assume 50% is free
|
|
|
|
|
const freeMemory = totalMemory * 0.5
|
|
|
|
|
|
|
|
|
|
return { totalMemory, freeMemory }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Worker environment
|
|
|
|
|
if (this.environment === Environment.WORKER) {
|
|
|
|
|
// Try using performance.memory if available (Chrome workers)
|
|
|
|
|
if (performance && (performance as any).memory) {
|
|
|
|
|
const memoryInfo = (performance as any).memory
|
|
|
|
|
|
|
|
|
|
const totalMemory = memoryInfo.jsHeapSizeLimit || 0
|
|
|
|
|
const usedMemory = memoryInfo.usedJSHeapSize || 0
|
|
|
|
|
const freeMemory = Math.max(totalMemory - usedMemory, 0)
|
|
|
|
|
|
|
|
|
|
return { totalMemory, freeMemory }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// For workers, use a conservative estimate
|
|
|
|
|
// Assume 2GB total memory with 1GB free
|
|
|
|
|
return {
|
|
|
|
|
totalMemory: 2 * 1024 * 1024 * 1024,
|
|
|
|
|
freeMemory: 1 * 1024 * 1024 * 1024
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If all detection methods fail, use conservative defaults
|
|
|
|
|
return {
|
|
|
|
|
totalMemory: 8 * 1024 * 1024 * 1024, // Assume 8GB total
|
|
|
|
|
freeMemory: 4 * 1024 * 1024 * 1024 // Assume 4GB free
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('Memory detection failed:', error)
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Tune cache parameters based on statistics and environment
|
|
|
|
|
* This method is called periodically if auto-tuning is enabled
|
|
|
|
|
*
|
|
|
|
|
* The auto-tuning process:
|
|
|
|
|
* 1. Retrieves storage statistics if available
|
|
|
|
|
* 2. Tunes each parameter based on statistics and environment
|
|
|
|
|
* 3. Logs the tuned parameters if debug is enabled
|
|
|
|
|
*
|
|
|
|
|
* Auto-tuning helps optimize cache performance by adapting to:
|
|
|
|
|
* - The current environment (Node.js, browser, worker)
|
|
|
|
|
* - Available system resources (memory, CPU)
|
|
|
|
|
* - Usage patterns (read-heavy vs. write-heavy workloads)
|
|
|
|
|
* - Cache efficiency (hit/miss ratios)
|
|
|
|
|
*/
|
|
|
|
|
private async tuneParameters(): Promise<void> {
|
|
|
|
|
// Skip if auto-tuning is disabled
|
|
|
|
|
if (!this.autoTune) return
|
|
|
|
|
|
|
|
|
|
// Check if it's time to tune parameters
|
|
|
|
|
const now = Date.now()
|
|
|
|
|
if (now - this.lastAutoTuneTime < this.autoTuneInterval) return
|
|
|
|
|
|
|
|
|
|
// Update last tune time
|
|
|
|
|
this.lastAutoTuneTime = now
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Get storage statistics if available
|
|
|
|
|
if (this.coldStorage && typeof this.coldStorage.getStatistics === 'function') {
|
|
|
|
|
this.storageStatistics = await this.coldStorage.getStatistics()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get cache statistics for adaptive tuning
|
|
|
|
|
const cacheStats = this.getStats()
|
|
|
|
|
|
|
|
|
|
// Use the async version of tuneHotCacheSize which uses detectOptimalCacheSizeAsync
|
|
|
|
|
await this.tuneHotCacheSize()
|
|
|
|
|
|
|
|
|
|
// Tune eviction threshold based on hit/miss ratio
|
|
|
|
|
this.tuneEvictionThreshold(cacheStats)
|
|
|
|
|
|
|
|
|
|
// Tune warm cache TTL based on access patterns
|
|
|
|
|
this.tuneWarmCacheTTL(cacheStats)
|
|
|
|
|
|
|
|
|
|
// Tune batch size based on access patterns and storage type
|
|
|
|
|
this.tuneBatchSize(cacheStats)
|
|
|
|
|
|
|
|
|
|
// Log tuned parameters if debug is enabled
|
|
|
|
|
if (process.env.DEBUG) {
|
|
|
|
|
console.log('Cache parameters auto-tuned:', {
|
|
|
|
|
hotCacheMaxSize: this.hotCacheMaxSize,
|
|
|
|
|
hotCacheEvictionThreshold: this.hotCacheEvictionThreshold,
|
|
|
|
|
warmCacheTTL: this.warmCacheTTL,
|
|
|
|
|
batchSize: this.batchSize,
|
|
|
|
|
cacheStats: {
|
|
|
|
|
hotCacheSize: cacheStats.hotCacheSize,
|
|
|
|
|
warmCacheSize: cacheStats.warmCacheSize,
|
|
|
|
|
hotCacheHits: cacheStats.hotCacheHits,
|
|
|
|
|
hotCacheMisses: cacheStats.hotCacheMisses,
|
|
|
|
|
warmCacheHits: cacheStats.warmCacheHits,
|
|
|
|
|
warmCacheMisses: cacheStats.warmCacheMisses
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('Error during cache parameter auto-tuning:', error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Tune hot cache size based on statistics, environment, and operating mode
|
|
|
|
|
*
|
|
|
|
|
* The hot cache size is tuned based on:
|
|
|
|
|
* 1. Available memory in the current environment
|
|
|
|
|
* 2. Total number of nodes and edges in the system
|
|
|
|
|
* 3. Cache hit/miss ratio
|
|
|
|
|
* 4. Operating mode (read-only vs. read-write)
|
|
|
|
|
* 5. Storage type (S3, filesystem, memory)
|
|
|
|
|
*
|
|
|
|
|
* Enhanced algorithm:
|
|
|
|
|
* - Start with a size based on available memory and operating mode
|
|
|
|
|
* - For large datasets in S3 or other remote storage, use more aggressive caching
|
|
|
|
|
* - Adjust based on access patterns (read-heavy vs. write-heavy)
|
|
|
|
|
* - For read-only mode, prioritize cache size over eviction speed
|
|
|
|
|
* - Dynamically adjust based on hit/miss ratio and query patterns
|
|
|
|
|
*/
|
|
|
|
|
private async tuneHotCacheSize(): Promise<void> {
|
|
|
|
|
// Use the async version to get more accurate memory information
|
|
|
|
|
let optimalSize = await this.detectOptimalCacheSizeAsync()
|
|
|
|
|
|
|
|
|
|
// Check if we're in read-only mode
|
|
|
|
|
const isReadOnly = this.options?.readOnly || false
|
|
|
|
|
|
|
|
|
|
// Check if we're using S3 or other remote storage
|
|
|
|
|
const isRemoteStorage =
|
|
|
|
|
this.coldStorageType === StorageType.S3 ||
|
|
|
|
|
this.coldStorageType === StorageType.REMOTE_API
|
|
|
|
|
|
|
|
|
|
// If we have storage statistics, adjust based on total nodes/edges
|
|
|
|
|
if (this.storageStatistics) {
|
|
|
|
|
const totalItems = (this.storageStatistics.totalNodes || 0) +
|
|
|
|
|
(this.storageStatistics.totalEdges || 0)
|
|
|
|
|
|
|
|
|
|
// If total items is significant, adjust cache size
|
|
|
|
|
if (totalItems > 0) {
|
|
|
|
|
// Base percentage to cache - adjusted based on mode and storage
|
|
|
|
|
let percentageToCache = 0.2 // Cache 20% of items by default
|
|
|
|
|
|
|
|
|
|
// For read-only mode, increase cache percentage
|
|
|
|
|
if (isReadOnly) {
|
|
|
|
|
percentageToCache = 0.3 // 30% for read-only mode
|
|
|
|
|
|
|
|
|
|
// For remote storage in read-only mode, be even more aggressive
|
|
|
|
|
if (isRemoteStorage) {
|
|
|
|
|
percentageToCache = 0.4 // 40% for remote storage in read-only mode
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// For remote storage in normal mode, increase slightly
|
|
|
|
|
else if (isRemoteStorage) {
|
|
|
|
|
percentageToCache = 0.25 // 25% for remote storage
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// For large datasets, cap the percentage to avoid excessive memory usage
|
|
|
|
|
if (totalItems > 1000000) { // Over 1 million items
|
|
|
|
|
percentageToCache = Math.min(percentageToCache, 0.15)
|
|
|
|
|
} else if (totalItems > 100000) { // Over 100K items
|
|
|
|
|
percentageToCache = Math.min(percentageToCache, 0.25)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const statisticsBasedSize = Math.ceil(totalItems * percentageToCache)
|
|
|
|
|
|
|
|
|
|
// Use the smaller of the two to avoid memory issues
|
|
|
|
|
optimalSize = Math.min(optimalSize, statisticsBasedSize)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Adjust based on hit/miss ratio if we have enough data
|
|
|
|
|
const totalAccesses = this.stats.hits + this.stats.misses
|
|
|
|
|
if (totalAccesses > 100) {
|
|
|
|
|
const hitRatio = this.stats.hits / totalAccesses
|
|
|
|
|
|
|
|
|
|
// Base adjustment factor
|
|
|
|
|
let hitRatioFactor = 1.0
|
|
|
|
|
|
|
|
|
|
// If hit ratio is low, we might need a larger cache
|
|
|
|
|
if (hitRatio < 0.5) {
|
|
|
|
|
// Calculate adjustment factor based on hit ratio
|
|
|
|
|
const baseAdjustment = 0.5 - hitRatio
|
|
|
|
|
|
|
|
|
|
// For read-only mode or remote storage, be more aggressive
|
|
|
|
|
if (isReadOnly || isRemoteStorage) {
|
|
|
|
|
hitRatioFactor = 1 + (baseAdjustment * 1.5) // Up to 75% increase
|
|
|
|
|
} else {
|
|
|
|
|
hitRatioFactor = 1 + baseAdjustment // Up to 50% increase
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
optimalSize = Math.ceil(optimalSize * hitRatioFactor)
|
|
|
|
|
}
|
|
|
|
|
// If hit ratio is very high, we might be able to reduce cache size slightly
|
|
|
|
|
else if (hitRatio > 0.9 && !isReadOnly && !isRemoteStorage) {
|
|
|
|
|
// Only reduce cache size in normal mode with local storage
|
|
|
|
|
// and only if hit ratio is very high
|
|
|
|
|
hitRatioFactor = 0.9 // 10% reduction
|
|
|
|
|
optimalSize = Math.ceil(optimalSize * hitRatioFactor)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check for operation patterns if available
|
|
|
|
|
if (this.storageStatistics?.operations) {
|
|
|
|
|
const ops = this.storageStatistics.operations
|
|
|
|
|
const totalOps = ops.total || 1
|
|
|
|
|
|
|
|
|
|
// Calculate read/write ratio
|
|
|
|
|
const readOps = (ops.search || 0) + (ops.get || 0)
|
|
|
|
|
const writeOps = (ops.add || 0) + (ops.update || 0) + (ops.delete || 0)
|
|
|
|
|
|
|
|
|
|
if (totalOps > 100) {
|
|
|
|
|
const readRatio = readOps / totalOps
|
|
|
|
|
|
|
|
|
|
// For read-heavy workloads, increase cache size
|
|
|
|
|
if (readRatio > 0.8) {
|
|
|
|
|
// More aggressive for remote storage
|
|
|
|
|
const readAdjustment = isRemoteStorage ? 1.3 : 1.2
|
|
|
|
|
optimalSize = Math.ceil(optimalSize * readAdjustment)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ensure we have a reasonable minimum size based on environment and mode
|
|
|
|
|
let minSize = 1000 // Default minimum
|
|
|
|
|
|
|
|
|
|
// For read-only mode, use a higher minimum
|
|
|
|
|
if (isReadOnly) {
|
|
|
|
|
minSize = 2000
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// For remote storage, use an even higher minimum
|
|
|
|
|
if (isRemoteStorage) {
|
|
|
|
|
minSize = isReadOnly ? 3000 : 2000
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
optimalSize = Math.max(optimalSize, minSize)
|
|
|
|
|
|
|
|
|
|
// Update the hot cache max size
|
|
|
|
|
this.hotCacheMaxSize = optimalSize
|
|
|
|
|
this.stats.maxSize = optimalSize
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Tune eviction threshold based on statistics
|
|
|
|
|
*
|
|
|
|
|
* The eviction threshold determines when items start being evicted from the hot cache.
|
|
|
|
|
* It is tuned based on:
|
|
|
|
|
* 1. Cache hit/miss ratio
|
|
|
|
|
* 2. Operation patterns (read-heavy vs. write-heavy workloads)
|
|
|
|
|
* 3. Memory pressure and available resources
|
|
|
|
|
*
|
|
|
|
|
* Algorithm:
|
|
|
|
|
* - Start with a default threshold of 0.8 (80% of max size)
|
|
|
|
|
* - For high hit ratios, increase the threshold to keep more items in cache
|
|
|
|
|
* - For low hit ratios, decrease the threshold to evict items more aggressively
|
|
|
|
|
* - For read-heavy workloads, use a higher threshold
|
|
|
|
|
* - For write-heavy workloads, use a lower threshold
|
|
|
|
|
* - Under memory pressure, use a lower threshold to conserve resources
|
|
|
|
|
*
|
|
|
|
|
* @param cacheStats Optional cache statistics for more adaptive tuning
|
|
|
|
|
*/
|
|
|
|
|
private tuneEvictionThreshold(cacheStats?: CacheStats): void {
|
|
|
|
|
// Default threshold
|
|
|
|
|
let threshold = 0.8
|
|
|
|
|
|
|
|
|
|
// Use provided cache stats or internal stats
|
|
|
|
|
const stats = cacheStats || this.getStats()
|
|
|
|
|
|
|
|
|
|
// Adjust based on hit/miss ratio if we have enough data
|
|
|
|
|
const totalHotAccesses = stats.hotCacheHits + stats.hotCacheMisses
|
|
|
|
|
if (totalHotAccesses > 100) {
|
|
|
|
|
const hotHitRatio = stats.hotCacheHits / totalHotAccesses
|
|
|
|
|
|
|
|
|
|
// If hit ratio is high, we can use a higher threshold
|
|
|
|
|
// If hit ratio is low, we should use a lower threshold to evict more aggressively
|
|
|
|
|
if (hotHitRatio > 0.8) {
|
|
|
|
|
// High hit ratio, increase threshold (up to 0.9)
|
|
|
|
|
threshold = Math.min(0.9, 0.8 + (hotHitRatio - 0.8) * 0.5)
|
|
|
|
|
} else if (hotHitRatio < 0.5) {
|
|
|
|
|
// Low hit ratio, decrease threshold (down to 0.6)
|
|
|
|
|
threshold = Math.max(0.6, 0.8 - (0.5 - hotHitRatio) * 0.5)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If we have storage statistics with operation counts, adjust based on operation patterns
|
|
|
|
|
if (this.storageStatistics && this.storageStatistics.operations) {
|
|
|
|
|
const ops = this.storageStatistics.operations
|
|
|
|
|
const totalOps = ops.total || 1
|
|
|
|
|
|
|
|
|
|
// Calculate read/write ratio
|
|
|
|
|
const readOps = ops.search || 0
|
|
|
|
|
const writeOps = (ops.add || 0) + (ops.update || 0) + (ops.delete || 0)
|
|
|
|
|
|
|
|
|
|
if (totalOps > 100) {
|
|
|
|
|
const readRatio = readOps / totalOps
|
|
|
|
|
const writeRatio = writeOps / totalOps
|
|
|
|
|
|
|
|
|
|
// For read-heavy workloads, use higher threshold
|
|
|
|
|
// For write-heavy workloads, use lower threshold
|
|
|
|
|
if (readRatio > 0.8) {
|
|
|
|
|
// Read-heavy, increase threshold slightly
|
|
|
|
|
threshold = Math.min(0.9, threshold + 0.05)
|
|
|
|
|
} else if (writeRatio > 0.5) {
|
|
|
|
|
// Write-heavy, decrease threshold
|
|
|
|
|
threshold = Math.max(0.6, threshold - 0.1)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check memory pressure - if hot cache is growing too fast relative to hits,
|
|
|
|
|
// reduce the threshold to conserve memory
|
|
|
|
|
if (stats.hotCacheSize > 0 && totalHotAccesses > 0) {
|
|
|
|
|
const sizeToAccessRatio = stats.hotCacheSize / totalHotAccesses
|
|
|
|
|
|
|
|
|
|
// If the ratio is high, it means we're caching a lot but not getting many hits
|
|
|
|
|
if (sizeToAccessRatio > 10) {
|
|
|
|
|
// Reduce threshold more aggressively under high memory pressure
|
|
|
|
|
threshold = Math.max(0.5, threshold - 0.1)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If we're in read-only mode, we can be more aggressive with caching
|
|
|
|
|
const isReadOnly = this.options?.readOnly || false
|
|
|
|
|
if (isReadOnly) {
|
|
|
|
|
threshold = Math.min(0.95, threshold + 0.05)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update the eviction threshold
|
|
|
|
|
this.hotCacheEvictionThreshold = threshold
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Tune warm cache TTL based on statistics
|
|
|
|
|
*
|
|
|
|
|
* The warm cache TTL determines how long items remain in the warm cache.
|
|
|
|
|
* It is tuned based on:
|
|
|
|
|
* 1. Update frequency from operation statistics
|
|
|
|
|
* 2. Warm cache hit/miss ratio
|
|
|
|
|
* 3. Access patterns and frequency
|
|
|
|
|
* 4. Available storage resources
|
|
|
|
|
*
|
|
|
|
|
* Algorithm:
|
|
|
|
|
* - Start with a default TTL of 24 hours
|
|
|
|
|
* - For frequently updated data, use a shorter TTL
|
|
|
|
|
* - For rarely updated data, use a longer TTL
|
|
|
|
|
* - For frequently accessed data, use a longer TTL
|
|
|
|
|
* - For rarely accessed data, use a shorter TTL
|
|
|
|
|
* - Under storage pressure, use a shorter TTL
|
|
|
|
|
*
|
|
|
|
|
* @param cacheStats Optional cache statistics for more adaptive tuning
|
|
|
|
|
*/
|
|
|
|
|
private tuneWarmCacheTTL(cacheStats?: CacheStats): void {
|
|
|
|
|
// Default TTL (24 hours)
|
|
|
|
|
let ttl = 24 * 60 * 60 * 1000
|
|
|
|
|
|
|
|
|
|
// Use provided cache stats or internal stats
|
|
|
|
|
const stats = cacheStats || this.getStats()
|
|
|
|
|
|
|
|
|
|
// Adjust based on warm cache hit/miss ratio if we have enough data
|
|
|
|
|
const totalWarmAccesses = stats.warmCacheHits + stats.warmCacheMisses
|
|
|
|
|
if (totalWarmAccesses > 50) {
|
|
|
|
|
const warmHitRatio = stats.warmCacheHits / totalWarmAccesses
|
|
|
|
|
|
|
|
|
|
// If warm cache hit ratio is high, items in warm cache are useful
|
|
|
|
|
// so we should keep them longer
|
|
|
|
|
if (warmHitRatio > 0.7) {
|
|
|
|
|
// High hit ratio, increase TTL (up to 36 hours)
|
|
|
|
|
ttl = Math.min(36 * 60 * 60 * 1000, ttl * (1 + (warmHitRatio - 0.7)))
|
|
|
|
|
} else if (warmHitRatio < 0.3) {
|
|
|
|
|
// Low hit ratio, decrease TTL (down to 12 hours)
|
|
|
|
|
ttl = Math.max(12 * 60 * 60 * 1000, ttl * (0.8 - (0.3 - warmHitRatio)))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If we have storage statistics with operation counts, adjust based on update frequency
|
|
|
|
|
if (this.storageStatistics && this.storageStatistics.operations) {
|
|
|
|
|
const ops = this.storageStatistics.operations
|
|
|
|
|
const totalOps = ops.total || 1
|
|
|
|
|
const updateOps = (ops.update || 0)
|
|
|
|
|
|
|
|
|
|
if (totalOps > 100) {
|
|
|
|
|
const updateRatio = updateOps / totalOps
|
|
|
|
|
|
|
|
|
|
// For frequently updated data, use shorter TTL
|
|
|
|
|
// For rarely updated data, use longer TTL
|
|
|
|
|
if (updateRatio > 0.3) {
|
|
|
|
|
// Frequently updated, decrease TTL (down to 6 hours)
|
|
|
|
|
ttl = Math.max(6 * 60 * 60 * 1000, ttl * (1 - updateRatio * 0.5))
|
|
|
|
|
} else if (updateRatio < 0.1) {
|
|
|
|
|
// Rarely updated, increase TTL (up to 48 hours)
|
|
|
|
|
ttl = Math.min(48 * 60 * 60 * 1000, ttl * (1.2 - updateRatio))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check warm cache size relative to hot cache size
|
|
|
|
|
// If warm cache is much larger than hot cache, reduce TTL to prevent excessive storage use
|
|
|
|
|
if (stats.warmCacheSize > 0 && stats.hotCacheSize > 0) {
|
|
|
|
|
const warmToHotRatio = stats.warmCacheSize / stats.hotCacheSize
|
|
|
|
|
|
|
|
|
|
if (warmToHotRatio > 5) {
|
|
|
|
|
// Warm cache is much larger than hot cache, reduce TTL
|
|
|
|
|
ttl = Math.max(6 * 60 * 60 * 1000, ttl * (0.9 - Math.min(0.3, (warmToHotRatio - 5) / 20)))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If we're in read-only mode, we can use a longer TTL
|
|
|
|
|
const isReadOnly = this.options?.readOnly || false
|
|
|
|
|
if (isReadOnly) {
|
|
|
|
|
ttl = Math.min(72 * 60 * 60 * 1000, ttl * 1.5)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update the warm cache TTL
|
|
|
|
|
this.warmCacheTTL = ttl
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Tune batch size based on environment, statistics, and operating mode
|
|
|
|
|
*
|
|
|
|
|
* The batch size determines how many items are processed in a single batch
|
|
|
|
|
* for operations like prefetching. It is tuned based on:
|
|
|
|
|
* 1. Current environment (Node.js, browser, worker)
|
|
|
|
|
* 2. Available memory
|
|
|
|
|
* 3. Operation patterns
|
|
|
|
|
* 4. Cache hit/miss ratio
|
|
|
|
|
* 5. Operating mode (read-only vs. read-write)
|
|
|
|
|
* 6. Storage type (S3, filesystem, memory)
|
|
|
|
|
* 7. Dataset size
|
|
|
|
|
* 8. Cache efficiency and access patterns
|
|
|
|
|
*
|
|
|
|
|
* Enhanced algorithm:
|
|
|
|
|
* - Start with a default based on the environment
|
|
|
|
|
* - For large datasets in S3 or other remote storage, use larger batches
|
|
|
|
|
* - For read-only mode, use larger batches to improve throughput
|
|
|
|
|
* - Dynamically adjust based on network latency and throughput
|
|
|
|
|
* - Balance between memory usage and performance
|
|
|
|
|
* - Adapt to cache hit/miss patterns
|
|
|
|
|
*
|
|
|
|
|
* @param cacheStats Optional cache statistics for more adaptive tuning
|
|
|
|
|
*/
|
|
|
|
|
private tuneBatchSize(cacheStats?: CacheStats): void {
|
|
|
|
|
// Default batch size
|
|
|
|
|
let batchSize = 10
|
|
|
|
|
|
|
|
|
|
// Use provided cache stats or internal stats
|
|
|
|
|
const stats = cacheStats || this.getStats()
|
|
|
|
|
|
|
|
|
|
// Check if we're in read-only mode
|
|
|
|
|
const isReadOnly = this.options?.readOnly || false
|
|
|
|
|
|
|
|
|
|
// Check if we're using S3 or other remote storage
|
|
|
|
|
const isRemoteStorage =
|
|
|
|
|
this.coldStorageType === StorageType.S3 ||
|
|
|
|
|
this.coldStorageType === StorageType.REMOTE_API
|
|
|
|
|
|
|
|
|
|
// Get the total dataset size if available
|
|
|
|
|
const totalItems = this.storageStatistics ?
|
|
|
|
|
(this.storageStatistics.totalNodes || 0) + (this.storageStatistics.totalEdges || 0) : 0
|
|
|
|
|
|
|
|
|
|
// Determine if we're dealing with a large dataset
|
|
|
|
|
const isLargeDataset = totalItems > 100000
|
|
|
|
|
const isVeryLargeDataset = totalItems > 1000000
|
|
|
|
|
|
|
|
|
|
// Base batch size adjustment based on environment
|
|
|
|
|
if (this.environment === Environment.NODE) {
|
|
|
|
|
// Node.js can handle larger batches
|
|
|
|
|
batchSize = isReadOnly ? 30 : 20
|
|
|
|
|
|
|
|
|
|
// For remote storage, increase batch size
|
|
|
|
|
if (isRemoteStorage) {
|
|
|
|
|
batchSize = isReadOnly ? 50 : 30
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// For large datasets, adjust batch size
|
|
|
|
|
if (isLargeDataset) {
|
|
|
|
|
batchSize = Math.min(100, batchSize * 1.5)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// For very large datasets, adjust even more
|
|
|
|
|
if (isVeryLargeDataset) {
|
|
|
|
|
batchSize = Math.min(200, batchSize * 2)
|
|
|
|
|
}
|
|
|
|
|
} else if (this.environment === Environment.BROWSER) {
|
|
|
|
|
// Browsers might need smaller batches
|
|
|
|
|
batchSize = isReadOnly ? 15 : 10
|
|
|
|
|
|
|
|
|
|
// If we have memory information, adjust accordingly
|
|
|
|
|
if (navigator.deviceMemory) {
|
|
|
|
|
// Scale batch size with available memory
|
|
|
|
|
const memoryFactor = isReadOnly ? 3 : 2
|
|
|
|
|
batchSize = Math.max(5, Math.min(30, Math.floor(navigator.deviceMemory * memoryFactor)))
|
|
|
|
|
|
|
|
|
|
// For large datasets, adjust based on memory
|
|
|
|
|
if (isLargeDataset && navigator.deviceMemory > 4) {
|
|
|
|
|
batchSize = Math.min(50, batchSize * 1.5)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else if (this.environment === Environment.WORKER) {
|
|
|
|
|
// Workers can handle moderate batch sizes
|
|
|
|
|
batchSize = isReadOnly ? 20 : 15
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Adjust based on cache hit/miss ratios
|
|
|
|
|
const totalHotAccesses = stats.hotCacheHits + stats.hotCacheMisses
|
|
|
|
|
const totalWarmAccesses = stats.warmCacheHits + stats.warmCacheMisses
|
|
|
|
|
|
|
|
|
|
if (totalHotAccesses > 100) {
|
|
|
|
|
const hotHitRatio = stats.hotCacheHits / totalHotAccesses
|
|
|
|
|
|
|
|
|
|
// If hot cache hit ratio is high, we're effectively using the cache
|
|
|
|
|
// so we can use larger batches for better throughput
|
|
|
|
|
if (hotHitRatio > 0.8) {
|
|
|
|
|
// High hit ratio, increase batch size
|
|
|
|
|
batchSize = Math.min(batchSize * 1.5, isRemoteStorage ? 250 : 150)
|
|
|
|
|
} else if (hotHitRatio < 0.4) {
|
|
|
|
|
// Low hit ratio, we might be fetching too much at once
|
|
|
|
|
// Reduce batch size to be more selective
|
|
|
|
|
batchSize = Math.max(5, batchSize * 0.8)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (totalWarmAccesses > 50) {
|
|
|
|
|
const warmHitRatio = stats.warmCacheHits / totalWarmAccesses
|
|
|
|
|
|
|
|
|
|
// If warm cache hit ratio is high, prefetching is effective
|
|
|
|
|
// so we can use larger batches
|
|
|
|
|
if (warmHitRatio > 0.7) {
|
|
|
|
|
// High warm hit ratio, increase batch size
|
|
|
|
|
batchSize = Math.min(batchSize * 1.3, isRemoteStorage ? 200 : 120)
|
|
|
|
|
} else if (warmHitRatio < 0.3) {
|
|
|
|
|
// Low warm hit ratio, reduce batch size
|
|
|
|
|
batchSize = Math.max(5, batchSize * 0.9)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If we have storage statistics with operation counts, adjust based on operation patterns
|
|
|
|
|
if (this.storageStatistics && this.storageStatistics.operations) {
|
|
|
|
|
const ops = this.storageStatistics.operations
|
|
|
|
|
const totalOps = ops.total || 1
|
|
|
|
|
const searchOps = (ops.search || 0)
|
|
|
|
|
const getOps = (ops.get || 0)
|
|
|
|
|
|
|
|
|
|
if (totalOps > 100) {
|
|
|
|
|
// Calculate search and get ratios
|
|
|
|
|
const searchRatio = searchOps / totalOps
|
|
|
|
|
const getRatio = getOps / totalOps
|
|
|
|
|
|
|
|
|
|
// For search-heavy workloads, use larger batch size
|
|
|
|
|
if (searchRatio > 0.6) {
|
|
|
|
|
// Search-heavy, increase batch size
|
|
|
|
|
const searchFactor = isRemoteStorage ? 1.8 : 1.5
|
|
|
|
|
batchSize = Math.min(isRemoteStorage ? 200 : 100, Math.ceil(batchSize * searchFactor))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// For get-heavy workloads, adjust batch size
|
|
|
|
|
if (getRatio > 0.6) {
|
|
|
|
|
// Get-heavy, adjust batch size based on storage type
|
|
|
|
|
if (isRemoteStorage) {
|
|
|
|
|
// For remote storage, larger batches reduce network overhead
|
|
|
|
|
batchSize = Math.min(150, Math.ceil(batchSize * 1.5))
|
|
|
|
|
} else {
|
|
|
|
|
// For local storage, smaller batches might be more efficient
|
|
|
|
|
batchSize = Math.max(10, Math.ceil(batchSize * 0.9))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if we're experiencing memory pressure
|
|
|
|
|
if (stats.hotCacheSize > 0 && this.hotCacheMaxSize > 0) {
|
|
|
|
|
const cacheUtilization = stats.hotCacheSize / this.hotCacheMaxSize
|
|
|
|
|
|
|
|
|
|
// If cache utilization is high, reduce batch size to avoid memory pressure
|
|
|
|
|
if (cacheUtilization > 0.85) {
|
|
|
|
|
batchSize = Math.max(5, Math.floor(batchSize * 0.8))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Adjust based on overall hit/miss ratio if we have enough data
|
|
|
|
|
const totalAccesses = stats.hotCacheHits + stats.hotCacheMisses + stats.warmCacheHits + stats.warmCacheMisses
|
|
|
|
|
if (totalAccesses > 100) {
|
|
|
|
|
const hitRatio = (stats.hotCacheHits + stats.warmCacheHits) / totalAccesses
|
|
|
|
|
|
|
|
|
|
// Base adjustment factors
|
|
|
|
|
let increaseFactorForLowHitRatio = isRemoteStorage ? 1.5 : 1.2
|
|
|
|
|
let decreaseFactorForHighHitRatio = 0.8
|
|
|
|
|
|
|
|
|
|
// In read-only mode, be more aggressive with batch size adjustments
|
|
|
|
|
if (isReadOnly) {
|
|
|
|
|
increaseFactorForLowHitRatio = isRemoteStorage ? 2.0 : 1.5
|
|
|
|
|
decreaseFactorForHighHitRatio = 0.9 // Less reduction in read-only mode
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If hit ratio is high, we can use smaller batches
|
|
|
|
|
if (hitRatio > 0.8 && !isVeryLargeDataset) {
|
|
|
|
|
// High hit ratio, decrease batch size slightly
|
|
|
|
|
// But don't decrease too much for large datasets or remote storage
|
|
|
|
|
if (!(isLargeDataset && isRemoteStorage)) {
|
|
|
|
|
batchSize = Math.max(isReadOnly ? 10 : 5, Math.floor(batchSize * decreaseFactorForHighHitRatio))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// If hit ratio is low, we need larger batches
|
|
|
|
|
else if (hitRatio < 0.5) {
|
|
|
|
|
// Low hit ratio, increase batch size
|
|
|
|
|
const maxBatchSize = isRemoteStorage ?
|
|
|
|
|
(isVeryLargeDataset ? 300 : 200) :
|
|
|
|
|
(isVeryLargeDataset ? 150 : 100)
|
|
|
|
|
|
|
|
|
|
batchSize = Math.min(maxBatchSize, Math.ceil(batchSize * increaseFactorForLowHitRatio))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Set minimum batch sizes based on storage type and mode
|
|
|
|
|
let minBatchSize = 5
|
|
|
|
|
|
|
|
|
|
if (isRemoteStorage) {
|
|
|
|
|
minBatchSize = isReadOnly ? 20 : 10
|
|
|
|
|
} else if (isReadOnly) {
|
|
|
|
|
minBatchSize = 10
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ensure batch size is within reasonable limits
|
|
|
|
|
batchSize = Math.max(minBatchSize, batchSize)
|
|
|
|
|
|
|
|
|
|
// Cap maximum batch size based on environment and storage
|
|
|
|
|
const maxBatchSize = isRemoteStorage ?
|
|
|
|
|
(this.environment === Environment.NODE ? 300 : 150) :
|
|
|
|
|
(this.environment === Environment.NODE ? 150 : 75)
|
|
|
|
|
|
|
|
|
|
batchSize = Math.min(maxBatchSize, batchSize)
|
|
|
|
|
|
|
|
|
|
// Update the batch size with the adaptively tuned value
|
|
|
|
|
this.batchSize = Math.round(batchSize)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Detect the appropriate warm storage type based on environment
|
|
|
|
|
*/
|
|
|
|
|
private detectWarmStorageType(): StorageType {
|
|
|
|
|
if (this.environment === Environment.BROWSER) {
|
|
|
|
|
// Use OPFS if available, otherwise use memory
|
|
|
|
|
if ('storage' in navigator && 'getDirectory' in navigator.storage) {
|
|
|
|
|
return StorageType.OPFS
|
|
|
|
|
}
|
|
|
|
|
return StorageType.MEMORY
|
|
|
|
|
} else if (this.environment === Environment.WORKER) {
|
|
|
|
|
// Use OPFS if available, otherwise use memory
|
|
|
|
|
if ('storage' in self && 'getDirectory' in (self as WorkerGlobalScope).storage!) {
|
|
|
|
|
return StorageType.OPFS
|
|
|
|
|
}
|
|
|
|
|
return StorageType.MEMORY
|
|
|
|
|
} else {
|
|
|
|
|
// In Node.js, use filesystem
|
|
|
|
|
return StorageType.FILESYSTEM
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Detect the appropriate cold storage type based on environment
|
|
|
|
|
*/
|
|
|
|
|
private detectColdStorageType(): StorageType {
|
|
|
|
|
if (this.environment === Environment.BROWSER) {
|
|
|
|
|
// Use OPFS if available, otherwise use memory
|
|
|
|
|
if ('storage' in navigator && 'getDirectory' in navigator.storage) {
|
|
|
|
|
return StorageType.OPFS
|
|
|
|
|
}
|
|
|
|
|
return StorageType.MEMORY
|
|
|
|
|
} else if (this.environment === Environment.WORKER) {
|
|
|
|
|
// Use OPFS if available, otherwise use memory
|
|
|
|
|
if ('storage' in self && 'getDirectory' in (self as WorkerGlobalScope).storage!) {
|
|
|
|
|
return StorageType.OPFS
|
|
|
|
|
}
|
|
|
|
|
return StorageType.MEMORY
|
|
|
|
|
} else {
|
|
|
|
|
// In Node.js, use S3 if configured, otherwise filesystem
|
|
|
|
|
return StorageType.S3
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Initialize warm storage adapter
|
|
|
|
|
*/
|
|
|
|
|
private initializeWarmStorage(): any {
|
|
|
|
|
// Implementation depends on the detected storage type
|
|
|
|
|
// For now, return null as this will be provided by the storage adapter
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Initialize cold storage adapter
|
|
|
|
|
*/
|
|
|
|
|
private initializeColdStorage(): any {
|
|
|
|
|
// Implementation depends on the detected storage type
|
|
|
|
|
// For now, return null as this will be provided by the storage adapter
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get an item from cache, trying each level in order
|
|
|
|
|
* @param id The item ID
|
|
|
|
|
* @returns The cached item or null if not found
|
|
|
|
|
*/
|
|
|
|
|
public async get(id: string): Promise<T | null> {
|
|
|
|
|
// Check if it's time to tune parameters
|
|
|
|
|
await this.checkAndTuneParameters()
|
|
|
|
|
|
|
|
|
|
// Try hot cache first (fastest)
|
|
|
|
|
const hotCacheEntry = this.hotCache.get(id)
|
|
|
|
|
if (hotCacheEntry) {
|
|
|
|
|
// Update access metadata
|
|
|
|
|
hotCacheEntry.lastAccessed = Date.now()
|
|
|
|
|
hotCacheEntry.accessCount++
|
|
|
|
|
|
|
|
|
|
// Update stats
|
|
|
|
|
this.stats.hits++
|
|
|
|
|
|
|
|
|
|
return hotCacheEntry.data
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Try warm cache next
|
|
|
|
|
try {
|
|
|
|
|
const warmCacheItem = await this.getFromWarmCache(id)
|
|
|
|
|
if (warmCacheItem) {
|
|
|
|
|
// Promote to hot cache
|
|
|
|
|
this.addToHotCache(id, warmCacheItem)
|
|
|
|
|
|
|
|
|
|
// Update stats
|
|
|
|
|
this.stats.hits++
|
|
|
|
|
|
|
|
|
|
return warmCacheItem
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn(`Error accessing warm cache for ${id}:`, error)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Finally, try cold storage
|
|
|
|
|
try {
|
|
|
|
|
const coldStorageItem = await this.getFromColdStorage(id)
|
|
|
|
|
if (coldStorageItem) {
|
|
|
|
|
// Promote to hot and warm caches
|
|
|
|
|
this.addToHotCache(id, coldStorageItem)
|
|
|
|
|
await this.addToWarmCache(id, coldStorageItem)
|
|
|
|
|
|
|
|
|
|
// Update stats
|
|
|
|
|
this.stats.misses++
|
|
|
|
|
|
|
|
|
|
return coldStorageItem
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn(`Error accessing cold storage for ${id}:`, error)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Item not found in any cache level
|
|
|
|
|
this.stats.misses++
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get an item from warm cache
|
|
|
|
|
* @param id The item ID
|
|
|
|
|
* @returns The cached item or null if not found
|
|
|
|
|
*/
|
|
|
|
|
private async getFromWarmCache(id: string): Promise<T | null> {
|
|
|
|
|
if (!this.warmStorage) return null
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
return await this.warmStorage.get(id)
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn(`Error getting item ${id} from warm cache:`, error)
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get an item from cold storage
|
|
|
|
|
* @param id The item ID
|
|
|
|
|
* @returns The item or null if not found
|
|
|
|
|
*/
|
|
|
|
|
private async getFromColdStorage(id: string): Promise<T | null> {
|
|
|
|
|
if (!this.coldStorage) return null
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
return await this.coldStorage.get(id)
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn(`Error getting item ${id} from cold storage:`, error)
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Add an item to hot cache
|
|
|
|
|
* @param id The item ID
|
|
|
|
|
* @param item The item to cache
|
|
|
|
|
*/
|
|
|
|
|
private addToHotCache(id: string, item: T): void {
|
|
|
|
|
// Check if we need to evict items
|
|
|
|
|
if (this.hotCache.size >= this.hotCacheMaxSize * this.hotCacheEvictionThreshold) {
|
|
|
|
|
this.evictFromHotCache()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add to hot cache
|
|
|
|
|
this.hotCache.set(id, {
|
|
|
|
|
data: item,
|
|
|
|
|
lastAccessed: Date.now(),
|
|
|
|
|
accessCount: 1,
|
|
|
|
|
expiresAt: null // Hot cache items don't expire
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Update stats
|
|
|
|
|
this.stats.size = this.hotCache.size
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Add an item to warm cache
|
|
|
|
|
* @param id The item ID
|
|
|
|
|
* @param item The item to cache
|
|
|
|
|
*/
|
|
|
|
|
private async addToWarmCache(id: string, item: T): Promise<void> {
|
|
|
|
|
if (!this.warmStorage) return
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Add to warm cache with TTL
|
|
|
|
|
await this.warmStorage.set(id, item, {
|
|
|
|
|
ttl: this.warmCacheTTL
|
|
|
|
|
})
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn(`Error adding item ${id} to warm cache:`, error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Evict items from hot cache based on LRU policy
|
|
|
|
|
*/
|
|
|
|
|
private evictFromHotCache(): void {
|
|
|
|
|
// Find the least recently used items
|
|
|
|
|
const entries = Array.from(this.hotCache.entries())
|
|
|
|
|
|
|
|
|
|
// Sort by last accessed time (oldest first)
|
|
|
|
|
entries.sort((a, b) => a[1].lastAccessed - b[1].lastAccessed)
|
|
|
|
|
|
|
|
|
|
// Remove the oldest 20% of items
|
|
|
|
|
const itemsToRemove = Math.ceil(this.hotCache.size * 0.2)
|
|
|
|
|
for (let i = 0; i < itemsToRemove && i < entries.length; i++) {
|
|
|
|
|
this.hotCache.delete(entries[i][0])
|
|
|
|
|
this.stats.evictions++
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update stats
|
|
|
|
|
this.stats.size = this.hotCache.size
|
|
|
|
|
|
|
|
|
|
if (process.env.DEBUG) {
|
|
|
|
|
console.log(`Evicted ${itemsToRemove} items from hot cache, new size: ${this.hotCache.size}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Set an item in all cache levels
|
|
|
|
|
* @param id The item ID
|
|
|
|
|
* @param item The item to cache
|
|
|
|
|
*/
|
|
|
|
|
public async set(id: string, item: T): Promise<void> {
|
|
|
|
|
// Add to hot cache
|
|
|
|
|
this.addToHotCache(id, item)
|
|
|
|
|
|
|
|
|
|
// Add to warm cache
|
|
|
|
|
await this.addToWarmCache(id, item)
|
|
|
|
|
|
|
|
|
|
// Add to cold storage
|
|
|
|
|
if (this.coldStorage) {
|
|
|
|
|
try {
|
|
|
|
|
await this.coldStorage.set(id, item)
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn(`Error adding item ${id} to cold storage:`, error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Delete an item from all cache levels
|
|
|
|
|
* @param id The item ID to delete
|
|
|
|
|
*/
|
|
|
|
|
public async delete(id: string): Promise<void> {
|
|
|
|
|
// Remove from hot cache
|
|
|
|
|
this.hotCache.delete(id)
|
|
|
|
|
|
|
|
|
|
// Remove from warm cache
|
|
|
|
|
if (this.warmStorage) {
|
|
|
|
|
try {
|
|
|
|
|
await this.warmStorage.delete(id)
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn(`Error deleting item ${id} from warm cache:`, error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Remove from cold storage
|
|
|
|
|
if (this.coldStorage) {
|
|
|
|
|
try {
|
|
|
|
|
await this.coldStorage.delete(id)
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn(`Error deleting item ${id} from cold storage:`, error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update stats
|
|
|
|
|
this.stats.size = this.hotCache.size
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Clear all cache levels
|
|
|
|
|
*/
|
|
|
|
|
public async clear(): Promise<void> {
|
|
|
|
|
// Clear hot cache
|
|
|
|
|
this.hotCache.clear()
|
|
|
|
|
|
|
|
|
|
// Clear warm cache
|
|
|
|
|
if (this.warmStorage) {
|
|
|
|
|
try {
|
|
|
|
|
await this.warmStorage.clear()
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('Error clearing warm cache:', error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Clear cold storage
|
|
|
|
|
if (this.coldStorage) {
|
|
|
|
|
try {
|
|
|
|
|
await this.coldStorage.clear()
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('Error clearing cold storage:', error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Reset stats
|
|
|
|
|
this.stats = {
|
|
|
|
|
hits: 0,
|
|
|
|
|
misses: 0,
|
|
|
|
|
evictions: 0,
|
|
|
|
|
size: 0,
|
|
|
|
|
maxSize: this.hotCacheMaxSize,
|
|
|
|
|
hotCacheSize: 0,
|
|
|
|
|
warmCacheSize: 0,
|
|
|
|
|
hotCacheHits: 0,
|
|
|
|
|
hotCacheMisses: 0,
|
|
|
|
|
warmCacheHits: 0,
|
|
|
|
|
warmCacheMisses: 0
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get cache statistics
|
|
|
|
|
* @returns Cache statistics
|
|
|
|
|
*/
|
|
|
|
|
public getStats(): CacheStats {
|
|
|
|
|
return { ...this.stats }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Prefetch items based on ID patterns or relationships
|
|
|
|
|
* @param ids Array of IDs to prefetch
|
|
|
|
|
*/
|
|
|
|
|
public async prefetch(ids: string[]): Promise<void> {
|
|
|
|
|
// Check if it's time to tune parameters
|
|
|
|
|
await this.checkAndTuneParameters()
|
|
|
|
|
|
|
|
|
|
// Prefetch in batches to avoid overwhelming the system
|
|
|
|
|
const batches: string[][] = []
|
|
|
|
|
|
|
|
|
|
// Split into batches using the configurable batch size
|
|
|
|
|
for (let i = 0; i < ids.length; i += this.batchSize) {
|
|
|
|
|
const batch = ids.slice(i, i + this.batchSize)
|
|
|
|
|
batches.push(batch)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Process each batch
|
|
|
|
|
for (const batch of batches) {
|
|
|
|
|
await Promise.all(
|
|
|
|
|
batch.map(async (id) => {
|
|
|
|
|
// Skip if already in hot cache
|
|
|
|
|
if (this.hotCache.has(id)) return
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Try to get from any cache level
|
|
|
|
|
await this.get(id)
|
|
|
|
|
} catch (error) {
|
|
|
|
|
// Ignore errors during prefetching
|
|
|
|
|
if (process.env.DEBUG) {
|
|
|
|
|
console.warn(`Error prefetching ${id}:`, error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if it's time to tune parameters and do so if needed
|
|
|
|
|
* This is called before operations that might benefit from tuned parameters
|
|
|
|
|
*
|
|
|
|
|
* This method serves as a checkpoint for auto-tuning, ensuring that:
|
|
|
|
|
* 1. Parameters are tuned periodically based on the auto-tune interval
|
|
|
|
|
* 2. Tuning happens before critical operations that would benefit from optimized parameters
|
|
|
|
|
* 3. Tuning doesn't happen too frequently, which could impact performance
|
|
|
|
|
*
|
|
|
|
|
* By calling this method before get(), getMany(), and prefetch() operations,
|
|
|
|
|
* we ensure that the cache parameters are optimized for the current workload
|
|
|
|
|
* without adding unnecessary overhead to every operation.
|
|
|
|
|
*/
|
|
|
|
|
private async checkAndTuneParameters(): Promise<void> {
|
|
|
|
|
// Skip if auto-tuning is disabled
|
|
|
|
|
if (!this.autoTune) return
|
|
|
|
|
|
|
|
|
|
// Check if it's time to tune parameters
|
|
|
|
|
const now = Date.now()
|
|
|
|
|
if (now - this.lastAutoTuneTime >= this.autoTuneInterval) {
|
|
|
|
|
await this.tuneParameters()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get multiple items at once, optimizing for batch retrieval
|
|
|
|
|
* @param ids Array of IDs to get
|
|
|
|
|
* @returns Map of ID to item
|
|
|
|
|
*/
|
|
|
|
|
public async getMany(ids: string[]): Promise<Map<string, T>> {
|
|
|
|
|
// Check if it's time to tune parameters
|
|
|
|
|
await this.checkAndTuneParameters()
|
|
|
|
|
|
|
|
|
|
const result = new Map<string, T>()
|
|
|
|
|
|
|
|
|
|
// First check hot cache for all IDs
|
|
|
|
|
const missingIds: string[] = []
|
|
|
|
|
for (const id of ids) {
|
|
|
|
|
const hotCacheEntry = this.hotCache.get(id)
|
|
|
|
|
if (hotCacheEntry) {
|
|
|
|
|
// Update access metadata
|
|
|
|
|
hotCacheEntry.lastAccessed = Date.now()
|
|
|
|
|
hotCacheEntry.accessCount++
|
|
|
|
|
|
|
|
|
|
// Add to result
|
|
|
|
|
result.set(id, hotCacheEntry.data)
|
|
|
|
|
|
|
|
|
|
// Update stats
|
|
|
|
|
this.stats.hits++
|
|
|
|
|
} else {
|
|
|
|
|
missingIds.push(id)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (missingIds.length === 0) {
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Try to get missing items from warm cache
|
|
|
|
|
if (this.warmStorage) {
|
|
|
|
|
try {
|
|
|
|
|
const warmCacheItems = await this.warmStorage.getMany(missingIds)
|
|
|
|
|
for (const [id, item] of warmCacheItems.entries()) {
|
|
|
|
|
if (item) {
|
|
|
|
|
// Promote to hot cache
|
|
|
|
|
this.addToHotCache(id, item)
|
|
|
|
|
|
|
|
|
|
// Add to result
|
|
|
|
|
result.set(id, item)
|
|
|
|
|
|
|
|
|
|
// Update stats
|
|
|
|
|
this.stats.hits++
|
|
|
|
|
|
|
|
|
|
// Remove from missing IDs
|
|
|
|
|
const index = missingIds.indexOf(id)
|
|
|
|
|
if (index !== -1) {
|
|
|
|
|
missingIds.splice(index, 1)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('Error accessing warm cache for batch:', error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (missingIds.length === 0) {
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Try to get remaining missing items from cold storage
|
|
|
|
|
if (this.coldStorage) {
|
|
|
|
|
try {
|
|
|
|
|
const coldStorageItems = await this.coldStorage.getMany(missingIds)
|
|
|
|
|
for (const [id, item] of coldStorageItems.entries()) {
|
|
|
|
|
if (item) {
|
|
|
|
|
// Promote to hot and warm caches
|
|
|
|
|
this.addToHotCache(id, item)
|
|
|
|
|
await this.addToWarmCache(id, item)
|
|
|
|
|
|
|
|
|
|
// Add to result
|
|
|
|
|
result.set(id, item)
|
|
|
|
|
|
|
|
|
|
// Update stats
|
|
|
|
|
this.stats.misses++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('Error accessing cold storage for batch:', error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Set the storage adapters for warm and cold caches
|
|
|
|
|
* @param warmStorage Warm cache storage adapter
|
|
|
|
|
* @param coldStorage Cold storage adapter
|
|
|
|
|
*/
|
|
|
|
|
public setStorageAdapters(warmStorage: any, coldStorage: any): void {
|
|
|
|
|
this.warmStorage = warmStorage
|
|
|
|
|
this.coldStorage = coldStorage
|
|
|
|
|
}
|
|
|
|
|
}
|