280 lines
7.4 KiB
TypeScript
280 lines
7.4 KiB
TypeScript
import { getLogger } from '@stock-bot/logger';
|
|
import { CacheProvider, CacheOptions, CacheStats } from '../types';
|
|
|
|
interface CacheEntry<T> {
|
|
value: T;
|
|
expiry: number;
|
|
accessed: number;
|
|
}
|
|
|
|
/**
|
|
* In-memory cache provider with LRU eviction and comprehensive metrics
|
|
*/
|
|
export class MemoryCache implements CacheProvider {
|
|
private store = new Map<string, CacheEntry<any>>();
|
|
private logger = getLogger('memory-cache');
|
|
private defaultTTL: number;
|
|
private keyPrefix: string;
|
|
private maxItems: number;
|
|
private enableMetrics: boolean;
|
|
private startTime = Date.now();
|
|
|
|
private stats: CacheStats = {
|
|
hits: 0,
|
|
misses: 0,
|
|
errors: 0,
|
|
hitRate: 0,
|
|
total: 0,
|
|
uptime: 0
|
|
};
|
|
|
|
constructor(options: CacheOptions = {}) {
|
|
this.defaultTTL = options.ttl ?? 3600; // 1 hour default
|
|
this.keyPrefix = options.keyPrefix ?? 'cache:';
|
|
this.maxItems = options.maxMemoryItems ?? 1000;
|
|
this.enableMetrics = options.enableMetrics ?? true;
|
|
|
|
this.logger.info('Memory cache initialized', {
|
|
maxItems: this.maxItems,
|
|
defaultTTL: this.defaultTTL,
|
|
enableMetrics: this.enableMetrics
|
|
});
|
|
|
|
// Cleanup expired entries every 5 minutes
|
|
setInterval(() => this.cleanup(), 5 * 60 * 1000);
|
|
}
|
|
|
|
private getKey(key: string): string {
|
|
return `${this.keyPrefix}${key}`;
|
|
}
|
|
|
|
private updateStats(hit: boolean, error = false): void {
|
|
if (!this.enableMetrics) return;
|
|
|
|
if (error) {
|
|
this.stats.errors++;
|
|
} else if (hit) {
|
|
this.stats.hits++;
|
|
} else {
|
|
this.stats.misses++;
|
|
}
|
|
|
|
this.stats.total = this.stats.hits + this.stats.misses;
|
|
this.stats.hitRate = this.stats.total > 0 ? this.stats.hits / this.stats.total : 0;
|
|
this.stats.uptime = Date.now() - this.startTime;
|
|
}
|
|
|
|
private cleanup(): void {
|
|
const now = Date.now();
|
|
let cleaned = 0;
|
|
|
|
for (const [key, entry] of this.store.entries()) {
|
|
if (entry.expiry < now) {
|
|
this.store.delete(key);
|
|
cleaned++;
|
|
}
|
|
}
|
|
|
|
if (cleaned > 0) {
|
|
this.logger.debug('Cleaned expired entries', {
|
|
cleaned,
|
|
remaining: this.store.size
|
|
});
|
|
}
|
|
}
|
|
|
|
private evictLRU(): void {
|
|
if (this.store.size <= this.maxItems) return;
|
|
|
|
// Find least recently accessed item
|
|
let oldestKey = '';
|
|
let oldestAccess = Date.now();
|
|
|
|
for (const [key, entry] of this.store.entries()) {
|
|
if (entry.accessed < oldestAccess) {
|
|
oldestAccess = entry.accessed;
|
|
oldestKey = key;
|
|
}
|
|
}
|
|
|
|
if (oldestKey) {
|
|
this.store.delete(oldestKey);
|
|
this.logger.debug('Evicted LRU entry', { key: oldestKey });
|
|
}
|
|
}
|
|
|
|
async get<T>(key: string): Promise<T | null> {
|
|
try {
|
|
const fullKey = this.getKey(key);
|
|
const entry = this.store.get(fullKey);
|
|
|
|
if (!entry) {
|
|
this.updateStats(false);
|
|
this.logger.debug('Cache miss', { key });
|
|
return null;
|
|
}
|
|
|
|
const now = Date.now();
|
|
if (entry.expiry < now) {
|
|
this.store.delete(fullKey);
|
|
this.updateStats(false);
|
|
this.logger.debug('Cache miss (expired)', { key });
|
|
return null;
|
|
}
|
|
|
|
// Update access time for LRU
|
|
entry.accessed = now;
|
|
this.updateStats(true);
|
|
this.logger.debug('Cache hit', { key, hitRate: this.stats.hitRate });
|
|
|
|
return entry.value;
|
|
} catch (error) {
|
|
this.updateStats(false, true);
|
|
this.logger.error('Cache get error', {
|
|
key,
|
|
error: error instanceof Error ? error.message : String(error)
|
|
});
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
|
|
try {
|
|
const fullKey = this.getKey(key);
|
|
const now = Date.now();
|
|
const expiry = now + 1000 * (ttl ?? this.defaultTTL);
|
|
|
|
// Evict if necessary
|
|
this.evictLRU();
|
|
|
|
this.store.set(fullKey, {
|
|
value,
|
|
expiry,
|
|
accessed: now
|
|
});
|
|
|
|
this.logger.debug('Cache set', { key, ttl: ttl ?? this.defaultTTL });
|
|
} catch (error) {
|
|
this.updateStats(false, true);
|
|
this.logger.error('Cache set error', {
|
|
key,
|
|
error: error instanceof Error ? error.message : String(error)
|
|
});
|
|
}
|
|
}
|
|
|
|
async del(key: string): Promise<void> {
|
|
try {
|
|
const fullKey = this.getKey(key);
|
|
const deleted = this.store.delete(fullKey);
|
|
this.logger.debug('Cache delete', { key, deleted });
|
|
} catch (error) {
|
|
this.updateStats(false, true);
|
|
this.logger.error('Cache delete error', {
|
|
key,
|
|
error: error instanceof Error ? error.message : String(error)
|
|
});
|
|
}
|
|
}
|
|
|
|
async exists(key: string): Promise<boolean> {
|
|
try {
|
|
const fullKey = this.getKey(key);
|
|
const entry = this.store.get(fullKey);
|
|
|
|
if (!entry) return false;
|
|
|
|
// Check if expired
|
|
if (entry.expiry < Date.now()) {
|
|
this.store.delete(fullKey);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
} catch (error) {
|
|
this.updateStats(false, true);
|
|
this.logger.error('Cache exists error', {
|
|
key,
|
|
error: error instanceof Error ? error.message : String(error)
|
|
});
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async clear(): Promise<void> {
|
|
try {
|
|
const size = this.store.size;
|
|
this.store.clear();
|
|
this.logger.info('Cache cleared', { entriesDeleted: size });
|
|
} catch (error) {
|
|
this.updateStats(false, true);
|
|
this.logger.error('Cache clear error', {
|
|
error: error instanceof Error ? error.message : String(error)
|
|
});
|
|
}
|
|
}
|
|
|
|
async health(): Promise<boolean> {
|
|
try {
|
|
// Simple health check - try to set and get a test value
|
|
await this.set('__health_check__', 'ok', 1);
|
|
const result = await this.get('__health_check__');
|
|
await this.del('__health_check__');
|
|
return result === 'ok';
|
|
} catch (error) {
|
|
this.logger.error('Memory cache health check failed', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
getStats(): CacheStats {
|
|
return {
|
|
...this.stats,
|
|
uptime: Date.now() - this.startTime
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get additional memory cache specific stats
|
|
*/
|
|
getMemoryStats() {
|
|
return {
|
|
...this.getStats(),
|
|
entries: this.store.size,
|
|
maxItems: this.maxItems,
|
|
memoryUsage: this.estimateMemoryUsage()
|
|
};
|
|
}
|
|
|
|
private estimateMemoryUsage(): number {
|
|
// Rough estimation of memory usage in bytes
|
|
let bytes = 0;
|
|
for (const [key, entry] of this.store.entries()) {
|
|
bytes += key.length * 2; // UTF-16 characters
|
|
bytes += JSON.stringify(entry.value).length * 2;
|
|
bytes += 24; // Overhead for entry object
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
async waitForReady(timeout: number = 5000): Promise<void> {
|
|
// Memory cache is always ready immediately
|
|
return Promise.resolve();
|
|
}
|
|
|
|
isReady(): boolean {
|
|
// Memory cache is always ready
|
|
return true;
|
|
}
|
|
|
|
private getMemoryUsage(): number {
|
|
// Rough estimation of memory usage in bytes
|
|
let bytes = 0;
|
|
for (const [key, entry] of this.store.entries()) {
|
|
bytes += key.length * 2; // UTF-16 characters
|
|
bytes += JSON.stringify(entry.value).length * 2;
|
|
bytes += 24; // Overhead for entry object
|
|
}
|
|
return bytes;
|
|
}
|
|
}
|