Skip to content

Commit 7157291

Browse files
feat(cache/unstable): add maxSize to TtlCache
Made-with: Cursor
1 parent 1cd63ca commit 7157291

2 files changed

Lines changed: 305 additions & 10 deletions

File tree

cache/ttl_cache.ts

Lines changed: 83 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,20 @@ export interface TtlCacheSetOptions {
3030
* @experimental **UNSTABLE**: New API, yet to be vetted.
3131
*/
3232
export interface TtlCacheOptions<K, V> {
33+
/**
34+
* Maximum number of entries the cache may hold. When a new entry would
35+
* exceed this limit, the entry that was
36+
* {@linkcode TtlCache.prototype.set | set()} least recently is evicted
37+
* before the new one is added. Must be a positive integer when provided.
38+
*/
39+
maxSize?: number;
3340
/**
3441
* Callback invoked when an entry is removed, whether by TTL expiry,
35-
* manual deletion, or clearing the cache.
42+
* capacity eviction, manual deletion, or clearing the cache. The entry is
43+
* already removed from the cache when this callback fires. Overwriting an
44+
* existing key via {@linkcode TtlCache.prototype.set | set()} does **not**
45+
* trigger this callback. The cache is not re-entrant during this callback:
46+
* calling `set`, `delete`, or `clear` will throw.
3647
*/
3748
onEject?: (ejectedKey: K, ejectedValue: V) => void;
3849
/**
@@ -93,7 +104,9 @@ export interface TtlCacheOptions<K, V> {
93104
export class TtlCache<K, V> extends Map<K, V>
94105
implements MemoizationCache<K, V> {
95106
#defaultTtl: number;
107+
#maxSize: number;
96108
#timeouts = new Map<K, number>();
109+
#ejecting = false;
97110
#eject?: ((ejectedKey: K, ejectedValue: V) => void) | undefined;
98111
#slidingExpiration: boolean;
99112
#entryTtls?: Map<K, number>;
@@ -119,7 +132,14 @@ export class TtlCache<K, V> extends Map<K, V>
119132
`Cannot create TtlCache: defaultTtl must be a finite, non-negative number: received ${defaultTtl}`,
120133
);
121134
}
135+
const maxSize = options?.maxSize;
136+
if (maxSize !== undefined && (!Number.isInteger(maxSize) || maxSize < 1)) {
137+
throw new RangeError(
138+
`Cannot create TtlCache: maxSize must be a positive integer: received ${maxSize}`,
139+
);
140+
}
122141
this.#defaultTtl = defaultTtl;
142+
this.#maxSize = maxSize ?? Infinity;
123143
this.#eject = options?.onEject;
124144
this.#slidingExpiration = options?.slidingExpiration ?? false;
125145
if (this.#slidingExpiration) {
@@ -128,6 +148,27 @@ export class TtlCache<K, V> extends Map<K, V>
128148
}
129149
}
130150

151+
/**
152+
* The maximum number of entries the cache may hold, or `Infinity` if no
153+
* limit was set.
154+
*
155+
* @experimental **UNSTABLE**: New API, yet to be vetted.
156+
*
157+
* @returns The maximum number of entries in the cache.
158+
*
159+
* @example Usage
160+
* ```ts
161+
* import { TtlCache } from "@std/cache/ttl-cache";
162+
* import { assertEquals } from "@std/assert/equals";
163+
*
164+
* const cache = new TtlCache<string, number>(1000, { maxSize: 5 });
165+
* assertEquals(cache.maxSize, 5);
166+
* ```
167+
*/
168+
get maxSize(): number {
169+
return this.#maxSize;
170+
}
171+
131172
/**
132173
* Set a value in the cache.
133174
*
@@ -158,6 +199,11 @@ export class TtlCache<K, V> extends Map<K, V>
158199
value: V,
159200
options?: TtlCacheSetOptions,
160201
): this {
202+
if (this.#ejecting) {
203+
throw new TypeError(
204+
"Cannot set entry in TtlCache: cache is not re-entrant during onEject callbacks",
205+
);
206+
}
161207
if (options?.absoluteExpiration !== undefined && !this.#slidingExpiration) {
162208
throw new TypeError(
163209
"Cannot set entry in TtlCache: absoluteExpiration requires slidingExpiration to be enabled",
@@ -178,8 +224,14 @@ export class TtlCache<K, V> extends Map<K, V>
178224
);
179225
}
180226

227+
const isNew = !super.has(key);
228+
if (isNew && this.size >= this.#maxSize) {
229+
this.delete(super.keys().next().value as K);
230+
}
231+
181232
const existing = this.#timeouts.get(key);
182233
if (existing !== undefined) clearTimeout(existing);
234+
super.delete(key);
183235
super.set(key, value);
184236
this.#timeouts.set(key, setTimeout(() => this.delete(key), ttl));
185237

@@ -285,6 +337,11 @@ export class TtlCache<K, V> extends Map<K, V>
285337
* ```
286338
*/
287339
override delete(key: K): boolean {
340+
if (this.#ejecting) {
341+
throw new TypeError(
342+
"Cannot delete entry in TtlCache: cache is not re-entrant during onEject callbacks",
343+
);
344+
}
288345
const value = super.get(key);
289346
const existed = super.delete(key);
290347
if (!existed) return false;
@@ -294,7 +351,14 @@ export class TtlCache<K, V> extends Map<K, V>
294351
this.#timeouts.delete(key);
295352
this.#entryTtls?.delete(key);
296353
this.#absoluteDeadlines?.delete(key);
297-
this.#eject?.(key, value!);
354+
if (this.#eject) {
355+
this.#ejecting = true;
356+
try {
357+
this.#eject(key, value!);
358+
} finally {
359+
this.#ejecting = false;
360+
}
361+
}
298362
return true;
299363
}
300364

@@ -317,6 +381,11 @@ export class TtlCache<K, V> extends Map<K, V>
317381
* ```
318382
*/
319383
override clear(): void {
384+
if (this.#ejecting) {
385+
throw new TypeError(
386+
"Cannot clear TtlCache: cache is not re-entrant during onEject callbacks",
387+
);
388+
}
320389
for (const timeout of this.#timeouts.values()) {
321390
clearTimeout(timeout);
322391
}
@@ -325,13 +394,19 @@ export class TtlCache<K, V> extends Map<K, V>
325394
this.#absoluteDeadlines?.clear();
326395
const entries = [...super.entries()];
327396
super.clear();
397+
if (!this.#eject) return;
398+
this.#ejecting = true;
328399
let error: unknown;
329-
for (const [key, value] of entries) {
330-
try {
331-
this.#eject?.(key, value);
332-
} catch (e) {
333-
error ??= e;
400+
try {
401+
for (const [key, value] of entries) {
402+
try {
403+
this.#eject(key, value);
404+
} catch (e) {
405+
error ??= e;
406+
}
334407
}
408+
} finally {
409+
this.#ejecting = false;
335410
}
336411
if (error !== undefined) throw error;
337412
}
@@ -361,9 +436,7 @@ export class TtlCache<K, V> extends Map<K, V>
361436
}
362437

363438
#resetTtl(key: K): void {
364-
const ttl = this.#entryTtls!.get(key);
365-
if (ttl === undefined) return;
366-
439+
const ttl = this.#entryTtls!.get(key)!;
367440
const deadline = this.#absoluteDeadlines!.get(key);
368441
const effectiveTtl = deadline !== undefined
369442
? Math.min(ttl, Math.max(0, deadline - Date.now()))

0 commit comments

Comments
 (0)