cache-manager.jsā¢14.9 kB
/**
* Cache Manager Module
* Provides intelligent caching and performance optimization
*/
import fs from 'fs-extra';
import path from 'path';
import crypto from 'crypto';
export class CacheManager {
constructor(config) {
this.config = config;
this.memoryCache = new Map();
this.diskCacheDir = path.join(process.cwd(), '.cache');
this.stats = {
hits: 0,
misses: 0,
evictions: 0,
diskReads: 0,
diskWrites: 0
};
this.maxMemoryItems = config.get('performance.caching.maxMemoryItems') || 100;
this.maxDiskItems = config.get('performance.caching.maxDiskItems') || 1000;
this.ttl = config.get('performance.caching.ttl') || 3600000; // 1 hour default
this.initializeDiskCache();
}
async initializeDiskCache() {
if (this.config.get('performance.caching.enableDiskCache')) {
try {
await fs.ensureDir(this.diskCacheDir);
await this.cleanupExpiredDiskCache();
} catch (error) {
console.warn('Failed to initialize disk cache:', error.message);
}
}
}
// Generate cache key from data
generateKey(data) {
const hash = crypto.createHash('sha256');
hash.update(JSON.stringify(data));
return hash.digest('hex').substring(0, 16);
}
// Get item from cache (memory first, then disk)
async get(key) {
// Check memory cache first
const memoryItem = this.memoryCache.get(key);
if (memoryItem && !this.isExpired(memoryItem)) {
this.stats.hits++;
return memoryItem.data;
}
// Remove expired memory item
if (memoryItem && this.isExpired(memoryItem)) {
this.memoryCache.delete(key);
}
// Check disk cache if enabled
if (this.config.get('performance.caching.enableDiskCache')) {
const diskItem = await this.getDiskCache(key);
if (diskItem && !this.isExpired(diskItem)) {
// Promote to memory cache
this.setMemoryCache(key, diskItem.data, diskItem.timestamp);
this.stats.hits++;
this.stats.diskReads++;
return diskItem.data;
}
}
this.stats.misses++;
return null;
}
// Set item in cache
async set(key, data, customTtl = null) {
const timestamp = Date.now();
const ttl = customTtl || this.ttl;
// Always set in memory cache
this.setMemoryCache(key, data, timestamp, ttl);
// Set in disk cache if enabled and data is significant
if (this.config.get('performance.caching.enableDiskCache') && this.shouldCacheToDisk(data)) {
await this.setDiskCache(key, data, timestamp, ttl);
}
}
// Memory cache operations
setMemoryCache(key, data, timestamp = Date.now(), ttl = null) {
// Evict oldest items if cache is full
if (this.memoryCache.size >= this.maxMemoryItems) {
this.evictOldestMemoryItems();
}
this.memoryCache.set(key, {
data,
timestamp,
ttl: ttl || this.ttl,
accessCount: 0,
lastAccess: timestamp
});
}
evictOldestMemoryItems() {
const items = Array.from(this.memoryCache.entries());
// Sort by last access time (oldest first)
items.sort((a, b) => a[1].lastAccess - b[1].lastAccess);
// Remove oldest 25% of items
const removeCount = Math.floor(this.maxMemoryItems * 0.25);
for (let i = 0; i < removeCount && items.length > 0; i++) {
this.memoryCache.delete(items[i][0]);
this.stats.evictions++;
}
}
// Disk cache operations
async getDiskCache(key) {
try {
const filePath = this.getDiskCachePath(key);
if (await fs.pathExists(filePath)) {
const content = await fs.readFile(filePath, 'utf8');
return JSON.parse(content);
}
} catch (error) {
console.warn(`Failed to read disk cache for key ${key}:`, error.message);
}
return null;
}
async setDiskCache(key, data, timestamp = Date.now(), ttl = null) {
try {
const filePath = this.getDiskCachePath(key);
const cacheItem = {
data,
timestamp,
ttl: ttl || this.ttl,
key
};
await fs.writeFile(filePath, JSON.stringify(cacheItem, null, 2));
this.stats.diskWrites++;
// Clean up old disk cache periodically
if (Math.random() < 0.1) { // 10% chance on each write
setImmediate(() => this.cleanupExpiredDiskCache());
}
} catch (error) {
console.warn(`Failed to write disk cache for key ${key}:`, error.message);
}
}
getDiskCachePath(key) {
return path.join(this.diskCacheDir, `${key}.json`);
}
// Cache invalidation and cleanup
async invalidate(pattern) {
// Invalidate memory cache
if (typeof pattern === 'string') {
this.memoryCache.delete(pattern);
} else if (pattern instanceof RegExp) {
for (const key of this.memoryCache.keys()) {
if (pattern.test(key)) {
this.memoryCache.delete(key);
}
}
}
// Invalidate disk cache
if (this.config.get('performance.caching.enableDiskCache')) {
await this.invalidateDiskCache(pattern);
}
}
async invalidateDiskCache(pattern) {
try {
const files = await fs.readdir(this.diskCacheDir);
const jsonFiles = files.filter(f => f.endsWith('.json'));
for (const file of jsonFiles) {
const key = path.basename(file, '.json');
if (typeof pattern === 'string' && key === pattern) {
await fs.remove(path.join(this.diskCacheDir, file));
} else if (pattern instanceof RegExp && pattern.test(key)) {
await fs.remove(path.join(this.diskCacheDir, file));
}
}
} catch (error) {
console.warn('Failed to invalidate disk cache:', error.message);
}
}
async cleanupExpiredDiskCache() {
try {
const files = await fs.readdir(this.diskCacheDir);
const jsonFiles = files.filter(f => f.endsWith('.json'));
let cleanedCount = 0;
for (const file of jsonFiles) {
const filePath = path.join(this.diskCacheDir, file);
try {
const content = await fs.readFile(filePath, 'utf8');
const cacheItem = JSON.parse(content);
if (this.isExpired(cacheItem)) {
await fs.remove(filePath);
cleanedCount++;
}
} catch (error) {
// Remove corrupted cache files
await fs.remove(filePath);
cleanedCount++;
}
}
// If we have too many files, remove oldest ones
const remainingFiles = await fs.readdir(this.diskCacheDir);
const remainingJsonFiles = remainingFiles.filter(f => f.endsWith('.json'));
if (remainingJsonFiles.length > this.maxDiskItems) {
const fileStats = await Promise.all(
remainingJsonFiles.map(async f => {
const filePath = path.join(this.diskCacheDir, f);
const stat = await fs.stat(filePath);
return { file: f, mtime: stat.mtime };
})
);
// Sort by modification time (oldest first)
fileStats.sort((a, b) => a.mtime - b.mtime);
const removeCount = remainingJsonFiles.length - this.maxDiskItems;
for (let i = 0; i < removeCount; i++) {
await fs.remove(path.join(this.diskCacheDir, fileStats[i].file));
cleanedCount++;
}
}
if (cleanedCount > 0) {
console.log(`Cleaned up ${cleanedCount} expired cache items`);
}
} catch (error) {
console.warn('Failed to cleanup disk cache:', error.message);
}
}
// Smart caching strategies
shouldCacheToDisk(data) {
const dataSize = JSON.stringify(data).length;
const minSizeForDisk = this.config.get('performance.caching.minDiskCacheSize') || 1024; // 1KB
return dataSize >= minSizeForDisk;
}
// Cache warming
async warmCache(cacheKeys) {
const warmingPromises = cacheKeys.map(async keyData => {
try {
const key = this.generateKey(keyData.input);
const cachedData = await this.get(key);
if (!cachedData && keyData.generator) {
const data = await keyData.generator(keyData.input);
await this.set(key, data);
return { key, status: 'warmed' };
} else if (cachedData) {
return { key, status: 'already_cached' };
}
} catch (error) {
console.warn(`Failed to warm cache for key:`, error.message);
return { key: 'unknown', status: 'failed', error: error.message };
}
});
const results = await Promise.all(warmingPromises);
console.log('Cache warming completed:', results);
return results;
}
// Utility methods
isExpired(cacheItem) {
return Date.now() - cacheItem.timestamp > cacheItem.ttl;
}
getStats() {
const hitRate = this.stats.hits / (this.stats.hits + this.stats.misses) || 0;
return {
...this.stats,
hitRate: Math.round(hitRate * 100) / 100,
memoryItems: this.memoryCache.size,
memoryUsage: this.estimateMemoryUsage()
};
}
estimateMemoryUsage() {
let totalSize = 0;
for (const [key, value] of this.memoryCache.entries()) {
totalSize += JSON.stringify({ key, value }).length;
}
return totalSize;
}
// Clear all caches
async clear() {
this.memoryCache.clear();
if (this.config.get('performance.caching.enableDiskCache')) {
try {
await fs.emptyDir(this.diskCacheDir);
} catch (error) {
console.warn('Failed to clear disk cache:', error.message);
}
}
// Reset stats
this.stats = {
hits: 0,
misses: 0,
evictions: 0,
diskReads: 0,
diskWrites: 0
};
}
// Configuration updates
updateConfig(newConfig) {
this.config = newConfig;
this.maxMemoryItems = newConfig.get('performance.caching.maxMemoryItems') || 100;
this.maxDiskItems = newConfig.get('performance.caching.maxDiskItems') || 1000;
this.ttl = newConfig.get('performance.caching.ttl') || 3600000;
}
// Export cache for analysis
async exportCache() {
const memoryData = Array.from(this.memoryCache.entries()).map(([key, value]) => ({
key,
...value,
size: JSON.stringify(value.data).length
}));
let diskData = [];
if (this.config.get('performance.caching.enableDiskCache')) {
try {
const files = await fs.readdir(this.diskCacheDir);
const jsonFiles = files.filter(f => f.endsWith('.json'));
diskData = await Promise.all(
jsonFiles.map(async file => {
try {
const filePath = path.join(this.diskCacheDir, file);
const content = await fs.readFile(filePath, 'utf8');
const parsed = JSON.parse(content);
const stat = await fs.stat(filePath);
return {
key: parsed.key,
timestamp: parsed.timestamp,
ttl: parsed.ttl,
size: stat.size,
expired: this.isExpired(parsed)
};
} catch (error) {
return { error: error.message, file };
}
})
);
} catch (error) {
console.warn('Failed to read disk cache for export:', error.message);
}
}
return {
memory: memoryData,
disk: diskData,
stats: this.getStats(),
timestamp: new Date().toISOString()
};
}
}
// Performance monitoring utilities
export class PerformanceMonitor {
constructor() {
this.metrics = new Map();
this.startTimes = new Map();
}
start(operationId) {
this.startTimes.set(operationId, performance.now());
}
end(operationId, metadata = {}) {
const startTime = this.startTimes.get(operationId);
if (startTime) {
const duration = performance.now() - startTime;
if (!this.metrics.has(operationId)) {
this.metrics.set(operationId, []);
}
this.metrics.get(operationId).push({
duration,
timestamp: Date.now(),
metadata
});
this.startTimes.delete(operationId);
return duration;
}
return null;
}
getMetrics(operationId) {
const measurements = this.metrics.get(operationId) || [];
if (measurements.length === 0) return null;
const durations = measurements.map(m => m.duration);
return {
count: measurements.length,
average: durations.reduce((a, b) => a + b, 0) / durations.length,
min: Math.min(...durations),
max: Math.max(...durations),
recent: measurements.slice(-10) // Last 10 measurements
};
}
getAllMetrics() {
const results = {};
for (const [operationId, measurements] of this.metrics.entries()) {
results[operationId] = this.getMetrics(operationId);
}
return results;
}
clear() {
this.metrics.clear();
this.startTimes.clear();
}
}