407 lines
11 KiB
JavaScript
407 lines
11 KiB
JavaScript
/**
|
|
* V3 Cache Manager
|
|
*
|
|
* High-performance LRU cache with TTL support, memory pressure handling,
|
|
* and write-through caching for the unified memory system.
|
|
*
|
|
* @module v3/memory/cache-manager
|
|
*/
|
|
import { EventEmitter } from 'node:events';
|
|
/**
|
|
* High-performance LRU Cache with TTL support
|
|
*
|
|
* Features:
|
|
* - O(1) get, set, delete operations
|
|
* - LRU eviction policy
|
|
* - TTL-based expiration
|
|
* - Memory pressure handling
|
|
* - Write-through caching support
|
|
* - Performance statistics
|
|
*/
|
|
export class CacheManager extends EventEmitter {
|
|
config;
|
|
cache = new Map();
|
|
head = null;
|
|
tail = null;
|
|
currentMemory = 0;
|
|
// Statistics
|
|
stats = {
|
|
hits: 0,
|
|
misses: 0,
|
|
evictions: 0,
|
|
expirations: 0,
|
|
writes: 0,
|
|
};
|
|
// Cleanup timer
|
|
cleanupInterval = null;
|
|
constructor(config = {}) {
|
|
super();
|
|
this.config = this.mergeConfig(config);
|
|
this.startCleanupTimer();
|
|
}
|
|
/**
|
|
* Get a value from the cache
|
|
*/
|
|
get(key) {
|
|
const node = this.cache.get(key);
|
|
if (!node) {
|
|
this.stats.misses++;
|
|
this.emit('cache:miss', { key });
|
|
return null;
|
|
}
|
|
// Check if expired
|
|
if (this.isExpired(node.value)) {
|
|
this.delete(key);
|
|
this.stats.misses++;
|
|
this.stats.expirations++;
|
|
this.emit('cache:expired', { key });
|
|
return null;
|
|
}
|
|
// Update access time and count
|
|
node.value.lastAccessedAt = Date.now();
|
|
node.value.accessCount++;
|
|
// Move to front (most recently used)
|
|
this.moveToFront(node);
|
|
this.stats.hits++;
|
|
this.emit('cache:hit', { key });
|
|
return node.value.data;
|
|
}
|
|
/**
|
|
* Set a value in the cache
|
|
*/
|
|
set(key, data, ttl) {
|
|
const now = Date.now();
|
|
const entryTtl = ttl || this.config.ttl;
|
|
// Check if key already exists
|
|
const existingNode = this.cache.get(key);
|
|
if (existingNode) {
|
|
// Update existing entry
|
|
existingNode.value.data = data;
|
|
existingNode.value.cachedAt = now;
|
|
existingNode.value.expiresAt = now + entryTtl;
|
|
existingNode.value.lastAccessedAt = now;
|
|
this.moveToFront(existingNode);
|
|
this.stats.writes++;
|
|
return;
|
|
}
|
|
// Calculate memory for new entry
|
|
const entryMemory = this.estimateSize(data);
|
|
// Evict entries if needed for memory pressure
|
|
if (this.config.maxMemory) {
|
|
while (this.currentMemory + entryMemory > this.config.maxMemory &&
|
|
this.cache.size > 0) {
|
|
this.evictLRU();
|
|
}
|
|
}
|
|
// Evict entries if at capacity
|
|
while (this.cache.size >= this.config.maxSize) {
|
|
this.evictLRU();
|
|
}
|
|
// Create new node
|
|
const cachedEntry = {
|
|
data,
|
|
cachedAt: now,
|
|
expiresAt: now + entryTtl,
|
|
lastAccessedAt: now,
|
|
accessCount: 0,
|
|
};
|
|
const node = {
|
|
key,
|
|
value: cachedEntry,
|
|
prev: null,
|
|
next: null,
|
|
};
|
|
// Add to cache
|
|
this.cache.set(key, node);
|
|
this.addToFront(node);
|
|
this.currentMemory += entryMemory;
|
|
this.stats.writes++;
|
|
this.emit('cache:set', { key, ttl: entryTtl });
|
|
}
|
|
/**
|
|
* Delete a value from the cache
|
|
*/
|
|
delete(key) {
|
|
const node = this.cache.get(key);
|
|
if (!node) {
|
|
return false;
|
|
}
|
|
this.removeNode(node);
|
|
this.cache.delete(key);
|
|
this.currentMemory -= this.estimateSize(node.value.data);
|
|
this.emit('cache:delete', { key });
|
|
return true;
|
|
}
|
|
/**
|
|
* Check if a key exists in the cache (without affecting LRU order)
|
|
*/
|
|
has(key) {
|
|
const node = this.cache.get(key);
|
|
if (!node)
|
|
return false;
|
|
if (this.isExpired(node.value)) {
|
|
this.delete(key);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
/**
|
|
* Clear all entries from the cache
|
|
*/
|
|
clear() {
|
|
this.cache.clear();
|
|
this.head = null;
|
|
this.tail = null;
|
|
this.currentMemory = 0;
|
|
this.emit('cache:cleared', { previousSize: this.cache.size });
|
|
}
|
|
/**
|
|
* Get cache statistics
|
|
*/
|
|
getStats() {
|
|
const total = this.stats.hits + this.stats.misses;
|
|
return {
|
|
size: this.cache.size,
|
|
hitRate: total > 0 ? this.stats.hits / total : 0,
|
|
hits: this.stats.hits,
|
|
misses: this.stats.misses,
|
|
evictions: this.stats.evictions,
|
|
memoryUsage: this.currentMemory,
|
|
};
|
|
}
|
|
/**
|
|
* Get all keys in the cache
|
|
*/
|
|
keys() {
|
|
return Array.from(this.cache.keys());
|
|
}
|
|
/**
|
|
* Get the size of the cache
|
|
*/
|
|
get size() {
|
|
return this.cache.size;
|
|
}
|
|
/**
|
|
* Prefetch multiple keys in a single batch
|
|
*/
|
|
async prefetch(keys, loader, ttl) {
|
|
const missing = keys.filter((key) => !this.has(key));
|
|
if (missing.length === 0) {
|
|
return;
|
|
}
|
|
const data = await loader(missing);
|
|
for (const [key, value] of data) {
|
|
this.set(key, value, ttl);
|
|
}
|
|
this.emit('cache:prefetched', { keys: missing.length });
|
|
}
|
|
/**
|
|
* Get or set pattern - get from cache or load and cache
|
|
*/
|
|
async getOrSet(key, loader, ttl) {
|
|
const cached = this.get(key);
|
|
if (cached !== null) {
|
|
return cached;
|
|
}
|
|
const data = await loader();
|
|
this.set(key, data, ttl);
|
|
return data;
|
|
}
|
|
/**
|
|
* Warm the cache with initial data
|
|
*/
|
|
warmUp(entries) {
|
|
for (const entry of entries) {
|
|
this.set(entry.key, entry.data, entry.ttl);
|
|
}
|
|
this.emit('cache:warmedUp', { count: entries.length });
|
|
}
|
|
/**
|
|
* Invalidate entries matching a pattern
|
|
*/
|
|
invalidatePattern(pattern) {
|
|
const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern;
|
|
let invalidated = 0;
|
|
for (const key of this.cache.keys()) {
|
|
if (regex.test(key)) {
|
|
this.delete(key);
|
|
invalidated++;
|
|
}
|
|
}
|
|
this.emit('cache:invalidated', { pattern: pattern.toString(), count: invalidated });
|
|
return invalidated;
|
|
}
|
|
/**
|
|
* Shutdown the cache manager
|
|
*/
|
|
shutdown() {
|
|
if (this.cleanupInterval) {
|
|
clearInterval(this.cleanupInterval);
|
|
this.cleanupInterval = null;
|
|
}
|
|
this.clear();
|
|
this.emit('cache:shutdown');
|
|
}
|
|
// ===== Private Methods =====
|
|
mergeConfig(config) {
|
|
return {
|
|
maxSize: config.maxSize || 10000,
|
|
ttl: config.ttl || 300000, // 5 minutes default
|
|
lruEnabled: config.lruEnabled !== false,
|
|
maxMemory: config.maxMemory,
|
|
writeThrough: config.writeThrough || false,
|
|
};
|
|
}
|
|
isExpired(entry) {
|
|
return Date.now() > entry.expiresAt;
|
|
}
|
|
estimateSize(data) {
|
|
try {
|
|
return JSON.stringify(data).length * 2; // Rough UTF-16 estimate
|
|
}
|
|
catch {
|
|
return 1000; // Default for non-serializable objects
|
|
}
|
|
}
|
|
addToFront(node) {
|
|
node.prev = null;
|
|
node.next = this.head;
|
|
if (this.head) {
|
|
this.head.prev = node;
|
|
}
|
|
this.head = node;
|
|
if (!this.tail) {
|
|
this.tail = node;
|
|
}
|
|
}
|
|
removeNode(node) {
|
|
if (node.prev) {
|
|
node.prev.next = node.next;
|
|
}
|
|
else {
|
|
this.head = node.next;
|
|
}
|
|
if (node.next) {
|
|
node.next.prev = node.prev;
|
|
}
|
|
else {
|
|
this.tail = node.prev;
|
|
}
|
|
}
|
|
moveToFront(node) {
|
|
if (node === this.head)
|
|
return;
|
|
this.removeNode(node);
|
|
this.addToFront(node);
|
|
}
|
|
evictLRU() {
|
|
if (!this.tail)
|
|
return;
|
|
const evictedKey = this.tail.key;
|
|
const evictedSize = this.estimateSize(this.tail.value.data);
|
|
this.removeNode(this.tail);
|
|
this.cache.delete(evictedKey);
|
|
this.currentMemory -= evictedSize;
|
|
this.stats.evictions++;
|
|
this.emit('cache:eviction', { key: evictedKey });
|
|
}
|
|
startCleanupTimer() {
|
|
// Clean up expired entries every minute
|
|
this.cleanupInterval = setInterval(() => {
|
|
this.cleanupExpired();
|
|
}, 60000);
|
|
}
|
|
cleanupExpired() {
|
|
const now = Date.now();
|
|
let cleaned = 0;
|
|
for (const [key, node] of this.cache) {
|
|
if (node.value.expiresAt < now) {
|
|
this.delete(key);
|
|
cleaned++;
|
|
}
|
|
}
|
|
if (cleaned > 0) {
|
|
this.emit('cache:cleanup', { expired: cleaned });
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Multi-layer cache with L1 (memory) and L2 (storage) tiers
|
|
*/
|
|
export class TieredCacheManager extends EventEmitter {
|
|
l1Cache;
|
|
l2Loader = null;
|
|
l2Writer = null;
|
|
constructor(l1Config = {}, l2Options) {
|
|
super();
|
|
this.l1Cache = new CacheManager(l1Config);
|
|
if (l2Options) {
|
|
this.l2Loader = l2Options.loader;
|
|
this.l2Writer = l2Options.writer ?? null;
|
|
}
|
|
// Forward L1 events
|
|
this.l1Cache.on('cache:hit', (data) => this.emit('l1:hit', data));
|
|
this.l1Cache.on('cache:miss', (data) => this.emit('l1:miss', data));
|
|
this.l1Cache.on('cache:eviction', (data) => this.emit('l1:eviction', data));
|
|
}
|
|
/**
|
|
* Get from tiered cache
|
|
*/
|
|
async get(key) {
|
|
// Try L1 first
|
|
const l1Result = this.l1Cache.get(key);
|
|
if (l1Result !== null) {
|
|
return l1Result;
|
|
}
|
|
// Try L2 if available
|
|
if (this.l2Loader) {
|
|
const l2Result = await this.l2Loader(key);
|
|
if (l2Result !== null) {
|
|
// Promote to L1
|
|
this.l1Cache.set(key, l2Result);
|
|
this.emit('l2:hit', { key });
|
|
return l2Result;
|
|
}
|
|
this.emit('l2:miss', { key });
|
|
}
|
|
return null;
|
|
}
|
|
/**
|
|
* Set in tiered cache
|
|
*/
|
|
async set(key, value, ttl) {
|
|
// Write to L1
|
|
this.l1Cache.set(key, value, ttl);
|
|
// Write-through to L2 if configured
|
|
if (this.l2Writer) {
|
|
await this.l2Writer(key, value);
|
|
this.emit('l2:write', { key });
|
|
}
|
|
}
|
|
/**
|
|
* Delete from tiered cache
|
|
*/
|
|
delete(key) {
|
|
return this.l1Cache.delete(key);
|
|
}
|
|
/**
|
|
* Get L1 cache statistics
|
|
*/
|
|
getStats() {
|
|
return this.l1Cache.getStats();
|
|
}
|
|
/**
|
|
* Clear L1 cache
|
|
*/
|
|
clear() {
|
|
this.l1Cache.clear();
|
|
}
|
|
/**
|
|
* Shutdown tiered cache
|
|
*/
|
|
shutdown() {
|
|
this.l1Cache.shutdown();
|
|
}
|
|
}
|
|
export default CacheManager;
|
|
//# sourceMappingURL=cache-manager.js.map
|