Skip to content

Commit 559671f

Browse files
committed
fix: bound keyurl caches with FIFO eviction to prevent memory leak
Replace unbounded Record/Map caches in networkV1.ts and AbstractRelayerProvider.ts with Map<string, CachedKey> bounded to MAX_KEYURL_CACHE_SIZE=16 entries. FIFO eviction via Map insertion order prevents indefinite growth in long-running server processes. Closes zama-ai#336
1 parent e833b38 commit 559671f

4 files changed

Lines changed: 126 additions & 6 deletions

File tree

src/relayer-provider/AbstractRelayerProvider.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,9 @@ import { uintToHex } from '@base/uint';
4949
////////////////////////////////////////////////////////////////////////////////
5050

5151
// Cache promises to avoid race conditions when multiple concurrent calls
52-
// are made before the first one completes
52+
// are made before the first one completes.
53+
// Bounded to MAX_KEYURL_CACHE_SIZE with FIFO eviction. Mirrors networkV1.ts.
54+
const MAX_KEYURL_CACHE_SIZE = 16;
5355
const privateKeyurlCache = new Map<string, Promise<TFHEPkeParams>>();
5456

5557
/**
@@ -106,6 +108,9 @@ export abstract class AbstractRelayerProvider {
106108
throw err;
107109
});
108110

111+
if (privateKeyurlCache.size >= MAX_KEYURL_CACHE_SIZE) {
112+
privateKeyurlCache.delete(privateKeyurlCache.keys().next().value!);
113+
}
109114
privateKeyurlCache.set(this.#relayerUrl, promise);
110115

111116
return promise;

src/relayer-provider/v1/networkV1.test.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { RelayerGetResponseKeyUrlSnakeCase } from '../types/private';
2-
import { getKeysFromRelayer } from './networkV1';
2+
import { getKeysFromRelayer, _clearKeyurlCache } from './networkV1';
33
import { tfheCompactPkeCrsBytes, tfheCompactPublicKeyBytes } from '../../test';
44
import { SERIALIZED_SIZE_LIMIT_PK } from '../../sdk/lowlevel/constants';
55
import fetchMock from 'fetch-mock';
@@ -134,9 +134,22 @@ const payload: RelayerGetResponseKeyUrlSnakeCase = {
134134
const describeIfFetchMock =
135135
TEST_CONFIG.type === 'fetch-mock' ? describe : describe.skip;
136136

137+
const pubKeyUrl = payload.response.fhe_key_info[0].fhe_public_key.urls[0];
138+
const crsUrl = payload.response.crs['2048'].urls[0];
139+
137140
////////////////////////////////////////////////////////////////////////////////
138141

139142
describeIfFetchMock('network', () => {
143+
beforeEach(() => {
144+
_clearKeyurlCache();
145+
fetchMock.removeRoutes();
146+
});
147+
148+
afterEach(() => {
149+
_clearKeyurlCache();
150+
fetchMock.removeRoutes();
151+
});
152+
140153
it('getKeysFromRelayer', async () => {
141154
fetchMock.get('https://test-relayer.net/v1/keyurl', payload);
142155

@@ -156,4 +169,58 @@ describeIfFetchMock('network', () => {
156169
material.publicKey.safe_serialize(SERIALIZED_SIZE_LIMIT_PK),
157170
).toStrictEqual(tfheCompactPublicKeyBytes);
158171
});
172+
173+
it('cache hit: second call returns same object, /keyurl called once', async () => {
174+
let keyurlCallCount = 0;
175+
fetchMock.get(TEST_CONFIG.v1.urls.keyUrl, () => {
176+
keyurlCallCount++;
177+
return payload;
178+
});
179+
fetchMock.get(pubKeyUrl, tfheCompactPublicKeyBytes);
180+
fetchMock.get(crsUrl, tfheCompactPkeCrsBytes);
181+
182+
const r1 = await getKeysFromRelayer(TEST_CONFIG.v1.urls.base);
183+
const r2 = await getKeysFromRelayer(TEST_CONFIG.v1.urls.base);
184+
185+
expect(r1).toBe(r2);
186+
expect(keyurlCallCount).toBe(1);
187+
});
188+
189+
it('FIFO eviction: 17th URL evicts url-00', async () => {
190+
const keyurlCallCounts: number[] = new Array(17).fill(0) as number[];
191+
192+
// Asset mocks registered once — all 17 payloads reference the same asset URLs
193+
fetchMock.get(pubKeyUrl, tfheCompactPublicKeyBytes);
194+
fetchMock.get(crsUrl, tfheCompactPkeCrsBytes);
195+
196+
// Register 17 unique keyurl mocks
197+
for (let n = 0; n < 17; n++) {
198+
const idx = n;
199+
fetchMock.get(
200+
`https://test-relayer.net/url-${String(idx).padStart(2, '0')}/keyurl`,
201+
() => {
202+
keyurlCallCounts[idx]++;
203+
return payload;
204+
},
205+
);
206+
}
207+
208+
// Fill cache with url-00..url-15 (16 entries)
209+
for (let n = 0; n < 16; n++) {
210+
await getKeysFromRelayer(
211+
`https://test-relayer.net/url-${String(n).padStart(2, '0')}`,
212+
);
213+
}
214+
215+
// Insert url-16 — evicts url-00
216+
await getKeysFromRelayer('https://test-relayer.net/url-16');
217+
218+
// url-01 is still in cache (only url-00 was evicted)
219+
await getKeysFromRelayer('https://test-relayer.net/url-01');
220+
expect(keyurlCallCounts[1]).toBe(1);
221+
222+
// Re-fetch url-00 — was evicted, must hit network again
223+
await getKeysFromRelayer('https://test-relayer.net/url-00');
224+
expect(keyurlCallCounts[0]).toBe(2);
225+
});
159226
});

src/relayer-provider/v1/networkV1.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,17 @@ type CachedKey = {
2626

2727
////////////////////////////////////////////////////////////////////////////////
2828

29-
const keyurlCache: Record<string, CachedKey> = {};
29+
// Bounded FIFO cache. Mirrors MAX_KEYURL_CACHE_SIZE in AbstractRelayerProvider.ts.
30+
const MAX_KEYURL_CACHE_SIZE = 16;
31+
const keyurlCache = new Map<string, CachedKey>();
32+
33+
/**
34+
* Clears the keyurl cache. Exported for testing purposes only.
35+
* @internal
36+
*/
37+
export function _clearKeyurlCache(): void {
38+
keyurlCache.clear();
39+
}
3040

