/**
* Cache Manager
*
* High-performance LRU cache with TTL support, memory pressure handling,
* and write-through caching for the unified memory system.
*
* @module @agentkits/memory/cache-manager
*/
import { EventEmitter } from 'node:events';
import {
CacheConfig,
CacheStats,
CachedEntry,
MemoryEntry,
} from './types.js';
/**
* Doubly-linked list node for LRU implementation
*/
interface LRUNode<T> {
key: string;
value: CachedEntry<T>;
prev: LRUNode<T> | null;
next: LRUNode<T> | null;
}
/**
* 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<T = MemoryEntry> extends EventEmitter {
private config: CacheConfig;
private cache: Map<string, LRUNode<T>> = new Map();
private head: LRUNode<T> | null = null;
private tail: LRUNode<T> | null = null;
private currentMemory: number = 0;
// Statistics
private stats: {
hits: number;
misses: number;
evictions: number;
expirations: number;
writes: number;
} = {
hits: 0,
misses: 0,
evictions: 0,
expirations: 0,
writes: 0,
};
// Cleanup timer
private cleanupInterval: NodeJS.Timeout | null = null;
constructor(config: Partial<CacheConfig> = {}) {
super();
this.config = this.mergeConfig(config);
this.startCleanupTimer();
}
/**
* Get a value from the cache
*/
get(key: string): T | null {
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: string, data: T, ttl?: number): void {
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: CachedEntry<T> = {
data,
cachedAt: now,
expiresAt: now + entryTtl,
lastAccessedAt: now,
accessCount: 0,
};
const node: LRUNode<T> = {
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: string): boolean {
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: string): boolean {
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(): void {
this.cache.clear();
this.head = null;
this.tail = null;
this.currentMemory = 0;
this.emit('cache:cleared', { previousSize: this.cache.size });
}
/**
* Get cache statistics
*/
getStats(): CacheStats {
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(): string[] {
return Array.from(this.cache.keys());
}
/**
* Get the size of the cache
*/
get size(): number {
return this.cache.size;
}
/**
* Prefetch multiple keys in a single batch
*/
async prefetch(
keys: string[],
loader: (keys: string[]) => Promise<Map<string, T>>,
ttl?: number
): Promise<void> {
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: string,
loader: () => Promise<T>,
ttl?: number
): Promise<T> {
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: Array<{ key: string; data: T; ttl?: number }>): void {
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: string | RegExp): number {
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(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
this.clear();
this.emit('cache:shutdown');
}
// ===== Private Methods =====
private mergeConfig(config: Partial<CacheConfig>): CacheConfig {
return {
maxSize: config.maxSize || 10000,
ttl: config.ttl || 300000, // 5 minutes default
lruEnabled: config.lruEnabled !== false,
maxMemory: config.maxMemory,
writeThrough: config.writeThrough || false,
};
}
private isExpired(entry: CachedEntry<T>): boolean {
return Date.now() > entry.expiresAt;
}
private estimateSize(data: T): number {
try {
return JSON.stringify(data).length * 2; // Rough UTF-16 estimate
} catch {
return 1000; // Default for non-serializable objects
}
}
private addToFront(node: LRUNode<T>): void {
node.prev = null;
node.next = this.head;
if (this.head) {
this.head.prev = node;
}
this.head = node;
if (!this.tail) {
this.tail = node;
}
}
private removeNode(node: LRUNode<T>): void {
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;
}
}
private moveToFront(node: LRUNode<T>): void {
if (node === this.head) return;
this.removeNode(node);
this.addToFront(node);
}
private evictLRU(): void {
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 });
}
private startCleanupTimer(): void {
// Clean up expired entries every minute
this.cleanupInterval = setInterval(() => {
this.cleanupExpired();
}, 60000);
}
private cleanupExpired(): void {
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<T = MemoryEntry> extends EventEmitter {
private l1Cache: CacheManager<T>;
private l2Loader: ((key: string) => Promise<T | null>) | null = null;
private l2Writer: ((key: string, value: T) => Promise<void>) | null = null;
constructor(
l1Config: Partial<CacheConfig> = {},
l2Options?: {
loader: (key: string) => Promise<T | null>;
writer?: (key: string, value: T) => Promise<void>;
}
) {
super();
this.l1Cache = new CacheManager<T>(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: string): Promise<T | null> {
// 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: string, value: T, ttl?: number): Promise<void> {
// 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: string): boolean {
return this.l1Cache.delete(key);
}
/**
* Get L1 cache statistics
*/
getStats(): CacheStats {
return this.l1Cache.getStats();
}
/**
* Clear L1 cache
*/
clear(): void {
this.l1Cache.clear();
}
/**
* Shutdown tiered cache
*/
shutdown(): void {
this.l1Cache.shutdown();
}
}
export default CacheManager;