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

safe-global / safe-client-gateway / 12595573134

03 Jan 2025 09:26AM UTC coverage: 89.902% (-0.2%) from 90.126%
12595573134

Pull #2232

github

web-flow
Merge c1013c948 into 48e5bc4de
Pull Request #2232: Add Timeout to Redis Queries and Handle Client Unavailability Gracefully

2856 of 3561 branches covered (80.2%)

Branch coverage included in aggregate %.

39 of 70 new or added lines in 6 files covered. (55.71%)

12 existing lines in 2 files now uncovered.

9679 of 10382 relevant lines covered (93.23%)

436.83 hits per line

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

61.74
/src/datasources/cache/redis.cache.service.ts
1
import { Inject, Injectable, OnModuleDestroy } from '@nestjs/common';
122✔
2
import { RedisClientType } from '@/datasources/cache/cache.module';
3
import { ICacheService } from '@/datasources/cache/cache.service.interface';
4
import { CacheDir } from '@/datasources/cache/entities/cache-dir.entity';
122✔
5
import { ICacheReadiness } from '@/domain/interfaces/cache-readiness.interface';
6
import { ILoggingService, LoggingService } from '@/logging/logging.interface';
122✔
7
import { IConfigurationService } from '@/config/configuration.service.interface';
122✔
8
import { CacheKeyPrefix } from '@/datasources/cache/constants';
122✔
9
import {
122✔
10
  PromiseTimeoutError,
11
  promiseWithTimeout,
12
} from '@/domain/common/utils/promise';
13

14
@Injectable()
15
export class RedisCacheService
122✔
16
  implements ICacheService, ICacheReadiness, OnModuleDestroy
