Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: Implement custom LRU cache with protected keys #3097

Closed
172 changes: 172 additions & 0 deletions packages/relay/src/lib/clients/cache/impl/customLRUCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/*
*
* Hedera JSON RPC Relay
*
* Copyright (C) 2024 Hedera Hashgraph, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

import LRUCache from 'lru-cache';
import findConfig from 'find-config';
import fs from 'fs';
import { Logger } from 'pino';
import { SpendingPlanConfig } from '../../../types/spendingPlanConfig';
import { EthAddressHbarSpendingPlanRepository } from '../../../db/repositories/hbarLimiter/ethAddressHbarSpendingPlanRepository';
import { IPAddressHbarSpendingPlanRepository } from '../../../db/repositories/hbarLimiter/ipAddressHbarSpendingPlanRepository';
import { HbarSpendingPlanRepository } from '../../../db/repositories/hbarLimiter/hbarSpendingPlanRepository';

export class CustomLRUCache<K, V> extends LRUCache<K, V> {
victor-yanev marked this conversation as resolved.
Show resolved Hide resolved
/**
* The name of the spending plans configuration file. Defaults to `spendingPlansConfig.json`.
*
* @type {string}
* @private
*/
private readonly SPENDING_PLANS_CONFIG_FILE: string =
process.env.HBAR_SPENDING_PLANS_CONFIG_FILE || 'spendingPlansConfig.json';

/**
* The spending plans configuration. This is loaded from the spending plans configuration file.
*
* @type {SpendingPlanConfig[]}
* @private
*/
private readonly spendingPlansConfig: SpendingPlanConfig[];

constructor(
private readonly logger: Logger,
options: LRUCache.Options<K, V>,
) {
super(options);
this.spendingPlansConfig = this.loadSpendingPlansConfig();
}

/**
* Deletes a key from the cache. If the key is protected, the deletion is ignored.
* @param {K} key - The key to delete.
* @returns {boolean} - True if the key was deleted, false otherwise.
* @template K - The key type.
*/
delete(key: K): boolean {
if (typeof key === 'string' && this.isKeyProtected(key)) {
this.logger.trace(`Deletion of key ${key} is ignored as it is protected.`);
return false;
}
return super.delete(key);
}

/**
* Deletes a key from the cache without checking if it is protected.
* @param {K} key - The key to delete.
* @returns {boolean} - True if the key was deleted, false otherwise.
* @template K - The key type.
*/
deleteUnsafe(key: K): boolean {
return super.delete(key);
}

/**
* Loads the pre-configured spending plans from the spending plans configuration file.
* @returns {SpendingPlanConfig[]} - The pre-configured spending plans.
* @private
*/
private loadSpendingPlansConfig(): SpendingPlanConfig[] {
const configPath = findConfig(this.SPENDING_PLANS_CONFIG_FILE);
if (!configPath || !fs.existsSync(configPath)) {
this.logger.trace(`Configuration file not found at path "${configPath ?? this.SPENDING_PLANS_CONFIG_FILE}"`);
return [];
}
try {
const rawData = fs.readFileSync(configPath, 'utf-8');
return JSON.parse(rawData) as SpendingPlanConfig[];
} catch (error: any) {
this.logger.error(`Failed to parse JSON from ${configPath}: ${error.message}`);
return [];
}
}

/**
* Determines if a key is protected. A key is protected if it is associated with a pre-configured spending plan.
* @param {string} key - The key to check.
* @returns {boolean} - True if the key is protected, false otherwise.
* @private
*/
private isKeyProtected(key: string): boolean {
victor-yanev marked this conversation as resolved.
Show resolved Hide resolved
if (this.isHbarSpendingPlanRepositoryKey(key)) {
return this.spendingPlansConfig.some((plan) => {
return (
this.isPreconfiguredPlanKey(key, plan) ||
this.isPreconfiguredEthAddressKey(key, plan) ||
this.isPreconfiguredIpAddressKey(key, plan)
);
});
}
return false;
}

