Skip to content

Commit 28e225c

Browse files
committed
feat: add cache factory & drop redis fallback & unify client int (#4558)
Signed-off-by: Mariusz Jasuwienas <[email protected]>
1 parent e1a74b9 commit 28e225c

28 files changed

+406
-337
lines changed

packages/relay/src/lib/clients/cache/ICacheClient.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@ export interface ICacheClient {
44
keys(pattern: string, callingMethod: string): Promise<string[]>;
55
get(key: string, callingMethod: string): Promise<any>;
66
set(key: string, value: any, callingMethod: string, ttl?: number): Promise<void>;
7-
multiSet(keyValuePairs: Record<string, any>, callingMethod: string): Promise<void>;
7+
multiSet(keyValuePairs: Record<string, any>, callingMethod: string, ttl?: number | undefined): Promise<void>;
88
pipelineSet(keyValuePairs: Record<string, any>, callingMethod: string, ttl?: number | undefined): Promise<void>;
99
delete(key: string, callingMethod: string): Promise<void>;
1010
clear(): Promise<void>;
11+
incrBy(key: string, amount: number, callingMethod: string): Promise<number>;
12+
rPush(key: string, value: any, callingMethod: string): Promise<number>;
13+
lRange<T = any>(key: string, start: number, end: number, callingMethod: string): Promise<T[]>;
1114
}

packages/relay/src/lib/clients/cache/IRedisCacheClient.ts

Lines changed: 0 additions & 16 deletions
This file was deleted.

packages/relay/src/lib/clients/cache/localLRUCache.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export class LocalLRUCache implements ICacheClient {
7272
* @constructor
7373
* @param {Logger} logger - The logger instance to be used for logging.
7474
* @param {Registry} register - The registry instance used for metrics tracking.
75+
* @param {Set<string>} reservedKeys - These are the cache keys delegated to the reserved cache.
7576
*/
7677
public constructor(logger: Logger, register: Registry, reservedKeys: Set<string> = new Set()) {
7778
this.cache = new LRUCache(this.options);
@@ -137,8 +138,9 @@ export class LocalLRUCache implements ICacheClient {
137138
* @param key - The key to check the remaining TTL for.
138139
* @param callingMethod - The name of the method calling the cache.
139140
* @returns The remaining TTL in milliseconds.
141+
* @private
140142
*/
141-
public async getRemainingTtl(key: string, callingMethod: string): Promise<number> {
143+
private async getRemainingTtl(key: string, callingMethod: string): Promise<number> {
142144
const prefixedKey = this.prefixKey(key);
143145
const cache = this.getCacheInstance(key);
144146
const remainingTtl = cache.getRemainingTTL(prefixedKey); // in milliseconds
@@ -280,6 +282,70 @@ export class LocalLRUCache implements ICacheClient {
280282
return matchingKeys.map((key) => key.substring(LocalLRUCache.CACHE_KEY_PREFIX.length));
281283
}
282284

285+
/**
286+
* Increments a value in the cache.
287+
*
288+
* @param key The key to increment
289+
* @param amount The amount to increment by
290+
* @param callingMethod The name of the calling method
291+
* @returns The value of the key after incrementing
292+
*/
293+
public async incrBy(key: string, amount: number, callingMethod: string): Promise<number> {
294+
const value = await this.get(key, callingMethod);
295+
const newValue = value + amount;
296+
const remainingTtl = await this.getRemainingTtl(key, callingMethod);
297+
await this.set(key, newValue, callingMethod, remainingTtl);
298+
return newValue;
299+
}
300+
301+
/**
302+
* Retrieves a range of elements from a list in the cache.
303+
*
304+
* @param key The key of the list
305+
* @param start The start index
306+
* @param end The end index
307+
* @param callingMethod The name of the calling method
308+
* @returns The list of elements in the range
309+
*/
310+
public async lRange(key: string, start: number, end: number, callingMethod: string): Promise<any[]> {
311+
const values = (await this.get(key, callingMethod)) ?? [];
312+
if (!Array.isArray(values)) {
313+
throw new Error(`Value at key ${key} is not an array`);
314+
}
315+
if (end < 0) {
316+
end = values.length + end;
317+
}
318+
return values.slice(start, end + 1);
319+
}
320+
321+
/**
322+
* Pushes a value to the end of a list in the cache.
323+
*
324+
* @param key The key of the list
325+
* @param value The value to push
326+
* @param callingMethod The name of the calling method
327+
* @returns The length of the list after pushing
328+
*/
329+
public async rPush(key: string, value: any, callingMethod: string): Promise<number> {
330+
const values = (await this.get(key, callingMethod)) ?? [];
331+
if (!Array.isArray(values)) {
332+
throw new Error(`Value at key ${key} is not an array`);
333+
}
334+
values.push(value);
335+
const remainingTtl = await this.getRemainingTtl(key, callingMethod);
336+
await this.set(key, values, callingMethod, remainingTtl);
337+
return values.length;
338+
}
339+
340+
/**
341+
* Returns the appropriate cache instance for the given key.
342+
* If a reserved cache exists and the key is marked as reserved,
343+
* the reserved cache is returned; otherwise the default cache is used.
344+
*
345+
* @param key - The cache key being accessed.
346+
* @returns The selected cache instance
347+
* @private
348+
*/
283349
private getCacheInstance(key: string): LRUCache<string, any> {
284350
return this.reservedCache && this.reservedKeys.has(key) ? this.reservedCache : this.cache;
285351
}
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: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,15 @@
22

33
import { ConfigService } from '@hashgraph/json-rpc-config-service/dist/services';
44
import { Logger } from 'pino';
5-
import { Registry } from 'prom-client';
65
import { RedisClientType } from 'redis';
76

8-
import { Utils } from '../../../utils';
9-
import { IRedisCacheClient } from './IRedisCacheClient';
7+
import { Utils } from '../../../../utils';
8+
import { ICacheClient } from '../ICacheClient';
109

1110
/**
1211
* A class that provides caching functionality using Redis.
1312
*/
14-
export class RedisCache implements IRedisCacheClient {
13+
export class RedisCache implements ICacheClient {
1514
/**
1615
* Prefix used to namespace all keys managed by this cache.
1716
*
@@ -29,19 +28,14 @@ export class RedisCache implements IRedisCacheClient {
2928
private readonly options = {
3029
// Max time to live in ms, for items before they are considered stale.
3130
ttl: ConfigService.get('CACHE_TTL'),
31+
multiSetEnabled: ConfigService.get('MULTI_SET'),
3232
};
3333

3434
/**
3535
* The logger used for logging all output from this class.
3636
* @private
3737
*/
38-
private readonly logger: Logger;
39-
40-
/**
41-
* The metrics register used for metrics tracking.
42-
* @private
43-
*/
44-
private readonly register: Registry;
38+
protected readonly logger: Logger;
4539

4640
/**
4741
* The Redis client.
@@ -53,11 +47,10 @@ export class RedisCache implements IRedisCacheClient {
5347
* Creates an instance of `RedisCache`.
5448
*
5549
* @param {Logger} logger - The logger instance.
56-
* @param {Registry} register - The metrics registry.
50+
* @param {RedisClientType} client
5751
*/
58-
public constructor(logger: Logger, register: Registry, client: RedisClientType) {
52+
public constructor(logger: Logger, client: RedisClientType) {
5953
this.logger = logger;
60-
this.register = register;
6154
this.client = client;
6255
}
6356

@@ -129,9 +122,11 @@ export class RedisCache implements IRedisCacheClient {
129122
*
130123
* @param keyValuePairs - An object where each property is a key and its value is the value to be cached.
131124
* @param callingMethod - The name of the calling method.
125+
* @param [ttl] - The time-to-live (expiration) of the cache item in milliseconds. Used in fallback to pipelineSet.
132126
* @returns A Promise that resolves when the values are cached.
133127
*/
134-
async multiSet(keyValuePairs: Record<string, any>, callingMethod: string): Promise<void> {
128+
async multiSet(keyValuePairs: Record<string, any>, callingMethod: string, ttl?: number): Promise<void> {
129+
if (!this.options.multiSetEnabled) return this.pipelineSet(keyValuePairs, callingMethod, ttl);
135130
// Serialize values and add prefix
136131
const serializedKeyValuePairs: Record<string, string> = {};
137132
for (const [key, value] of Object.entries(keyValuePairs)) {
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';
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
import { ConfigService } from '@hashgraph/json-rpc-config-service/dist/services';
4+
import type { Logger } from 'pino';
5+
import { Registry } from 'prom-client';
6+
import { RedisClientType } from 'redis';
7+
8+
import { LocalLRUCache, RedisCache } from '../clients';
9+
import { ICacheClient } from '../clients/cache/ICacheClient';
10+
11+
export class CacheClientFactory {
12+
static create(
13+
logger: Logger,
14+
register: Registry = new Registry(),
15+
reservedKeys: Set<string> = new Set(),
16+
redisClient?: RedisClientType,
17+
): ICacheClient {
18+
return !ConfigService.get('TEST') && redisClient !== undefined
19+
? new RedisCache(logger.child({ name: 'redisCache' }), redisClient)
20+
: new LocalLRUCache(logger.child({ name: 'localLRUCache' }), register, reservedKeys);
21+
}
22+
}

0 commit comments

Comments
 (0)