|
| 1 | +import { assert } from '@metamask/utils'; |
| 2 | + |
1 | 3 | import type { Serializable } from '../serialization/types'; |
| 4 | +import type { ILogger } from '../utils/logger'; |
2 | 5 | import type { ICache } from './ICache'; |
| 6 | +import type { CacheEntry } from './types'; |
3 | 7 |
|
4 | 8 | /** |
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. |
6 | 10 | * |
7 | 11 | * WARNINGS: |
8 | 12 | * - This cache is not persistent and will be lost when the process is restarted. |
9 | | - * - It does not support TTL. |
10 | 13 | */ |
11 | 14 | 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 | + } |
13 | 54 |
|
14 | 55 | async get(key: string): Promise<Serializable | undefined> { |
15 | | - return this.#cache.get(key); |
| 56 | + const result = await this.mget([key]); |
| 57 | + return result[key]; |
16 | 58 | } |
17 | 59 |
|
18 | 60 | async set( |
19 | 61 | key: string, |
20 | 62 | value: Serializable, |
21 | | - _ttlMilliseconds?: number, |
| 63 | + ttlMilliseconds = Number.MAX_SAFE_INTEGER, |
22 | 64 | ): 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 | + }); |
24 | 74 | } |
25 | 75 |
|
26 | 76 | async delete(key: string): Promise<boolean> { |
27 | | - return this.#cache.delete(key); |
| 77 | + const result = await this.mdelete([key]); |
| 78 | + return result[key] ?? false; |
28 | 79 | } |
29 | 80 |
|
30 | 81 | async clear(): Promise<void> { |
31 | 82 | this.#cache.clear(); |
32 | 83 | } |
33 | 84 |
|
34 | 85 | 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; |
36 | 97 | } |
37 | 98 |
|
38 | 99 | async keys(): Promise<string[]> { |
| 100 | + await this.#cleanupExpiredEntries(); |
39 | 101 | return Array.from(this.#cache.keys()); |
40 | 102 | } |
41 | 103 |
|
42 | 104 | async size(): Promise<number> { |
| 105 | + await this.#cleanupExpiredEntries(); |
43 | 106 | return this.#cache.size; |
44 | 107 | } |
45 | 108 |
|
46 | 109 | 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; |
48 | 121 | } |
49 | 122 |
|
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; |
54 | 143 | } |
55 | 144 |
|
56 | 145 | async mset( |
57 | | - entries: { key: string; value: Serializable; _ttlMilliseconds?: number }[], |
| 146 | + entries: { key: string; value: Serializable; ttlMilliseconds?: number }[], |
58 | 147 | ): 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 | + }); |
61 | 174 | }); |
62 | 175 | } |
63 | 176 |
|
64 | 177 | async mdelete(keys: string[]): Promise<Record<string, boolean>> { |
65 | 178 | return Object.fromEntries( |
66 | 179 | keys.map((key) => [key, this.#cache.delete(key)]), |
67 | | - ) as Record<string, boolean>; |
| 180 | + ); |
68 | 181 | } |
69 | 182 | } |
0 commit comments