Skip to content

Commit 7855ec9

Browse files
authored
fix: use in-memory cache in price service (#427)
* Swap `StateCache` to `InMemoryCache` in `PriceApiClient` * Implement TTL logic for the `InMemoryCache`
1 parent 65a2b28 commit 7855ec9

7 files changed

Lines changed: 151 additions & 34 deletions

File tree

packages/snap/snap.manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"url": "https://github.com/MetaMask/snap-solana-wallet.git"
88
},
99
"source": {
10-
"shasum": "fYdnpG929p3sfCbwUNQQe4P+LSPyGQdZtXgdMUtt97E=",
10+
"shasum": "XiO/mFkK3oitUU/FZOqAC3nBmYGcP2ZIPt0notbfeaY=",
1111
"location": {
1212
"npm": {
1313
"filePath": "dist/bundle.js",
Lines changed: 130 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,182 @@
1+
import { assert } from '@metamask/utils';
2+
13
import type { Serializable } from '../serialization/types';
4+
import type { ILogger } from '../utils/logger';
25
import type { ICache } from './ICache';
6+
import type { CacheEntry } from './types';
37

48
/**
5-
* A simple in-memory cache implementation, primarily used for testing purposes.
9+
* A simple in-memory cache implementation supporting TTL (Time To Live) functionality.
610
*
711
* WARNINGS:
812
* - This cache is not persistent and will be lost when the process is restarted.
9-
* - It does not support TTL.
1013
*/
1114
export class InMemoryCache implements ICache<Serializable> {
12-
#cache: Map<string, Serializable> = new Map();
15+
#cache: Map<string, CacheEntry> = new Map();
16+
17+
public readonly logger: ILogger;
18+
19+
constructor(logger: ILogger) {
20+
this.logger = logger;
21+
}
22+
23+
#validateTtlOrThrow(ttlMilliseconds?: number): void {
24+
if (ttlMilliseconds === undefined) {
25+
return;
26+
}
27+
28+
if (typeof ttlMilliseconds !== 'number') {
29+
throw new Error('TTL must be a number');
30+
}
31+
32+
if (ttlMilliseconds < 0) {
33+
throw new Error('TTL must be positive');
34+
}
35+
36+
if (ttlMilliseconds > Number.MAX_SAFE_INTEGER) {
37+
throw new Error('TTL must be less than 2^53 - 1');
38+
}
39+
}
40+
41+
#isExpired(cacheEntry: CacheEntry): boolean {
42+
return cacheEntry.expiresAt < Date.now();
43+
}
44+
45+
async #cleanupExpiredEntries(): Promise<void> {
46+
const expiredKeys: string[] = [];
47+
for (const [key, entry] of this.#cache.entries()) {
48+
if (this.#isExpired(entry)) {
49+
expiredKeys.push(key);
50+
}
51+
}
52+
await this.mdelete(expiredKeys);
53+
}
1354

1455
async get(key: string): Promise<Serializable | undefined> {
15-
return this.#cache.get(key);
56+
const result = await this.mget([key]);
57+
return result[key];
1658
}
1759

1860
async set(
1961
key: string,
2062
value: Serializable,
21-
_ttlMilliseconds?: number,
63+
ttlMilliseconds = Number.MAX_SAFE_INTEGER,
2264
): Promise<void> {
23-
this.#cache.set(key, value);
65+
this.#validateTtlOrThrow(ttlMilliseconds);
66+
67+
this.#cache.set(key, {
68+
value,
69+
expiresAt: Math.min(
70+
Date.now() + (ttlMilliseconds ?? Number.MAX_SAFE_INTEGER),
71+
Number.MAX_SAFE_INTEGER,
72+
),
73+
});
2474
}
2575

2676
async delete(key: string): Promise<boolean> {
27-
return this.#cache.delete(key);
77+
const result = await this.mdelete([key]);
78+
return result[key] ?? false;
2879
}
2980

3081
async clear(): Promise<void> {
3182
this.#cache.clear();
3283
}
3384

3485
async has(key: string): Promise<boolean> {
35-
return this.#cache.has(key);
86+
const cacheEntry = this.#cache.get(key);
87+
if (!cacheEntry) {
88+
return false;
89+
}
90+
91+
if (this.#isExpired(cacheEntry)) {
92+
this.#cache.delete(key);
93+
return false;
94+
}
95+
96+
return true;
3697
}
3798

