261 lines
7.3 KiB
TypeScript
261 lines
7.3 KiB
TypeScript
import { createLogger } from '@stock-bot/logger';
|
|
import { CacheProvider, CacheOptions, CacheStats } from '../types';
|
|
import { RedisCache } from './redis-cache';
|
|
import { MemoryCache } from './memory-cache';
|
|
|
|
/**
|
|
* Hybrid cache provider that uses memory as L1 cache and Redis as L2 cache
|
|
* Provides the best of both worlds: fast memory access and persistent Redis storage
|
|
*/
|
|
export class HybridCache implements CacheProvider {
|
|
private memoryCache: MemoryCache;
|
|
private redisCache: RedisCache;
|
|
private logger = createLogger('hybrid-cache');
|
|
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.enableMetrics = options.enableMetrics ?? true;
|
|
|
|
// Create L1 (memory) cache with shorter TTL
|
|
this.memoryCache = new MemoryCache({
|
|
...options,
|
|
ttl: options.memoryTTL ?? 300, // 5 minutes for memory
|
|
maxMemoryItems: options.maxMemoryItems ?? 1000,
|
|
enableMetrics: false // We'll handle metrics at hybrid level
|
|
});
|
|
|
|
// Create L2 (Redis) cache with longer TTL
|
|
this.redisCache = new RedisCache({
|
|
...options,
|
|
enableMetrics: false // We'll handle metrics at hybrid level
|
|
});
|
|
|
|
this.logger.info('Hybrid cache initialized', {
|
|
memoryTTL: options.memoryTTL ?? 300,
|
|
redisTTL: options.ttl ?? 3600,
|
|
maxMemoryItems: options.maxMemoryItems ?? 1000
|
|
});
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
async get<T>(key: string): Promise<T | null> {
|
|
try {
|
|
// Try L1 cache first (memory)
|
|
const memoryValue = await this.memoryCache.get<T>(key);
|
|
if (memoryValue !== null) {
|
|
this.updateStats(true);
|
|
this.logger.debug('L1 cache hit', { key, hitRate: this.stats.hitRate });
|
|
return memoryValue;
|
|
}
|
|
|
|
// Try L2 cache (Redis)
|
|
const redisValue = await this.redisCache.get<T>(key);
|
|
if (redisValue !== null) {
|
|
// Populate L1 cache for next access
|
|
await this.memoryCache.set(key, redisValue);
|
|
this.updateStats(true);
|
|
this.logger.debug('L2 cache hit, populating L1', { key, hitRate: this.stats.hitRate });
|
|
return redisValue;
|
|
}
|
|
|
|
// Complete miss
|
|
this.updateStats(false);
|
|
this.logger.debug('Cache miss (both L1 and L2)', { key });
|
|
return null;
|
|
|
|
} catch (error) {
|
|
this.updateStats(false, true);
|
|
this.logger.error('Hybrid 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 {
|
|
// Set in both caches
|
|
const memoryPromise = this.memoryCache.set(key, value, Math.min(ttl ?? 300, 300));
|
|
const redisPromise = this.redisCache.set(key, value, ttl);
|
|
|
|
await Promise.allSettled([memoryPromise, redisPromise]);
|
|
this.logger.debug('Cache set (both L1 and L2)', { key, ttl });
|
|
|
|
} catch (error) {
|
|
this.updateStats(false, true);
|
|
this.logger.error('Hybrid cache set error', {
|
|
key,
|
|
error: error instanceof Error ? error.message : String(error)
|
|
});
|
|
}
|
|
}
|
|
|
|
async del(key: string): Promise<void> {
|
|
try {
|
|
// Delete from both caches
|
|
const memoryPromise = this.memoryCache.del(key);
|
|
const redisPromise = this.redisCache.del(key);
|
|
|
|
await Promise.allSettled([memoryPromise, redisPromise]);
|
|
this.logger.debug('Cache delete (both L1 and L2)', { key });
|
|
|
|
} catch (error) {
|
|
this.updateStats(false, true);
|
|
this.logger.error('Hybrid cache delete error', {
|
|
key,
|
|
error: error instanceof Error ? error.message : String(error)
|
|
});
|
|
}
|
|
}
|
|
|
|
async exists(key: string): Promise<boolean> {
|
|
try {
|
|
// Check memory first, then Redis
|
|
const memoryExists = await this.memoryCache.exists(key);
|
|
if (memoryExists) return true;
|
|
|
|
return await this.redisCache.exists(key);
|
|
|
|
} catch (error) {
|
|
this.updateStats(false, true);
|
|
this.logger.error('Hybrid cache exists error', {
|
|
key,
|
|
error: error instanceof Error ? error.message : String(error)
|
|
});
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async clear(): Promise<void> {
|
|
try {
|
|
// Clear both caches
|
|
const memoryPromise = this.memoryCache.clear();
|
|
const redisPromise = this.redisCache.clear();
|
|
|
|
await Promise.allSettled([memoryPromise, redisPromise]);
|
|
this.logger.info('Cache cleared (both L1 and L2)');
|
|
|
|
} catch (error) {
|
|
this.updateStats(false, true);
|
|
this.logger.error('Hybrid cache clear error', {
|
|
error: error instanceof Error ? error.message : String(error)
|
|
});
|
|
}
|
|
}
|
|
|
|
async health(): Promise<boolean> {
|
|
try {
|
|
const memoryHealthy = await this.memoryCache.health();
|
|
const redisHealthy = await this.redisCache.health();
|
|
|
|
// Hybrid cache is healthy if at least one cache is working
|
|
const isHealthy = memoryHealthy || redisHealthy;
|
|
|
|
this.logger.debug('Hybrid cache health check', {
|
|
memory: memoryHealthy,
|
|
redis: redisHealthy,
|
|
overall: isHealthy
|
|
});
|
|
|
|
return isHealthy;
|
|
} catch (error) {
|
|
this.logger.error('Hybrid cache health check failed', { error });
|
|
return false;
|
|
}
|
|
}
|
|
|
|
getStats(): CacheStats {
|
|
return {
|
|
...this.stats,
|
|
uptime: Date.now() - this.startTime
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get detailed stats for both cache layers
|
|
*/
|
|
getDetailedStats() {
|
|
return {
|
|
hybrid: this.getStats(),
|
|
memory: this.memoryCache.getStats(),
|
|
redis: this.redisCache.getStats()
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Warm up the memory cache with frequently accessed keys from Redis
|
|
*/
|
|
async warmupMemoryCache(keys: string[]): Promise<void> {
|
|
this.logger.info('Starting memory cache warmup', { keyCount: keys.length });
|
|
|
|
let warmed = 0;
|
|
for (const key of keys) {
|
|
try {
|
|
const value = await this.redisCache.get(key);
|
|
if (value !== null) {
|
|
await this.memoryCache.set(key, value);
|
|
warmed++;
|
|
}
|
|
} catch (error) {
|
|
this.logger.warn('Failed to warm up key', { key, error });
|
|
}
|
|
}
|
|
|
|
this.logger.info('Memory cache warmup completed', {
|
|
requested: keys.length,
|
|
warmed
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Sync memory cache with Redis for specific keys
|
|
*/
|
|
async syncCaches(keys: string[]): Promise<void> {
|
|
for (const key of keys) {
|
|
try {
|
|
const redisValue = await this.redisCache.get(key);
|
|
if (redisValue !== null) {
|
|
await this.memoryCache.set(key, redisValue);
|
|
} else {
|
|
await this.memoryCache.del(key);
|
|
}
|
|
} catch (error) {
|
|
this.logger.warn('Failed to sync key', { key, error });
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Close connections for both caches
|
|
*/
|
|
async disconnect(): Promise<void> {
|
|
await this.redisCache.disconnect();
|
|
this.logger.info('Hybrid cache disconnected');
|
|
}
|
|
}
|