Skip to content

Commit

Permalink
fix: Protect preconfigured spending plans from being deleted automati…
Browse files Browse the repository at this point in the history
…cally in `LRUCache` (#3140)

* fix: Protect pre-configured spending plans from being deleted automatically

Signed-off-by: Victor Yanev <[email protected]>

* fix: Protect pre-configured spending plans from being deleted automatically

Signed-off-by: Victor Yanev <[email protected]>

* fix: Protect pre-configured spending plans from being deleted automatically

Signed-off-by: Victor Yanev <[email protected]>

* test: fix hbarSpendingPlanConfigService.spec.ts

Signed-off-by: Victor Yanev <[email protected]>

* fix: do not throw error from `getPreconfiguredSpendingPlanKeys` when the configuration file cannot be loaded

Signed-off-by: Victor Yanev <[email protected]>

* chore: use object destructuring for better readability

Signed-off-by: Victor Yanev <[email protected]>

* chore: update hbarSpendingPlanConfigService.spec.ts

Signed-off-by: Victor Yanev <[email protected]>

* chore: fix after merge from main

Signed-off-by: Victor Yanev <[email protected]>

* chore: improve readabiltiy in hbarSpendingPlanConfigService.spec.ts

Signed-off-by: Victor Yanev <[email protected]>

* Merge branch 'main' into 3096-Protect-preconfigured-spending-plans-from-being-deleted

Signed-off-by: Victor Yanev <[email protected]>

# Conflicts:
#	packages/server/tests/acceptance/hbarLimiter.spec.ts

---------

Signed-off-by: Victor Yanev <[email protected]>
  • Loading branch information
victor-yanev authored Oct 28, 2024
1 parent 9afe7c2 commit ef9d696
Show file tree
Hide file tree
Showing 16 changed files with 449 additions and 132 deletions.
4 changes: 1 addition & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/config-service/src/services/globalConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ export class GlobalConfig {
envName: 'HBAR_SPENDING_PLANS_CONFIG_FILE',
type: 'string',
required: false,
defaultValue: null,
defaultValue: 'spendingPlansConfig.json',
},
INITIAL_BALANCE: {
envName: 'INITIAL_BALANCE',
Expand Down
45 changes: 34 additions & 11 deletions packages/relay/src/lib/clients/cache/localLRUCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,24 @@ export class LocalLRUCache implements ICacheClient {
private readonly logger: Logger;

/**
* The metrics register used for metrics tracking.
* The gauge used for tracking the size of the cache.
* @private
*/
private readonly register: Registry;
private readonly cacheKeyGauge: Gauge<string>;

/**
* A set of keys that should never be evicted from the cache.
* @private
*/
private readonly reservedKeys: Set<string>;

/**
* The LRU cache used for caching items from requests that should never be evicted.
*
* @private
*/
private readonly reservedCache?: LRUCache<string, any>;

/**
* Represents a LocalLRUCache instance that uses an LRU (Least Recently Used) caching strategy
* for caching items internally from requests.
Expand All @@ -76,10 +88,13 @@ export class LocalLRUCache implements ICacheClient {
* @param {Logger} logger - The logger instance to be used for logging.
* @param {Registry} register - The registry instance used for metrics tracking.
*/
public constructor(logger: Logger, register: Registry) {
public constructor(logger: Logger, register: Registry, reservedKeys: Set<string> = new Set()) {
this.cache = new LRUCache(this.options);
this.logger = logger;
this.register = register;
this.reservedKeys = reservedKeys;
if (reservedKeys.size > 0) {
this.reservedCache = new LRUCache({ max: reservedKeys.size });
}

const cacheSizeCollect = (): void => {
this.purgeStale();
Expand Down Expand Up @@ -107,7 +122,8 @@ export class LocalLRUCache implements ICacheClient {
* @returns {*} The cached value if found, otherwise null.
*/
public async get(key: string, callingMethod: string, requestDetails: RequestDetails): Promise<any> {
const value = this.cache.get(key);
const cache = this.getCacheInstance(key);
const value = cache.get(key);
if (value !== undefined) {
const censoredKey = key.replace(Utils.IP_ADDRESS_REGEX, '<REDACTED>');
const censoredValue = JSON.stringify(value).replace(/"ipAddress":"[^"]+"/, '"ipAddress":"<REDACTED>"');
Expand All @@ -127,7 +143,8 @@ export class LocalLRUCache implements ICacheClient {
* @returns {Promise<number>} The remaining TTL in milliseconds.
*/
public async getRemainingTtl(key: string, callingMethod: string, requestDetails: RequestDetails): Promise<number> {
const remainingTtl = this.cache.getRemainingTTL(key); // in milliseconds
const cache = this.getCacheInstance(key);
const remainingTtl = cache.getRemainingTTL(key); // in milliseconds
this.logger.trace(
`${requestDetails.formattedRequestId} returning remaining TTL ${key}:${remainingTtl} on ${callingMethod} call`,
);
Expand All @@ -151,10 +168,11 @@ export class LocalLRUCache implements ICacheClient {
ttl?: number,
): Promise<void> {
const resolvedTtl = ttl ?? this.options.ttl;
const cache = this.getCacheInstance(key);
if (resolvedTtl > 0) {
this.cache.set(key, value, { ttl: resolvedTtl });
cache.set(key, value, { ttl: resolvedTtl });
} else {
this.cache.set(key, value, { ttl: 0 }); // 0 means indefinite time
cache.set(key, value, { ttl: 0 }); // 0 means indefinite time
}
const censoredKey = key.replace(Utils.IP_ADDRESS_REGEX, '<REDACTED>');
const censoredValue = JSON.stringify(value).replace(/"ipAddress":"[^"]+"/, '"ipAddress":"<REDACTED>"');
Expand Down Expand Up @@ -214,8 +232,9 @@ export class LocalLRUCache implements ICacheClient {
* @param {RequestDetails} requestDetails - The request details for logging and tracking.
*/
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.logger.trace(`${requestDetails.formattedRequestId} delete cache for ${key} on ${callingMethod} call`);
const cache = this.getCacheInstance(key);
cache.delete(key);
}

/**
Expand All @@ -242,7 +261,7 @@ export class LocalLRUCache implements ICacheClient {
* @returns {Promise<string[]>} An array of keys that match the pattern.
*/
public async keys(pattern: string, callingMethod: string, requestDetails: RequestDetails): Promise<string[]> {
const keys = Array.from(this.cache.rkeys());
const keys = [...this.cache.rkeys(), ...(this.reservedCache?.rkeys() ?? [])];

// Replace escaped special characters with placeholders
let regexPattern = pattern
Expand Down Expand Up @@ -275,4 +294,8 @@ export class LocalLRUCache implements ICacheClient {
);
return matchingKeys;
}

private getCacheInstance(key: string): LRUCache<string, any> {
return this.reservedCache && this.reservedKeys.has(key) ? this.reservedCache : this.cache;
}
}
50 changes: 35 additions & 15 deletions packages/relay/src/lib/config/hbarSpendingPlanConfigService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ import { ConfigService } from '@hashgraph/json-rpc-config-service/dist/services'
* It reads the pre-configured spending plans from a JSON file and populates the cache with them.
*
* @see SpendingPlanConfig
* @see SPENDING_PLANS_CONFIG_FILE
*/
export class HbarSpendingPlanConfigService {
/**
Expand All @@ -48,16 +47,6 @@ export class HbarSpendingPlanConfigService {
*/
private readonly TTL: number = -1;

/**
* The name of the spending plans configuration file. Defaults to `spendingPlansConfig.json`.
*
* @type {string}
* @private
*/
// @ts-ignore
private readonly SPENDING_PLANS_CONFIG_FILE: string =
ConfigService.get('HBAR_SPENDING_PLANS_CONFIG_FILE') || 'spendingPlansConfig.json';

/**
* Creates an instance of `HbarSpendingPlanConfigService`.
*
Expand All @@ -74,14 +63,44 @@ export class HbarSpendingPlanConfigService {
private readonly ipAddressHbarSpendingPlanRepository: IPAddressHbarSpendingPlanRepository,
) {}

/**
* Returns the cache keys for the pre-configured spending plans.
*
* @param {Logger} logger - The logger instance.
* @returns {Set<string>} - A set of cache keys for the pre-configured spending plans.
*/
public static getPreconfiguredSpendingPlanKeys(logger: Logger): Set<string> {
try {
const { collectionKey: hbarSpendingPlanKey } = HbarSpendingPlanRepository;
const { collectionKey: ethAddressHbarSpendingPlanKey } = EthAddressHbarSpendingPlanRepository;
const { collectionKey: ipAddressHbarSpendingPlanKey } = IPAddressHbarSpendingPlanRepository;

return new Set<string>(
this.loadSpendingPlansConfig(logger).flatMap((plan) => {
const { id, ethAddresses = [], ipAddresses = [] } = plan;
return [
`${hbarSpendingPlanKey}:${id}`,
`${hbarSpendingPlanKey}:${id}:amountSpent`,
`${hbarSpendingPlanKey}:${id}:spendingHistory`,
...ethAddresses.map((ethAddress) => `${ethAddressHbarSpendingPlanKey}:${ethAddress.trim().toLowerCase()}`),
...ipAddresses.map((ipAddress) => `${ipAddressHbarSpendingPlanKey}:${ipAddress}`),
];
}),
);
} catch (error: any) {
logger.error(`Failed to get pre-configured spending plan keys: ${error.message}`);
return new Set<string>();
}
}

/**
* Populates the database with pre-configured spending plans.
*
* @returns {Promise<number>} - A promise that resolves with the number of spending plans which were added or deleted.
* @throws {Error} - If the spending plans configuration file is not found or cannot be loaded.
*/
public async populatePreconfiguredSpendingPlans(): Promise<number> {
const spendingPlanConfigs = this.loadSpendingPlansConfig();
const spendingPlanConfigs = HbarSpendingPlanConfigService.loadSpendingPlansConfig(this.logger);
if (!spendingPlanConfigs.length) {
return 0;
}
Expand All @@ -107,10 +126,11 @@ export class HbarSpendingPlanConfigService {
* @throws {Error} If the configuration file is not found or cannot be read or parsed.
* @private
*/
private loadSpendingPlansConfig(): SpendingPlanConfig[] {
const configPath = findConfig(this.SPENDING_PLANS_CONFIG_FILE);
private static loadSpendingPlansConfig(logger: Logger): SpendingPlanConfig[] {
const filename = String(ConfigService.get('HBAR_SPENDING_PLANS_CONFIG_FILE'));
const configPath = findConfig(filename);
if (!configPath || !fs.existsSync(configPath)) {
this.logger.trace(`Configuration file not found at path "${configPath ?? this.SPENDING_PLANS_CONFIG_FILE}"`);
logger.trace(`Configuration file not found at path "${configPath ?? filename}"`);
return [];
}
try {
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}`;
}
}
7 changes: 3 additions & 4 deletions packages/relay/src/lib/eth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,10 +326,9 @@ export class EthImpl implements Eth {
requestDetails: RequestDetails,
): Promise<IFeeHistory | JsonRpcError> {
const requestIdPrefix = requestDetails.formattedRequestId;
const maxResults =
ConfigService.get('TEST')
? constants.DEFAULT_FEE_HISTORY_MAX_RESULTS
: Number(ConfigService.get('FEE_HISTORY_MAX_RESULTS'));
const maxResults = ConfigService.get('TEST')
? constants.DEFAULT_FEE_HISTORY_MAX_RESULTS
: Number(ConfigService.get('FEE_HISTORY_MAX_RESULTS'));

this.logger.trace(
`${requestIdPrefix} feeHistory(blockCount=${blockCount}, newestBlock=${newestBlock}, rewardPercentiles=${rewardPercentiles})`,
Expand Down
3 changes: 2 additions & 1 deletion packages/relay/src/lib/relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,8 @@ export class RelayImpl implements Relay {
const total = constants.HBAR_RATE_LIMIT_TOTAL;

this.eventEmitter = new EventEmitter();
this.cacheService = new CacheService(logger.child({ name: 'cache-service' }), register);
const reservedKeys = HbarSpendingPlanConfigService.getPreconfiguredSpendingPlanKeys(logger);
this.cacheService = new CacheService(logger.child({ name: 'cache-service' }), register, reservedKeys);

const hbarSpendingPlanRepository = new HbarSpendingPlanRepository(
this.cacheService,
Expand Down
4 changes: 2 additions & 2 deletions packages/relay/src/lib/services/cacheService/cacheService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,11 @@ export class CacheService {

private readonly cacheMethodsCounter: Counter;

public constructor(logger: Logger, register: Registry) {
public constructor(logger: Logger, register: Registry, reservedKeys: Set<string> = new Set()) {
this.logger = logger;
this.register = register;

this.internalCache = new LocalLRUCache(logger.child({ name: 'localLRUCache' }), register);
this.internalCache = new LocalLRUCache(logger.child({ name: 'localLRUCache' }), register, reservedKeys);
this.sharedCache = this.internalCache;
// @ts-ignore
this.isSharedCacheEnabled = !ConfigService.get('TEST') && this.isRedisEnabled();
Expand Down
28 changes: 28 additions & 0 deletions packages/relay/tests/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1009,3 +1009,31 @@ export const estimateFileTransactionsFee = (

return estimatedTxFee;
};

/**
* Verifies the result of a function call.
* @param {() => Promise<T>} func - The function to call.
* @param {Partial<T> | null} expected - The expected result.
* @param {string} [errorMessage] - The expected error message.
* @param {Function | Error} [errorType] - The expected error type.
* @returns {Promise<void>} - A promise that resolves when the verification is complete.
* @template T
*/
export const verifyResult = async <T>(
func: () => Promise<T>,
expected: Partial<T> | null,
errorMessage?: string,
errorType?: Function | Error,
): Promise<void> => {
if (expected) {
await expect(func()).to.eventually.deep.include(expected);
} else {
if (errorType) {
await expect(func()).to.eventually.be.rejectedWith(errorType, errorMessage);
} else if (errorMessage) {
await expect(func()).to.eventually.be.rejectedWith(errorMessage);
} else {
await expect(func()).to.eventually.be.rejected;
}
}
};
Loading

0 comments on commit ef9d696

Please sign in to comment.