UNPKG

@toolprint/hypertool-mcp

Version:

HyperTool MCP proxy server for routing requests between clients and multiple underlying MCP servers

609 lines • 19.5 kB
/** * High-Performance Persona Cache System * * This module implements a comprehensive caching system for LoadedPersona objects * with TTL-based expiration, LRU eviction, memory management, and file system * monitoring for automatic invalidation. Designed for optimal performance with * minimal memory footprint and non-blocking operations. * * @fileoverview Advanced persona caching with performance monitoring and management */ import { EventEmitter } from "events"; import { watch } from "fs"; import { dirname } from "path"; /** * Cache eviction reasons for monitoring */ export var EvictionReason; (function (EvictionReason) { EvictionReason["TTL_EXPIRED"] = "ttl_expired"; EvictionReason["LRU_EVICTION"] = "lru_eviction"; EvictionReason["MEMORY_LIMIT"] = "memory_limit"; EvictionReason["FILE_CHANGED"] = "file_changed"; EvictionReason["MANUAL_CLEAR"] = "manual_clear"; })(EvictionReason || (EvictionReason = {})); /** * Cache events for monitoring and integration */ export var CacheEvents; (function (CacheEvents) { CacheEvents["CACHE_HIT"] = "cache:hit"; CacheEvents["CACHE_MISS"] = "cache:miss"; CacheEvents["CACHE_SET"] = "cache:set"; CacheEvents["CACHE_EVICTED"] = "cache:evicted"; CacheEvents["CACHE_CLEARED"] = "cache:cleared"; CacheEvents["FILE_CHANGED"] = "cache:file_changed"; CacheEvents["MEMORY_WARNING"] = "cache:memory_warning"; })(CacheEvents || (CacheEvents = {})); /** * High-performance PersonaCache with comprehensive features * * Features: * - TTL-based expiration with configurable defaults * - LRU eviction for memory management * - File system monitoring for auto-invalidation * - Memory usage tracking and limits * - Performance metrics and statistics * - Thread-safe operations * - Event emission for monitoring */ export class PersonaCache extends EventEmitter { config; cache = new Map(); accessOrder = []; stats; metrics; totalMemoryUsage = 0; operationCounter = 0; lookupTimes = []; // Cleanup intervals ttlCleanupInterval; metricsUpdateInterval; constructor(config = {}) { super(); // Apply defaults this.config = { ttl: config.ttl ?? 5 * 60 * 1000, // 5 minutes maxSize: config.maxSize ?? 100, enableStats: config.enableStats ?? true, }; // Initialize statistics this.stats = { hits: 0, misses: 0, size: 0, hitRate: 0, memoryUsage: 0, }; // Initialize metrics this.metrics = { operations: 0, averageLookupTime: 0, hitRatioHistory: [], memoryUsageHistory: [], evictionCounts: { [EvictionReason.TTL_EXPIRED]: 0, [EvictionReason.LRU_EVICTION]: 0, [EvictionReason.MEMORY_LIMIT]: 0, [EvictionReason.FILE_CHANGED]: 0, [EvictionReason.MANUAL_CLEAR]: 0, }, }; this.startBackgroundTasks(); } /** * Get a cached persona with performance tracking */ get(name, sourcePath) { const startTime = process.hrtime.bigint(); this.operationCounter++; try { const key = this.createCacheKey(name, sourcePath); const keyString = this.serializeCacheKey(key); const entry = this.cache.get(keyString); if (!entry) { this.recordCacheMiss(); return null; } // Check TTL expiration const now = Date.now(); if (now > entry.expiresAt) { this.evictEntry(keyString, EvictionReason.TTL_EXPIRED); this.recordCacheMiss(); return null; } // Update access tracking for LRU entry.lastAccessedAt = now; entry.accessCount++; this.updateAccessOrder(keyString); this.recordCacheHit(); this.emit(CacheEvents.CACHE_HIT, { name, sourcePath, persona: entry.persona, }); return entry.persona; } finally { // Record lookup time const endTime = process.hrtime.bigint(); const durationMicros = Number(endTime - startTime) / 1000; this.lookupTimes.push(durationMicros); // Keep only recent lookup times for rolling average if (this.lookupTimes.length > 1000) { this.lookupTimes = this.lookupTimes.slice(-500); } } } /** * Set a persona in the cache with file system monitoring */ set(persona) { const key = this.createCacheKey(persona.config.name, persona.sourcePath); const keyString = this.serializeCacheKey(key); const now = Date.now(); // Check if we need to evict before adding this.enforceMemoryLimits(); this.enforceSizeLimit(); // Remove existing entry if present if (this.cache.has(keyString)) { this.evictEntry(keyString, EvictionReason.MANUAL_CLEAR); } // Estimate memory usage const memorySize = this.estimatePersonaMemoryUsage(persona); // Create cache entry const entry = { persona, createdAt: now, lastAccessedAt: now, expiresAt: now + this.config.ttl, accessCount: 1, memorySize, watchers: this.setupFileWatchers(persona, keyString), }; // Add to cache this.cache.set(keyString, entry); this.accessOrder.push(keyString); this.totalMemoryUsage += memorySize; this.updateStats(); this.emit(CacheEvents.CACHE_SET, { name: persona.config.name, sourcePath: persona.sourcePath, }); } /** * Check if a persona is cached */ has(name, sourcePath) { const key = this.createCacheKey(name, sourcePath); const keyString = this.serializeCacheKey(key); const entry = this.cache.get(keyString); if (!entry) { return false; } // Check TTL const now = Date.now(); if (now > entry.expiresAt) { this.evictEntry(keyString, EvictionReason.TTL_EXPIRED); return false; } return true; } /** * Remove a specific persona from cache */ delete(name, sourcePath) { const key = this.createCacheKey(name, sourcePath); const keyString = this.serializeCacheKey(key); if (this.cache.has(keyString)) { this.evictEntry(keyString, EvictionReason.MANUAL_CLEAR); return true; } return false; } /** * Clear all cached personas */ clear() { const keysToEvict = Array.from(this.cache.keys()); for (const key of keysToEvict) { this.evictEntry(key, EvictionReason.MANUAL_CLEAR); } this.emit(CacheEvents.CACHE_CLEARED, { count: keysToEvict.length }); } /** * Get current cache statistics */ getStats() { return { ...this.stats }; } /** * Get detailed cache metrics */ getMetrics() { return { ...this.metrics, averageLookupTime: this.calculateAverageLookupTime(), }; } /** * Get all cached persona names */ getCachedPersonaNames() { const names = new Set(); this.cache.forEach((_, keyString) => { const key = this.deserializeCacheKey(keyString); names.add(key.name); }); return Array.from(names); } /** * Invalidate cache entries for a specific source path */ invalidateByPath(sourcePath) { let invalidatedCount = 0; const keysToInvalidate = []; this.cache.forEach((entry, keyString) => { if (entry.persona.sourcePath === sourcePath) { keysToInvalidate.push(keyString); } }); for (const key of keysToInvalidate) { this.evictEntry(key, EvictionReason.FILE_CHANGED); invalidatedCount++; } return invalidatedCount; } /** * Refresh TTL for a cached persona */ refreshTTL(name, sourcePath, customTTL) { const key = this.createCacheKey(name, sourcePath); const keyString = this.serializeCacheKey(key); const entry = this.cache.get(keyString); if (!entry) { return false; } const ttl = customTTL ?? this.config.ttl; entry.expiresAt = Date.now() + ttl; entry.lastAccessedAt = Date.now(); this.updateAccessOrder(keyString); return true; } /** * Manually trigger cleanup of expired entries */ cleanup() { this.cleanupExpiredEntries(); } /** * Cleanup and destroy cache */ destroy() { this.clear(); this.stopBackgroundTasks(); this.removeAllListeners(); } /** * Create cache key for a persona */ createCacheKey(name, sourcePath) { // Create a simple hash of the source path for uniqueness const pathHash = sourcePath ? this.hashString(sourcePath) : "default"; return { name, pathHash, }; } /** * Serialize cache key to string */ serializeCacheKey(key) { return `${key.name}:${key.pathHash}`; } /** * Deserialize cache key from string */ deserializeCacheKey(keyString) { const [name, pathHash] = keyString.split(":", 2); return { name, pathHash }; } /** * Simple string hashing function */ hashString(str) { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = (hash << 5) - hash + char; hash = hash & hash; // Convert to 32-bit integer } return Math.abs(hash).toString(36); } /** * Setup file system watchers for auto-invalidation */ setupFileWatchers(persona, cacheKey) { const watchers = []; try { // Watch the main config file if (persona.assets.configFile) { const configWatcher = watch(persona.assets.configFile, { persistent: false, }); configWatcher.on("change", () => this.handleFileChange(cacheKey, persona.assets.configFile)); watchers.push(configWatcher); } // Watch MCP config file if present if (persona.assets.mcpConfigFile) { const mcpWatcher = watch(persona.assets.mcpConfigFile, { persistent: false, }); mcpWatcher.on("change", () => this.handleFileChange(cacheKey, persona.assets.mcpConfigFile)); watchers.push(mcpWatcher); } // Watch the persona directory for structural changes const dirWatcher = watch(dirname(persona.assets.configFile), { persistent: false, }); dirWatcher.on("change", () => this.handleFileChange(cacheKey, persona.sourcePath)); watchers.push(dirWatcher); } catch (error) { // File watching is best-effort, don't fail the cache operation console.warn(`Failed to setup file watchers for persona "${persona.config.name}":`, error); } return watchers; } /** * Handle file system changes */ handleFileChange(cacheKey, filePath) { this.emit(CacheEvents.FILE_CHANGED, { cacheKey, filePath }); this.evictEntry(cacheKey, EvictionReason.FILE_CHANGED); } /** * Evict a cache entry with cleanup */ evictEntry(keyString, reason) { const entry = this.cache.get(keyString); if (!entry) { return; } // Cleanup file watchers if (entry.watchers) { for (const watcher of entry.watchers) { try { watcher.close(); } catch (error) { // Ignore cleanup errors } } } // Remove from cache and tracking this.cache.delete(keyString); this.totalMemoryUsage -= entry.memorySize; const orderIndex = this.accessOrder.indexOf(keyString); if (orderIndex >= 0) { this.accessOrder.splice(orderIndex, 1); } // Update metrics this.metrics.evictionCounts[reason]++; this.updateStats(); this.emit(CacheEvents.CACHE_EVICTED, { name: entry.persona.config.name, sourcePath: entry.persona.sourcePath, reason, }); } /** * Update access order for LRU */ updateAccessOrder(keyString) { const index = this.accessOrder.indexOf(keyString); if (index >= 0) { // Move to end (most recently used) this.accessOrder.splice(index, 1); this.accessOrder.push(keyString); } } /** * Enforce memory limits with LRU eviction */ enforceMemoryLimits() { // Simple memory pressure check (could be made more sophisticated) const memoryLimitBytes = 100 * 1024 * 1024; // 100MB default limit while (this.totalMemoryUsage > memoryLimitBytes && this.accessOrder.length > 0) { const lruKey = this.accessOrder[0]; this.evictEntry(lruKey, EvictionReason.MEMORY_LIMIT); } // Emit warning if memory usage is still high if (this.totalMemoryUsage > memoryLimitBytes * 0.8) { this.emit(CacheEvents.MEMORY_WARNING, { currentUsage: this.totalMemoryUsage, limit: memoryLimitBytes, }); } } /** * Enforce maximum cache size with LRU eviction */ enforceSizeLimit() { while (this.cache.size >= this.config.maxSize && this.accessOrder.length > 0) { const lruKey = this.accessOrder[0]; this.evictEntry(lruKey, EvictionReason.LRU_EVICTION); } } /** * Estimate memory usage of a persona */ estimatePersonaMemoryUsage(persona) { // Rough estimation based on JSON serialization try { const jsonSize = JSON.stringify(persona).length * 2; // UTF-16 characters const overhead = 200; // Object overhead return jsonSize + overhead; } catch { // Fallback to a reasonable estimate return 10 * 1024; // 10KB per persona } } /** * Record cache hit with statistics update */ recordCacheHit() { this.stats.hits++; this.updateHitRate(); } /** * Record cache miss with statistics update */ recordCacheMiss() { this.stats.misses++; this.updateHitRate(); } /** * Update hit rate calculation */ updateHitRate() { const total = this.stats.hits + this.stats.misses; this.stats.hitRate = total > 0 ? this.stats.hits / total : 0; } /** * Update cache statistics */ updateStats() { this.stats.size = this.cache.size; this.stats.memoryUsage = this.totalMemoryUsage; this.metrics.operations = this.operationCounter; } /** * Calculate average lookup time */ calculateAverageLookupTime() { if (this.lookupTimes.length === 0) { return 0; } const sum = this.lookupTimes.reduce((a, b) => a + b, 0); return sum / this.lookupTimes.length; } /** * Start background cleanup tasks */ startBackgroundTasks() { // TTL cleanup every 30 seconds this.ttlCleanupInterval = setInterval(() => { this.cleanupExpiredEntries(); }, 30000); this.ttlCleanupInterval?.unref(); // Allow CLI exit (see docs/bugs/process-exit-unref.md) // Metrics update every 60 seconds this.metricsUpdateInterval = setInterval(() => { this.updateMetricsHistory(); }, 60000); this.metricsUpdateInterval?.unref(); // Allow CLI exit (see docs/bugs/process-exit-unref.md) } /** * Stop background cleanup tasks */ stopBackgroundTasks() { if (this.ttlCleanupInterval) { clearInterval(this.ttlCleanupInterval); this.ttlCleanupInterval = undefined; } if (this.metricsUpdateInterval) { clearInterval(this.metricsUpdateInterval); this.metricsUpdateInterval = undefined; } } /** * Cleanup expired cache entries */ cleanupExpiredEntries() { const now = Date.now(); const expiredKeys = []; this.cache.forEach((entry, keyString) => { if (now > entry.expiresAt) { expiredKeys.push(keyString); } }); for (const key of expiredKeys) { this.evictEntry(key, EvictionReason.TTL_EXPIRED); } } /** * Update metrics history for trending */ updateMetricsHistory() { // Keep last 60 data points (1 hour with 1-minute intervals) const maxHistoryLength = 60; // Update hit ratio history this.metrics.hitRatioHistory.push(this.stats.hitRate); if (this.metrics.hitRatioHistory.length > maxHistoryLength) { this.metrics.hitRatioHistory = this.metrics.hitRatioHistory.slice(-maxHistoryLength); } // Update memory usage history this.metrics.memoryUsageHistory.push(this.totalMemoryUsage); if (this.metrics.memoryUsageHistory.length > maxHistoryLength) { this.metrics.memoryUsageHistory = this.metrics.memoryUsageHistory.slice(-maxHistoryLength); } } } /** * Create a persona cache with default configuration */ export function createPersonaCache(config) { return new PersonaCache(config); } /** * Singleton cache instance for application-wide use */ export const defaultPersonaCache = new PersonaCache(); /** * Cache factory for testing and multiple cache instances */ export class PersonaCacheFactory { static instances = new Map(); /** * Get or create a named cache instance */ static getInstance(name, config) { if (!this.instances.has(name)) { this.instances.set(name, new PersonaCache(config)); } return this.instances.get(name); } /** * Destroy a named cache instance */ static destroyInstance(name) { const instance = this.instances.get(name); if (instance) { instance.destroy(); this.instances.delete(name); return true; } return false; } /** * Destroy all cache instances */ static destroyAll() { this.instances.forEach((instance, name) => { instance.destroy(); }); this.instances.clear(); } /** * Get all active cache instance names */ static getActiveInstances() { return Array.from(this.instances.keys()); } } //# sourceMappingURL=cache.js.map