/**
* Determines if a key is associated with any of the spending plan repositories.
* @param {string} key - The key to check.
* @returns {boolean} - True if the key is associated with a spending plan repository, false otherwise.
* @private
*/
private isHbarSpendingPlanRepositoryKey(key: string): boolean {
if (!key) {
return false;

Check warning on line 127 in packages/relay/src/lib/clients/cache/impl/customLRUCache.ts

View check run for this annotation

Codecov / codecov/patch

packages/relay/src/lib/clients/cache/impl/customLRUCache.ts#L127

Added line #L127 was not covered by tests
}
return (
key.startsWith(HbarSpendingPlanRepository.collectionKey) ||
key.startsWith(EthAddressHbarSpendingPlanRepository.collectionKey) ||
key.startsWith(IPAddressHbarSpendingPlanRepository.collectionKey)
);
}

/**
* Determines if a key is associated with a pre-configured spending plan.
* @param {string} key - The key to check.
* @param {SpendingPlanConfig} plan - The spending plan to check against.
* @returns {boolean} - True if the key is associated with the spending plan, false otherwise.
* @private
*/
private isPreconfiguredPlanKey(key: string, plan: SpendingPlanConfig): boolean {
return key.includes(`${HbarSpendingPlanRepository.collectionKey}:${plan.id}`);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: This should work since we're working with fixed length keys. In general I would try to get an exact match, in the case that a key contains the substring of another key.

  const expectedKey = `${HbarSpendingPlanRepository.collectionKey}:${plan.id}`;
  return key === expectedKey;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intentionally done here, because we want to also match the amountSpent and spendingHistory keys which contain the ${HbarSpendingPlanRepository.collectionKey}:${plan.id} prefix:

  • ${HbarSpendingPlanRepository.collectionKey}:${plan.id}:amountSpent
  • ${HbarSpendingPlanRepository.collectionKey}:${plan.id}:spendingHistory

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to a more general approach where we initialize a set of protected keys in the constructor and then in delete we check for the existence of the key in this set of protected keys:

  delete(key: K): boolean {
    if (this.protectedKeys.has(key)) {
      this.logger.trace(`Deletion of key ${key} is ignored as it is protected.`);
      return false;
    }
    return super.delete(key);
  }

}

/**
* Determines if a key is associated with a pre-configured ETH address.
* @param {string} key - The key to check.
* @param {SpendingPlanConfig} plan - The spending plan to check against.
* @returns {boolean} - True if the key is associated with the ETH address, false otherwise.
* @private
*/
private isPreconfiguredEthAddressKey(key: string, plan: SpendingPlanConfig): boolean {
return (plan.ethAddresses || []).some((ethAddress) => {
return key.includes(`${EthAddressHbarSpendingPlanRepository.collectionKey}:${ethAddress.trim().toLowerCase()}`);
});
}

/**
* Determines if a key is associated with a pre-configured IP address.
* @param {string} key - The key to check.
* @param {SpendingPlanConfig} plan - The spending plan to check against.
* @returns {boolean} - True if the key is associated with the IP address, false otherwise.
* @private
*/
private isPreconfiguredIpAddressKey(key: string, plan: SpendingPlanConfig): boolean {
return (plan.ipAddresses || []).some((ipAddress) => {
return key.includes(`${IPAddressHbarSpendingPlanRepository.collectionKey}:${ipAddress}`);
});
}
}
9 changes: 5 additions & 4 deletions packages/relay/src/lib/clients/cache/localLRUCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ import { Logger } from 'pino';
import { Gauge, Registry } from 'prom-client';
import { ICacheClient } from './ICacheClient';
import constants from '../../constants';
import LRUCache, { LimitedByCount, LimitedByTTL } from 'lru-cache';
import { LimitedByCount, LimitedByTTL } from 'lru-cache';
import { RequestDetails } from '../../types';
import { Utils } from '../../../utils';
import { CustomLRUCache } from './impl/customLRUCache';

/**
* Represents a LocalLRUCache instance that uses an LRU (Least Recently Used) caching strategy
Expand All @@ -49,7 +50,7 @@ export class LocalLRUCache implements ICacheClient {
*
* @private
*/
private readonly cache: LRUCache<string, any>;
private readonly cache: CustomLRUCache<string, any>;

