• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

hicommonwealth / commonwealth / 13823409586

12 Mar 2025 11:19PM UTC coverage: 44.323% (-0.3%) from 44.638%
13823409586

Pull #11431

github

web-flow
Merge ca0051440 into eb9a2bd81
Pull Request #11431: Implement home feed on backend

1355 of 3390 branches covered (39.97%)

Branch coverage included in aggregate %.

2 of 61 new or added lines in 2 files covered. (3.28%)

11 existing lines in 1 file now uncovered.

2564 of 5452 relevant lines covered (47.03%)

37.35 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

36.76
/libs/adapters/src/redis/RedisCacheAdapter.ts
1
import {
2
  Cache,
3
  ILogger,
4
  logger,
5
  type CacheNamespaces,
6
} from '@hicommonwealth/core';
7
import { delay } from '@hicommonwealth/shared';
8
import { RedisClientOptions, createClient, type RedisClientType } from 'redis';
9

10
const CONNECT_TIMEOUT = 5000;
5✔
11

12
export function redisRetryStrategy(retries: number) {
13
  // Don't stop retrying while app is running
14
  // if (retries > 5) {
15
  //   return new Error('Redis max connection retries exceeded');
16
  // }
17

18
  // timetable: 0, 1000, 8000, 27000, 64000, 125000, 216000, 343000, 512000, 729000, 1000000
19
  // from 1 sec to 16.67 minutes
20
  return Math.min((retries * 10) ** 3, 10 * 60 * 1000);
×
21
}
22

23
/**
24
 * This class facilitates interacting with Redis and constructing a Redis Cache. Note that all keys must use a namespace
25
 * prefix to divide the Redis keyspace. If a specific Redis command is not supported by this class you can access the
26
 * client directly or better yet open a PR that implements the class methods necessary to support the new Redis command.
27
 * WARNING: If running blocking arbitrary commands using the client directly be sure to include the 'isolated' option
28
 * in order to avoid blocking the client for other requests that may be occurring.
29
 */
