MCP GitHub Issue Server
by sammcj
import { Logger } from '../../../logging/index.js';
import { EventManager } from '../../../events/event-manager.js';
import { EventTypes } from '../../../types/events.js';
import { CacheMetrics } from './cache-metrics.js';
import { CacheOptions, CacheEntry } from '../../../types/cache.js';
export class CacheManager {
private readonly cache: Map<string, CacheEntry<any>>;
private readonly logger: Logger;
private readonly metrics: CacheMetrics;
private readonly eventManager: EventManager;
private cleanupInterval?: NodeJS.Timeout;
private metricsInterval?: NodeJS.Timeout;
private readonly options: Required<CacheOptions>;
private static instance: CacheManager | null = null;
private constructor(options: CacheOptions = {}) {
this.cache = new Map();
this.logger = Logger.getInstance().child({ component: 'CacheManager' });
this.metrics = new CacheMetrics();
this.eventManager = EventManager.getInstance();
// Set default options
this.options = {
maxSize: options.maxSize || 500, // Reduced default size for VSCode
ttl: options.ttl || 60 * 1000, // 1 minute default TTL
cleanupInterval: options.cleanupInterval || 15 * 1000, // 15 seconds cleanup
baseTTL: options.baseTTL || 30 * 1000, // 30 seconds base TTL
maxTTL: options.maxTTL || 2 * 60 * 1000, // 2 minutes max TTL
};
this.startCleanupInterval();
this.startMetricsInterval();
}
static getInstance(options?: CacheOptions): CacheManager {
if (!CacheManager.instance) {
CacheManager.instance = new CacheManager(options);
}
return CacheManager.instance;
}
static resetInstance(): void {
if (CacheManager.instance) {
CacheManager.instance.stop();
CacheManager.instance = null;
}
}
async get<T>(key: string): Promise<T | undefined> {
const entry = this.cache.get(key);
if (!entry) {
this.metrics.recordMiss();
return undefined;
}
// Check if entry has expired
if (Date.now() > entry.expires) {
this.cache.delete(key);
this.metrics.recordMiss();
this.updateMetrics();
return undefined;
}
// Update last accessed time and extend TTL if needed
const now = Date.now();
entry.lastAccessed = now;
// Extend TTL if more than half has elapsed
const elapsed = now - (entry.expires - this.options.ttl);
if (elapsed > this.options.ttl / 2) {
const newTTL = Math.min(this.options.ttl * 1.5, this.options.maxTTL);
entry.expires = now + newTTL;
}
this.metrics.recordHit();
return entry.value;
}
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
// Check cache size and cleanup if needed
if (this.cache.size >= this.options.maxSize) {
await this.cleanup(); // Try cleanup first
if (this.cache.size >= this.options.maxSize) {
await this.evictLeastRecentlyUsed();
}
}
const expires = Date.now() + (ttl || this.options.ttl);
this.cache.set(key, {
value,
expires,
lastAccessed: Date.now(),
size: this.estimateSize(value),
});
this.updateMetrics();
}
async invalidate(pattern?: string): Promise<void> {
const startTime = Date.now();
let removedCount = 0;
if (pattern) {
const regex = new RegExp(pattern);
for (const key of this.cache.keys()) {
if (regex.test(key)) {
this.cache.delete(key);
removedCount++;
}
}
} else {
removedCount = this.cache.size;
this.cache.clear();
}
this.metrics.recordInvalidation();
this.updateMetrics();
this.eventManager.emit({
type: EventTypes.CACHE_INVALIDATED,
timestamp: Date.now(),
batchId: `cache_invalidate_${Date.now()}`,
metadata: {
pattern,
entriesRemoved: removedCount,
entriesRemaining: this.cache.size,
duration: Date.now() - startTime,
},
});
}
async reduce(percentage: number = 0.5): Promise<void> {
const startSize = this.cache.size;
const targetSize = Math.floor(startSize * (1 - percentage));
await this.evictEntries(startSize - targetSize);
const endSize = this.cache.size;
this.updateMetrics();
this.logger.info('Cache reduced', {
startSize,
endSize,
reduction: startSize - endSize,
targetReduction: startSize - targetSize,
});
}
async delete(key: string): Promise<void> {
const deleted = this.cache.delete(key);
if (deleted) {
this.updateMetrics();
}
}
async clear(): Promise<void> {
const startSize = this.cache.size;
this.cache.clear();
this.metrics.recordClear();
this.updateMetrics();
this.eventManager.emit({
type: EventTypes.CACHE_CLEARED,
timestamp: Date.now(),
batchId: `cache_clear_${Date.now()}`,
metadata: {
entriesCleared: startSize,
reason: 'manual_clear',
},
});
}
getMetrics(): Record<string, unknown> {
return {
...this.metrics.getMetrics(),
currentSize: this.cache.size,
maxSize: this.options.maxSize,
usage: Math.round((this.cache.size / this.options.maxSize) * 100),
};
}
private startCleanupInterval(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
this.cleanupInterval = setInterval(() => {
this.cleanup().catch(error => {
this.logger.error('Cache cleanup failed', { error });
});
}, this.options.cleanupInterval);
}
private startMetricsInterval(): void {
if (this.metricsInterval) {
clearInterval(this.metricsInterval);
}
this.metricsInterval = setInterval(() => {
this.checkCacheSize();
}, 30000); // Every 30 seconds
}
private checkCacheSize(): void {
const currentSize = this.cache.size;
const maxSize = this.options.maxSize;
const memoryUsage = process.memoryUsage();
const heapUsed = memoryUsage.heapUsed / memoryUsage.heapTotal;
// Check both cache size and memory pressure
const isHighCacheUsage = currentSize > maxSize * 0.6; // Lowered threshold
const isHighMemoryPressure = heapUsed > 0.7;
if (isHighCacheUsage || isHighMemoryPressure) {
const reductionRatio = this.calculateReductionRatio(heapUsed, currentSize / maxSize);
this.logger.warn('Resource pressure detected', {
cacheUsage: {
currentSize,
maxSize,
usage: `${Math.round((currentSize / maxSize) * 100)}%`,
},
memoryUsage: {
heapUsed: `${Math.round(heapUsed * 100)}%`,
rss: `${Math.round(memoryUsage.rss / 1024 / 1024)}MB`,
heapTotal: `${Math.round(memoryUsage.heapTotal / 1024 / 1024)}MB`,
},
action: {
type: 'cache_reduction',
ratio: reductionRatio,
trigger: isHighMemoryPressure ? 'memory_pressure' : 'cache_usage',
},
});
// Immediate cleanup followed by reduction if needed
this.cleanup()
.then(() => {
if (this.cache.size > maxSize * 0.5) {
return this.reduce(reductionRatio);
}
return Promise.resolve();
})
.catch(error => {
this.logger.error('Failed to manage cache size', { error });
return Promise.reject(error);
});
}
}
/**
* Calculate cache reduction ratio based on pressure metrics
*/
private calculateReductionRatio(heapUsage: number, cacheUsage: number): number {
// Base reduction on the more severe pressure metric
const memoryPressure = Math.max(0, (heapUsage - 0.7) / 0.3); // Scale 0-1 above 70%
const cachePressure = Math.max(0, (cacheUsage - 0.6) / 0.4); // Scale 0-1 above 60%
const pressure = Math.max(memoryPressure, cachePressure);
// Progressive reduction: more pressure = more aggressive reduction
if (pressure > 0.8) return 0.6; // Critical: 60% reduction
if (pressure > 0.5) return 0.4; // High: 40% reduction
if (pressure > 0.3) return 0.25; // Moderate: 25% reduction
return 0.15; // Low: 15% reduction
}
private async cleanup(): Promise<void> {
const now = Date.now();
let removed = 0;
let totalSize = 0;
const totalMemoryBefore = process.memoryUsage().heapUsed;
// Enhanced two-phase cleanup with priority
const toRemove: Array<{ key: string; priority: number }> = [];
// Mark phase with priority calculation
for (const [key, entry] of this.cache.entries()) {
const age = now - entry.lastAccessed;
const timeToExpire = entry.expires - now;
const size = entry.size || 0;
totalSize += size;
// Calculate removal priority
let priority = 0;
if (now > entry.expires) {
priority = 1; // Expired entries highest priority
} else {
// Priority based on age, size, and time to expire
const ageFactor = age / this.options.ttl;
const sizeFactor = size / (totalSize / this.cache.size); // Relative to average size
const expireFactor = 1 - timeToExpire / this.options.ttl;
priority = ageFactor * 0.4 + sizeFactor * 0.3 + expireFactor * 0.3;
}
toRemove.push({ key, priority });
}
// Sort by priority and remove top candidates
toRemove.sort((a, b) => b.priority - a.priority);
// Remove entries with high priority or if memory pressure is high
const memoryPressure = process.memoryUsage().heapUsed / process.memoryUsage().heapTotal;
const removalThreshold = memoryPressure > 0.8 ? 0.3 : 0.7; // More aggressive under pressure
for (const { key, priority } of toRemove) {
if (priority > removalThreshold) {
this.cache.delete(key);
removed++;
}
}
if (removed > 0) {
const totalMemoryAfter = process.memoryUsage().heapUsed;
const memorySaved = Math.max(0, totalMemoryBefore - totalMemoryAfter);
this.updateMetrics();
this.logger.debug('Cache cleanup completed', {
entriesRemoved: removed,
remainingEntries: this.cache.size,
totalSize: `${Math.round(totalSize / 1024)}KB`,
memorySaved: `${Math.round(memorySaved / 1024)}KB`,
memoryPressure: `${Math.round(memoryPressure * 100)}%`,
});
// Emit cleanup event
this.eventManager.emit({
type: EventTypes.CACHE_CLEANED,
timestamp: now,
batchId: `cache_cleanup_${now}`,
metadata: {
entriesRemoved: removed,
memorySaved,
trigger: memoryPressure > 0.8 ? 'high_memory_pressure' : 'routine',
},
});
}
}
private async evictLeastRecentlyUsed(): Promise<void> {
let oldest: { key: string; lastAccessed: number } | null = null;
for (const [key, entry] of this.cache.entries()) {
if (!oldest || entry.lastAccessed < oldest.lastAccessed) {
oldest = { key, lastAccessed: entry.lastAccessed };
}
}
if (oldest) {
this.cache.delete(oldest.key);
this.updateMetrics();
this.logger.debug('Evicted LRU entry', { key: oldest.key });
}
}
private async evictEntries(count: number): Promise<void> {
// Sort entries by last accessed time and size
const entries = Array.from(this.cache.entries()).sort(([, a], [, b]) => {
// Prioritize removing larger, older entries
const ageDiff = a.lastAccessed - b.lastAccessed;
const sizeDiff = (b.size || 0) - (a.size || 0);
return ageDiff + sizeDiff;
});
// Remove entries
let removed = 0;
for (let i = 0; i < Math.min(count, entries.length); i++) {
this.cache.delete(entries[i][0]);
removed++;
}
if (removed > 0) {
this.updateMetrics();
this.logger.debug('Evicted entries', { count: removed });
}
}
private estimateSize(value: any): number {
try {
// More accurate size estimation for VSCode environment
let size = 0;
if (typeof value === 'string') {
size = value.length * 2; // UTF-16 characters
} else if (typeof value === 'number') {
size = 8; // 64-bit number
} else if (typeof value === 'boolean') {
size = 4;
} else if (value === null || value === undefined) {
size = 0;
} else if (Array.isArray(value)) {
size = value.reduce((acc, item) => acc + this.estimateSize(item), 0);
} else if (typeof value === 'object') {
size = Object.entries(value).reduce(
(acc, [key, val]) => acc + key.length * 2 + this.estimateSize(val),
0
);
}
// Add overhead for object structure
return size + 32; // Base object overhead
} catch {
return 2048; // Conservative 2KB estimate if calculation fails
}
}
private updateMetrics(): void {
this.metrics.updateSize(this.cache.size);
}
stop(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = undefined;
}
if (this.metricsInterval) {
clearInterval(this.metricsInterval);
this.metricsInterval = undefined;
}
this.cache.clear();
this.metrics.recordClear();
this.updateMetrics();
this.logger.info('Cache manager stopped', {
finalMetrics: this.getMetrics(),
});
}
}