UNPKG

bc-webclient-mcp

Version:

Model Context Protocol (MCP) server for Microsoft Dynamics 365 Business Central via WebUI protocol. Enables AI assistants to interact with BC through the web client protocol, supporting Card, List, and Document pages with full line item support and server

306 lines 9.27 kB
/** * Cache Manager for Business Central MCP Server * * Provides time-based LRU caching with: * - TTL (Time To Live) expiration * - LRU eviction when cache full * - Cache stampede protection (coalescing) * - Hit/miss statistics * - Background cleanup * * Usage: * ```typescript * const cache = new CacheManager({ maxEntries: 1000 }); * * // Check cache * const cached = cache.get<SearchResult>('search:customer:Card:10'); * if (cached) return cached; * * // Fetch and cache * const result = await fetchData(); * cache.set('search:customer:Card:10', result, 300000); // 5 min TTL * ``` */ import { logger } from '../core/logger.js'; /** * Cache manager with TTL and LRU eviction * * Features: * - Time-based expiration (TTL) * - LRU eviction when cache full * - Cache stampede protection via coalescing * - Hit/miss statistics * - Background cleanup */ export class CacheManager { maxEntries; cleanupIntervalMs; defaultTtlMs; enableCoalescing; /** Cache storage */ cache = new Map(); /** Pending operations (for stampede protection) */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Generic operations require any for type flexibility pendingOperations = new Map(); /** Cleanup interval handle */ cleanupInterval = null; /** Statistics */ stats = { totalRequests: 0, hits: 0, misses: 0, evictions: 0, expirations: 0, }; constructor(config) { this.maxEntries = config?.maxEntries ?? 1000; this.cleanupIntervalMs = config?.cleanupIntervalMs ?? 60000; // 1 minute this.defaultTtlMs = config?.defaultTtlMs ?? 300000; // 5 minutes this.enableCoalescing = config?.enableCoalescing ?? true; // Validate configuration if (this.maxEntries < 1) { throw new Error('maxEntries must be >= 1'); } // Start background cleanup this.startCleanup(); } /** * Get a value from the cache * * @param key The cache key * @returns The cached value or null if not found/expired */ get(key) { this.stats.totalRequests++; const entry = this.cache.get(key); if (!entry) { this.stats.misses++; return null; } // Check if expired if (Date.now() > entry.expiresAt) { this.stats.misses++; this.cache.delete(key); this.stats.expirations++; return null; } // Update access time (for LRU) entry.accessedAt = Date.now(); this.stats.hits++; return entry.value; } /** * Set a value in the cache * * @param key The cache key * @param value The value to cache * @param ttlMs Time to live in milliseconds (default: config.defaultTtlMs) */ set(key, value, ttlMs) { const ttl = ttlMs ?? this.defaultTtlMs; const now = Date.now(); const entry = { key, value, expiresAt: now + ttl, accessedAt: now, createdAt: now, }; // Check if we need to evict entries if (this.cache.size >= this.maxEntries && !this.cache.has(key)) { this.evictLRU(); } this.cache.set(key, entry); logger.debug(`Cache set: ${key} (TTL: ${ttl}ms, size: ${this.cache.size}/${this.maxEntries})`); } /** * Delete a value from the cache * * @param key The cache key * @returns True if the entry was deleted */ delete(key) { return this.cache.delete(key); } /** * Clear all entries matching a pattern * * @param pattern Glob-style pattern (e.g., "search:*") */ invalidate(pattern) { // Convert glob pattern to regex const regexPattern = pattern .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars .replace(/\*/g, '.*') // Convert * to .* .replace(/\?/g, '.'); // Convert ? to . const regex = new RegExp(`^${regexPattern}$`); let count = 0; for (const key of this.cache.keys()) { if (regex.test(key)) { this.cache.delete(key); count++; } } if (count > 0) { logger.info(`Invalidated ${count} cache entries matching pattern: ${pattern}`); } return count; } /** * Clear the entire cache */ clear() { const size = this.cache.size; this.cache.clear(); this.pendingOperations.clear(); logger.info(`Cache cleared (${size} entries removed)`); } /** * Get cache statistics */ getStats() { return { totalRequests: this.stats.totalRequests, hits: this.stats.hits, misses: this.stats.misses, hitRate: this.stats.totalRequests > 0 ? this.stats.hits / this.stats.totalRequests : 0, size: this.cache.size, maxEntries: this.maxEntries, evictions: this.stats.evictions, expirations: this.stats.expirations, }; } /** * Reset statistics */ resetStats() { this.stats = { totalRequests: 0, hits: 0, misses: 0, evictions: 0, expirations: 0, }; logger.debug('Cache statistics reset'); } /** * Execute an operation with cache stampede protection * * If multiple requests for the same key arrive simultaneously, * only the first one executes the operation. Others wait for * the result and receive the same value. * * @param key The cache key * @param operation The operation to execute if not cached * @param ttlMs TTL for the cached result * @returns The cached or computed value */ async getOrCompute(key, operation, ttlMs) { // Check cache first const cached = this.get(key); if (cached !== null) { return cached; } // If coalescing disabled, just execute if (!this.enableCoalescing) { const result = await operation(); this.set(key, result, ttlMs); return result; } // Check if operation already pending for this key const pending = this.pendingOperations.get(key); if (pending) { logger.debug(`Cache coalescing: waiting for pending operation ${key}`); return pending.promise; } // Create pending operation let resolve; let reject; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); this.pendingOperations.set(key, { promise, resolve: resolve, reject: reject }); try { logger.debug(`Cache miss: executing operation for ${key}`); const result = await operation(); // Cache the result this.set(key, result, ttlMs); // Resolve pending operation resolve(result); return result; } catch (error) { // Reject pending operation reject(error instanceof Error ? error : new Error(String(error))); throw error; } finally { // Clean up pending operation this.pendingOperations.delete(key); } } /** * Shutdown the cache manager * Stops background cleanup */ shutdown() { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = null; } logger.info('Cache manager shutdown'); } /** * Evict the least recently used entry * @private */ evictLRU() { let oldestKey = null; let oldestTime = Infinity; // Find the entry with the oldest access time for (const [key, entry] of this.cache.entries()) { if (entry.accessedAt < oldestTime) { oldestTime = entry.accessedAt; oldestKey = key; } } if (oldestKey) { this.cache.delete(oldestKey); this.stats.evictions++; logger.debug(`Cache LRU eviction: ${oldestKey}`); } } /** * Start background cleanup of expired entries * @private */ startCleanup() { this.cleanupInterval = setInterval(() => { this.cleanupExpired(); }, this.cleanupIntervalMs); } /** * Remove expired entries from cache * @private */ cleanupExpired() { const now = Date.now(); const expiredKeys = []; for (const [key, entry] of this.cache.entries()) { if (now > entry.expiresAt) { expiredKeys.push(key); } } for (const key of expiredKeys) { this.cache.delete(key); this.stats.expirations++; } if (expiredKeys.length > 0) { logger.debug(`Cache cleanup: removed ${expiredKeys.length} expired entries`); } } } //# sourceMappingURL=cache-manager.js.map