import Redis from 'ioredis'; import { getLogger } from '@stock-bot/logger'; import { RedisConnectionManager } from './connection-manager'; import { CacheOptions, CacheProvider, CacheStats } from './types'; /** * Simplified Redis-based cache provider using connection manager */ export class RedisCache implements CacheProvider { private redis: Redis; private logger = getLogger('redis-cache'); private defaultTTL: number; private keyPrefix: string; private enableMetrics: boolean; private isConnected = false; private startTime = Date.now(); private connectionManager: RedisConnectionManager; 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.enableMetrics = options.enableMetrics ?? true; // Get connection manager instance this.connectionManager = RedisConnectionManager.getInstance(); // Generate connection name based on cache type const baseName = options.name || this.keyPrefix .replace(':', '') .replace(/[^a-zA-Z0-9]/g, '') .toUpperCase() || 'CACHE'; // Get Redis connection (shared by default for cache) this.redis = this.connectionManager.getConnection({ name: `${baseName}-SERVICE`, singleton: options.shared ?? true, // Default to shared connection for cache }); // Only setup event handlers for non-shared connections to avoid memory leaks if (!(options.shared ?? true)) { this.setupEventHandlers(); } else { // For shared connections, just monitor the connection status without adding handlers this.isConnected = this.redis.status === 'ready'; } } private setupEventHandlers(): void { this.redis.on('connect', () => { this.logger.info('Redis cache connected'); }); this.redis.on('ready', () => { this.isConnected = true; this.logger.info('Redis cache ready'); }); this.redis.on('error', (error: any) => { this.isConnected = false; this.logger.error('Redis cache connection error', { error: error.message }); }); this.redis.on('close', () => { this.isConnected = false; this.logger.warn('Redis cache connection closed'); }); this.redis.on('reconnecting', () => { this.logger.info('Redis cache reconnecting...'); }); } 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 async safeExecute( operation: () => Promise, fallback: T, operationName: string ): Promise { try { if (!this.isReady()) { this.logger.warn(`Redis not ready for ${operationName}, using fallback`); this.updateStats(false, true); return fallback; } return await operation(); } catch (error) { this.logger.error(`Redis ${operationName} failed`, { error: error instanceof Error ? error.message : String(error), }); this.updateStats(false, true); return fallback; } } async get(key: string): Promise { return this.safeExecute( async () => { const fullKey = this.getKey(key); const value = await this.redis.get(fullKey); if (value === null) { this.updateStats(false); this.logger.debug('Cache miss', { key }); return null; } this.updateStats(true); this.logger.debug('Cache hit', { key }); try { return JSON.parse(value) as T; } catch { // If parsing fails, return as string return value as unknown as T; } }, null, 'get' ); } async set( key: string, value: T, options?: | number | { ttl?: number; preserveTTL?: boolean; onlyIfExists?: boolean; onlyIfNotExists?: boolean; getOldValue?: boolean; } ): Promise { return this.safeExecute( async () => { const fullKey = this.getKey(key); const serialized = typeof value === 'string' ? value : JSON.stringify(value); // Handle backward compatibility - if options is a number, treat as TTL const config = typeof options === 'number' ? { ttl: options } : options || {}; let oldValue: T | null = null; // Get old value if requested if (config.getOldValue) { const existingValue = await this.redis.get(fullKey); if (existingValue !== null) { try { oldValue = JSON.parse(existingValue) as T; } catch { oldValue = existingValue as unknown as T; } } } // Handle preserveTTL logic if (config.preserveTTL) { const currentTTL = await this.redis.ttl(fullKey); if (currentTTL === -2) { // Key doesn't exist if (config.onlyIfExists) { this.logger.debug('Set skipped - key does not exist and onlyIfExists is true', { key, }); return oldValue; } // Set with default or specified TTL const ttl = config.ttl ?? this.defaultTTL; await this.redis.setex(fullKey, ttl, serialized); this.logger.debug('Cache set with new TTL (key did not exist)', { key, ttl }); } else if (currentTTL === -1) { // Key exists but has no expiry - preserve the no-expiry state await this.redis.set(fullKey, serialized); this.logger.debug('Cache set preserving no-expiry', { key }); } else { // Key exists with TTL - preserve it await this.redis.setex(fullKey, currentTTL, serialized); this.logger.debug('Cache set preserving existing TTL', { key, ttl: currentTTL }); } } else { // Standard set logic with conditional operations if (config.onlyIfExists && config.onlyIfNotExists) { throw new Error('Cannot specify both onlyIfExists and onlyIfNotExists'); } if (config.onlyIfExists) { // Only set if key exists (XX flag) const ttl = config.ttl ?? this.defaultTTL; const result = await this.redis.set(fullKey, serialized, 'EX', ttl, 'XX'); if (result === null) { this.logger.debug('Set skipped - key does not exist', { key }); return oldValue; } } else if (config.onlyIfNotExists) { // Only set if key doesn't exist (NX flag) const ttl = config.ttl ?? this.defaultTTL; const result = await this.redis.set(fullKey, serialized, 'EX', ttl, 'NX'); if (result === null) { this.logger.debug('Set skipped - key already exists', { key }); return oldValue; } } else { // Standard set const ttl = config.ttl ?? this.defaultTTL; await this.redis.setex(fullKey, ttl, serialized); } this.logger.debug('Cache set', { key, ttl: config.ttl ?? this.defaultTTL }); } return oldValue; }, null, 'set' ); } async del(key: string): Promise { await this.safeExecute( async () => { const fullKey = this.getKey(key); await this.redis.del(fullKey); this.logger.debug('Cache delete', { key }); }, undefined, 'del' ); } async exists(key: string): Promise { return this.safeExecute( async () => { const fullKey = this.getKey(key); const result = await this.redis.exists(fullKey); return result === 1; }, false, 'exists' ); } async clear(): Promise { await this.safeExecute( async () => { const pattern = `${this.keyPrefix}*`; const keys = await this.redis.keys(pattern); if (keys.length > 0) { await this.redis.del(...keys); this.logger.info('Cache cleared', { keysDeleted: keys.length }); } }, undefined, 'clear' ); } async keys(pattern: string): Promise { return this.safeExecute( async () => { const fullPattern = `${this.keyPrefix}${pattern}`; const keys = await this.redis.keys(fullPattern); // Remove the prefix from returned keys to match the interface expectation return keys.map(key => key.replace(this.keyPrefix, '')); }, [], 'keys' ); } async health(): Promise { try { const pong = await this.redis.ping(); return pong === 'PONG'; } catch (error) { this.logger.error('Redis health check failed', { error: error instanceof Error ? error.message : String(error), }); return false; } } getStats(): CacheStats { return { ...this.stats, uptime: Date.now() - this.startTime, }; } async waitForReady(timeout = 5000): Promise { return new Promise((resolve, reject) => { if (this.redis.status === 'ready') { resolve(); return; } const timeoutId = setTimeout(() => { reject(new Error(`Redis connection timeout after ${timeout}ms`)); }, timeout); this.redis.once('ready', () => { clearTimeout(timeoutId); resolve(); }); this.redis.once('error', error => { clearTimeout(timeoutId); reject(error); }); }); } isReady(): boolean { // Always check the actual Redis connection status const ready = this.redis.status === 'ready'; // Update local flag if we're not using shared connection if (this.isConnected !== ready) { this.isConnected = ready; } return ready; } // Enhanced convenience methods async update(key: string, value: T): Promise { return this.set(key, value, { preserveTTL: true, getOldValue: true }); } async setIfExists(key: string, value: T, ttl?: number): Promise { const result = await this.set(key, value, { ttl, onlyIfExists: true }); return result !== null || (await this.exists(key)); } async setIfNotExists(key: string, value: T, ttl?: number): Promise { const oldValue = await this.set(key, value, { ttl, onlyIfNotExists: true, getOldValue: true }); return oldValue === null; // Returns true if key didn't exist before } async replace(key: string, value: T, ttl?: number): Promise { return this.set(key, value, { ttl, onlyIfExists: true, getOldValue: true }); } // Atomic update with transformation async updateField( key: string, updater: (current: T | null) => T, ttl?: number ): Promise { return this.safeExecute( async () => { const fullKey = this.getKey(key); // Use Lua script for atomic read-modify-write const luaScript = ` local key = KEYS[1] -- Get current value and TTL local current_value = redis.call('GET', key) local current_ttl = redis.call('TTL', key) -- Return current value for processing return {current_value, current_ttl} `; const [currentValue, currentTTL] = (await this.redis.eval(luaScript, 1, fullKey)) as [ string | null, number, ]; // Parse current value let parsed: T | null = null; if (currentValue !== null) { try { parsed = JSON.parse(currentValue) as T; } catch { parsed = currentValue as unknown as T; } } // Apply updater function const newValue = updater(parsed); // Set the new value with appropriate TTL logic if (ttl !== undefined) { // Use specified TTL await this.set(key, newValue, ttl); } else if (currentTTL === -2) { // Key didn't exist, use default TTL await this.set(key, newValue); } else { // Preserve existing TTL await this.set(key, newValue, { preserveTTL: true }); } return parsed; }, null, 'updateField' ); } }