30
export class RedisCache implements Cache {
31
  private _client: RedisClientType;
32
  private _log: ILogger;
33

34
  constructor(redis_url: string) {
35
    const redisOptions: RedisClientOptions = {};
3✔
36
    redisOptions['url'] = redis_url;
3✔
37
    if (redis_url.includes('rediss')) {
3!
38
      redisOptions['socket'] = {
×
39
        tls: true,
40
        rejectUnauthorized: false,
41
        reconnectStrategy: redisRetryStrategy,
42
        connectTimeout: CONNECT_TIMEOUT,
43
      };
44
    } else {
45
      redisOptions['socket'] = {
3✔
46
        reconnectStrategy: redisRetryStrategy,
47
        connectTimeout: CONNECT_TIMEOUT,
48
      };
49
    }
50

51
    this._log = logger(import.meta);
3✔
52
    this._log.info(`Connecting to Redis at: ${redis_url}`);
3✔
53
    this._client = createClient(redisOptions) as RedisClientType;
3✔
54

55
    this._client.on('ready', () =>
3✔
56
      this._log.info(`RedisCache connection ready`),
3✔
57
    );
58
    this._client.on('reconnecting', () =>
3✔
59
      this._log.info(`RedisCache reconnecting...`),
×
60
    );
61
    this._client.on('end', () => this._log.info(`RedisCache disconnected`));
3✔
62
    this._client.on('error', (err: Error) => {
3✔
63
      this._log.error(err.message, err);
×
64
    });
65

66
    void this._client.connect();
3✔
67
  }
68

69
  // get namespace key for redis
70
  static getNamespaceKey(namespace: CacheNamespaces, key: string) {
71
    return `${namespace}_${key}`;
35✔
72
  }
73

74
  public get name(): string {
75
    return 'RedisCache';
9✔
76
  }
77

78
  public async dispose(): Promise<void> {
79
    try {
3✔
80
      await this._client.disconnect();
3✔
81
      await this._client.quit();
3✔
82
    } catch {
83
      // ignore this
84
    }
85
  }
86

87
  /**
88
   * Awaits redis connection / cache ready
89
   * @returns
90
   */
91
  public ready(retries = 3, retryDelay = 1000) {
6✔
92
    // eslint-disable-next-line no-async-promise-executor, @typescript-eslint/no-misused-promises
93
    return new Promise<boolean>(async (resolve, reject) => {
3✔
94
      for (let i = 0; i < retries; i++) {
3✔
95
        if (this.isReady()) {
6✔
96
          resolve(true);
3✔
97
          return;
3✔
98
        }
99
        await delay(retryDelay);
3✔
100
      }
101
      reject('RedisCache ready timeout');
×
102
    });
103
  }
104

105
  /**
106
   * Check if redis is initialized
107
   * @returns boolean
108
   */
109
  public isReady(): boolean {
110
    return this._client.isReady;
71✔
111
  }
112

113
  /**
114
   * This function facilitates setting a key-value pair in Redis. Since Redis has a single keyspace we include a prefix
115
   * to simulate many keyspaces. That is, all key-value pairs for a specific functionality should use a matching prefix.
116
   * For example, for if we had chat WebSockets we store user_id => address key-value pairs. Since we may want to store
117
   * other data in which the key would be the user_id we use the 'chat_socket' prefix for all key-pairs pertaining to
118
   * the chat websocket. The resulting key would thus be 'chat_socket_[user_id]'. The prefix can be thought of as the
119
   * namespace of the data that you are trying to store.
120
   * @param namespace The prefix to append to the dynamic key i.e. the namespace. An instance of the
121
   * CacheNamespaces enum.
122
   * @param key The actual key you want to store (can be any valid string).
123
   * @param value The value to associate with the namespace and key
124
   * @param duration The number of seconds after which the key should be automatically 'deleted' by Redis i.e. TTL
125
   * @param notExists If true and the key already exists the key will not be set
126
   */
127
  public async setKey(
128
    namespace: CacheNamespaces,
129
    key: string,
130
    value: string,
131
    duration = 0,
7✔
132
    notExists = false,
13✔
133
  ): Promise<boolean> {
134
    if (!this.isReady()) return false;
13!
135
    try {
13✔
136
      const options: { NX: boolean; EX?: number } = {
13✔
137
        NX: notExists,
138
      };
139
      duration > 0 && (options.EX = duration);
13✔
140
      const finalKey = RedisCache.getNamespaceKey(namespace, key);
13✔
141
      if (typeof value !== 'string') {
13!
142
        value = JSON.stringify(value);
×
143
      }
144
      const res = await this._client.set(
13✔
145
        finalKey as any,
146
        value,
147
        options as any,
148
      );
149
      return res === 'OK';
13✔
150
    } catch (e) {
151
      const msg = `An error occurred while setting the following key value pair '${namespace} ${key}: ${value}'`;
×
152
      this._log.error(msg, e as Error);
×
153
      return false;
×
154
    }
155
  }
156

157
  public async getKey(
158
    namespace: CacheNamespaces,
159
    key: string,
160
  ): Promise<string | null> {
161
    if (!this.isReady()) return null;
16!
162
    try {
16✔
163
      const finalKey = RedisCache.getNamespaceKey(namespace, key);
16✔
164
      return await this._client.get(finalKey);
16✔
165
    } catch (e) {
166
      const msg = `An error occurred while getting the following key '${key}'`;
×
167
      this._log.error(msg, e as Error);
×
168
      return null;
×
169
    }
170
  }
171

172
  /**
173
   * This function works the same way at the 'setKey' function above but is meant to be used when multiple key value
174
   * pairs need to be inserted at the same time in an 'all or none' fashion i.e. SQL transaction style.
175
   * @param namespace The prefix to append to the key.
176
   * @param data The key-value pairs to set in Redis
177
   * @param duration The TTL for each key in data.
178
   * @param transaction This boolean indicates whether keys should all be set within a transaction when
179
   * the duration parameter is set. Specifically, if transaction is true we use multi-exec and if
180
   * transaction is false we use a pipeline and return any keys that failed to set. Note that if transaction
181
   * is true, a blocking and potentially less performant operation is executed.
182
   */
183
  public async setKeys(
184
    namespace: CacheNamespaces,
185
    data: { [key: string]: string },
186
    duration = 0,
×
187
    transaction = true,
×
188
  ): Promise<false | Array<'OK' | null>> {
189
    if (!this.isReady()) return false;
×
190

191
    // add the namespace prefix to all keys
192
    const transformedData = Object.keys(data).reduce((result, key) => {
×
193
      result[RedisCache.getNamespaceKey(namespace, key)] = data[key];
×
194
      return result;
×
195
    }, {} as any);
196

197
    if (duration > 0) {
×
198
      // MSET doesn't support setting TTL, so we need use
199
      // a multi-exec to process many SET commands
200
      const multi = this._client.multi();
×
201
      for (const key of Object.keys(transformedData)) {
×
202
        multi.set(key, transformedData[key], { EX: duration });
×
203
      }
204

205
      try {
×
206
        if (transaction) return (await multi.exec()) as Array<'OK' | null>;
×
207
        else return (await multi.execAsPipeline()) as Array<'OK' | null>;
×
208
      } catch (e) {
209
        const msg =
210
          `Error occurred while setting multiple keys ` +
×
211
          `${transaction ? 'in a transaction' : 'in a pipeline'}`;
×
212
        this._log.error(msg, e as Error);
×
213
        return false;
×
214
      }
215
    } else {
216
      try {
×
217
        return [(await this._client.MSET(transformedData)) as 'OK'];
×
218
      } catch (e) {
219
        const msg = 'Error occurred while setting multiple keys';
×
220
        this._log.error(msg, e as Error);
×
221
        return false;
×
222
      }
223
    }
224
  }
225

226
  public async getKeys(
227
    namespace: CacheNamespaces,
228
    keys: string[],
229
  ): Promise<false | Record<string, unknown>> {
230
    if (!this.isReady()) return false;
×
231
    const transformedKeys = keys.map((k) =>
×
232
      RedisCache.getNamespaceKey(namespace, k),
×
233
    );
234
    try {
×
235
      const values = await this._client.MGET(transformedKeys);
×
236
      return transformedKeys.reduce((obj, key, index) => {
×
237
        if (values[index] !== null) {
×
238
          obj[key] = values[index];
×
239
        }
240
        return obj;
×
241
      }, {} as any);
242
    } catch (e) {
243
      const msg = 'An error occurred while getting many keys';
×
244
      this._log.error(msg, e as Error);
×
245
      return false;
×
246
    }
247
  }
248

249
  /**
250
   * Increments the integer value of a key by the given amount.
251
   * @param namespace The namespace of the key to increment.
252
   * @param key The key whose value is to be incremented.
253
   * @param increment The amount by which the key's value should be incremented.
254
   * @returns The new value of the key after the increment.
255
   */
256
  public async incrementKey(
257
    namespace: CacheNamespaces,
258
    key: string,
259
    increment = 1,
×
260
  ): Promise<number | null> {
261
    if (!this.isReady()) return null;
×
262
    try {
×
263
      const finalKey = RedisCache.getNamespaceKey(namespace, key);
×
264
      return await this._client.incrBy(finalKey, increment);
×
265
    } catch (e) {
266
      const msg = `An error occurred while incrementing the key: ${key}`;
×
267
      this._log.error(msg, e as Error);
×
268
      return null;
×
269
    }
270
  }
271

272
  /**
273
   * Decrements the integer value of a key by the given amount.
274
   * @param namespace The namespace of the key to decrement.
275
   * @param key The key whose value is to be decremented.
276
   * @param decrement The amount by which the key's value should be decremented.
277
   * @returns The new value of the key after the decrement.
278
   */
279
  public async decrementKey(
280
    namespace: CacheNamespaces,
281
    key: string,
282
    decrement = 1,
×
283
  ): Promise<number | null> {
284
    if (!this.isReady()) return null;
×
285
    try {
×
286
      const finalKey = RedisCache.getNamespaceKey(namespace, key);
×
287
      return await this._client.decrBy(finalKey, decrement);
×
288
    } catch (e) {
289
      const msg = `An error occurred while decrementing the key: ${key}`;
×
290
      this._log.error(msg, e as Error);
×
291
      return null;
×
292
    }
293
  }
294

295
  /**
296
   * Sets the expiration (TTL) of a key within a specific namespace.
297
   * @param namespace The namespace of the key for which to set the expiration.
298
   * @param key The key for which to set the expiration.
299
   * @param ttlInSeconds The time to live (TTL) in seconds for the key. Use 0 to remove the expiration.
300
   * @returns True if the expiration was set successfully, false otherwise.
301
   */
302
  public async setKeyTTL(
303
    namespace: CacheNamespaces,
304
    key: string,
305
    ttlInSeconds: number,
306
  ): Promise<boolean> {
307
    if (!this.isReady()) return false;
×
308
    try {
×
309
      const finalKey = RedisCache.getNamespaceKey(namespace, key);
×
310
      if (ttlInSeconds === 0) {
×
311
        // If ttlInSeconds is 0, remove the expiration (PERSIST command).
312
        return await this._client.persist(finalKey);
×
313
      } else {
314
        // Set the expiration using the EXPIRE command.
315
        return await this._client.expire(finalKey, ttlInSeconds);
×
316
      }
317
    } catch (e) {
318
      const msg = `An error occurred while setting the expiration of the key: ${key}`;
×
319
      this._log.error(msg, e as Error);
×
320
      return false;
×
321
    }
322
  }
323

324
  /**
325
   * Retrieves the current Time to Live (TTL) of a key within a specific namespace.
326
   * @param namespace The namespace of the key for which to get the TTL.
327
   * @param key The key for which to get the TTL.
328
   * @returns The TTL in seconds for the specified key, or -1 if the key does not exist or has no associated expiration.
329
   */
330
  public async getKeyTTL(
331
    namespace: CacheNamespaces,
332
    key: string,
333
  ): Promise<number> {
334
    if (!this.isReady()) return -2;
×
335
    try {
×
336
      const finalKey = RedisCache.getNamespaceKey(namespace, key);
×
337
      // TTL in seconds; -2 if the key does not exist, -1 if the key exists but has no associated expire.
338
      return await this._client.ttl(finalKey);
×
339
    } catch (e) {
340
      const msg = `An error occurred while retrieving the TTL of the key: ${key}`;
×
341
      this._log.error(msg, e as Error);
×
342
      return -2;
×
343
    }
344
  }
345

346
  /**
347
   * Get all the key-value pairs of a specific namespace.
348
   * @param namespace The name of the namespace to retrieve keys from
349
   * @param maxResults The maximum number of keys to retrieve from the given namespace
350
   */
351
  public async getNamespaceKeys(
352
    namespace: CacheNamespaces,
353
    maxResults = 1000,
10✔
354
  ): Promise<{ [key: string]: string } | boolean> {
355
    const keys = [];
10✔
356
    const data = {} as any;
10✔
357
    if (!this.isReady()) return false;
10!
358
    try {
10✔
359
      for await (const key of this._client.scanIterator({
10✔
360
        MATCH: `${namespace}*`,
361
        COUNT: maxResults,
362
      })) {
363
        keys.push(key);
11✔
364
      }
365
      for (const key of keys) {
10✔
366
        data[key] = await this._client.get(key);
11✔
367
      }
368
      return data;
10✔
369
    } catch (e) {
370
      const msg = 'An error occurred while fetching the namespace keys';
×
371
      this._log.error(msg, e as Error);
×
372
      return false;
×
373
    }
374
  }
375

376
  /**
377
   * delete redis key by namespace and key
378
   * @returns boolean
379
   */
380
  public async deleteNamespaceKeys(
381
    namespace: CacheNamespaces,
382
  ): Promise<number | boolean> {
383
    if (!this.isReady()) return false;
8!
384
    try {
8✔
385
      let count = 0;
8✔
386
      const data = await this.getNamespaceKeys(namespace);
8✔
387
      if (data) {
8!
388
        for (const key of Object.keys(data)) {
8✔
389
          try {
8✔
390
            const resp = await this._client.del(key);
8✔
391
            count += resp ?? 0;
8!
392
            this._log.trace(`deleted key ${key} ${resp} ${count}`);
8✔
393
          } catch (err) {
UNCOV
394
            this._log.trace(`error deleting key ${key}`);
×
UNCOV
395
            this._log.trace((err as Error).message);
×
396
          }
397
        }
398
      }
399
      return count;
8✔
400
    } catch (e) {
401
      const msg = `An error occurred while deleting a all keys in the ${namespace} namespace`;
×
402
      this._log.error(msg, e as Error);
×
UNCOV
403
      return false;
×
404
    }
405
  }
406

407
  public async deleteKey(
408
    namespace: CacheNamespaces,
409
    key: string,
410
  ): Promise<number> {
UNCOV
411
    if (!this.isReady()) return 0;
×
UNCOV
412
    const finalKey = RedisCache.getNamespaceKey(namespace, key);
×
UNCOV
413
    try {
×
UNCOV
414
      return this._client.del(finalKey);
×
415
    } catch (e) {
UNCOV
416
      return 0;
×
417
    }
418
  }
419

420
  public async flushAll(): Promise<void> {
421
    if (!this.isReady()) return;
×
UNCOV
422
    try {
×
423
      await this._client.flushAll();
×
424
    } catch (e) {
UNCOV
425
      this._log.error('An error occurred while flushing redis', e as Error);
×
426
    }
427
  }
428

429
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
430
  public async sendCommand(args: string[]): Promise<any> {
UNCOV
431
    return await this._client.sendCommand(args);
×
432
  }
433
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc