Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions docs/testing-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ import chaiAsPromised from 'chai-as-promised';
import sinon from 'sinon';
import { MyClass } from './my-class';
import { CacheService } from './cache-service';
import { CacheClientFactory } from './cacheClientFactory';

chai.use(chaiAsPromised);

Expand All @@ -165,7 +166,7 @@ describe('MyClass', function() {

beforeEach(function() {
// Common setup for all tests
cacheService = new CacheService();
cacheService = CacheClientFactory.create();
myClass = new MyClass(cacheService);
});

Expand Down Expand Up @@ -229,7 +230,7 @@ describe('MyClass', function() {
});
});
});

describe('anotherMethod', () => {
// Tests for anotherMethod
// Use analogous formatting to the tests for myMethod
Expand Down Expand Up @@ -268,6 +269,9 @@ import sinon from 'sinon';
import pino from 'pino';
import { overrideEnvsInMochaDescribe, useInMemoryRedisServer, withOverriddenEnvsInMochaTest } from './helpers';

import { CacheService } from './cache-service';
import { CacheClientFactory } from './cacheClientFactory';

chai.use(chaiAsPromised);

describe('MyClass', function() {
Expand All @@ -289,7 +293,7 @@ describe('MyClass', function() {
beforeEach(function() {
// Common setup for all tests
serviceThatDependsOnEnv = new ServiceThatDependsOnEnv();
cacheService = new CacheService();
cacheService = CacheClientFactory.create();
myClass = new MyClass(serviceThatDependsOnEnv, cacheService);
});

Expand Down
5 changes: 5 additions & 0 deletions packages/relay/src/lib/clients/cache/ICacheClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,9 @@ export interface ICacheClient {
incrBy(key: string, amount: number, callingMethod: string): Promise<number>;
rPush(key: string, value: any, callingMethod: string): Promise<number>;
lRange<T = any>(key: string, start: number, end: number, callingMethod: string): Promise<T[]>;

/**
* @deprecated Alias of `get`; consider removing. Left in place to avoid modifying the CacheService interface.
*/
getAsync<T = any>(key: string, callingMethod: string): Promise<T>;
}
13 changes: 13 additions & 0 deletions packages/relay/src/lib/clients/cache/localLRUCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,19 @@ export class LocalLRUCache implements ICacheClient {
return `${LocalLRUCache.CACHE_KEY_PREFIX}${key}`;
}

/**
* Alias for the `get` method.
*
* @param key - The key associated with the cached value.
* @param callingMethod - The name of the method calling the cache.
* @returns The cached value if found, otherwise null.
*
* @deprecated use `get` instead.
*/
public getAsync(key: string, callingMethod: string): Promise<any> {
return this.get(key, callingMethod);
}

/**
* Retrieves a cached value associated with the given key.
* If the value exists in the cache, updates metrics and logs the retrieval.
Expand Down
201 changes: 201 additions & 0 deletions packages/relay/src/lib/clients/cache/measurableCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
// SPDX-License-Identifier: Apache-2.0

import { Counter } from 'prom-client';

import { ICacheClient } from './ICacheClient';

/**
* Represents a cache client that performs the caching operations and tracks and counts all processed events.
*
* @implements {ICacheClient}
*/
export class MeasurableCache implements ICacheClient {
private decorated: ICacheClient;
private readonly cacheMethodsCounter: Counter;

public static readonly methods = {
GET: 'get',
GET_ASYNC: 'getAsync',
SET: 'set',
DELETE: 'delete',
MSET: 'mSet',
PIPELINE: 'pipeline',
INCR_BY: 'incrBy',
RPUSH: 'rpush',
LRANGE: 'lrange',
};

private cacheType: string;
private callMap: Map<string, string[]>;

public constructor(
decorated: ICacheClient,
cacheMethodsCounter: Counter,
cacheType: string,
callMap: Map<string, string[]>,
) {
this.decorated = decorated;
this.cacheMethodsCounter = cacheMethodsCounter;
this.cacheType = cacheType;
this.callMap = callMap;
}

/**
* Alias for the `get` method.
*
* @param key - The key associated with the cached value.
* @param callingMethod - The name of the method calling the cache.
* @returns The cached value if found, otherwise null.
*
* @deprecated use `get` instead.
*/
public getAsync(key: string, callingMethod: string): Promise<any> {
return this.decorated.get(key, callingMethod);
}

/**
* Calls the method that retrieves a cached value associated with the given key
* and tracks how many times this event occurs.
*
* @param key - The key associated with the cached value.
* @param callingMethod - The name of the method calling the cache.
* @returns The cached value if found, otherwise null.
*/
public async get(key: string, callingMethod: string): Promise<any> {
this.count(callingMethod, MeasurableCache.methods.GET_ASYNC);
return await this.decorated.get(key, callingMethod);
}

/**
* Calls the method that sets a value in the cache for the given key
* and tracks how many times this event occurs.
*
* @param key - The key to associate with the value.
* @param value - The value to cache.
* @param callingMethod - The name of the method calling the cache.
* @param ttl - Time to live for the cached value in milliseconds (optional).
*/
public async set(key: string, value: any, callingMethod: string, ttl?: number): Promise<void> {
this.count(callingMethod, MeasurableCache.methods.SET);
return await this.decorated.set(key, value, callingMethod, ttl);
}

/**
* Calls the method that stores multiple key–value pairs in the cache
* and tracks how many times this event occurs.
*
* @param keyValuePairs - An object where each property is a key and its value is the value to be cached.
* @param callingMethod - The name of the calling method.
* @param ttl - Time to live on the set values
* @returns A Promise that resolves when the values are cached.
*/
public async multiSet(keyValuePairs: Record<string, any>, callingMethod: string, ttl?: number): Promise<void> {
await this.decorated.multiSet(keyValuePairs, callingMethod, ttl);
this.count(callingMethod, MeasurableCache.methods.MSET);
}

/**
* Calls the pipelineSet method that stores multiple key–value pairs in the cache
* and tracks how many times this event occurs.
*
* @param keyValuePairs - An object where each property is a key and its value is the value to be cached.
* @param callingMethod - The name of the calling method.
* @param ttl - Time to live on the set values
* @returns A Promise that resolves when the values are cached.
*/
public async pipelineSet(keyValuePairs: Record<string, any>, callingMethod: string, ttl?: number): Promise<void> {
await this.decorated.pipelineSet(keyValuePairs, callingMethod, ttl);
this.count(callingMethod, MeasurableCache.methods.PIPELINE);
}

/**
* Calls the method that deletes the cached value associated with the given key
* and tracks how many times this event occurs.
*
* @param key - The key associated with the cached value to delete.
* @param callingMethod - The name of the method calling the cache.
*/
public async delete(key: string, callingMethod: string): Promise<void> {
this.count(callingMethod, MeasurableCache.methods.DELETE);
await this.decorated.delete(key, callingMethod);
}

/**
* Calls the method that clears the entire cache, removing all entries.
*/
public async clear(): Promise<void> {
await this.decorated.clear();
}

/**
* Call the method that retrieves all keys in the cache that match the given pattern.
*
* @param pattern - The pattern to match keys against.
* @param callingMethod - The name of the method calling the cache.
* @returns An array of keys that match the pattern (without the cache prefix).
*/
public async keys(pattern: string, callingMethod: string): Promise<string[]> {
return await this.decorated.keys(pattern, callingMethod);
}

/**
* Calls the method that increments a cached value and tracks how many times this event occurs.
*
* @param key The key to increment
* @param amount The amount to increment by
* @param callingMethod The name of the calling method
* @returns The value of the key after incrementing
*/
public async incrBy(key: string, amount: number, callingMethod: string): Promise<number> {
this.count(callingMethod, MeasurableCache.methods.INCR_BY);
return await this.decorated.incrBy(key, amount, callingMethod);
}

/**
* Calls the method that retrieves a range of elements from a list in the cache
* and tracks how many times this event occurs.
*
* @param key The key of the list
* @param start The start index
* @param end The end index
* @param callingMethod The name of the calling method
* @returns The list of elements in the range
*/
public async lRange(key: string, start: number, end: number, callingMethod: string): Promise<any[]> {
this.count(callingMethod, MeasurableCache.methods.LRANGE);
return await this.decorated.lRange(key, start, end, callingMethod);
}

/**
* Calls the method that pushes a value to the end of a list in the cache
* and tracks how many times this event occurs.
*
* @param key The key of the list
* @param value The value to push
* @param callingMethod The name of the calling method
* @returns The length of the list after pushing
*/
public async rPush(key: string, value: any, callingMethod: string): Promise<number> {
this.count(callingMethod, MeasurableCache.methods.RPUSH);
return await this.decorated.rPush(key, value, callingMethod);
}

/**
* Counts the number of occurrences of the given caching related operation.
* Depending on the underlying client implementation, the actual caching behavior may vary.
* The `callMap` allows us to account for these differences when counting occurrences.
*
* For example, if the underlying cache mechanism (such as LRU) does not provide an lRange method,
* we can implement it ourselves by using get and set instead. We want to count each lRange call
* as corresponding get and set calls then.
*
* @param caller The name of the calling method
* @param callee Actual caching operation
* @private
*/
private count(caller: string, callee: string): void {
(this.callMap.get(callee) || [callee]).forEach((value: string) =>
this.cacheMethodsCounter.labels(caller, this.cacheType, value).inc(1),
);
}
}
13 changes: 13 additions & 0 deletions packages/relay/src/lib/clients/cache/redisCache/redisCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,19 @@ export class RedisCache implements ICacheClient {
return `${RedisCache.CACHE_KEY_PREFIX}${key}`;
}

/**
* Alias for the `get` method.
*
* @param key - The key associated with the cached value.
* @param callingMethod - The name of the method calling the cache.
* @returns The cached value if found, otherwise null.
*
* @deprecated use `get` instead.
*/
public getAsync(key: string, callingMethod: string): Promise<any> {
return this.get(key, callingMethod);
}

/**
* Retrieves a value from the cache.
*
Expand Down
13 changes: 13 additions & 0 deletions packages/relay/src/lib/clients/cache/redisCache/safeRedisCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,19 @@ import { RedisCache } from './redisCache';
* Thanks to that our application will be able to continue functioning even with Redis being down...
*/
export class SafeRedisCache extends RedisCache {
/**
* Alias for the `get` method.
*
* @param key - The key associated with the cached value.
* @param callingMethod - The name of the method calling the cache.
* @returns The cached value if found, otherwise null.
*
* @deprecated use `get` instead.
*/
public getAsync(key: string, callingMethod: string): Promise<any> {
return this.get(key, callingMethod);
}

/**
* Retrieves a value from the cache.
*
Expand Down
39 changes: 36 additions & 3 deletions packages/relay/src/lib/factories/cacheClientFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,44 @@

import { ConfigService } from '@hashgraph/json-rpc-config-service/dist/services';
import type { Logger } from 'pino';
import { Registry } from 'prom-client';
import { Counter, Registry } from 'prom-client';
import { RedisClientType } from 'redis';

import { LocalLRUCache, RedisCache } from '../clients';
import { ICacheClient } from '../clients/cache/ICacheClient';
import { MeasurableCache } from '../clients/cache/measurableCache';

const measurable = (client: ICacheClient, register: Registry, configType: 'lru' | 'redis') => {
/**
* Labels:
* callingMethod - The method initiating the cache operation
* cacheType - redis/lru
* method - The CacheService method being called
*/
const metricName = 'rpc_cache_service_methods_counter';
register.removeSingleMetric(metricName);
const methodsCounter = new Counter({
name: metricName,
help: 'Counter for calls to methods of CacheService separated by CallingMethod and CacheType',
registers: [register],
labelNames: ['callingMethod', 'cacheType', 'method'],
});

const config = {
lru: new Map([
[MeasurableCache.methods.GET_ASYNC, [MeasurableCache.methods.GET]],
[MeasurableCache.methods.MSET, [MeasurableCache.methods.SET]],
[MeasurableCache.methods.INCR_BY, [MeasurableCache.methods.GET, MeasurableCache.methods.SET]],
[MeasurableCache.methods.LRANGE, [MeasurableCache.methods.GET]],
[MeasurableCache.methods.RPUSH, [MeasurableCache.methods.GET, MeasurableCache.methods.SET]],
]),
redis: ConfigService.get('MULTI_SET')
? new Map()
: new Map([[MeasurableCache.methods.MSET, [MeasurableCache.methods.PIPELINE]]]),
};

return new MeasurableCache(client, methodsCounter, configType, config[configType]);
};

export class CacheClientFactory {
static create(
Expand All @@ -16,7 +49,7 @@ export class CacheClientFactory {
redisClient?: RedisClientType,
): ICacheClient {
return !ConfigService.get('TEST') && redisClient !== undefined
? new RedisCache(logger.child({ name: 'redisCache' }), redisClient)
: new LocalLRUCache(logger.child({ name: 'localLRUCache' }), register, reservedKeys);
? measurable(new RedisCache(logger.child({ name: 'redisCache' }), redisClient), register, 'redis')
: measurable(new LocalLRUCache(logger.child({ name: 'localLRUCache' }), register, reservedKeys), register, 'lru');
}
}
11 changes: 4 additions & 7 deletions packages/relay/src/lib/relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,14 +284,11 @@ export class Relay {
const reservedKeys = HbarSpendingPlanConfigService.getPreconfiguredSpendingPlanKeys(this.logger);

// Create CacheService with the connected Redis client (or undefined for LRU-only)
this.cacheService = new CacheService(
CacheClientFactory.create(
this.logger.child({ name: 'cache-service' }),
this.register,
reservedKeys,
this.redisClient,
),
this.cacheService = CacheClientFactory.create(
this.logger.child({ name: 'cache-service' }),
this.register,
reservedKeys,
this.redisClient,
);

// Create spending plan repositories
Expand Down
Loading