Skip to content

Commit 6df6f50

Browse files
committed
feat: handle redis errors (hiero-ledger#4558)
Signed-off-by: Mariusz Jasuwienas <jasuwienas@gmail.com>
1 parent 57dc361 commit 6df6f50

File tree

5 files changed

+193
-38
lines changed

5 files changed

+193
-38
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
export { SafeRedisCache as RedisCache } from './safeRedisCache';

packages/relay/src/lib/clients/cache/redisCache.ts renamed to packages/relay/src/lib/clients/cache/redisCache/redisCache.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import { ConfigService } from '@hashgraph/json-rpc-config-service/dist/services'
44
import { Logger } from 'pino';
55
import { RedisClientType } from 'redis';
66

7-
import { Utils } from '../../../utils';
8-
import { ICacheClient } from './ICacheClient';
7+
import { Utils } from '../../../../utils';
8+
import { ICacheClient } from '../ICacheClient';
99

1010
/**
1111
* A class that provides caching functionality using Redis.
@@ -35,7 +35,7 @@ export class RedisCache implements ICacheClient {
3535
* The logger used for logging all output from this class.
3636
* @private
3737
*/
38-
private readonly logger: Logger;
38+
protected readonly logger: Logger;
3939

4040
/**
4141
* The Redis client.
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
import { RedisCacheError } from '../../../errors/RedisCacheError';
4+
import { RedisCache } from './redisCache';
5+
6+
/**
7+
* A safer wrapper around {@link RedisCache} which is responsible for:
8+
* - ignoring all Redis command errors.
9+
* - logging all errors,
10+
* - returning default values in cases of failures.
11+
*
12+
* Thanks to that our application will be able to continue functioning even with Redis being down...
13+
*/
14+
export class SafeRedisCache extends RedisCache {
15+
/**
16+
* Retrieves a value from the cache.
17+
*
18+
* This method wraps {@link RedisCache.get} and ensures `null` is returned instead of throwing error.
19+
*
20+
* @param key - The cache key.
21+
* @param callingMethod - Name of the method making the request (for logging).
22+
* @returns The cached value, or `null` if Redis fails or the value does not exist.
23+
*/
24+
async get(key: string, callingMethod: string): Promise<any> {
25+
return await this.safeCall(() => super.get(key, callingMethod), null);
26+
}
27+
28+
/**
29+
/**
30+
* Stores a value in the cache safely.
31+
*
32+
* Wraps {@link RedisCache.set} and suppresses Redis errors.
33+
* On failure, nothing is thrown and the error is logged.
34+
*
35+
* @param key - The cache key.
36+
* @param value - The value to store.
37+
* @param callingMethod - Name of the calling method.
38+
* @param ttl - Optional TTL in milliseconds.
39+
*/
40+
async set(key: string, value: any, callingMethod: string, ttl?: number): Promise<void> {
41+
await this.safeCall(() => super.set(key, value, callingMethod, ttl), undefined);
42+
}
43+
44+
/**
45+
* Stores multiple key-value pairs safely.
46+
*
47+
* Wraps {@link RedisCache.multiSet} with error suppression.
48+
*
49+
* @param keyValuePairs - Object of key-value pairs to set.
50+
* @param callingMethod - Name of the calling method.
51+
* @param ttl - Optional TTL used in fallback pipeline mode.
52+
*/
53+
async multiSet(keyValuePairs: Record<string, any>, callingMethod: string, ttl?: number): Promise<void> {
54+
await this.safeCall(() => super.multiSet(keyValuePairs, callingMethod, ttl), undefined);
55+
}
56+
57+
/**
58+
* Performs a pipelined multi-set operation safely.
59+
*
60+
* Wraps {@link RedisCache.pipelineSet} with error suppression.
61+
*
62+
* @param keyValuePairs - Key-value pairs to write.
63+
* @param callingMethod - Name of the calling method.
64+
* @param ttl - Optional TTL.
65+
*/
66+
async pipelineSet(keyValuePairs: Record<string, any>, callingMethod: string, ttl?: number): Promise<void> {
67+
await this.safeCall(() => super.pipelineSet(keyValuePairs, callingMethod, ttl), undefined);
68+
}
69+
70+
/**
71+
* Deletes a value from the cache safely.
72+
*
73+
* Wraps {@link RedisCache.delete} with error suppression.
74+
*
75+
* @param key - Key to delete.
76+
* @param callingMethod - Name of the calling method.
77+
*/
78+
async delete(key: string, callingMethod: string): Promise<void> {
79+
await this.safeCall(() => super.delete(key, callingMethod), undefined);
80+
}
81+
82+
/**
83+
* Increments a numeric value safely.
84+
*
85+
* Wraps {@link RedisCache.incrBy}.
86+
* On failure, returns the `amount` argument as fallback.
87+
*
88+
* @param key - Key to increment.
89+
* @param amount - Increment amount.
90+
* @param callingMethod - Name of the calling method.
91+
* @returns The incremented value or the fallback (amount) if Redis fails.
92+
*/
93+
async incrBy(key: string, amount: number, callingMethod: string): Promise<number> {
94+
return await this.safeCall(() => super.incrBy(key, amount, callingMethod), amount);
95+
}
96+
97+
/**
98+
* Retrieves a list slice safely.
99+
*
100+
* Wraps {@link RedisCache.lRange}.
101+
* On error, returns an empty array.
102+
*
103+
* @param key - List key.
104+
* @param start - Start index.
105+
* @param end - End index.
106+
* @param callingMethod - Name of the calling method.
107+
* @returns List of elements, or an empty array on failure.
108+
*/
109+
async lRange(key: string, start: number, end: number, callingMethod: string): Promise<any[]> {
110+
return await this.safeCall(() => super.lRange(key, start, end, callingMethod), []);
111+
}
112+
113+
/**
114+
* Pushes a value to a list safely.
115+
*
116+
* Wraps {@link RedisCache.rPush}.
117+
* Returns `0` on failure.
118+
*
119+
* @param key - List key.
120+
* @param value - Value to push.
121+
* @param callingMethod - Name of the calling method.
122+
* @returns The new list length, or `0` if Redis fails.
123+
*/
124+
async rPush(key: string, value: any, callingMethod: string): Promise<number> {
125+
return await this.safeCall(() => super.rPush(key, value, callingMethod), 0);
126+
}
127+
128+
/**
129+
* Retrieves keys matching a pattern safely.
130+
*
131+
* Wraps {@link RedisCache.keys}.
132+
* Returns an empty array on error.
133+
*
134+
* @param pattern - Match pattern.
135+
* @param callingMethod - Name of the calling method.
136+
* @returns Array of matched keys (prefix removed), or empty array on error.
137+
*/
138+
async keys(pattern: string, callingMethod: string): Promise<string[]> {
139+
return await this.safeCall(() => super.keys(pattern, callingMethod), []);
140+
}
141+
142+
/**
143+
* Clears all cache keys safely.
144+
*
145+
* Wraps {@link RedisCache.clear}.
146+
* Any Redis failure is logged and ignored.
147+
*/
148+
149+
async clear(): Promise<void> {
150+
await this.safeCall(() => super.clear(), null);
151+
}
152+
153+
/**
154+
* Executes a Redis call safely.
155+
*
156+
* This is the core safety mechanism of {@link SafeRedisCache}.
157+
*
158+
* @template T The expected return type.
159+
* @param fn - Function containing the Redis call.
160+
* @param fallback - Value to return if an error occurs.
161+
* @returns The result of `fn()` or the fallback.
162+
*/
163+
async safeCall<T>(fn: () => Promise<T>, fallback: T): Promise<T> {
164+
try {
165+
return await fn();
166+
} catch (error) {
167+
const redisError = new RedisCacheError(error);
168+
this.logger.error(redisError, 'Error occurred while getting the cache from Redis.');
169+
return fallback;
170+
}
171+
}
172+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// SPDX-License-Identifier: Apache-2.0
22

33
export * from './cache/localLRUCache';
4-
export * from './cache/redisCache';
4+
export * from './cache/redisCache/index';
55
export * from './mirrorNodeClient';
66
export * from './sdkClient';

packages/relay/tests/lib/services/cacheService/cacheService.spec.ts

Lines changed: 14 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -324,58 +324,43 @@ describe('CacheService Test Suite', async function () {
324324
}
325325
});
326326

327-
it('should be able to getAsync from internal cache in case of Redis error', async function () {
327+
it('should be able to ignore getAsync failure in case of Redis error', async function () {
328328
const key = 'string';
329329
await redisManager.disconnect();
330-
331-
const cachedValue = await cacheService.getAsync(key, callingMethod);
332-
expect(cachedValue).eq(null);
330+
await expect(cacheService.getAsync(key, callingMethod)).to.eventually.not.be.rejected;
333331
});
334332

335-
it('should be able to set to internal cache in case of Redis error', async function () {
333+
it('should fail to set in case of Redis error', async function () {
336334
const key = 'string';
337335
const value = 'value';
338336

339337
await redisManager.disconnect();
340338

341339
await expect(cacheService.set(key, value, callingMethod)).to.eventually.not.be.rejected;
342-
343-
const internalCacheRes = await cacheService.getAsync(key, callingMethod);
344-
expect(internalCacheRes).to.eq(value);
345340
});
346341

347-
it('should be able to multiSet to internal cache in case of Redis error', async function () {
342+
it('should be able to ignore multiSet failure in case of Redis error', async function () {
348343
await redisManager.disconnect();
349344

350345
await expect(cacheService.multiSet(multiSetEntries, callingMethod)).to.eventually.not.be.rejected;
351-
352-
for (const [key, value] of Object.entries(multiSetEntries)) {
353-
const internalCacheRes = await cacheService.getAsync(key, callingMethod);
354-
expect(internalCacheRes).to.eq(value);
355-
}
356346
});
357347

358-
it('should be able to pipelineSet to internal cache in case of Redis error', async function () {
348+
it('should be able to ignore pipelineSet failure in case of Redis error', async function () {
359349
// @ts-ignore
360350
cacheService['shouldMultiSet'] = false;
361351

362352
await redisManager.disconnect();
363353

364354
await expect(cacheService.multiSet(multiSetEntries, callingMethod)).to.eventually.not.be.rejected;
365-
366-
for (const [key, value] of Object.entries(multiSetEntries)) {
367-
const internalCacheRes = await cacheService.getAsync(key, callingMethod);
368-
expect(internalCacheRes).to.eq(value);
369-
}
370355
});
371356

372-
it('should be able to clear from internal cache in case of Redis error', async function () {
357+
it('should be able to ignore clear failure in case of Redis error', async function () {
373358
await redisManager.disconnect();
374359

375360
await expect(cacheService.clear()).to.eventually.not.be.rejected;
376361
});
377362

378-
it('should be able to delete from internal cache in case of Redis error', async function () {
363+
it('should be able to ignore delete failure in case of Redis error', async function () {
379364
const key = 'string';
380365
await redisManager.disconnect();
381366

@@ -414,16 +399,11 @@ describe('CacheService Test Suite', async function () {
414399
expect(newValue).to.equal(15);
415400
});
416401

417-
it('should increment value in internal cache in case of Redis error', async function () {
402+
it('should fail to increment value in case of Redis error', async function () {
418403
const key = 'counter';
419-
const amount = 5;
420-
421-
await redisManager.disconnect();
422-
423404
await cacheService.set(key, 10, callingMethod);
424-
const newValue = await cacheService.incrBy(key, amount, callingMethod);
425-
426-
expect(newValue).to.equal(15);
405+
await redisManager.disconnect();
406+
expect(cacheService.incrBy(key, 5, callingMethod)).to.eventually.be.rejected;
427407
});
428408
});
429409

@@ -438,7 +418,7 @@ describe('CacheService Test Suite', async function () {
438418
expect(cachedValue).to.deep.equal([value]);
439419
});
440420

441-
it('should push value to internal cache in case of Redis error', async function () {
421+
it('should not push value in case of Redis error', async function () {
442422
const key = 'list';
443423
const value = 'item';
444424

@@ -447,7 +427,7 @@ describe('CacheService Test Suite', async function () {
447427
await cacheService.rPush(key, value, callingMethod);
448428
const cachedValue = await cacheService.lRange(key, 0, -1, callingMethod);
449429

450-
expect(cachedValue).to.deep.equal([value]);
430+
expect(cachedValue).to.deep.equal([]);
451431
});
452432
});
453433

@@ -476,7 +456,7 @@ describe('CacheService Test Suite', async function () {
476456
expect(range).to.deep.equal(['item2', 'item3']);
477457
});
478458

479-
it('should retrieve range from internal cache in case of Redis error', async function () {
459+
it('should not retrieve range in case of Redis error', async function () {
480460
await redisManager.disconnect();
481461

482462
const key = 'list';
@@ -487,7 +467,7 @@ describe('CacheService Test Suite', async function () {
487467

488468
const range = await cacheService.lRange(key, 0, 1, callingMethod);
489469

490-
expect(range).to.deep.equal(['item1', 'item2']);
470+
expect(range).to.deep.equal([]);
491471
});
492472
});
493473

0 commit comments

Comments
 (0)