/**
* The logger used for logging all output from this class.
Expand All @@ -74,7 +75,7 @@ export class LocalLRUCache implements ICacheClient {
* @param {Registry} register - The registry instance used for metrics tracking.
*/
public constructor(logger: Logger, register: Registry) {
this.cache = new LRUCache(this.options);
this.cache = new CustomLRUCache(logger.child({ name: 'custom-lru-cache' }), this.options);
this.logger = logger;
this.register = register;

Expand Down Expand Up @@ -212,7 +213,7 @@ export class LocalLRUCache implements ICacheClient {
*/
public async delete(key: string, callingMethod: string, requestDetails: RequestDetails): Promise<void> {
this.logger.trace(`${requestDetails.formattedRequestId} delete cache for ${key}`);
this.cache.delete(key);
this.cache.deleteUnsafe(key);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { EthAddressHbarSpendingPlan } from '../../entities/hbarLimiter/ethAddres
import { RequestDetails } from '../../../types';

export class EthAddressHbarSpendingPlanRepository {
private readonly collectionKey = 'ethAddressHbarSpendingPlan';
public static readonly collectionKey = 'ethAddressHbarSpendingPlan';

/**
* The cache service used for storing data.
Expand Down Expand Up @@ -161,6 +161,6 @@ export class EthAddressHbarSpendingPlanRepository {
* @private
*/
private getKey(ethAddress: string): string {
return `${this.collectionKey}:${ethAddress?.trim().toLowerCase()}`;
return `${EthAddressHbarSpendingPlanRepository.collectionKey}:${ethAddress?.trim().toLowerCase()}`;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { HbarSpendingPlan } from '../../entities/hbarLimiter/hbarSpendingPlan';
import { RequestDetails } from '../../../types';

export class HbarSpendingPlanRepository {
private readonly collectionKey = 'hbarSpendingPlan';
public static readonly collectionKey = 'hbarSpendingPlan';

/**
* The cache service used for storing data.
Expand Down Expand Up @@ -266,7 +266,7 @@ export class HbarSpendingPlanRepository {
* @private
*/
private getKey(id: string): string {
return `${this.collectionKey}:${id}`;
return `${HbarSpendingPlanRepository.collectionKey}:${id}`;
}

/**
Expand All @@ -275,7 +275,7 @@ export class HbarSpendingPlanRepository {
* @private
*/
private getAmountSpentKey(id: string): string {
return `${this.collectionKey}:${id}:amountSpent`;
return `${HbarSpendingPlanRepository.collectionKey}:${id}:amountSpent`;
}

/**
Expand All @@ -284,6 +284,6 @@ export class HbarSpendingPlanRepository {
* @private
*/
private getSpendingHistoryKey(id: string): string {
return `${this.collectionKey}:${id}:spendingHistory`;
return `${HbarSpendingPlanRepository.collectionKey}:${id}:spendingHistory`;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { IPAddressHbarSpendingPlan } from '../../entities/hbarLimiter/ipAddressH
import { RequestDetails } from '../../../types';

export class IPAddressHbarSpendingPlanRepository {
private readonly collectionKey = 'ipAddressHbarSpendingPlan';
public static readonly collectionKey = 'ipAddressHbarSpendingPlan';

/**
* The cache service used for storing data.
Expand Down Expand Up @@ -161,6 +161,6 @@ export class IPAddressHbarSpendingPlanRepository {
* @private
*/
private getKey(ipAddress: string): string {
return `${this.collectionKey}:${ipAddress}`;
return `${IPAddressHbarSpendingPlanRepository.collectionKey}:${ipAddress}`;
}
}
5 changes: 5 additions & 0 deletions packages/relay/tests/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ const random20BytesAddress = (addHexPrefix = true) => {
return (addHexPrefix ? '0x' : '') + crypto.randomBytes(20).toString('hex');
};

const randomIpAddress = () => {
return Array.from({ length: 4 }, () => Math.floor(Math.random() * 256)).join('.');
};

export const toHex = (num) => {
return `0x${Number(num).toString(16)}`;
};
Expand Down Expand Up @@ -358,6 +362,7 @@ export {
signTransaction,
mockData,
random20BytesAddress,
randomIpAddress,
getRequestId,
getQueryParams,
};
Expand Down
Loading
Loading