File size: 9,493 Bytes
46252cd
 
b58ffca
46252cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b58ffca
 
 
46252cd
 
 
 
 
 
b58ffca
 
46252cd
 
 
 
 
 
 
b58ffca
46252cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
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<string, number>;
}

// 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<boolean>('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<string>('redis.host', 'localhost');
    const port = this.configService.get<number>('redis.port', 6379);
    const tls = this.configService.get<RedisOptions['tls']>('redis.tls');

    this.logger.log(`Connecting to Redis at ${host}:${port}`);

    const redis = new Redis({
      host,
      port,
      username: this.configService.get<string>('redis.username'),
      password: this.configService.get<string>('redis.password'),
      db: this.configService.get<number>('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<number>('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<boolean> {
    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<void> {
    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<void>(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<boolean> {
    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<string | null> {
    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<void> {
    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<SessionInfo | null> {
    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<void> {
    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<string | null> {
    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<void> {
    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<string[] | null> {
    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<void> {
    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<SessionStats | null> {
    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<void> {
    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)}`);
    }
  }
}