1306 lines
56 KiB
JavaScript
1306 lines
56 KiB
JavaScript
|
|
/**
|
||
|
|
* 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
|
||
|
|
*/
|
||
|
|
// Environment detection for storage selection
|
||
|
|
var Environment;
|
||
|
|
(function (Environment) {
|
||
|
|
Environment[Environment["BROWSER"] = 0] = "BROWSER";
|
||
|
|
Environment[Environment["NODE"] = 1] = "NODE";
|
||
|
|
Environment[Environment["WORKER"] = 2] = "WORKER";
|
||
|
|
})(Environment || (Environment = {}));
|
||
|
|
// Storage type for warm and cold caches
|
||
|
|
var StorageType;
|
||
|
|
(function (StorageType) {
|
||
|
|
StorageType[StorageType["MEMORY"] = 0] = "MEMORY";
|
||
|
|
StorageType[StorageType["OPFS"] = 1] = "OPFS";
|
||
|
|
StorageType[StorageType["FILESYSTEM"] = 2] = "FILESYSTEM";
|
||
|
|
StorageType[StorageType["S3"] = 3] = "S3";
|
||
|
|
StorageType[StorageType["REMOTE_API"] = 4] = "REMOTE_API";
|
||
|
|
})(StorageType || (StorageType = {}));
|
||
|
|
/**
|
||
|
|
* Multi-level cache manager for efficient data access
|
||
|
|
*/
|
||
|
|
export class CacheManager {
|
||
|
|
/**
|
||
|
|
* Initialize the cache manager
|
||
|
|
* @param options Configuration options
|
||
|
|
*/
|
||
|
|
constructor(options = {}) {
|
||
|
|
// Hot cache (RAM)
|
||
|
|
this.hotCache = new Map();
|
||
|
|
// Cache statistics
|
||
|
|
this.stats = {
|
||
|
|
hits: 0,
|
||
|
|
misses: 0,
|
||
|
|
evictions: 0,
|
||
|
|
size: 0,
|
||
|
|
maxSize: 0,
|
||
|
|
hotCacheSize: 0,
|
||
|
|
warmCacheSize: 0,
|
||
|
|
hotCacheHits: 0,
|
||
|
|
hotCacheMisses: 0,
|
||
|
|
warmCacheHits: 0,
|
||
|
|
warmCacheMisses: 0
|
||
|
|
};
|
||
|
|
this.lastAutoTuneTime = 0;
|
||
|
|
this.autoTuneInterval = 5 * 60 * 1000; // 5 minutes
|
||
|
|
this.storageStatistics = null;
|
||
|
|
// 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
|
||
|
|
*/
|
||
|
|
detectEnvironment() {
|
||
|
|
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
|
||
|
|
*/
|
||
|
|
detectOptimalCacheSize() {
|
||
|
|
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;
|
||
|
|
// Check if we're in read-only mode (from parent BrainyData instance)
|
||
|
|
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
|
||
|
|
*/
|
||
|
|
async detectOptimalCacheSizeAsync() {
|
||
|
|
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;
|
||
|
|
// Check if we're in read-only mode (from parent BrainyData instance)
|
||
|
|
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
|
||
|
|
*/
|
||
|
|
async detectAvailableMemory() {
|
||
|
|
try {
|
||
|
|
// Node.js environment
|
||
|
|
if (this.environment === Environment.NODE) {
|
||
|
|
try {
|
||
|
|
// Use dynamic import for OS module
|
||
|
|
const os = await import('os');
|
||
|
|
// 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.memory) {
|
||
|
|
const memoryInfo = performance.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.memory) {
|
||
|
|
const memoryInfo = performance.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)
|
||
|
|
*/
|
||
|
|
async tuneParameters() {
|
||
|
|
// 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
|
||
|
|
*/
|
||
|
|
async tuneHotCacheSize() {
|
||
|
|
// 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
|
||
|
|
*/
|
||
|
|
tuneEvictionThreshold(cacheStats) {
|
||
|
|
// 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
|
||
|
|
*/
|
||
|
|
tuneWarmCacheTTL(cacheStats) {
|
||
|
|
// 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
|
||
|
|
*/
|
||
|
|
tuneBatchSize(cacheStats) {
|
||
|
|
// 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
|
||
|
|
*/
|
||
|
|
detectWarmStorageType() {
|
||
|
|
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.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
|
||
|
|
*/
|
||
|
|
detectColdStorageType() {
|
||
|
|
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.storage) {
|
||
|
|
return StorageType.OPFS;
|
||
|
|
}
|
||
|
|
return StorageType.MEMORY;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
// In Node.js, use S3 if configured, otherwise filesystem
|
||
|
|
return StorageType.S3;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Initialize warm storage adapter
|
||
|
|
*/
|
||
|
|
initializeWarmStorage() {
|
||
|
|
// 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
|
||
|
|
*/
|
||
|
|
initializeColdStorage() {
|
||
|
|
// 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
|
||
|
|
*/
|
||
|
|
async get(id) {
|
||
|
|
// 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
|
||
|
|
*/
|
||
|
|
async getFromWarmCache(id) {
|
||
|
|
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
|
||
|
|
*/
|
||
|
|
async getFromColdStorage(id) {
|
||
|
|
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
|
||
|
|
*/
|
||
|
|
addToHotCache(id, item) {
|
||
|
|
// 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
|
||
|
|
*/
|
||
|
|
async addToWarmCache(id, item) {
|
||
|
|
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
|
||
|
|
*/
|
||
|
|
evictFromHotCache() {
|
||
|
|
// 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
|
||
|
|
*/
|
||
|
|
async set(id, item) {
|
||
|
|
// 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
|
||
|
|
*/
|
||
|
|
async delete(id) {
|
||
|
|
// 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
|
||
|
|
*/
|
||
|
|
async clear() {
|
||
|
|
// 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
|
||
|
|
*/
|
||
|
|
getStats() {
|
||
|
|
return { ...this.stats };
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Prefetch items based on ID patterns or relationships
|
||
|
|
* @param ids Array of IDs to prefetch
|
||
|
|
*/
|
||
|
|
async prefetch(ids) {
|
||
|
|
// Check if it's time to tune parameters
|
||
|
|
await this.checkAndTuneParameters();
|
||
|
|
// Prefetch in batches to avoid overwhelming the system
|
||
|
|
const batches = [];
|
||
|
|
// 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.
|
||
|
|
*/
|
||
|
|
async checkAndTuneParameters() {
|
||
|
|
// 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
|
||
|
|
*/
|
||
|
|
async getMany(ids) {
|
||
|
|
// Check if it's time to tune parameters
|
||
|
|
await this.checkAndTuneParameters();
|
||
|
|
const result = new Map();
|
||
|
|
// First check hot cache for all IDs
|
||
|
|
const missingIds = [];
|
||
|
|
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
|
||
|
|
*/
|
||
|
|
setStorageAdapters(warmStorage, coldStorage) {
|
||
|
|
this.warmStorage = warmStorage;
|
||
|
|
this.coldStorage = coldStorage;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
//# sourceMappingURL=cacheManager.js.map
|