import { Redis as IORedis } from 'ioredis'; import { CacheProvider, CacheStats, RedisCacheOptions } from '../interfaces'; import { serialize, deserialize } from '../utils'; /** * Redis cache provider implementation */ export class RedisCache implements CacheProvider { private client!: IORedis; private options: RedisCacheOptions; private isConnected: boolean = false; private stats: { hits: number; misses: number; }; private logger: any; /** * Creates a new RedisCache instance * @param options Redis cache options */ constructor(options: RedisCacheOptions = {}) { this.options = { ttl: 3600000, // 1 hour default keyPrefix: 'cache:', serialize: true, enableLogging: false, logLevel: 'info', handleErrors: true, maxReconnectAttempts: 10, reconnectStrategy: 'exponential', ...options, }; this.stats = { hits: 0, misses: 0, }; // Initialize client if provided, otherwise it will be done in connect() if (this.options.client) { this.client = this.options.client; this.isConnected = this.client.status === 'ready'; } // Initialize logger if enabled if (this.options.enableLogging) { try { // Try to use a logger from @unidev-hub/logger if available this.logger = require('@unidev-hub/logger').getLogger('cache:redis'); } catch (error) { // Fallback to console this.logger = { debug: console.debug, info: console.info, warn: console.warn, error: console.error, }; } } } /** * Get a prefixed key * @param key Original key * @returns Prefixed key */ private getKey(key: string): string { return `${this.options.keyPrefix}${key}`; } /** * Log a cache operation if logging is enabled * @param level Log level * @param message Message to log * @param data Additional data to log */ private log(level: 'debug' | 'info' | 'warn' | 'error', message: string, data?: any): void { if (this.options.enableLogging && this.logger) { // Only log if the level is at or above the configured level const levels = ['debug', 'info', 'warn', 'error']; const configLevelIndex = levels.indexOf(this.options.logLevel || 'info'); const logLevelIndex = levels.indexOf(level); if (logLevelIndex >= configLevelIndex) { this.logger[level](message, data); } } } /** * Get a value from the cache * @param key Cache key * @returns Promise resolving to the cached value or undefined if not found */ async get(key: string): Promise { if (!this.isConnected) { await this.connect(); } const prefixedKey = this.getKey(key); try { const value = await this.client.get(prefixedKey); if (value === null) { this.stats.misses++; this.log('debug', `Cache miss for key: ${key}`); return undefined; } this.stats.hits++; this.log('debug', `Cache hit for key: ${key}`); if (!this.options.serialize) { return value as unknown as T; } // Deserialize the value try { return deserialize(value); } catch (error) { this.log('error', `Failed to deserialize value for key: ${key}`, error); return undefined; } } catch (error) { this.log('error', `Error getting cache key: ${key}`, error); return undefined; } } /** * Set a value in the cache * @param key Cache key * @param value Value to cache * @param ttl Time-to-live in milliseconds (optional) * @returns Promise resolving to true if successful */ async set(key: string, value: T, ttl?: number): Promise { if (!this.isConnected) { await this.connect(); } const prefixedKey = this.getKey(key); const effectiveTtl = ttl || this.options.ttl; try { let valueToStore: any = value; // Serialize the value if needed if (this.options.serialize) { valueToStore = serialize(value); } // If TTL is provided, use it, otherwise use the default TTL if (effectiveTtl) { await this.client.set(prefixedKey, valueToStore, 'PX', effectiveTtl); } else { // No expiration await this.client.set(prefixedKey, valueToStore); } this.log('debug', `Cache set for key: ${key}`, { ttl: effectiveTtl }); return true; } catch (error) { this.log('error', `Failed to set cache for key: ${key}`, error); return false; } } /** * Delete a value from the cache * @param key Cache key * @returns Promise resolving to true if successful */ async delete(key: string): Promise { if (!this.isConnected) { await this.connect(); } const prefixedKey = this.getKey(key); try { const deleted = await this.client.del(prefixedKey); const success = deleted > 0; this.log('debug', `Cache delete for key: ${key}`, { deleted: success }); return success; } catch (error) { this.log('error', `Failed to delete cache for key: ${key}`, error); return false; } } /** * Check if a key exists in the cache * @param key Cache key * @returns Promise resolving to true if the key exists */ async has(key: string): Promise { if (!this.isConnected) { await this.connect(); } const prefixedKey = this.getKey(key); try { const exists = await this.client.exists(prefixedKey); const result = exists === 1; this.log('debug', `Cache check for key: ${key}`, { exists: result }); return result; } catch (error) { this.log('error', `Failed to check existence for key: ${key}`, error); return false; } } /** * Clear all values from the cache (with the same prefix) * @returns Promise resolving to true if successful */ async clear(): Promise { if (!this.isConnected) { await this.connect(); } try { // Find all keys with the prefix const pattern = `${this.options.keyPrefix}*`; const stream = this.client.scanStream({ match: pattern, count: 100, }); let totalDeleted = 0; await new Promise((resolve, reject) => { stream.on('data', async (keys: string[]) => { if (keys.length) { stream.pause(); try { const deleted = await this.client.del(...keys); totalDeleted += deleted; stream.resume(); } catch (error) { reject(error); } } }); stream.on('end', () => { resolve(); }); stream.on('error', (error) => { reject(error); }); }); this.log('info', 'Cache cleared', { keysDeleted: totalDeleted }); return true; } catch (error) { this.log('error', 'Failed to clear cache', error); return false; } } /** * Get multiple values from the cache * @param keys Array of cache keys * @returns Promise resolving to a map of key-value pairs for found items */ async getMany(keys: string[]): Promise> { if (!this.isConnected) { await this.connect(); } if (keys.length === 0) { return new Map(); } const prefixedKeys = keys.map(key => this.getKey(key)); try { const values = await this.client.mget(...prefixedKeys); const result = new Map(); for (let i = 0; i < keys.length; i++) { const key = keys[i]; const value = values[i]; if (typeof value === "string") { // Ensure value is a valid string this.stats.hits++; if (this.options.serialize) { try { if (key) { result.set(key, deserialize(value)); // Now value is always a string } else { this.log('error', `missing key for value: ${value}`); } } catch (error) { this.log('error', `Failed to deserialize value for key: ${key}`, error); } } else { if (key) { result.set(key, value as unknown as T); } else { this.log('error', `missing key for value: ${value}`); } } } else { this.stats.misses++; } } this.log('debug', `Cache getMany for keys: ${keys.join(', ')}`, { requested: keys.length, found: result.size, }); return result; } catch (error) { this.log('error', `Failed to get multiple cache entries: ${keys.join(', ')}`, error); return new Map(); } } /** * Set multiple values in the cache * @param entries Map of key-value pairs to cache * @param ttl Time-to-live in milliseconds (optional) * @returns Promise resolving to true if successful */ async setMany(entries: Map, ttl?: number): Promise { if (!this.isConnected) { await this.connect(); } if (entries.size === 0) { return true; } try { // Use pipeline for better performance const pipeline = this.client.pipeline(); const effectiveTtl = ttl || this.options.ttl; for (const [key, value] of entries.entries()) { const prefixedKey = this.getKey(key); let valueToStore: any = value; if (this.options.serialize) { valueToStore = serialize(value); } // If TTL is provided, use it, otherwise use the default TTL if (effectiveTtl) { pipeline.set(prefixedKey, valueToStore, 'PX', effectiveTtl); } else { // No expiration pipeline.set(prefixedKey, valueToStore); } } await pipeline.exec(); this.log('debug', `Cache setMany for ${entries.size} keys`, { ttl: effectiveTtl, }); return true; } catch (error) { this.log('error', 'Failed to set multiple cache entries', error); return false; } } /** * Delete multiple values from the cache * @param keys Array of cache keys to delete * @returns Promise resolving to true if successful */ async deleteMany(keys: string[]): Promise { if (!this.isConnected) { await this.connect(); } if (keys.length === 0) { return true; } const prefixedKeys = keys.map(key => this.getKey(key)); try { const deleted = await this.client.del(...prefixedKeys); this.log('debug', `Cache deleteMany for keys: ${keys.join(', ')}`, { deleted, requested: keys.length, }); return deleted > 0; } catch (error) { this.log('error', `Failed to delete multiple cache entries: ${keys.join(', ')}`, error); return false; } } /** * Get the Time-To-Live (TTL) for a cache key * @param key Cache key * @returns Promise resolving to TTL in milliseconds, or undefined if not found */ async getTtl(key: string): Promise { if (!this.isConnected) { await this.connect(); } const prefixedKey = this.getKey(key); try { // pttl returns -1 for no expiry, -2 for key not found const ttl = await this.client.pttl(prefixedKey); if (ttl === -2) { // Key does not exist return undefined; } else if (ttl === -1) { // Key exists but has no expiry return 0; } this.log('debug', `Cache getTtl for key: ${key}`, { ttl }); return ttl; } catch (error) { this.log('error', `Failed to get TTL for key: ${key}`, error); return undefined; } } /** * Update the Time-To-Live (TTL) for a cache key * @param key Cache key * @param ttl New TTL in milliseconds * @returns Promise resolving to true if successful */ async updateTtl(key: string, ttl: number): Promise { if (!this.isConnected) { await this.connect(); } const prefixedKey = this.getKey(key); try { // Use pexpire for millisecond precision const result = await this.client.pexpire(prefixedKey, ttl); const success = result === 1; this.log('debug', `Cache updateTtl for key: ${key}`, { ttl, success }); return success; } catch (error) { this.log('error', `Failed to update TTL for key: ${key}`, error); return false; } } /** * Increment a numeric value in the cache * @param key Cache key * @param increment Amount to increment (default: 1) * @returns Promise resolving to the new value */ async increment(key: string, increment: number = 1): Promise { if (!this.isConnected) { await this.connect(); } const prefixedKey = this.getKey(key); try { let newValue: number; if (increment === 1) { // Use INCR for single increment newValue = await this.client.incr(prefixedKey); } else { // Use INCRBY for custom increment newValue = await this.client.incrby(prefixedKey, increment); } this.log('debug', `Cache increment for key: ${key}`, { increment, newValue, }); return newValue; } catch (error) { this.log('error', `Failed to increment key: ${key}`, error); throw error; } } /** * Decrement a numeric value in the cache * @param key Cache key * @param decrement Amount to decrement (default: 1) * @returns Promise resolving to the new value */ async decrement(key: string, decrement: number = 1): Promise { if (!this.isConnected) { await this.connect(); } const prefixedKey = this.getKey(key); try { let newValue: number; if (decrement === 1) { // Use DECR for single decrement newValue = await this.client.decr(prefixedKey); } else { // Use DECRBY for custom decrement newValue = await this.client.decrby(prefixedKey, decrement); } this.log('debug', `Cache decrement for key: ${key}`, { decrement, newValue, }); return newValue; } catch (error) { this.log('error', `Failed to decrement key: ${key}`, error); throw error; } } /** * Connect to the Redis server * @returns Promise resolving when connected */ async connect(): Promise { if (this.isConnected) { return; } // If client is already provided, use it if (this.client) { // Wait for ready state if not ready if (this.client.status !== 'ready') { await new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error('Timeout waiting for Redis connection')); }, 10000); // 10 second timeout this.client.once('ready', () => { clearTimeout(timeout); resolve(); }); this.client.once('error', (err) => { clearTimeout(timeout); reject(err); }); }); } this.isConnected = true; this.log('info', 'Using existing Redis connection'); return; } // Create new Redis client try { const conn = this.options.connection || {}; // Create client from URL if provided if (conn.url) { this.client = new IORedis(conn.url, { retryStrategy: (times) => { if (times > (this.options.maxReconnectAttempts || 10)) { return null; // Stop retrying } if (this.options.reconnectStrategy === 'exponential') { return Math.min(times * 100, 3000); // Exponential backoff } else { return 1000; // Linear strategy - retry every 1 second } }, }); } else { // Create client from individual options this.client = new IORedis({ host: conn.host || 'localhost', port: conn.port || 6379, username: conn.username, password: conn.password, db: conn.db || 0, tls: conn.tls ? {} : undefined, retryStrategy: (times) => { if (times > (this.options.maxReconnectAttempts || 10)) { return null; // Stop retrying } if (this.options.reconnectStrategy === 'exponential') { return Math.min(times * 100, 3000); // Exponential backoff } else { return 1000; // Linear strategy - retry every 1 second } }, }); } // Set up error handling if enabled if (this.options.handleErrors) { this.client.on('error', (err) => { this.log('error', 'Redis connection error', err); }); } // Wait for ready event await new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error('Timeout waiting for Redis connection')); }, 10000); // 10 second timeout this.client.once('ready', () => { clearTimeout(timeout); resolve(); }); this.client.once('error', (err) => { clearTimeout(timeout); reject(err); }); }); this.isConnected = true; this.log('info', 'Redis connection established'); } catch (error) { this.log('error', 'Failed to connect to Redis', error); throw error; } } /** * Disconnect from the Redis server * @returns Promise resolving when disconnected */ async disconnect(): Promise { if (!this.isConnected || !this.client) { return; } try { await this.client.quit(); this.isConnected = false; this.log('info', 'Redis connection closed'); } catch (error) { this.log('error', 'Error disconnecting from Redis', error); throw error; } } /** * Get cache stats * @returns Promise resolving to cache statistics */ async getStats(): Promise { if (!this.isConnected) { await this.connect(); } try { // Get size by counting keys with our prefix const keys = await this.client.keys(`${this.options.keyPrefix}*`); const size = keys.length; // Get Redis info for additional stats const info = await this.client.info(); // Parse Redis info const redisStats: Record = {}; const lines = info.split('\r\n'); for (const line of lines) { if (line.includes(':')) { const [key, value] = line.split(':'); if (key) { // Check if key is defined redisStats[key] = value; } } } const hits = this.stats.hits; const misses = this.stats.misses; const total = hits + misses; const hitRatio = total > 0 ? hits / total : 0; return { size, hits, misses, hitRatio, // Add Redis-specific stats redis: { memory: redisStats['used_memory'], clients: redisStats['connected_clients'], uptime: redisStats['uptime_in_seconds'], }, }; } catch (error) { this.log('error', 'Failed to get cache stats', error); // Return basic stats even if we can't get Redis info return { size: 0, hits: this.stats.hits, misses: this.stats.misses, hitRatio: 0, }; } } }