@openzeppelin/relayer-plugin-channels 0.18.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/plugin/constants.d.ts +6 -4
- package/dist/plugin/constants.d.ts.map +1 -1
- package/dist/plugin/constants.js +11 -7
- package/dist/plugin/handler.d.ts +14 -0
- package/dist/plugin/handler.d.ts.map +1 -1
- package/dist/plugin/handler.js +33 -2
- package/dist/plugin/pool.d.ts +8 -5
- package/dist/plugin/pool.d.ts.map +1 -1
- package/dist/plugin/pool.js +62 -65
- package/package.json +1 -1
|
@@ -24,12 +24,14 @@ export declare const CONFIG: {
|
|
|
24
24
|
};
|
|
25
25
|
export declare const POOL: {
|
|
26
26
|
readonly CLAIM_LOCK_TTL_SECONDS: 3;
|
|
27
|
-
readonly ACQUIRE_MAX_SPINS:
|
|
28
|
-
readonly
|
|
29
|
-
readonly
|
|
27
|
+
readonly ACQUIRE_MAX_SPINS: 12;
|
|
28
|
+
readonly ACQUIRE_BASE_DELAY_MS: 25;
|
|
29
|
+
readonly ACQUIRE_MAX_DELAY_MS: 500;
|
|
30
|
+
readonly MAX_CLAIMS_PER_SPIN: 3;
|
|
30
31
|
readonly CHANNEL_COOLDOWN_MS: 6000;
|
|
31
|
-
readonly
|
|
32
|
+
readonly LRU_KEY_TTL_SECONDS: 86400;
|
|
32
33
|
};
|
|
34
|
+
export declare const RELAYER_INFO_CACHE_TTL_SECONDS = 1800;
|
|
33
35
|
export declare const TIME: {
|
|
34
36
|
readonly MAX_TIME_BOUND_OFFSET_SECONDS: 60;
|
|
35
37
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../../src/plugin/constants.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,eAAO,MAAM,WAAW;;;;;;;;;;CAUd,CAAC;AAGX,eAAO,MAAM,MAAM;;;;;;;CAOT,CAAC;AAGX,eAAO,MAAM,IAAI
|
|
1
|
+
{"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../../src/plugin/constants.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,eAAO,MAAM,WAAW;;;;;;;;;;CAUd,CAAC;AAGX,eAAO,MAAM,MAAM;;;;;;;CAOT,CAAC;AAGX,eAAO,MAAM,IAAI;;;;;;;;CAcP,CAAC;AAGX,eAAO,MAAM,8BAA8B,OAAQ,CAAC;AAGpD,eAAO,MAAM,IAAI;;CAEP,CAAC;AAGX,eAAO,MAAM,UAAU;;;;;;IAMrB,uLAAuL;;CAE/K,CAAC;AAGX,eAAO,MAAM,OAAO;;;CAGV,CAAC;AAGX,eAAO,MAAM,OAAO;;;CAGV,CAAC;AAEX,eAAO,MAAM,GAAG;;CAGN,CAAC"}
|
package/dist/plugin/constants.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Centralized constants for the channel accounts plugin.
|
|
6
6
|
*/
|
|
7
7
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
-
exports.FEE = exports.POLLING = exports.TIMEOUT = exports.SIMULATION = exports.TIME = exports.POOL = exports.CONFIG = exports.HTTP_STATUS = void 0;
|
|
8
|
+
exports.FEE = exports.POLLING = exports.TIMEOUT = exports.SIMULATION = exports.TIME = exports.RELAYER_INFO_CACHE_TTL_SECONDS = exports.POOL = exports.CONFIG = exports.HTTP_STATUS = void 0;
|
|
9
9
|
// HTTP Status Codes
|
|
10
10
|
exports.HTTP_STATUS = {
|
|
11
11
|
BAD_REQUEST: 400,
|
|
@@ -32,15 +32,19 @@ exports.POOL = {
|
|
|
32
32
|
// Per-channel claim lock TTL — must exceed worst-case callback latency
|
|
33
33
|
// to prevent TTL expiry allowing a second worker into the same claim section
|
|
34
34
|
CLAIM_LOCK_TTL_SECONDS: 3,
|
|
35
|
-
// Retry policy
|
|
36
|
-
ACQUIRE_MAX_SPINS:
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
// Retry policy: exponential backoff with full jitter
|
|
36
|
+
ACQUIRE_MAX_SPINS: 12,
|
|
37
|
+
ACQUIRE_BASE_DELAY_MS: 25,
|
|
38
|
+
ACQUIRE_MAX_DELAY_MS: 500,
|
|
39
|
+
// Max claim-lock attempts per spin (batch size)
|
|
40
|
+
MAX_CLAIMS_PER_SPIN: 3,
|
|
39
41
|
// Hard-block cooldown for uncertain-outcome channels (~1 Stellar ledger with margin)
|
|
40
42
|
CHANNEL_COOLDOWN_MS: 6000,
|
|
41
|
-
// Housekeeping TTL for
|
|
42
|
-
|
|
43
|
+
// Housekeeping TTL for per-channel LRU keys
|
|
44
|
+
LRU_KEY_TTL_SECONDS: 86400,
|
|
43
45
|
};
|
|
46
|
+
// Relayer info cache — address and network_type are effectively immutable
|
|
47
|
+
exports.RELAYER_INFO_CACHE_TTL_SECONDS = 1800; // 30 minutes
|
|
44
48
|
// Time Constants
|
|
45
49
|
exports.TIME = {
|
|
46
50
|
MAX_TIME_BOUND_OFFSET_SECONDS: 60,
|
package/dist/plugin/handler.d.ts
CHANGED
|
@@ -5,7 +5,20 @@
|
|
|
5
5
|
* Orchestrates the transaction processing pipeline using channel accounts with fee bumping.
|
|
6
6
|
*/
|
|
7
7
|
import { PluginContext } from '@openzeppelin/relayer-sdk';
|
|
8
|
+
import type { Relayer } from '@openzeppelin/relayer-sdk';
|
|
8
9
|
import { Transaction, xdr } from '@stellar/stellar-sdk';
|
|
10
|
+
/** Subset of relayer metadata used by the plugin (address + network_type). */
|
|
11
|
+
type CachedRelayerInfo = {
|
|
12
|
+
address: string;
|
|
13
|
+
network_type: string;
|
|
14
|
+
};
|
|
15
|
+
/** @internal Exported for test isolation only. */
|
|
16
|
+
export declare function clearRelayerInfoCache(): void;
|
|
17
|
+
/**
|
|
18
|
+
* Return cached relayer info or fetch from the API and cache the result.
|
|
19
|
+
* Returns null if the relayer has no address (misconfigured).
|
|
20
|
+
*/
|
|
21
|
+
export declare function getCachedRelayerInfo(network: string, relayerId: string, relayer: Relayer): Promise<CachedRelayerInfo | null>;
|
|
9
22
|
/**
|
|
10
23
|
* Extracts func and auth from an unsigned Soroban transaction.
|
|
11
24
|
* Returns null if the transaction is not a single invokeHostFunction operation.
|
|
@@ -18,4 +31,5 @@ export declare function extractFuncAuthFromUnsignedXdr(tx: Transaction): {
|
|
|
18
31
|
* Main plugin handler exported for OpenZeppelin Relayer
|
|
19
32
|
*/
|
|
20
33
|
export declare function handler(context: PluginContext): Promise<any>;
|
|
34
|
+
export {};
|
|
21
35
|
//# sourceMappingURL=handler.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../../src/plugin/handler.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,aAAa,EAAe,MAAM,2BAA2B,CAAC;
|
|
1
|
+
{"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../../src/plugin/handler.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,aAAa,EAAe,MAAM,2BAA2B,CAAC;AACvE,OAAO,KAAK,EAA4B,OAAO,EAAE,MAAM,2BAA2B,CAAC;AAQnF,OAAO,EAAE,WAAW,EAAE,GAAG,EAAE,MAAM,sBAAsB,CAAC;AAOxD,8EAA8E;AAC9E,KAAK,iBAAiB,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,CAAC;AAUnE,kDAAkD;AAClD,wBAAgB,qBAAqB,IAAI,IAAI,CAE5C;AAED;;;GAGG;AACH,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,OAAO,GACf,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAanC;AAsBD;;;GAGG;AACH,wBAAgB,8BAA8B,CAC5C,EAAE,EAAE,WAAW,GACd;IAAE,IAAI,EAAE,GAAG,CAAC,YAAY,CAAC;IAAC,IAAI,EAAE,GAAG,CAAC,yBAAyB,EAAE,CAAA;CAAE,GAAG,IAAI,CAkB1E;AAyTD;;GAEG;AACH,wBAAsB,OAAO,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,GAAG,CAAC,CAElE"}
|
package/dist/plugin/handler.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* Orchestrates the transaction processing pipeline using channel accounts with fee bumping.
|
|
7
7
|
*/
|
|
8
8
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.clearRelayerInfoCache = clearRelayerInfoCache;
|
|
10
|
+
exports.getCachedRelayerInfo = getCachedRelayerInfo;
|
|
9
11
|
exports.extractFuncAuthFromUnsignedXdr = extractFuncAuthFromUnsignedXdr;
|
|
10
12
|
exports.handler = handler;
|
|
11
13
|
const relayer_sdk_1 = require("@openzeppelin/relayer-sdk");
|
|
@@ -21,6 +23,35 @@ const fee_1 = require("./fee");
|
|
|
21
23
|
const tx_1 = require("./tx");
|
|
22
24
|
const fee_tracking_1 = require("./fee-tracking");
|
|
23
25
|
const sequence_1 = require("./sequence");
|
|
26
|
+
/**
|
|
27
|
+
* In-memory cache for relayer info. Avoids a remote API call (getRelayer → HTTP GET)
|
|
28
|
+
* on every request while a channel lock is held. Keyed by `${network}:${relayerId}`.
|
|
29
|
+
* Entries expire after RELAYER_INFO_CACHE_TTL_SECONDS; stale entries are evicted on miss.
|
|
30
|
+
*/
|
|
31
|
+
const relayerInfoCache = new Map();
|
|
32
|
+
/** @internal Exported for test isolation only. */
|
|
33
|
+
function clearRelayerInfoCache() {
|
|
34
|
+
relayerInfoCache.clear();
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Return cached relayer info or fetch from the API and cache the result.
|
|
38
|
+
* Returns null if the relayer has no address (misconfigured).
|
|
39
|
+
*/
|
|
40
|
+
async function getCachedRelayerInfo(network, relayerId, relayer) {
|
|
41
|
+
const cacheKey = `${network}:${relayerId}`;
|
|
42
|
+
const cached = relayerInfoCache.get(cacheKey);
|
|
43
|
+
if (cached && cached.expiresAt > Date.now())
|
|
44
|
+
return cached.info;
|
|
45
|
+
// Evict stale entry so it doesn't linger in memory
|
|
46
|
+
if (cached)
|
|
47
|
+
relayerInfoCache.delete(cacheKey);
|
|
48
|
+
const info = await relayer.getRelayer();
|
|
49
|
+
if (!info?.address)
|
|
50
|
+
return null;
|
|
51
|
+
const entry = { address: info.address, network_type: info.network_type };
|
|
52
|
+
relayerInfoCache.set(cacheKey, { info: entry, expiresAt: Date.now() + constants_1.RELAYER_INFO_CACHE_TTL_SECONDS * 1000 });
|
|
53
|
+
return entry;
|
|
54
|
+
}
|
|
24
55
|
function getApiKey(headers, headerName) {
|
|
25
56
|
const values = headers[headerName];
|
|
26
57
|
return values?.[0]?.trim() || undefined;
|
|
@@ -94,7 +125,7 @@ async function handleFuncAuthSubmit(func, auth, ctx, skipWait) {
|
|
|
94
125
|
try {
|
|
95
126
|
poolLock = await ctx.pool.acquire(ctx.acquireOptions);
|
|
96
127
|
const channelRelayer = ctx.api.useRelayer(poolLock.relayerId);
|
|
97
|
-
const channelInfo = await channelRelayer
|
|
128
|
+
const channelInfo = await getCachedRelayerInfo(ctx.network, poolLock.relayerId, channelRelayer);
|
|
98
129
|
console.log(`[channels] Acquired channel: ${poolLock.relayerId}`);
|
|
99
130
|
if (!channelInfo || !channelInfo.address) {
|
|
100
131
|
throw (0, relayer_sdk_1.pluginError)('Channel relayer not found', {
|
|
@@ -224,7 +255,7 @@ async function channelAccounts(context) {
|
|
|
224
255
|
hash: stellar.hash ?? null,
|
|
225
256
|
};
|
|
226
257
|
}
|
|
227
|
-
const fundInfo = await fundRelayer
|
|
258
|
+
const fundInfo = await getCachedRelayerInfo(config.network, fundRelayerId, fundRelayer);
|
|
228
259
|
if (!fundInfo || !fundInfo.address) {
|
|
229
260
|
throw (0, relayer_sdk_1.pluginError)('Fund relayer not found', {
|
|
230
261
|
code: 'RELAYER_UNAVAILABLE',
|
package/dist/plugin/pool.d.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* - Membership comes from KV: <network>:channel:relayer-ids
|
|
6
6
|
* - Per-relayer locks with tokens: <network>:channel:in-use:<relayerId>
|
|
7
7
|
* - Uses per-channel claim locks to make acquire safe across workers.
|
|
8
|
+
* - LRU ordering via per-channel keys: <network>:channel:lru:<relayerId>
|
|
8
9
|
*/
|
|
9
10
|
import { PluginKVStore } from '@openzeppelin/relayer-sdk';
|
|
10
11
|
export type PoolLock = {
|
|
@@ -23,8 +24,8 @@ export declare class ChannelPool {
|
|
|
23
24
|
constructor(network: 'testnet' | 'mainnet', kv: PluginKVStore, lockTtlSeconds: number);
|
|
24
25
|
/** Acquire a relayerId with a token lock */
|
|
25
26
|
acquire(options: AcquireOptions): Promise<PoolLock>;
|
|
26
|
-
/**
|
|
27
|
-
private
|
|
27
|
+
/** Try to claim one channel from a batch of candidates */
|
|
28
|
+
private tryClaimBatch;
|
|
28
29
|
/** Attempt to claim a single channel under its per-channel lock */
|
|
29
30
|
private tryClaimChannel;
|
|
30
31
|
/** Extend the lock TTL if we still own it (e.g. after WAIT_TIMEOUT) */
|
|
@@ -34,11 +35,13 @@ export declare class ChannelPool {
|
|
|
34
35
|
/** Release with cooldown: keeps lock alive with short TTL to hard-block the channel. */
|
|
35
36
|
releaseWithCooldown(lock: PoolLock, cooldownMs?: 6000): Promise<void>;
|
|
36
37
|
private membershipKey;
|
|
37
|
-
private lockKeyPrefix;
|
|
38
38
|
private lockKey;
|
|
39
39
|
private claimKey;
|
|
40
|
-
private
|
|
41
|
-
|
|
40
|
+
private lruKey;
|
|
41
|
+
/** Read per-channel LRU timestamps in parallel (partial failures keep successful reads) */
|
|
42
|
+
private readLruMap;
|
|
43
|
+
/** Fire-and-forget LRU timestamp update for a single channel */
|
|
44
|
+
private updateLru;
|
|
42
45
|
private getRelayerIdsFromKV;
|
|
43
46
|
private getPoolCapacityDetails;
|
|
44
47
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pool.d.ts","sourceRoot":"","sources":["../../src/plugin/pool.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"pool.d.ts","sourceRoot":"","sources":["../../src/plugin/pool.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,aAAa,EAAe,MAAM,2BAA2B,CAAC;AAIvE,MAAM,MAAM,QAAQ,GAAG;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAE5D,MAAM,MAAM,cAAc,GAAG;IAC3B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gBAAgB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC9B,aAAa,EAAE,MAAM,CAAC;CACvB,CAAC;AAUF,qBAAa,WAAW;IACtB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAwB;IAChD,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAS;IAC3C,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAgB;gBAEvB,OAAO,EAAE,SAAS,GAAG,SAAS,EAAE,EAAE,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM;IAMrF,4CAA4C;IACtC,OAAO,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,QAAQ,CAAC;IA8DzD,0DAA0D;YAC5C,aAAa;IAQ3B,mEAAmE;YACrD,eAAe;IAuB7B,uEAAuE;IACjE,UAAU,CAAC,IAAI,EAAE,QAAQ,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAgBhE,oCAAoC;IAC9B,OAAO,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IAY5C,wFAAwF;IAClF,mBAAmB,CAAC,IAAI,EAAE,QAAQ,EAAE,UAAU,OAA2B,GAAG,OAAO,CAAC,IAAI,CAAC;IAa/F,OAAO,CAAC,aAAa;IAIrB,OAAO,CAAC,OAAO;IAIf,OAAO,CAAC,QAAQ;IAIhB,OAAO,CAAC,MAAM;IAId,2FAA2F;YAC7E,UAAU;IASxB,gEAAgE;IAChE,OAAO,CAAC,SAAS;YAMH,mBAAmB;YAYnB,sBAAsB;CAUrC"}
|
package/dist/plugin/pool.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* - Membership comes from KV: <network>:channel:relayer-ids
|
|
7
7
|
* - Per-relayer locks with tokens: <network>:channel:in-use:<relayerId>
|
|
8
8
|
* - Uses per-channel claim locks to make acquire safe across workers.
|
|
9
|
+
* - LRU ordering via per-channel keys: <network>:channel:lru:<relayerId>
|
|
9
10
|
*/
|
|
10
11
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
11
12
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
@@ -24,12 +25,46 @@ class ChannelPool {
|
|
|
24
25
|
/** Acquire a relayerId with a token lock */
|
|
25
26
|
async acquire(options) {
|
|
26
27
|
const maxSpins = constants_1.POOL.ACQUIRE_MAX_SPINS;
|
|
28
|
+
// --- Read state ONCE for the entire retry loop ---
|
|
29
|
+
let ids = await this.getRelayerIdsFromKV();
|
|
30
|
+
if (ids.length === 0) {
|
|
31
|
+
throw (0, relayer_sdk_1.pluginError)('No channel accounts configured. Use the management API to set channel accounts.', {
|
|
32
|
+
code: 'NO_CHANNELS_CONFIGURED',
|
|
33
|
+
status: constants_1.HTTP_STATUS.SERVICE_UNAVAILABLE,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
if (options.contractId && options.limitedContracts.has(options.contractId)) {
|
|
37
|
+
ids = filterChannelsForLimitedContract(ids, options.capacityRatio);
|
|
38
|
+
}
|
|
39
|
+
// Shuffle all candidates; batch size scales so every spin covers an
|
|
40
|
+
// equal slice of the pool, guaranteeing full coverage across all spins.
|
|
41
|
+
const candidates = ids.slice();
|
|
42
|
+
shuffle(candidates);
|
|
43
|
+
// Read LRU for a small prefix only (capped at maxSpins * POOL.MAX_CLAIMS_PER_SPIN) to guide initial ordering.
|
|
44
|
+
// The rest stay in random shuffled order — no extra Redis reads.
|
|
45
|
+
const lruSampleSize = Math.min(candidates.length, maxSpins * constants_1.POOL.MAX_CLAIMS_PER_SPIN);
|
|
46
|
+
const lruMap = await this.readLruMap(candidates.slice(0, lruSampleSize));
|
|
47
|
+
const prefix = candidates.slice(0, lruSampleSize);
|
|
48
|
+
prefix.sort((a, b) => (lruMap[a] ?? 0) - (lruMap[b] ?? 0));
|
|
49
|
+
for (let j = 0; j < prefix.length; j++)
|
|
50
|
+
candidates[j] = prefix[j];
|
|
51
|
+
// Batch size: cover the full pool across maxSpins iterations
|
|
52
|
+
const batchSize = Math.max(constants_1.POOL.MAX_CLAIMS_PER_SPIN, Math.ceil(candidates.length / maxSpins));
|
|
53
|
+
let offset = 0;
|
|
27
54
|
for (let i = 0; i < maxSpins; i++) {
|
|
28
|
-
|
|
55
|
+
if (offset >= candidates.length) {
|
|
56
|
+
// Full sweep done — reshuffle for next sweep
|
|
57
|
+
offset = 0;
|
|
58
|
+
shuffle(candidates);
|
|
59
|
+
}
|
|
60
|
+
const batch = candidates.slice(offset, offset + batchSize);
|
|
61
|
+
offset += batchSize;
|
|
62
|
+
const result = await this.tryClaimBatch(batch);
|
|
29
63
|
if (result)
|
|
30
64
|
return result;
|
|
31
|
-
|
|
32
|
-
|
|
65
|
+
// Exponential backoff with full jitter
|
|
66
|
+
const baseDelay = Math.min(constants_1.POOL.ACQUIRE_MAX_DELAY_MS, constants_1.POOL.ACQUIRE_BASE_DELAY_MS * Math.pow(2, i));
|
|
67
|
+
const jitter = Math.floor(Math.random() * baseDelay);
|
|
33
68
|
await sleep(jitter);
|
|
34
69
|
}
|
|
35
70
|
const diagnostics = await this.getPoolCapacityDetails(options, maxSpins);
|
|
@@ -40,50 +75,9 @@ class ChannelPool {
|
|
|
40
75
|
details: diagnostics,
|
|
41
76
|
});
|
|
42
77
|
}
|
|
43
|
-
/**
|
|
44
|
-
async
|
|
45
|
-
|
|
46
|
-
let ids = await this.getRelayerIdsFromKV();
|
|
47
|
-
if (ids.length === 0) {
|
|
48
|
-
throw (0, relayer_sdk_1.pluginError)('No channel accounts configured. Use the management API to set channel accounts.', {
|
|
49
|
-
code: 'NO_CHANNELS_CONFIGURED',
|
|
50
|
-
status: constants_1.HTTP_STATUS.SERVICE_UNAVAILABLE,
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
if (options.contractId && options.limitedContracts.has(options.contractId)) {
|
|
54
|
-
ids = filterChannelsForLimitedContract(ids, options.capacityRatio);
|
|
55
|
-
}
|
|
56
|
-
const lockPrefix = this.lockKeyPrefix();
|
|
57
|
-
let lruMap = {};
|
|
58
|
-
try {
|
|
59
|
-
lruMap = (await this.kv.get(this.lruMapKey())) ?? {};
|
|
60
|
-
}
|
|
61
|
-
catch (err) {
|
|
62
|
-
console.warn('[channels] LRU map read failed, using empty ordering map', err);
|
|
63
|
-
}
|
|
64
|
-
let lockedSet;
|
|
65
|
-
try {
|
|
66
|
-
const lockedKeys = await this.kv.listKeys(`${lockPrefix}*`);
|
|
67
|
-
lockedSet = new Set(lockedKeys.map((k) => k.slice(lockPrefix.length)));
|
|
68
|
-
}
|
|
69
|
-
catch (err) {
|
|
70
|
-
// Fallback: if listKeys fails, degrade to O(N) per-channel exists checks.
|
|
71
|
-
// This is expensive with many channels — log so persistent failures are observable.
|
|
72
|
-
console.warn('[channels] listKeys failed, falling back to per-channel exists checks', err);
|
|
73
|
-
const results = await Promise.all(ids.map((id) => this.kv.exists(this.lockKey(id))));
|
|
74
|
-
lockedSet = new Set(ids.filter((_, i) => results[i]));
|
|
75
|
-
}
|
|
76
|
-
const unlocked = ids.filter((id) => !lockedSet.has(id));
|
|
77
|
-
if (unlocked.length === 0)
|
|
78
|
-
return null;
|
|
79
|
-
// Sort by LRU ascending — oldest channel is always first (deterministic).
|
|
80
|
-
// Shuffle-then-stable-sort: tie-break among equal timestamps is random,
|
|
81
|
-
// spreading contention when multiple channels share the same LRU value.
|
|
82
|
-
shuffle(unlocked);
|
|
83
|
-
unlocked.sort((a, b) => (lruMap[a] ?? 0) - (lruMap[b] ?? 0));
|
|
84
|
-
const candidates = unlocked;
|
|
85
|
-
// --- CLAIM PHASE (per-channel lock) ---
|
|
86
|
-
for (const candidate of candidates) {
|
|
78
|
+
/** Try to claim one channel from a batch of candidates */
|
|
79
|
+
async tryClaimBatch(batch) {
|
|
80
|
+
for (const candidate of batch) {
|
|
87
81
|
const result = await this.tryClaimChannel(candidate);
|
|
88
82
|
if (result)
|
|
89
83
|
return result;
|
|
@@ -98,7 +92,8 @@ class ChannelPool {
|
|
|
98
92
|
return null;
|
|
99
93
|
const token = randomToken();
|
|
100
94
|
await this.kv.set(this.lockKey(relayerId), { token, lockedAt: new Date().toISOString() }, { ttlSec: this.channelLockTtlSec });
|
|
101
|
-
|
|
95
|
+
// Fire-and-forget: LRU is best-effort, don't hold the claim lock for it
|
|
96
|
+
this.updateLru(relayerId);
|
|
102
97
|
return { relayerId, token };
|
|
103
98
|
}, { ttlSec: constants_1.POOL.CLAIM_LOCK_TTL_SECONDS, onBusy: 'skip' });
|
|
104
99
|
}
|
|
@@ -145,28 +140,29 @@ class ChannelPool {
|
|
|
145
140
|
membershipKey() {
|
|
146
141
|
return `${this.network}:channel:relayer-ids`;
|
|
147
142
|
}
|
|
148
|
-
lockKeyPrefix() {
|
|
149
|
-
return `${this.network}:channel:in-use:`;
|
|
150
|
-
}
|
|
151
143
|
lockKey(relayerId) {
|
|
152
|
-
return `${this.
|
|
144
|
+
return `${this.network}:channel:in-use:${relayerId}`;
|
|
153
145
|
}
|
|
154
146
|
claimKey(relayerId) {
|
|
155
147
|
return `${this.network}:channel:claim:${relayerId}`;
|
|
156
148
|
}
|
|
157
|
-
|
|
158
|
-
return `${this.network}:channel:lru
|
|
149
|
+
lruKey(relayerId) {
|
|
150
|
+
return `${this.network}:channel:lru:${relayerId}`;
|
|
159
151
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
152
|
+
/** Read per-channel LRU timestamps in parallel (partial failures keep successful reads) */
|
|
153
|
+
async readLruMap(ids) {
|
|
154
|
+
const lruMap = {};
|
|
155
|
+
const results = await Promise.allSettled(ids.map((id) => this.kv.get(this.lruKey(id))));
|
|
156
|
+
results.forEach((r, i) => {
|
|
157
|
+
lruMap[ids[i]] = r.status === 'fulfilled' ? (r.value?.ts ?? 0) : 0;
|
|
158
|
+
});
|
|
159
|
+
return lruMap;
|
|
160
|
+
}
|
|
161
|
+
/** Fire-and-forget LRU timestamp update for a single channel */
|
|
162
|
+
updateLru(relayerId) {
|
|
163
|
+
this.kv.set(this.lruKey(relayerId), { ts: Date.now() }, { ttlSec: constants_1.POOL.LRU_KEY_TTL_SECONDS }).catch((err) => {
|
|
164
|
+
console.debug('[channels] failed to update LRU key', err);
|
|
165
|
+
});
|
|
170
166
|
}
|
|
171
167
|
async getRelayerIdsFromKV() {
|
|
172
168
|
try {
|
|
@@ -232,8 +228,9 @@ function simpleHash(str) {
|
|
|
232
228
|
*/
|
|
233
229
|
function filterChannelsForLimitedContract(ids, ratio) {
|
|
234
230
|
const k = Math.max(1, Math.floor(ratio * ids.length));
|
|
231
|
+
const hashes = new Map(ids.map((id) => [id, simpleHash(id)]));
|
|
235
232
|
return ids
|
|
236
233
|
.slice()
|
|
237
|
-
.sort((a, b) =>
|
|
234
|
+
.sort((a, b) => hashes.get(a) - hashes.get(b) || a.localeCompare(b))
|
|
238
235
|
.slice(0, k);
|
|
239
236
|
}
|