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
JavaScript
/**
* 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