3141
////////////////////////////////////////////////////////////////////////////////
3242

@@ -35,8 +45,8 @@ export async function getKeysFromRelayer(
3545
publicKeyId?: string | null,
3646
options?: FhevmInstanceOptions,
3747
): Promise<CachedKey> {
38-
if (versionUrl in keyurlCache) {
39-
return keyurlCache[versionUrl];
48+
if (keyurlCache.has(versionUrl)) {
49+
return keyurlCache.get(versionUrl)!;
4050
}
4151

4252
const data: RelayerGetResponseKeyUrlSnakeCase = (await fetchRelayerV1Get(
@@ -139,7 +149,10 @@ export async function getKeysFromRelayer(
139149
},
140150
},
141151
};
142-
keyurlCache[versionUrl] = result;
152+
if (keyurlCache.size >= MAX_KEYURL_CACHE_SIZE) {
153+
keyurlCache.delete(keyurlCache.keys().next().value!);
154+
}
155+
keyurlCache.set(versionUrl, result);
143156
return result;
144157
} catch (e) {
145158
throw new Error('Impossible to fetch public key: wrong relayer url.', {

src/relayer-provider/v2/RelayerV2Provider_keyurl.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,41 @@ describeIfFetchMock('RelayerV2Provider - TFHEPkeParams Caching', () => {
441441
expect(mockTFHEPkeParamsFetch).toHaveBeenCalledTimes(1);
442442
});
443443

444+
it('FIFO eviction: 17th provider evicts first provider cache entry', async () => {
445+
const BASE_URL = 'https://test-relayer.net/eviction';
446+
447+
// Register 17 unique /keyurl mocks
448+
for (let n = 0; n < 17; n++) {
449+
fetchMock.get(
450+
`${BASE_URL}/provider-${String(n).padStart(2, '0')}/keyurl`,
451+
relayerV1ResponseGetKeyUrl,
452+
);
453+
}
454+
455+
// Create 17 providers
456+
const providers = Array.from({ length: 17 }, (_, n) =>
457+
createRelayerProvider(
458+
`${BASE_URL}/provider-${String(n).padStart(2, '0')}`,
459+
1,
460+
),
461+
);
462+
463+
// Fetch all 17 — fills the cache to 16 and evicts provider-00 on provider-16
464+
for (const provider of providers) {
465+
await provider.fetchTFHEPkeParams();
466+
}
467+
468+
expect(mockTFHEPkeParamsFetch).toHaveBeenCalledTimes(17);
469+
470+
// provider-01 is still in cache (only provider-00 was evicted by provider-16)
471+
await providers[1].fetchTFHEPkeParams();
472+
expect(mockTFHEPkeParamsFetch).toHaveBeenCalledTimes(17);
473+
474+
// Re-fetch provider-00 — it was evicted, so spy should be called again
475+
await providers[0].fetchTFHEPkeParams();
476+
expect(mockTFHEPkeParamsFetch).toHaveBeenCalledTimes(18);
477+
});
478+
444479
it('caches separately for different relayer URLs', async () => {
445480
const testRelayerUrlV2 = TEST_CONFIG.v2.fhevmInstanceConfig.relayerUrl;
446481
let fetchCount1 = 0;

0 commit comments

Comments
 (0)