Skip to content

Commit be9ec8b

Browse files
simzzzkonstantinabl
authored andcommitted
feat: Add Redis lock strategy (#4617)
Signed-off-by: Simeon Nakov <[email protected]> Signed-off-by: Konstantina Blazhukova <[email protected]>
1 parent 1c802a9 commit be9ec8b

File tree

12 files changed

+530
-23
lines changed

12 files changed

+530
-23
lines changed

.env.http.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ OPERATOR_KEY_MAIN= # Operator private key used to sign transaction
6262
# ========== TRANSACTION POOL ========
6363
# PENDING_TRANSACTION_STORAGE_TTL=30 # Time-to-live (TTL) in seconds for transaction payloads stored in Redis
6464

65+
# ========== LOCK SERVICE ===========
66+
# LOCK_MAX_HOLD_MS=30000 # Maximum time in milliseconds a lock can be held before automatic force-release
67+
# LOCAL_LOCK_MAX_ENTRIES=1000 # Maximum number of lock entries stored in memory
68+
# LOCK_QUEUE_POLL_INTERVAL_MS=50 # Interval in milliseconds between queue position checks when waiting for a lock
69+
6570
# ========== HBAR RATE LIMITING ==========
6671
# HBAR_RATE_LIMIT_TINYBAR=25000000000 # Total HBAR budget (250 HBARs)
6772
# HBAR_RATE_LIMIT_DURATION=86400000 # HBAR budget limit duration (1 day)

.env.ws.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ SUBSCRIPTIONS_ENABLED=true # Must be true for the WebSocket server to func
5959
# ========== TRANSACTION POOL ========
6060
# PENDING_TRANSACTION_STORAGE_TTL=30 # Time-to-live (TTL) in seconds for transaction payloads stored in Redis
6161

62+
# ========== LOCK SERVICE ===========
63+
# LOCK_MAX_HOLD_MS=30000 # Maximum time in milliseconds a lock can be held before automatic force-release
64+
# LOCAL_LOCK_MAX_ENTRIES=1000 # Maximum number of lock entries stored in memory
65+
# LOCK_QUEUE_POLL_INTERVAL_MS=50 # Interval in milliseconds between queue position checks when waiting for a lock
66+
6267
# ========== OTHER SETTINGS ==========
6368
# CLIENT_TRANSPORT_SECURITY=false # Enable or disable TLS for both networks
6469
# USE_ASYNC_TX_PROCESSING=true # If true, returns tx hash immediately after prechecks

charts/hedera-json-rpc-relay/values.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,11 @@ config:
123123
# REDIS_RECONNECT_DELAY_MS:
124124
# MULTI_SET:
125125

126+
# ========== LOCK SERVICE CONFIGURATION ==========
127+
# LOCK_MAX_HOLD_MS:
128+
# LOCAL_LOCK_MAX_ENTRIES:
129+
# LOCK_QUEUE_POLL_INTERVAL_MS:
130+
126131
# ========== DEVELOPMENT & TESTING ==========
127132
# LOG_LEVEL: 'trace'
128133

docs/configuration.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,6 @@ Unless you need to set a non-default value, it is recommended to only populate o
6969
| `JUMBO_TX_ENABLED` | "true" | Controls how large transactions are handled during `eth_sendRawTransaction`. When set to `true`, transactions up to 128KB can be sent directly to consensus nodes without using Hedera File Service (HFS), as long as contract bytecode doesn't exceed 24KB. When set to `false`, all transactions containing contract deployments use the traditional HFS approach. This feature leverages the increased transaction size limit to simplify processing of standard Ethereum transactions. |
7070
| `LIMIT_DURATION` | "60000" | The maximum duration in ms applied to IP-method based rate limits. |
7171
| `LOCAL_LOCK_MAX_ENTRIES` | "1000" | Maximum number of lock entries stored in memory. Prevents unbounded memory growth. |
72-
| `LOCAL_LOCK_MAX_LOCK_TIME` | "30000" | Timer to auto-release if lock not manually released (in ms). |
7372
| `MAX_GAS_ALLOWANCE_HBAR` | "0" | The maximum amount, in hbars, that the JSON-RPC Relay is willing to pay to complete the transaction in case the senders don't provide enough funds. Please note, in case of fully subsidized transactions, the sender must set the gas price to `0` and the JSON-RPC Relay must configure the `MAX_GAS_ALLOWANCE_HBAR` parameter high enough to cover the entire transaction cost. |
7473
| `MAX_TRANSACTION_FEE_THRESHOLD` | "15000000" | Used to set the max transaction fee. This is the HAPI fee which is paid by the relay operator account. |
7574
| `MIRROR_NODE_AGENT_CACHEABLE_DNS` | "true" | Flag to set if the mirror node agent should cacheable DNS lookups, using better-lookup library. |
@@ -107,6 +106,8 @@ Unless you need to set a non-default value, it is recommended to only populate o
107106
| `TX_DEFAULT_GAS` | "400000" | Default gas for transactions that do not specify gas. |
108107
| `TXPOOL_API_ENABLED` | "false" | Enables all txpool related methods. |
109108
| `USE_ASYNC_TX_PROCESSING` | "true" | Set to `true` to enable `eth_sendRawTransaction` to return the transaction hash immediately after passing all prechecks, while processing the transaction asynchronously in the background. |
109+
| `LOCK_MAX_HOLD_MS` | "30000" | Maximum time in milliseconds a lock can be held before automatic force-release. This TTL prevents deadlocks when transaction processing hangs or crashes. Default is 30 seconds. |
110+
| `LOCK_QUEUE_POLL_INTERVAL_MS` | "50" | Interval in milliseconds between queue position checks when waiting for a lock. Lower values provide faster lock acquisition but increase Redis load. Default is 50ms. |
110111
| `USE_MIRROR_NODE_MODULARIZED_SERVICES` | null | Controls routing of Mirror Node traffic through modularized services. When set to `true`, enables routing a percentage of traffic to modularized services. When set to `false`, ensures traffic follows the traditional non-modularized flow. When not set (i.e. `null` by default), no specific routing preference is applied. As Mirror Node gradually transitions to a fully modularized architecture across all networks, this setting will eventually default to `true`. |
111112

112113
## Server

packages/config-service/src/services/globalConfig.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -368,11 +368,6 @@ const _CONFIG = {
368368
required: false,
369369
defaultValue: 1000,
370370
},
371-
LOCAL_LOCK_MAX_LOCK_TIME: {
372-
type: 'number',
373-
required: false,
374-
defaultValue: 30000,
375-
},
376371
LOG_LEVEL: {
377372
type: 'string',
378373
required: false,
@@ -659,6 +654,16 @@ const _CONFIG = {
659654
required: false,
660655
defaultValue: true,
661656
},
657+
LOCK_MAX_HOLD_MS: {
658+
type: 'number',
659+
required: false,
660+
defaultValue: 30000,
661+
},
662+
LOCK_QUEUE_POLL_INTERVAL_MS: {
663+
type: 'number',
664+
required: false,
665+
defaultValue: 50,
666+
},
662667
USE_MIRROR_NODE_MODULARIZED_SERVICES: {
663668
type: 'boolean',
664669
required: false,

packages/relay/src/lib/services/lockService/LocalLockStrategy.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { randomUUID } from 'crypto';
66
import { LRUCache } from 'lru-cache';
77
import { Logger } from 'pino';
88

9+
import { LockService } from './LockService';
10+
911
/**
1012
* Represents the internal state for a lock associated with a given address.
1113
*/
@@ -71,7 +73,7 @@ export class LocalLockStrategy {
7173
// Start a 30-second timer to auto-release if lock not manually released
7274
state.lockTimeoutId = setTimeout(() => {
7375
this.forceReleaseExpiredLock(address, sessionKey);
74-
}, ConfigService.get('LOCAL_LOCK_MAX_LOCK_TIME'));
76+
}, ConfigService.get('LOCK_MAX_HOLD_MS'));
7577

7678
return sessionKey;
7779
}
@@ -83,13 +85,13 @@ export class LocalLockStrategy {
8385
* @param sessionKey - The session key of the lock holder
8486
*/
8587
async releaseLock(address: string, sessionKey: string): Promise<void> {
86-
if (this.logger.isLevelEnabled('debug')) {
87-
const holdTime = Date.now() - state.acquiredAt!;
88+
const state = this.localLockStates.get(address);
89+
90+
if (this.logger.isLevelEnabled('debug') && state?.acquiredAt) {
91+
const holdTime = Date.now() - state.acquiredAt;
8892
this.logger.debug(`Releasing lock for address ${address} and session key ${sessionKey} held for ${holdTime}ms.`);
8993
}
9094

91-
const state = this.localLockStates.get(address);
92-
9395
// Ensure only the lock owner can release
9496
if (state?.sessionKey === sessionKey) {
9597
await this.doRelease(state);
@@ -103,17 +105,17 @@ export class LocalLockStrategy {
103105
* @returns The LockState object associated with the address
104106
*/
105107
private getOrCreateState(address: string): LockState {
106-
address = address.toLowerCase();
107-
if (!this.localLockStates.has(address)) {
108-
this.localLockStates.set(address, {
108+
const normalizedAddress = LockService.normalizeAddress(address);
109+
if (!this.localLockStates.has(normalizedAddress)) {
110+
this.localLockStates.set(normalizedAddress, {
109111
mutex: new Mutex(),
110112
sessionKey: null,
111113
acquiredAt: null,
112114
lockTimeoutId: null,
113115
});
114116
}
115117

116-
return this.localLockStates.get(address)!;
118+
return this.localLockStates.get(normalizedAddress)!;
117119
}
118120

119121
/**

packages/relay/src/lib/services/lockService/LockService.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@ export class LockService {
2626
* Blocks until the lock is available (no timeout on waiting).
2727
*
2828
* @param address - The sender address to acquire the lock for.
29-
* @returns A promise that resolves to a unique session key.
29+
* @returns A promise that resolves to a unique session key, or null if acquisition fails (fail open).
3030
*/
31-
async acquireLock(address: string): Promise<string> {
31+
async acquireLock(address: string): Promise<string | null> {
3232
return await this.strategy.acquireLock(address);
3333
}
3434

@@ -42,4 +42,14 @@ export class LockService {
4242
async releaseLock(address: string, sessionKey: string): Promise<void> {
4343
await this.strategy.releaseLock(address, sessionKey);
4444
}
45+
46+
/**
47+
* Normalizes an address to lowercase for consistent key generation across lock strategies.
48+
*
49+
* @param address - The address to normalize.
50+
* @returns The normalized address.
51+
*/
52+
static normalizeAddress(address: string): string {
53+
return address.toLowerCase();
54+
}
4555
}

packages/relay/src/lib/services/lockService/LockStrategyFactory.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { RedisClientType } from 'redis';
55

66
import { LockStrategy } from '../../types';
77
import { LocalLockStrategy } from './LocalLockStrategy';
8+
import { RedisLockStrategy } from './RedisLockStrategy';
89

910
/**
1011
* Factory for creating LockStrategy instances.
@@ -23,11 +24,10 @@ export class LockStrategyFactory {
2324
*/
2425

2526
static create(redisClient: RedisClientType | undefined, logger: Logger): LockStrategy {
26-
// TODO: Remove placeholder errors once strategies are implemented
2727
if (redisClient) {
28-
// throw new Error('Redis lock strategy not yet implemented');
28+
return new RedisLockStrategy(redisClient, logger.child({ name: 'redis-lock-strategy' }));
2929
}
3030

31-
return new LocalLockStrategy(logger);
31+
return new LocalLockStrategy(logger.child({ name: 'local-lock-strategy' }));
3232
}
3333
}

0 commit comments

Comments
 (0)