Skip to content
Merged
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
36 changes: 28 additions & 8 deletions packages/relay/src/lib/clients/cache/localLRUCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ type LRUCacheOptions = LRUCache.OptionsMaxLimit<string, any, unknown> & LRUCache
* @implements {ICacheClient}
*/
export class LocalLRUCache implements ICacheClient {
private static readonly CACHE_KEY_PREFIX = 'cache:';

/**
* Configurable options used when initializing the cache.
*
Expand Down Expand Up @@ -96,6 +98,17 @@ export class LocalLRUCache implements ICacheClient {
});
}

/**
* Adds the cache prefix to a key.
*
* @param key - The key to prefix.
* @returns The prefixed key.
* @private
*/
private prefixKey(key: string): string {
return `${LocalLRUCache.CACHE_KEY_PREFIX}${key}`;
}

/**
* Retrieves a cached value associated with the given key.
* If the value exists in the cache, updates metrics and logs the retrieval.
Expand All @@ -104,8 +117,9 @@ export class LocalLRUCache implements ICacheClient {
* @returns The cached value if found, otherwise null.
*/
public async get(key: string, callingMethod: string): Promise<any> {
const prefixedKey = this.prefixKey(key);
const cache = this.getCacheInstance(key);
const value = cache.get(key);
const value = cache.get(prefixedKey);
if (value !== undefined) {
const censoredKey = key.replace(Utils.IP_ADDRESS_REGEX, '<REDACTED>');
const censoredValue = JSON.stringify(value).replace(/"ipAddress":"[^"]+"/, '"ipAddress":"<REDACTED>"');
Expand All @@ -125,8 +139,9 @@ export class LocalLRUCache implements ICacheClient {
* @returns The remaining TTL in milliseconds.
*/
public async getRemainingTtl(key: string, callingMethod: string): Promise<number> {
const prefixedKey = this.prefixKey(key);
const cache = this.getCacheInstance(key);
const remainingTtl = cache.getRemainingTTL(key); // in milliseconds
const remainingTtl = cache.getRemainingTTL(prefixedKey); // in milliseconds
if (this.logger.isLevelEnabled('trace')) {
this.logger.trace(`returning remaining TTL ${key}:${remainingTtl} on ${callingMethod} call`);
}
Expand All @@ -142,12 +157,13 @@ export class LocalLRUCache implements ICacheClient {
* @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> {
const prefixedKey = this.prefixKey(key);
const resolvedTtl = ttl ?? this.options.ttl;
const cache = this.getCacheInstance(key);
if (resolvedTtl > 0) {
cache.set(key, value, { ttl: resolvedTtl });
cache.set(prefixedKey, value, { ttl: resolvedTtl });
} else {
cache.set(key, value, { ttl: 0 }); // 0 means indefinite time
cache.set(prefixedKey, value, { ttl: 0 }); // 0 means indefinite time
}
if (this.logger.isLevelEnabled('trace')) {
const censoredKey = key.replace(Utils.IP_ADDRESS_REGEX, '<REDACTED>');
Expand Down Expand Up @@ -195,11 +211,12 @@ export class LocalLRUCache implements ICacheClient {
* @param callingMethod - The name of the method calling the cache.
*/
public async delete(key: string, callingMethod: string): Promise<void> {
const prefixedKey = this.prefixKey(key);
if (this.logger.isLevelEnabled('trace')) {
this.logger.trace(`delete cache for ${key} on ${callingMethod} call`);
}
const cache = this.getCacheInstance(key);
cache.delete(key);
cache.delete(prefixedKey);
}

/**
Expand All @@ -223,13 +240,15 @@ export class LocalLRUCache implements ICacheClient {
* 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.
* @returns An array of keys that match the pattern (without the cache prefix).
*/
public async keys(pattern: string, callingMethod: string): Promise<string[]> {
const keys = [...this.cache.rkeys(), ...(this.reservedCache?.rkeys() ?? [])];

const prefixedPattern = this.prefixKey(pattern);

// Replace escaped special characters with placeholders
let regexPattern = pattern
let regexPattern = prefixedPattern
.replace(/\\\*/g, '__ESCAPED_STAR__')
.replace(/\\\?/g, '__ESCAPED_QUESTION__')
.replace(/\\\[/g, '__ESCAPED_OPEN_BRACKET__')
Expand Down Expand Up @@ -257,7 +276,8 @@ export class LocalLRUCache implements ICacheClient {
if (this.logger.isLevelEnabled('trace')) {
this.logger.trace(`retrieving keys matching ${pattern} on ${callingMethod} call`);
}
return matchingKeys;
// Remove the prefix from the returned keys
return matchingKeys.map((key) => key.substring(LocalLRUCache.CACHE_KEY_PREFIX.length));
}

private getCacheInstance(key: string): LRUCache<string, any> {
Expand Down
65 changes: 46 additions & 19 deletions packages/relay/src/lib/clients/cache/redisCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ import { IRedisCacheClient } from './IRedisCacheClient';
* A class that provides caching functionality using Redis.
*/
export class RedisCache implements IRedisCacheClient {
/**
* Prefix used to namespace all keys managed by this cache.
*
* @remarks
* Using a prefix allows efficient scanning and cleanup of related keys
* without interfering with keys from other services (e.g., pending:, hbar-limit:).
*/
private static readonly CACHE_KEY_PREFIX = 'cache:';

/**
* Configurable options used when initializing the cache.
*
Expand Down Expand Up @@ -52,6 +61,17 @@ export class RedisCache implements IRedisCacheClient {
this.client = client;
}

/**
* Adds the cache prefix to a key.
*
* @param key - The key to prefix.
* @returns The prefixed key.
* @private
*/
private prefixKey(key: string): string {
return `${RedisCache.CACHE_KEY_PREFIX}${key}`;
}

/**
* Retrieves a value from the cache.
*
Expand All @@ -60,7 +80,8 @@ export class RedisCache implements IRedisCacheClient {
* @returns The cached value or null if not found.
*/
async get(key: string, callingMethod: string): Promise<any> {
const result = await this.client.get(key);
const prefixedKey = this.prefixKey(key);
const result = await this.client.get(prefixedKey);
if (result) {
if (this.logger.isLevelEnabled('trace')) {
const censoredKey = key.replace(Utils.IP_ADDRESS_REGEX, '<REDACTED>');
Expand All @@ -83,12 +104,13 @@ export class RedisCache implements IRedisCacheClient {
* @returns A Promise that resolves when the value is cached.
*/
async set(key: string, value: any, callingMethod: string, ttl?: number): Promise<void> {
const prefixedKey = this.prefixKey(key);
const serializedValue = JSON.stringify(value);
const resolvedTtl = ttl ?? this.options.ttl; // in milliseconds
if (resolvedTtl > 0) {
await this.client.set(key, serializedValue, { PX: resolvedTtl });
await this.client.set(prefixedKey, serializedValue, { PX: resolvedTtl });
} else {
await this.client.set(key, serializedValue);
await this.client.set(prefixedKey, serializedValue);
}

const censoredKey = key.replace(Utils.IP_ADDRESS_REGEX, '<REDACTED>');
Expand All @@ -110,10 +132,11 @@ export class RedisCache implements IRedisCacheClient {
* @returns A Promise that resolves when the values are cached.
*/
async multiSet(keyValuePairs: Record<string, any>, callingMethod: string): Promise<void> {
// Serialize values
// Serialize values and add prefix
const serializedKeyValuePairs: Record<string, string> = {};
for (const [key, value] of Object.entries(keyValuePairs)) {
serializedKeyValuePairs[key] = JSON.stringify(value);
const prefixedKey = this.prefixKey(key);
serializedKeyValuePairs[prefixedKey] = JSON.stringify(value);
}

// Perform mSet operation
Expand All @@ -140,8 +163,9 @@ export class RedisCache implements IRedisCacheClient {
const pipeline = this.client.multi();

for (const [key, value] of Object.entries(keyValuePairs)) {
const prefixedKey = this.prefixKey(key);
const serializedValue = JSON.stringify(value);
pipeline.set(key, serializedValue, { PX: resolvedTtl });
pipeline.set(prefixedKey, serializedValue, { PX: resolvedTtl });
}

// Execute pipeline operation
Expand All @@ -162,7 +186,8 @@ export class RedisCache implements IRedisCacheClient {
* @returns A Promise that resolves when the value is deleted from the cache.
*/
async delete(key: string, callingMethod: string): Promise<void> {
await this.client.del(key);
const prefixedKey = this.prefixKey(key);
await this.client.del(prefixedKey);
if (this.logger.isLevelEnabled('trace')) {
this.logger.trace(`delete cache for ${key} on ${callingMethod} call`);
}
Expand All @@ -178,7 +203,8 @@ export class RedisCache implements IRedisCacheClient {
* @returns The value of the key after incrementing
*/
async incrBy(key: string, amount: number, callingMethod: string): Promise<number> {
const result = await this.client.incrBy(key, amount);
const prefixedKey = this.prefixKey(key);
const result = await this.client.incrBy(prefixedKey, amount);
if (this.logger.isLevelEnabled('trace')) {
this.logger.trace(`incrementing ${key} by ${amount} on ${callingMethod} call`);
}
Expand All @@ -195,7 +221,8 @@ export class RedisCache implements IRedisCacheClient {
* @returns The list of elements in the range
*/
async lRange(key: string, start: number, end: number, callingMethod: string): Promise<any[]> {
const result = await this.client.lRange(key, start, end);
const prefixedKey = this.prefixKey(key);
const result = await this.client.lRange(prefixedKey, start, end);
if (this.logger.isLevelEnabled('trace')) {
this.logger.trace(`retrieving range [${start}:${end}] from ${key} on ${callingMethod} call`);
}
Expand All @@ -211,8 +238,9 @@ export class RedisCache implements IRedisCacheClient {
* @returns The length of the list after pushing
*/
async rPush(key: string, value: any, callingMethod: string): Promise<number> {
const prefixedKey = this.prefixKey(key);
const serializedValue = JSON.stringify(value);
const result = await this.client.rPush(key, serializedValue);
const result = await this.client.rPush(prefixedKey, serializedValue);
if (this.logger.isLevelEnabled('trace')) {
this.logger.trace(`pushing ${serializedValue} to ${key} on ${callingMethod} call`);
}
Expand All @@ -223,27 +251,26 @@ export class RedisCache implements IRedisCacheClient {
* Retrieves all keys matching a pattern.
* @param pattern The pattern to match
* @param callingMethod The name of the calling method
* @returns The list of keys matching the pattern
* @returns The list of keys matching the pattern (without the cache prefix)
*/
async keys(pattern: string, callingMethod: string): Promise<string[]> {
const result = await this.client.keys(pattern);
const prefixedPattern = this.prefixKey(pattern);
const result = await this.client.keys(prefixedPattern);
if (this.logger.isLevelEnabled('trace')) {
this.logger.trace(`retrieving keys matching ${pattern} on ${callingMethod} call`);
}
return result;
// Remove the prefix from the returned keys
return result.map((key) => key.substring(RedisCache.CACHE_KEY_PREFIX.length));
}

/**
* Clears the entire cache leaving out the transaction pool.
* Clears only the cache keys (those with cache: prefix).
* Uses pipelining for efficient bulk deletion with UNLINK (non-blocking).
*
* @returns {Promise<void>} A Promise that resolves when the cache is cleared.
*/
async clear(): Promise<void> {
const allKeys = await this.client.keys('*');

// Filter out keys that start with "pending:"
const keysToDelete = allKeys.filter((key) => !key.startsWith('pending:'));
const keysToDelete = await this.client.keys(`${RedisCache.CACHE_KEY_PREFIX}*`);

if (keysToDelete.length > 0) {
// Use pipeline for efficient bulk deletion
Expand All @@ -256,7 +283,7 @@ export class RedisCache implements IRedisCacheClient {
await pipeline.exec();

if (this.logger.isLevelEnabled('trace')) {
this.logger.trace('Cleared cache');
this.logger.trace(`Cleared ${keysToDelete.length} cache keys`);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ export class RedisPendingTransactionStorage implements PendingTransactionStorage
* Prefix used to namespace all keys managed by this storage.
*
* @remarks
* Using a prefix allows efficient scanning and cleanup of related keys
* Using a prefix allows efficient scanning and cleanup of related keys.
* Uses 'txpool:pending:' to distinguish from other transaction pool states
* (e.g., future 'txpool:queue:').
*/
private readonly keyPrefix = 'pending:';
private readonly keyPrefix = 'txpool:pending:';

/**
* The time-to-live (TTL) for the pending transaction storage in seconds.
Expand All @@ -29,7 +31,7 @@ export class RedisPendingTransactionStorage implements PendingTransactionStorage
* Resolves the Redis key for a given address.
*
* @param addr - Account address whose pending list key should be derived.
* @returns The Redis key (e.g., `pending:<address>`).
* @returns The Redis key (e.g., `txpool:pending:<address>`).
*/
private keyFor(address: string): string {
return `${this.keyPrefix}${address}`;
Expand Down Expand Up @@ -67,7 +69,7 @@ export class RedisPendingTransactionStorage implements PendingTransactionStorage
}

/**
* Removes all keys managed by this storage (all `pending:*`).
* Removes all keys managed by this storage (all `txpool:pending:*`).
*/
async removeAll(): Promise<void> {
const keys = await this.redisClient.keys(`${this.keyPrefix}*`);
Expand Down
2 changes: 1 addition & 1 deletion packages/relay/tests/lib/clients/localLRUCache.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ describe('LocalLRUCache Test Suite', async function () {
const ttl = -1;

await customLocalLRUCache.set(key, value, callingMethod, ttl);
sinon.assert.calledOnceWithExactly(lruCacheSpy.set, key, value, { ttl: 0 });
sinon.assert.calledOnceWithExactly(lruCacheSpy.set, `cache:${key}`, value, { ttl: 0 });

const cachedValue = await customLocalLRUCache.get(key, callingMethod);
expect(cachedValue).equal(value);
Expand Down
44 changes: 41 additions & 3 deletions packages/relay/tests/lib/clients/redisCache.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,9 @@ describe('RedisCache Test Suite', async function () {
const ttl = 100;

await redisCache.set(key, value, callingMethod, ttl);
sinon.assert.calledOnceWithExactly(redisClient.set as sinon.SinonSpy, key, JSON.stringify(value), { PX: ttl });
sinon.assert.calledOnceWithExactly(redisClient.set as sinon.SinonSpy, `cache:${key}`, JSON.stringify(value), {
PX: ttl,
});

const cachedValue = await redisCache.get(key, callingMethod);
expect(cachedValue).equal(value);
Expand All @@ -120,7 +122,9 @@ describe('RedisCache Test Suite', async function () {
const ttl = 1100;

await redisCache.set(key, value, callingMethod, ttl);
sinon.assert.calledOnceWithExactly(redisClient.set as sinon.SinonSpy, key, JSON.stringify(value), { PX: ttl });
sinon.assert.calledOnceWithExactly(redisClient.set as sinon.SinonSpy, `cache:${key}`, JSON.stringify(value), {
PX: ttl,
});

const cachedValue = await redisCache.get(key, callingMethod);
expect(cachedValue).equal(value);
Expand All @@ -137,7 +141,7 @@ describe('RedisCache Test Suite', async function () {
const ttl = -1;

await redisCache.set(key, value, callingMethod, ttl);
sinon.assert.calledOnceWithExactly(redisClient.set as sinon.SinonSpy, key, JSON.stringify(value));
sinon.assert.calledOnceWithExactly(redisClient.set as sinon.SinonSpy, `cache:${key}`, JSON.stringify(value));

const cachedValue = await redisCache.get(key, callingMethod);
expect(cachedValue).equal(value);
Expand Down Expand Up @@ -446,6 +450,40 @@ describe('RedisCache Test Suite', async function () {
});
});

describe('Clear Test Suite', () => {
it('should only clear cache:* keys and not other namespaces', async () => {
// Add some cache keys
await redisCache.set('eth_blockNumber', '123', callingMethod);
await redisCache.set('eth_gasPrice', '456', callingMethod);

// Add keys from other namespaces to simulate other services
await redisClient.set('txpool:pending:0x123', 'pendingtx');
await redisClient.set('txpool:queue:0x456', 'queuedtx');
await redisClient.set('hbar-limit:0x789', 'limitdata');
await redisClient.set('other:namespace:key', 'value');

// Clear the cache
await redisCache.clear();

// Verify cache keys are gone
const cacheValue1 = await redisCache.get('eth_blockNumber', callingMethod);
const cacheValue2 = await redisCache.get('eth_gasPrice', callingMethod);
expect(cacheValue1).to.be.null;
expect(cacheValue2).to.be.null;

// Verify other namespace keys are still present
const pendingTx = await redisClient.get('txpool:pending:0x123');
const queueTx = await redisClient.get('txpool:queue:0x456');
const limitData = await redisClient.get('hbar-limit:0x789');
const otherKey = await redisClient.get('other:namespace:key');

expect(pendingTx).to.equal('pendingtx');
expect(queueTx).to.equal('queuedtx');
expect(limitData).to.equal('limitdata');
expect(otherKey).to.equal('value');
});
});

describe('Disconnect Test Suite', () => {
it('should disconnect from the Redis cache', async () => {
await redisClientManager.disconnect();
Expand Down
Loading
Loading