UNPKG

cachly

Version:

Type-safe, production-ready in-memory cache system for Node.js and TypeScript with advanced features.

833 lines (707 loc) 23.4 kB
import { CacheOptions, CacheItem, CacheStats, CacheConfig, PersistenceAdapter, CacheEventMap, CacheEventType, WarmupItem, ProgressiveWarmupItem, DependencyWarmupItem, HealthStatus, Metrics, CircuitBreakerState, PartitionInfo, CacheGroup, BulkOperation, CacheTag, } from './types'; import { CompressionUtil } from './utils/compression'; import { CircuitBreaker } from './utils/CircuitBreaker'; import { PartitioningUtil } from './utils/Partitioning'; import { MonitoringUtil } from './utils/Monitoring'; export class Cachly { private items = new Map<string, CacheItem>(); private config: Required<CacheConfig>; private cacheStats: CacheStats; private persistence?: PersistenceAdapter; private cleanupInterval?: ReturnType<typeof setInterval>; private eventEmitter = new (require('events').EventEmitter)() as any; // Advanced features private compressionUtil?: CompressionUtil; private circuitBreaker?: CircuitBreaker; private partitioningUtil?: PartitioningUtil; private monitoringUtil?: MonitoringUtil; private circuitBreakerTrips = 0; // New features private tags = new Map<string, CacheTag>(); private groups = new Map<string, CacheGroup>(); private hitHook?: (key: string) => void; private missHook?: (key: string) => void; constructor(config: CacheConfig = {}) { this.config = { maxItems: 1000, maxMemory: 0, defaultTtl: 0, staleWhileRevalidate: false, log: false, namespace: 'main', persistence: 'none', evictionPolicy: 'lru', compression: { enabled: false, algorithm: 'gzip', threshold: 1024 }, circuitBreaker: { enabled: false, failureThreshold: 5, recoveryTimeout: 30000 }, partitioning: { enabled: false, strategy: 'hash', partitions: 4 }, monitoring: { enabled: false, metrics: [] }, distributed: { enabled: false, nodes: [], replication: false, consistency: 'eventual', partitionStrategy: 'consistent-hashing' }, onHit: () => {}, onMiss: () => {}, ...config, }; this.cacheStats = { hits: 0, misses: 0, evictions: 0, memoryUsage: 0, keyCount: 0, totalAccesses: 0, hitRate: 0, missRate: 0, avgLoadTime: 0, compressionRatio: 0, }; // Initialize advanced features if (this.config.compression.enabled) { this.compressionUtil = new CompressionUtil(); } if (this.config.circuitBreaker.enabled) { this.circuitBreaker = new CircuitBreaker(this.config.circuitBreaker); } if (this.config.partitioning.enabled) { this.partitioningUtil = new PartitioningUtil(this.config.partitioning); } if (this.config.monitoring.enabled) { this.monitoringUtil = new MonitoringUtil(this.config.monitoring); } if (this.config.persistence !== 'none') { this.persistence = this.config.persistence as PersistenceAdapter; this.initializePersistence(); } if (this.config.defaultTtl > 0) { this.startCleanupInterval(); } if (typeof config['onHit'] === 'function') { this.hitHook = config['onHit']; } if (typeof config['onMiss'] === 'function') { this.missHook = config['onMiss']; } } async set<T>(key: string, value: T, options: Partial<CacheOptions> = {}): Promise<void> { const startTime = Date.now(); const now = Date.now(); const ttl = options.ttl ?? this.config.defaultTtl; const expiresAt = ttl && ttl > 0 ? now + ttl : undefined; const staleAt = options.staleTtl ? now + options.staleTtl : undefined; let processedValue: any = value; let originalSize = 0; let compressedSize = 0; let compressed = false; // Apply compression if enabled if (this.compressionUtil && this.config.compression.enabled) { try { const compressionResult = await CompressionUtil.compress(value, this.config.compression); processedValue = compressionResult.data; originalSize = compressionResult.originalSize; compressedSize = compressionResult.compressedSize; compressed = originalSize !== compressedSize; if (compressed) { this.eventEmitter.emit('compress', key, originalSize, compressedSize); } } catch (error) { if (this.config.log) { console.error(`[Cachly] Compression failed for ${key}:`, error); } } } const item: CacheItem<any> = { value: processedValue, createdAt: now, expiresAt, staleAt, dependsOn: new Set(options.dependsOn || []), dependents: new Set(), tags: new Set(options.tags || []), accessCount: 0, lastAccessed: now, compressed, originalSize, compressedSize, }; this.items.set(key, item); this.updateDependencies(key, item.dependsOn); this.updateTags(key, item.tags || new Set()); this.updateStats(); this.eventEmitter.emit('set', key, value); if (this.config.log) { console.log(`[Cachly] Set: ${key}`); } this.evictIfNeeded(); // Record load time for monitoring if (this.monitoringUtil) { this.monitoringUtil.recordLoadTime(Date.now() - startTime); } } async get<T>(key: string): Promise<T | undefined> { const item = this.items.get(key); if (!item) { this.cacheStats.misses++; this.cacheStats.totalAccesses++; this.eventEmitter.emit('miss', key); if (this.missHook) this.missHook(key); return undefined; } if (this.isExpired(item)) { this.delete(key); this.cacheStats.misses++; this.cacheStats.totalAccesses++; this.eventEmitter.emit('miss', key); if (this.missHook) this.missHook(key); return undefined; } item.accessCount++; item.lastAccessed = Date.now(); this.cacheStats.hits++; this.cacheStats.totalAccesses++; this.eventEmitter.emit('hit', key); if (this.hitHook) this.hitHook(key); // Emit partition hit event if (this.partitioningUtil) { const partition = this.partitioningUtil.getPartition(key); this.eventEmitter.emit('partitionHit', partition, key); } let result = item.value as T; // Decompress if needed if (item.compressed && this.compressionUtil) { try { result = await CompressionUtil.decompress(item.value as Buffer, this.config.compression.algorithm); } catch (error) { if (this.config.log) { console.error(`[Cachly] Decompression failed for ${key}:`, error); } return undefined; } } return result; } async getOrCompute<T>( key: string, loader: () => Promise<T>, options: Partial<CacheOptions> = {} ): Promise<T> { const cached = await this.get<T>(key); if (cached !== undefined) { return cached; } // Use circuit breaker if enabled if (this.circuitBreaker) { return this.circuitBreaker.execute(key, async () => { const value = await loader(); await this.set(key, value, options); return value; }, this.config.circuitBreaker.fallback); } const value = await loader(); await this.set(key, value, options); return value; } async getOrComputeWithStale<T>( key: string, loader: () => Promise<T>, options: Partial<CacheOptions> = {} ): Promise<T> { let item = this.items.get(key); if (item && !this.isExpired(item)) { item.accessCount++; item.lastAccessed = Date.now(); this.cacheStats.hits++; this.cacheStats.totalAccesses++; this.eventEmitter.emit('hit', key); let result = item.value as T; if (item.compressed && this.compressionUtil) { result = await CompressionUtil.decompress(item.value as Buffer, this.config.compression.algorithm); } return result; } if (item && this.isStale(item) && this.config.staleWhileRevalidate) { this.cacheStats.hits++; this.cacheStats.totalAccesses++; this.eventEmitter.emit('hit', key); if ((options as any).__testAwaitBackground) { try { const value = await loader(); const staleTtl = options.staleTtl ?? 0; const ttl = options.ttl ?? this.config.defaultTtl; await this.set(key, value, { ...options, staleTtl, ttl }); // Update item reference for test determinism item = this.items.get(key); } catch (error) { if (this.config.log) { console.error(`[Cachly] Background refresh failed for ${key}:`, error); } } } else { setTimeout(async () => { try { const value = await loader(); const staleTtl = options.staleTtl ?? 0; const ttl = options.ttl ?? this.config.defaultTtl; await this.set(key, value, { ...options, staleTtl, ttl }); const updated = this.items.get(key); if (updated) { updated.staleAt = staleTtl ? Date.now() + staleTtl : undefined; updated.expiresAt = ttl && ttl > 0 ? Date.now() + ttl : undefined; } } catch (error) { if (this.config.log) { console.error(`[Cachly] Background refresh failed for ${key}:`, error); } } }, 0); } let result = item ? (item.value as T) : undefined; if (item && item.compressed && this.compressionUtil) { result = await CompressionUtil.decompress(item.value as Buffer, this.config.compression.algorithm); } return result as T; } const value = await loader(); await this.set(key, value, options); return value; } has(key: string): boolean { const item = this.items.get(key); return item !== undefined && !this.isExpired(item); } delete(key: string): void { const item = this.items.get(key); if (item) { this.removeDependencies(key, item.dependsOn); this.removeTags(key, item.tags); this.invalidateDependentsOnDelete(key); this.items.delete(key); this.updateStats(); this.eventEmitter.emit('delete', key); } } clear(): void { this.items.clear(); this.updateStats(); this.eventEmitter.emit('clear'); } stats(): CacheStats { const stats = { ...this.cacheStats }; // Calculate derived stats if (stats.totalAccesses > 0) { stats.hitRate = (stats.hits / stats.totalAccesses) * 100; stats.missRate = (stats.misses / stats.totalAccesses) * 100; } // Calculate compression ratio let totalOriginalSize = 0; let totalCompressedSize = 0; let compressedItems = 0; for (const item of this.items.values()) { if (item.compressed && item.originalSize && item.compressedSize) { totalOriginalSize += item.originalSize; totalCompressedSize += item.compressedSize; compressedItems++; } } if (totalOriginalSize > 0) { stats.compressionRatio = CompressionUtil.calculateCompressionRatio(totalOriginalSize, totalCompressedSize); } // Add monitoring metrics if (this.monitoringUtil) { const monitoringMetrics = this.monitoringUtil.getMetrics(); stats.avgLoadTime = monitoringMetrics.avgLoadTime; } return stats; } async warm(items: WarmupItem[]): Promise<void> { const promises = items.map(async (item) => { try { const value = await item.loader(); await this.set(item.key, value, { ttl: item.ttl, staleTtl: item.staleTtl, dependsOn: item.dependsOn, }); } catch (error) { if (this.config.log) { console.error(`[Cachly] Warmup failed for ${item.key}:`, error); } } }); await Promise.all(promises); } async warmProgressive(items: ProgressiveWarmupItem[]): Promise<void> { const priorities = ['high', 'medium', 'low']; for (const priority of priorities) { const priorityItems = items.filter(item => item.priority === priority); await this.warm(priorityItems); } } async warmWithDependencies(items: DependencyWarmupItem[]): Promise<void> { // Sort items by dependency depth const sortedItems = this.sortByDependencies(items); await this.warm(sortedItems); } private sortByDependencies(items: DependencyWarmupItem[]): DependencyWarmupItem[] { const dependencyMap = new Map<string, DependencyWarmupItem>(); const visited = new Set<string>(); const sorted: DependencyWarmupItem[] = []; // Build dependency map for (const item of items) { dependencyMap.set(item.key, item); } const visit = (item: DependencyWarmupItem) => { if (visited.has(item.key)) return; visited.add(item.key); // Visit dependencies first for (const dep of item.deps) { const depItem = dependencyMap.get(dep); if (depItem) { visit(depItem); } } sorted.push(item); }; for (const item of items) { visit(item); } return sorted; } health(): HealthStatus { if (this.monitoringUtil) { return this.monitoringUtil.health(); } return { status: 'healthy', issues: [], lastCheck: Date.now(), uptime: Date.now() - (this as any).startTime || 0, }; } metrics(): Metrics { const stats = this.stats(); const baseMetrics = this.monitoringUtil?.getMetrics() || { hitRate: 0, missRate: 0, avgLoadTime: 0, memoryEfficiency: 0, compressionRatio: 0, circuitBreakerTrips: 0, partitionDistribution: {}, }; return { ...baseMetrics, hitRate: stats.hitRate, missRate: stats.missRate, avgLoadTime: stats.avgLoadTime, memoryEfficiency: (stats.memoryUsage / (this.config.maxMemory || 1)) * 100, compressionRatio: stats.compressionRatio, circuitBreakerTrips: this.circuitBreakerTrips, partitionDistribution: this.partitioningUtil?.getPartitionDistribution() || {}, }; } getCircuitBreakerState(): CircuitBreakerState | undefined { return this.circuitBreaker?.getState(); } getPartitionInfo(partitionId: number): PartitionInfo | undefined { return this.partitioningUtil?.getPartitionInfo(partitionId); } getAllPartitions(): PartitionInfo[] { return this.partitioningUtil?.getAllPartitions() || []; } isBalanced(): boolean { return this.partitioningUtil?.isBalanced() || true; } private isExpired(item: CacheItem): boolean { return item.expiresAt !== undefined && Date.now() > item.expiresAt; } private isStale(item: CacheItem): boolean { return item.staleAt !== undefined && Date.now() > item.staleAt; } private updateDependencies(key: string, dependencies: Set<string>): void { for (const dep of dependencies) { const depItem = this.items.get(dep); if (depItem) { depItem.dependents.add(key); } } } private removeDependencies(key: string, dependencies: Set<string>): void { for (const dep of dependencies) { const depItem = this.items.get(dep); if (depItem) { depItem.dependents.delete(key); } } } private invalidateDependents(key: string): void { const item = this.items.get(key); if (!item) return; const dependents = new Set(item.dependents); for (const dependent of dependents) { this.delete(dependent); } } private invalidateDependentsOnDelete(key: string): void { this.invalidateDependents(key); } private evictIfNeeded(): void { while (this.config.maxItems && this.items.size > this.config.maxItems) { this.evict(); } } private evict(): void { if (this.config.evictionPolicy === 'lru') { this.evictLRU(); } else if (this.config.evictionPolicy === 'ttl') { this.evictTTL(); } else if (this.config.evictionPolicy === 'lfu') { this.evictLFU(); } } private evictLRU(): void { let oldestKey: string | null = null; let oldestTime = Date.now(); for (const [key, item] of this.items) { if (item.lastAccessed < oldestTime) { oldestTime = item.lastAccessed; oldestKey = key; } } if (oldestKey) { this.items.delete(oldestKey); this.cacheStats.evictions++; this.eventEmitter.emit('evict', oldestKey, 'lru'); this.updateStats(); } } private evictTTL(): void { const now = Date.now(); for (const [key, item] of this.items) { if (item.expiresAt && now > item.expiresAt) { this.items.delete(key); this.cacheStats.evictions++; this.eventEmitter.emit('evict', key, 'ttl'); } } this.updateStats(); } private evictLFU(): void { let leastFrequentKey: string | null = null; let leastFrequentCount = Infinity; for (const [key, item] of this.items) { if (item.accessCount < leastFrequentCount) { leastFrequentCount = item.accessCount; leastFrequentKey = key; } } if (leastFrequentKey) { this.items.delete(leastFrequentKey); this.cacheStats.evictions++; this.eventEmitter.emit('evict', leastFrequentKey, 'lfu'); this.updateStats(); } } private updateStats(): void { this.cacheStats.keyCount = this.items.size; this.cacheStats.memoryUsage = this.estimateMemoryUsage(); } private estimateMemoryUsage(): number { let total = 0; for (const [key, item] of this.items) { total += key.length * 2; if (item.compressed && item.compressedSize) { total += item.compressedSize; } else { total += JSON.stringify(item.value).length * 2; } total += 100; } return total; } private startCleanupInterval(): void { this.cleanupInterval = setInterval(() => { this.evictTTL(); }, 60000); } private initializePersistence(): void { // Initialize persistence layer if needed if (this.persistence) { // Future implementation for persistence initialization } } destroy(): void { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); } this.clear(); this.eventEmitter.removeAllListeners(); } on<K extends CacheEventType>(event: K, listener: CacheEventMap[K]): this { this.eventEmitter.on(event, listener); return this; } off<K extends CacheEventType>(event: K, listener: CacheEventMap[K]): this { this.eventEmitter.off(event, listener); return this; } async invalidateByTag(tag: string): Promise<string[]> { const tagData = this.tags.get(tag); if (!tagData) return []; const affectedKeys = Array.from(tagData.keys); for (const key of affectedKeys) { this.delete(key); } this.tags.delete(tag); this.eventEmitter.emit('tagInvalidated', tag, affectedKeys); return affectedKeys; } async invalidateByTags(tags: string[]): Promise<Record<string, string[]>> { const results: Record<string, string[]> = {}; for (const tag of tags) { results[tag] = await this.invalidateByTag(tag); } return results; } getKeysByTag(tag: string): string[] { const tagData = this.tags.get(tag); return tagData ? Array.from(tagData.keys) : []; } getTagsByKey(key: string): string[] { const item = this.items.get(key); return item?.tags ? Array.from(item.tags) : []; } createGroup(name: string, config: Partial<CacheConfig> = {}): CacheGroup { const group: CacheGroup = { name, keys: new Set(), config, }; this.groups.set(name, group); this.eventEmitter.emit('groupCreated', group); return group; } addToGroup(groupName: string, key: string): boolean { const group = this.groups.get(groupName); if (!group) return false; group.keys.add(key); return true; } removeFromGroup(groupName: string, key: string): boolean { const group = this.groups.get(groupName); if (!group) return false; return group.keys.delete(key); } getGroupKeys(groupName: string): string[] { const group = this.groups.get(groupName); return group ? Array.from(group.keys) : []; } deleteGroup(groupName: string): boolean { const group = this.groups.get(groupName); if (!group) return false; for (const key of group.keys) { this.delete(key); } this.groups.delete(groupName); this.eventEmitter.emit('groupDeleted', groupName); return true; } async bulk(operation: BulkOperation): Promise<any> { const startTime = Date.now(); const results: any = { get: {}, set: [], delete: [], invalidateByTag: {}, }; for (const key of operation.get) { results.get[key] = await this.get(key); } for (const { key, value, options } of operation.set) { await this.set(key, value, options); results.set.push(key); } for (const key of operation.delete) { this.delete(key); results.delete.push(key); } for (const tag of operation.invalidateByTag) { results.invalidateByTag[tag] = await this.invalidateByTag(tag); } const duration = Date.now() - startTime; this.eventEmitter.emit('bulkOperation', operation, { results, duration }); return results; } getKeys(pattern?: string): string[] { const keys = Array.from(this.items.keys()); if (!pattern) return keys; const regex = new RegExp(pattern.replace(/\*/g, '.*')); return keys.filter(key => regex.test(key)); } getKeysByPattern(pattern: string): string[] { return this.getKeys(pattern); } getItemsByPattern(pattern: string): Array<{ key: string; item: CacheItem }> { const keys = this.getKeysByPattern(pattern); return keys.map(key => ({ key, item: this.items.get(key)! })); } getTopKeys(limit: number = 10): Array<{ key: string; accessCount: number }> { return Array.from(this.items.entries()) .map(([key, item]) => ({ key, accessCount: item.accessCount })) .sort((a, b) => b.accessCount - a.accessCount) .slice(0, limit); } getLeastUsedKeys(limit: number = 10): Array<{ key: string; accessCount: number }> { return Array.from(this.items.entries()) .map(([key, item]) => ({ key, accessCount: item.accessCount })) .sort((a, b) => a.accessCount - b.accessCount) .slice(0, limit); } getKeysByAge(limit: number = 10): Array<{ key: string; age: number }> { const now = Date.now(); return Array.from(this.items.entries()) .map(([key, item]) => ({ key, age: now - item.createdAt })) .sort((a, b) => b.age - a.age) .slice(0, limit); } private updateTags(key: string, tags: Set<string>): void { for (const tag of tags) { if (!this.tags.has(tag)) { this.tags.set(tag, { name: tag, keys: new Set(), createdAt: Date.now() }); } this.tags.get(tag)!.keys.add(key); } } private removeTags(key: string, tags?: Set<string>): void { if (!tags) return; for (const tag of tags) { const tagData = this.tags.get(tag); if (tagData) { tagData.keys.delete(key); if (tagData.keys.size === 0) { this.tags.delete(tag); } } } } setHitHook(hook: (key: string) => void) { this.hitHook = hook; } setMissHook(hook: (key: string) => void) { this.missHook = hook; } }