import { Injectable, OnModuleDestroy } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import Redis, { type RedisOptions } from 'ioredis'; import { createLogger } from '../services/logger.service'; export interface SessionInfo { id: string; name: string; status: string; phone?: string; pushName?: string; connectedAt?: string; } export interface SessionStats { active: number; total: number; byStatus: Record; } // TTL constants in seconds const TTL = { SESSION_STATUS: 300, // 5 min SESSION_INFO: 600, // 10 min SESSION_QR: 60, // 1 min SESSIONS_LIST: 30, // 30 sec SESSIONS_STATS: 15, // 15 sec }; /** Max time to await a graceful `redis.quit()` on shutdown before force-disconnecting (see onModuleDestroy). */ export const CACHE_QUIT_TIMEOUT_MS = 2000; @Injectable() export class CacheService implements OnModuleDestroy { private readonly logger = createLogger('CacheService'); private redis: Redis | null = null; private readonly enabled: boolean; constructor(private readonly configService: ConfigService) { // Check REDIS_ENABLED env var directly (from saved .env.generated) // Fallback to config 'cache.enabled' for backward compatibility this.enabled = process.env.REDIS_ENABLED === 'true' || configService.get('cache.enabled', false); this.logger.log(`CacheService: enabled=${this.enabled}, REDIS_ENABLED=${process.env.REDIS_ENABLED}`); // Don't connect immediately - the client is created lazily on first use via isAvailable(), so a // Redis container that is not ready at boot doesn't fail startup. } /** * Lazily create the single shared Redis client. ioredis owns all (re)connection: the retry strategy * never gives up, so the client heals itself whether Redis is down at boot or restarts later — it is * created once here and only torn down on shutdown (onModuleDestroy). The previous manual * connect/attempt-count logic gave up permanently after a fixed number of failures and never cleared * a dead client, so a Redis restart left the cache dead until the whole app was restarted. */ private ensureClient(): void { if (this.redis) return; const host = this.configService.get('redis.host', 'localhost'); const port = this.configService.get('redis.port', 6379); const tls = this.configService.get('redis.tls'); this.logger.log(`Connecting to Redis at ${host}:${port}`); const redis = new Redis({ host, port, username: this.configService.get('redis.username'), password: this.configService.get('redis.password'), db: this.configService.get('REDIS_CACHE_DB', 1), lazyConnect: true, // Cache is best-effort: a command issued while disconnected fails fast (the caller falls back to // the source of truth) instead of being queued and stalling the request until reconnect. enableOfflineQueue: false, maxRetriesPerRequest: 3, connectTimeout: this.configService.get('redis.connectTimeoutMs', 5000), ...(tls ? { tls } : {}), // Reconnect forever with bounded backoff. Returning null (the previous behavior after 3 tries) // makes ioredis abandon reconnection permanently, which is exactly what left the cache dead // across a Redis restart. retryStrategy: times => Math.min(times * 500, 5000), }); redis.on('error', err => { this.logger.warn(`Redis error: ${err.message}`); }); redis.on('connect', () => { this.logger.log('Redis cache connected'); }); this.redis = redis; // Kick off the initial connection but don't await it — ioredis keeps retrying per retryStrategy on // failure, and isAvailable()'s ping reflects the live state, so a down-at-boot Redis never blocks a // caller. The first isAvailable() while still connecting reports false; the next one, once ready, // reports true. redis.connect().catch(() => undefined); } private async ping(): Promise { if (!this.redis) return false; try { await this.redis.ping(); return true; } catch (error) { this.logger.debug(`Redis ping failed: ${String(error)}`); return false; } } async onModuleDestroy(): Promise { if (!this.redis) return; const redis = this.redis; // Bound the teardown: redis.quit() waits for the QUIT reply, which never arrives on a half-open / // partitioned socket — leaving app.close() blocked until the orchestrator SIGKILLs the process. // Race a graceful QUIT against a short deadline so shutdown always proceeds. let timer: NodeJS.Timeout | undefined; const deadline = new Promise(resolve => { timer = setTimeout(resolve, CACHE_QUIT_TIMEOUT_MS); timer.unref(); }); try { await Promise.race([redis.quit().catch(() => undefined), deadline]); } finally { if (timer) clearTimeout(timer); // Always release the socket. With the never-give-up retryStrategy, a Redis that is down at // shutdown leaves the client stuck 'reconnecting', and (enableOfflineQueue:false) quit() rejects // instantly WITHOUT closing it — so ioredis's reconnect timer would outlive teardown. disconnect() // is idempotent, so calling it after a clean quit is harmless; this guarantees no live handle // survives onModuleDestroy regardless of connection state, rather than relying on process.exit to // reap it. redis.disconnect(); } } async isAvailable(): Promise { if (!this.enabled) return false; // Create the client on first use, then let ioredis manage (re)connection. A ping reflects whether // the connection is live right now — false during an outage (caller bypasses to the DB), true again // once ioredis has reconnected. this.ensureClient(); return this.ping(); } // ========== Session Status ========== async getSessionStatus(id: string): Promise { if (!(await this.isAvailable())) return null; try { return await this.redis!.get(`session:${id}:status`); } catch (error) { this.logger.warn(`Cache read failed (session:status): ${String(error)}`); return null; } } async setSessionStatus(id: string, status: string): Promise { if (!(await this.isAvailable())) return; try { await this.redis!.setex(`session:${id}:status`, TTL.SESSION_STATUS, status); } catch (error) { this.logger.warn(`Cache write failed (session:status): ${String(error)}`); } } // ========== Session Info ========== async getSessionInfo(id: string): Promise { if (!(await this.isAvailable())) return null; try { const data = await this.redis!.get(`session:${id}:info`); return data ? (JSON.parse(data) as SessionInfo) : null; } catch (error) { this.logger.warn(`Cache read failed (session:info): ${String(error)}`); return null; } } async setSessionInfo(id: string, info: SessionInfo): Promise { if (!(await this.isAvailable())) return; try { await this.redis!.setex(`session:${id}:info`, TTL.SESSION_INFO, JSON.stringify(info)); } catch (error) { this.logger.warn(`Cache write failed (session:info): ${String(error)}`); } } // ========== Session QR ========== async getSessionQR(id: string): Promise { if (!(await this.isAvailable())) return null; try { return await this.redis!.get(`session:${id}:qr`); } catch (error) { this.logger.warn(`Cache read failed (session:qr): ${String(error)}`); return null; } } async setSessionQR(id: string, qr: string): Promise { if (!(await this.isAvailable())) return; try { await this.redis!.setex(`session:${id}:qr`, TTL.SESSION_QR, qr); } catch (error) { this.logger.warn(`Cache write failed (session:qr): ${String(error)}`); } } // ========== Sessions List ========== async getSessionsList(): Promise { if (!(await this.isAvailable())) return null; try { const data = await this.redis!.get('sessions:list'); return data ? (JSON.parse(data) as string[]) : null; } catch (error) { this.logger.warn(`Cache read failed (sessions:list): ${String(error)}`); return null; } } async setSessionsList(ids: string[]): Promise { if (!(await this.isAvailable())) return; try { await this.redis!.setex('sessions:list', TTL.SESSIONS_LIST, JSON.stringify(ids)); } catch (error) { this.logger.warn(`Cache write failed (sessions:list): ${String(error)}`); } } // ========== Sessions Stats ========== async getSessionsStats(): Promise { if (!(await this.isAvailable())) return null; try { const data = await this.redis!.get('sessions:stats'); return data ? (JSON.parse(data) as SessionStats) : null; } catch (error) { this.logger.warn(`Cache read failed (sessions:stats): ${String(error)}`); return null; } } async setSessionsStats(stats: SessionStats): Promise { if (!(await this.isAvailable())) return; try { await this.redis!.setex('sessions:stats', TTL.SESSIONS_STATS, JSON.stringify(stats)); } catch (error) { this.logger.warn(`Cache write failed (sessions:stats): ${String(error)}`); } } }