@toolprint/hypertool-mcp
Version:
HyperTool MCP proxy server for routing requests between clients and multiple underlying MCP servers
609 lines • 19.5 kB
JavaScript
/**
* 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