3899
async keys(): Promise<string[]> {
100+
await this.#cleanupExpiredEntries();
39101
return Array.from(this.#cache.keys());
40102
}
41103

42104
async size(): Promise<number> {
105+
await this.#cleanupExpiredEntries();
43106
return this.#cache.size;
44107
}
45108

46109
async peek(key: string): Promise<Serializable | undefined> {
47-
return this.#cache.get(key);
110+
const cacheEntry = this.#cache.get(key);
111+
if (!cacheEntry) {
112+
return undefined;
113+
}
114+
115+
if (this.#isExpired(cacheEntry)) {
116+
this.#cache.delete(key);
117+
return undefined;
118+
}
119+
120+
return cacheEntry.value;
48121
}
49122

50-
async mget(keys: string[]): Promise<Record<string, Serializable>> {
51-
return Object.fromEntries(
52-
keys.map((key) => [key, this.#cache.get(key)]),
53-
) as Record<string, Serializable>;
123+
async mget(
124+
keys: string[],
125+
): Promise<Record<string, Serializable | undefined>> {
126+
await this.#cleanupExpiredEntries();
127+
128+
const result: Record<string, Serializable | undefined> = {};
129+
130+
for (const key of keys) {
131+
const cacheEntry = this.#cache.get(key);
132+
if (!cacheEntry) {
133+
this.logger.info(`[InMemoryCache] ❌ Cache miss for key "${key}"`);
134+
result[key] = undefined;
135+
continue;
136+
}
137+
138+
this.logger.info(`[InMemoryCache] 🎉 Cache hit for key "${key}"`);
139+
result[key] = cacheEntry.value;
140+
}
141+
142+
return result;
54143
}
55144

56145
async mset(
57-
entries: { key: string; value: Serializable; _ttlMilliseconds?: number }[],
146+
entries: { key: string; value: Serializable; ttlMilliseconds?: number }[],
58147
): Promise<void> {
59-
entries.forEach(({ key, value }) => {
60-
this.#cache.set(key, value);
148+
if (entries.length === 0) {
149+
return;
150+
}
151+
152+
if (entries.length === 1) {
153+
assert(entries[0]); // Enforce type narrowing as TS cannot infer that entries[0] is defined
154+
const { key, value, ttlMilliseconds } = entries[0];
155+
await this.set(key, value, ttlMilliseconds);
156+
return;
157+
}
158+
159+
entries.forEach(({ ttlMilliseconds }) => {
160+
this.#validateTtlOrThrow(ttlMilliseconds);
161+
});
162+
163+
entries.forEach(({ key, value, ttlMilliseconds }) => {
164+
if (value === undefined) {
165+
return;
166+
}
167+
this.#cache.set(key, {
168+
value,
169+
expiresAt: Math.min(
170+
Date.now() + (ttlMilliseconds ?? Number.MAX_SAFE_INTEGER),
171+
Number.MAX_SAFE_INTEGER,
172+
),
173+
});
61174
});
62175
}
63176

64177
async mdelete(keys: string[]): Promise<Record<string, boolean>> {
65178
return Object.fromEntries(
66179
keys.map((key) => [key, this.#cache.delete(key)]),
67-
) as Record<string, boolean>;
180+
);
68181
}
69182
}

packages/snap/src/core/caching/StateCache.ts

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,7 @@ import type { Serializable } from '../serialization/types';
66
import type { IStateManager } from '../services/state/IStateManager';
77
import type { ILogger } from '../utils/logger';
88
import type { ICache } from './ICache';
9-
10-
export type TimestampMilliseconds = number;
11-
12-
/**
13-
* A single cache entry.
14-
*/
15-
export type CacheEntry = {
16-
value: Serializable;
17-
expiresAt: TimestampMilliseconds;
18-
};
9+
import type { CacheEntry } from './types';
1910

2011
/**
2112
* The whole cache store.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { Serializable } from '../serialization/types';
2+
3+
export type TimestampMilliseconds = number;
4+
5+
/**
6+
* A single cache entry.
7+
*/
8+
export type CacheEntry = {
9+
value: Serializable;
10+
expiresAt: TimestampMilliseconds;
11+
};

packages/snap/src/core/clients/price-api/PriceApiClient.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ describe('PriceApiClient', () => {
3131
}),
3232
} as unknown as ConfigProvider;
3333

34-
mockCache = new InMemoryCache();
34+
mockCache = new InMemoryCache(mockLogger);
3535

3636
client = new PriceApiClient(
3737
mockConfigProvider,

packages/snap/src/core/services/assets/AssetsService.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ describe('AssetsService', () => {
6464

6565
mockState = new InMemoryState(DEFAULT_UNENCRYPTED_STATE);
6666

67-
mockCache = new InMemoryCache();
67+
mockCache = new InMemoryCache(logger);
6868

6969
stateUpdateSpy = jest.spyOn(mockState, 'update');
7070

packages/snap/src/snapContext.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ICache } from './core/caching/ICache';
2+
import { InMemoryCache } from './core/caching/InMemoryCache';
23
import { StateCache } from './core/caching/StateCache';
34
import { PriceApiClient } from './core/clients/price-api/PriceApiClient';
45
import { SecurityAlertsApiClient } from './core/clients/security-alerts-api/SecurityAlertsApiClient';
@@ -56,7 +57,8 @@ const state = new State({
5657
defaultState: DEFAULT_UNENCRYPTED_STATE,
5758
});
5859

59-
const cache = new StateCache(state, logger);
60+
const stateCache = new StateCache(state, logger);
61+
const inMemoryCache = new InMemoryCache(logger);
6062

6163
const connection = new SolanaConnection(configProvider);
6264
const transactionHelper = new TransactionHelper(connection, logger);
@@ -67,7 +69,7 @@ const sendSplTokenBuilder = new SendSplTokenBuilder(
6769
logger,
6870
);
6971
const tokenMetadataClient = new TokenMetadataClient(configProvider);
70-
const priceApiClient = new PriceApiClient(configProvider, cache);
72+
const priceApiClient = new PriceApiClient(configProvider, inMemoryCache);
7173

7274
const tokenMetadataService = new TokenMetadataService({
7375
tokenMetadataClient,
@@ -80,7 +82,7 @@ const assetsService = new AssetsService({
8082
configProvider,
8183
state,
8284
tokenMetadataService,
83-
cache,
85+
cache: inMemoryCache,
8486
});
8587

8688
const transactionsService = new TransactionsService({
@@ -128,7 +130,7 @@ const snapContext: SnapExecutionContext = {
128130
keyring,
129131
priceApiClient,
130132
state,
131-
cache,
133+
cache: stateCache,
132134
/* Services */
133135
assetsService,
134136
tokenPricesService,

0 commit comments

Comments
 (0)