17
{
18
  private readonly quitTimeoutInSeconds: number = 2;
52✔
19
  private readonly defaultExpirationTimeInSeconds: number;
20

21
  constructor(
22
    @Inject('RedisClient') private readonly client: RedisClientType,
52✔
23
    @Inject(LoggingService) private readonly loggingService: ILoggingService,
52✔
24
    @Inject(IConfigurationService)
25
    private readonly configurationService: IConfigurationService,
52✔
26
    @Inject(CacheKeyPrefix) private readonly keyPrefix: string,
52✔
27
  ) {
28
    this.defaultExpirationTimeInSeconds =
52✔
29
      this.configurationService.getOrThrow<number>(
30
        'expirationTimeInSeconds.default',
31
      );
32
  }
33

34
  async ping(): Promise<unknown> {
35
    return this.client.ping();
2✔
36
  }
37

38
  ready(): boolean {
39
    return this.client.isReady;
742✔
40
  }
41

42
  async getCounter(key: string): Promise<number | null> {
43
    if (!this.ready()) {
10!
NEW
44
      this.logRedisClientUnreadyState('getCounter');
×
45

NEW
46
      return null;
×
47
    }
48

49
    const value = await this.client.get(this._prefixKey(key));
10✔
50
    const numericValue = Number(value);
10✔
51
    return Number.isInteger(numericValue) ? numericValue : null;
10✔
52
  }
53

54
  async hSet(
55
    cacheDir: CacheDir,
56
    value: string,
57
    expireTimeSeconds: number | undefined,
58
  ): Promise<void> {
59
    if (!expireTimeSeconds || expireTimeSeconds <= 0) {
318✔
60
      return;
4✔
61
    }
62

63
    if (!this.ready()) {
314!
NEW
UNCOV
64
      this.logRedisClientUnreadyState('hSet');
×
65

NEW
UNCOV
66
      return undefined;
×
67
    }
68

69
    const key = this._prefixKey(cacheDir.key);
314✔
70

71
    try {
314✔
72
      await this.timeout(this.client.hSet(key, cacheDir.field, value));
314✔
73
      // NX - Set expiry only when the key has no expiry
74
      // See https://redis.io/commands/expire/
75
      await this.timeout(this.client.expire(key, expireTimeSeconds, 'NX'));
314✔
76
    } catch (error) {
77
      await this.timeout(this.client.hDel(key, cacheDir.field));
2✔
78
      throw error;
2✔
79
    }
80
  }
81

82
  async hGet(cacheDir: CacheDir): Promise<string | undefined> {
83
    if (!this.ready()) {
80!
NEW
UNCOV
84
      this.logRedisClientUnreadyState('hGet');
×
85

NEW
UNCOV
86
      return undefined;
×
87
    }
88

89
    const key = this._prefixKey(cacheDir.key);
80✔
90
    return await this.timeout(this.client.hGet(key, cacheDir.field));
80✔
91
  }
92

93
  async deleteByKey(key: string): Promise<number | undefined> {
94
    if (!this.ready()) {
286!
NEW
95
      this.logRedisClientUnreadyState('deleteByKey');
×
96

NEW
97
      return undefined;
×
98
    }
99

100
    const keyWithPrefix = this._prefixKey(key);
286✔
101
    // see https://redis.io/commands/unlink/
102
    const result = await this.timeout(this.client.unlink(keyWithPrefix));
286✔
103
    await this.timeout(
286✔
104
      this.hSet(
105
        new CacheDir(`invalidationTimeMs:${key}`, ''),
106
        Date.now().toString(),
107
        this.defaultExpirationTimeInSeconds,
108
      ),
109
    );
110
    return result;
286✔
111
  }
112

113
  async increment(
114
    cacheKey: string,
115
    expireTimeSeconds: number | undefined,
116
  ): Promise<number | undefined> {
117
    if (!this.ready()) {
24!
NEW
UNCOV
118
      this.logRedisClientUnreadyState('increment');
×
119

NEW
UNCOV
120
      return undefined;
×
121
    }
122

123
    const transaction = this.client.multi().incr(cacheKey);
24✔
124
    if (expireTimeSeconds !== undefined && expireTimeSeconds > 0) {
24✔
125
      transaction.expire(cacheKey, expireTimeSeconds, 'NX');
2✔
126
    }
127
    const [incrRes] = await transaction.get(cacheKey).exec();
24✔
128
    return Number(incrRes);
24✔
129
  }
130

131
  async setCounter(
132
    key: string,
133
    value: number,
134
    expireTimeSeconds: number,
135
  ): Promise<void> {
136
    if (!this.ready()) {
6!
NEW
UNCOV
137
      this.logRedisClientUnreadyState('setCounter');
×
138

NEW
UNCOV
139
      return undefined;
×
140
    }
141

142
    await this.client.set(key, value, { EX: expireTimeSeconds, NX: true });
6✔
143
  }
144

145
  /**
146
   * Constructs a prefixed key string.
147
   *
148
   * This function takes a key string as an input and prefixes it with `this.keyPrefix`.
149
   * If `this.keyPrefix` is empty, it returns the original key without any prefix.
150
   *
151
   * @param key - The original key string that needs to be prefixed.
152
   * @returns A string that combines `this.keyPrefix` and the original `key` with a hyphen.
153
   *          If `this.keyPrefix` is empty, the original `key` is returned without any modification.
154
   * @private
155
   */
156
  private _prefixKey(key: string): string {
157
    if (this.keyPrefix.length === 0) {
690✔
158
      return key;
38✔
159
    }
160

161
    return `${this.keyPrefix}-${key}`;
652✔
162
  }
163

164
  /**
165
   * Closes the connection to Redis when the module associated with this service
166
   * is destroyed. This tries to gracefully close the connection. If the Redis
167
   * instance is not responding it invokes {@link forceQuit}.
168
   */
169
  async onModuleDestroy(): Promise<void> {
170
    if (!this.ready()) {
22!
NEW
171
      this.logRedisClientUnreadyState('onModuleDestroy');
×
172

NEW
173
      return undefined;
×
174
    }
175
    this.loggingService.info('Closing Redis connection...');
22✔
176
    try {
22✔
177
      await promiseWithTimeout(
22✔
178
        this.client.quit(),
179
        this.quitTimeoutInSeconds * 1000,
180
      );
181
      this.loggingService.info('Redis connection closed');
22✔
182
    } catch (error) {
NEW
183
      if (error instanceof PromiseTimeoutError) {
×
NEW
184
        await this.forceQuit();
×
185
      }
186
    }
187
  }
188

189
  /**
190
   * Forces the closing of the Redis connection associated with this service.
191
   */
192
  private async forceQuit(): Promise<void> {
NEW
193
    if (!this.ready()) {
×
NEW
194
      this.logRedisClientUnreadyState('forceQuit');
×
195

NEW
196
      return undefined;
×
197
    }
NEW
198
    this.loggingService.warn('Forcing Redis connection to close...');
×
NEW
199
    try {
×
NEW
200
      await this.client.disconnect();
×
NEW
201
      this.loggingService.warn('Redis connection forcefully closed!');
×
202
    } catch (error) {
NEW
203
      this.loggingService.error(`Cannot close Redis connection: ${error}`);
×
204
    }
205
  }
206

207
  private async timeout<T>(
208
    queryObject: Promise<T>,
209
    timeout?: number,
210
  ): Promise<T | undefined> {
211
    timeout =
1,282✔
212
      timeout ?? this.configurationService.getOrThrow<number>('redis.timeout');
1,923!
213
    try {
1,282✔
214
      return await promiseWithTimeout(queryObject, timeout);
1,282✔
215
    } catch (error) {
216
      if (error instanceof PromiseTimeoutError) {
2!
NEW
UNCOV
217
        this.loggingService.error('Redis Query Timed out!');
×
218

NEW
UNCOV
219
        return undefined;
×
220
      }
221

222
      throw error;
2✔
223
    }
224
  }
225

226
  private logRedisClientUnreadyState(operation: string): void {
NEW
UNCOV
227
    this.loggingService.error(
×
228
      `Redis client is not ready. Redis ${operation} failed!`,
229
    );
230
  }
231
}
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

© 2025 Coveralls, Inc