Skip to content

Commit 008db8d

Browse files
feat(cache/unstable): add sliding expiration to TtlCache (#7046)
Sliding expiration: entries can now stay alive as long as they're being accessed, with an optional hard deadline. Useful for sessions or rate-limit windows.
1 parent 21ba810 commit 008db8d

2 files changed

Lines changed: 286 additions & 15 deletions

File tree

cache/ttl_cache.ts

Lines changed: 116 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ export interface TtlCacheSetOptions {
1414
* overrides the cache's default TTL. Must be a finite, non-negative number.
1515
*/
1616
ttl?: number;
17+
/**
18+
* A maximum lifetime in milliseconds for this entry, measured from the
19+
* time it is set. When
20+
* {@linkcode TtlCacheOptions.slidingExpiration | slidingExpiration} is
21+
* enabled, the sliding window cannot extend past this duration. Throws
22+
* if `slidingExpiration` is not enabled.
23+
*/
24+
absoluteExpiration?: number;
1725
}
1826

1927
/**
@@ -27,6 +35,16 @@ export interface TtlCacheOptions<K, V> {
2735
* manual deletion, or clearing the cache.
2836
*/
2937
onEject?: (ejectedKey: K, ejectedValue: V) => void;
38+
/**
39+
* When `true`, each {@linkcode TtlCache.prototype.get | get()} call resets
40+
* the entry's TTL.
41+
*
42+
* If both `slidingExpiration` and `absoluteExpiration` are set on an entry,
43+
* the sliding window cannot extend past the absolute expiration.
44+
*
45+
* @default {false}
46+
*/
47+
slidingExpiration?: boolean;
3048
}
3149

3250
/**
@@ -38,7 +56,6 @@ export interface TtlCacheOptions<K, V> {
3856
*
3957
* @typeParam K The type of the cache keys.
4058
* @typeParam V The type of the cache values.
41-
*
4259
* @example Usage
4360
* ```ts
4461
* import { TtlCache } from "@std/cache/ttl-cache";
@@ -53,31 +70,34 @@ export interface TtlCacheOptions<K, V> {
5370
* assertEquals(cache.size, 0);
5471
* ```
5572
*
56-
* @example Adding an onEject callback
73+
* @example Sliding expiration
5774
* ```ts
5875
* import { TtlCache } from "@std/cache/ttl-cache";
59-
* import { delay } from "@std/async/delay";
6076
* import { assertEquals } from "@std/assert/equals";
77+
* import { FakeTime } from "@std/testing/time";
6178
*
62-
* const cache = new TtlCache<string, string>(100, { onEject: (key, value) => {
63-
* console.log("Revoking: ", key)
64-
* URL.revokeObjectURL(value)
65-
* }})
66-
*
67-
* cache.set(
68-
* "fast-url",
69-
* URL.createObjectURL(new Blob(["Hello, World"], { type: "text/plain" }))
70-
* );
79+
* using time = new FakeTime(0);
80+
* const cache = new TtlCache<string, number>(100, {
81+
* slidingExpiration: true,
82+
* });
7183
*
72-
* await delay(200) // "Revoking: fast-url"
73-
* assertEquals(cache.get("fast-url"), undefined)
84+
* cache.set("a", 1);
85+
* time.now = 80;
86+
* assertEquals(cache.get("a"), 1); // resets TTL
87+
* time.now = 160;
88+
* assertEquals(cache.get("a"), 1); // still alive, TTL was reset at t=80
89+
* time.now = 260;
90+
* assertEquals(cache.get("a"), undefined); // expired
7491
* ```
7592
*/
7693
export class TtlCache<K, V> extends Map<K, V>
7794
implements MemoizationCache<K, V> {
7895
#defaultTtl: number;
7996
#timeouts = new Map<K, number>();
8097
#eject?: ((ejectedKey: K, ejectedValue: V) => void) | undefined;
98+
#slidingExpiration: boolean;
99+
#entryTtls?: Map<K, number>;
100+
#absoluteDeadlines?: Map<K, number>;
81101

82102
/**
83103
* Constructs a new instance.
@@ -101,6 +121,11 @@ export class TtlCache<K, V> extends Map<K, V>
101121
}
102122
this.#defaultTtl = defaultTtl;
103123
this.#eject = options?.onEject;
124+
this.#slidingExpiration = options?.slidingExpiration ?? false;
125+
if (this.#slidingExpiration) {
126+
this.#entryTtls = new Map();
127+
this.#absoluteDeadlines = new Map();
128+
}
104129
}
105130

106131
/**
@@ -128,7 +153,17 @@ export class TtlCache<K, V> extends Map<K, V>
128153
* assertEquals(cache.get("a"), undefined);
129154
* ```
130155
*/
131-
override set(key: K, value: V, options?: TtlCacheSetOptions): this {
156+
override set(
157+
key: K,
158+
value: V,
159+
options?: TtlCacheSetOptions,
160+
): this {
161+
if (options?.absoluteExpiration !== undefined && !this.#slidingExpiration) {
162+
throw new TypeError(
163+
"Cannot set entry in TtlCache: absoluteExpiration requires slidingExpiration to be enabled",
164+
);
165+
}
166+
132167
const ttl = options?.ttl ?? this.#defaultTtl;
133168
if (!(ttl >= 0) || !Number.isFinite(ttl)) {
134169
throw new RangeError(
@@ -140,9 +175,54 @@ export class TtlCache<K, V> extends Map<K, V>
140175
if (existing !== undefined) clearTimeout(existing);
141176
super.set(key, value);
142177
this.#timeouts.set(key, setTimeout(() => this.delete(key), ttl));
178+
179+
if (this.#slidingExpiration) {
180+
this.#entryTtls!.set(key, ttl);
181+
if (options?.absoluteExpiration !== undefined) {
182+
const abs = options.absoluteExpiration;
183+
if (!(abs >= 0) || !Number.isFinite(abs)) {
184+
throw new RangeError(
185+
`Cannot set entry in TtlCache: absoluteExpiration must be a finite, non-negative number: received ${abs}`,
186+
);
187+
}
188+
this.#absoluteDeadlines!.set(key, Date.now() + abs);
189+
} else {
190+
this.#absoluteDeadlines!.delete(key);
191+
}
192+
}
193+
143194
return this;
144195
}
145196

197+
/**
198+
* Gets the value associated with the specified key.
199+
*
200+
* @experimental **UNSTABLE**: New API, yet to be vetted.
201+
*
202+
* When {@linkcode TtlCacheOptions.slidingExpiration | slidingExpiration} is
203+
* enabled, accessing an entry resets its TTL.
204+
*
205+
* @param key The key to get the value for.
206+
* @returns The value associated with the specified key, or `undefined` if
207+
* the key is not present in the cache.
208+
*
209+
* @example Usage
210+
* ```ts
211+
* import { TtlCache } from "@std/cache/ttl-cache";
212+
* import { assertEquals } from "@std/assert/equals";
213+
*
214+
* using cache = new TtlCache<string, number>(1000);
215+
*
216+
* cache.set("a", 1);
217+
* assertEquals(cache.get("a"), 1);
218+
* ```
219+
*/
220+
override get(key: K): V | undefined {
221+
if (!super.has(key)) return undefined;
222+
if (this.#slidingExpiration) this.#resetTtl(key);
223+
return super.get(key);
224+
}
225+
146226
/**
147227
* Deletes the value associated with the given key.
148228
*
@@ -171,6 +251,8 @@ export class TtlCache<K, V> extends Map<K, V>
171251
const timeout = this.#timeouts.get(key);
172252
if (timeout !== undefined) clearTimeout(timeout);
173253
this.#timeouts.delete(key);
254+
this.#entryTtls?.delete(key);
255+
this.#absoluteDeadlines?.delete(key);
174256
this.#eject?.(key, value!);
175257
return true;
176258
}
@@ -198,6 +280,8 @@ export class TtlCache<K, V> extends Map<K, V>
198280
clearTimeout(timeout);
199281
}
200282
this.#timeouts.clear();
283+
this.#entryTtls?.clear();
284+
this.#absoluteDeadlines?.clear();
201285
const entries = [...super.entries()];
202286
super.clear();
203287
let error: unknown;
@@ -234,4 +318,21 @@ export class TtlCache<K, V> extends Map<K, V>
234318
[Symbol.dispose](): void {
235319
this.clear();
236320
}
321+
322+
#resetTtl(key: K): void {
323+
const ttl = this.#entryTtls!.get(key);
324+
if (ttl === undefined) return;
325+
326+
const deadline = this.#absoluteDeadlines!.get(key);
327+
const effectiveTtl = deadline !== undefined
328+
? Math.min(ttl, Math.max(0, deadline - Date.now()))
329+
: ttl;
330+
331+
const existing = this.#timeouts.get(key);
332+
if (existing !== undefined) clearTimeout(existing);
333+
this.#timeouts.set(
334+
key,
335+
setTimeout(() => this.delete(key), effectiveTtl),
336+
);
337+
}
237338
}

cache/ttl_cache_test.ts

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,176 @@ Deno.test("TtlCache validates TTL", async (t) => {
265265
});
266266
});
267267

268+
Deno.test("TtlCache get() returns undefined for missing key with sliding expiration", () => {
269+
using cache = new TtlCache<string, number>(100, {
270+
slidingExpiration: true,
271+
});
272+
assertEquals(cache.get("missing"), undefined);
273+
});
274+
275+
Deno.test("TtlCache sliding expiration", async (t) => {
276+
await t.step("get() resets TTL", () => {
277+
using time = new FakeTime(0);
278+
const cache = new TtlCache<string, number>(100, {
279+
slidingExpiration: true,
280+
});
281+
282+
cache.set("a", 1);
283+
284+
time.now = 80;
285+
assertEquals(cache.get("a"), 1);
286+
287+
// TTL was reset at t=80, so entry lives until t=180
288+
time.now = 160;
289+
assertEquals(cache.get("a"), 1);
290+
291+
// TTL was reset at t=160, so entry lives until t=260
292+
time.now = 250;
293+
assertEquals(cache.get("a"), 1);
294+
295+
time.now = 350;
296+
assertEquals(cache.get("a"), undefined);
297+
});
298+
299+
await t.step("has() does not reset TTL", () => {
300+
using time = new FakeTime(0);
301+
const cache = new TtlCache<string, number>(100, {
302+
slidingExpiration: true,
303+
});
304+
305+
cache.set("a", 1);
306+
307+
time.now = 80;
308+
assertEquals(cache.has("a"), true);
309+
310+
// has() did not reset the TTL, so the entry still expires at t=100
311+
time.now = 100;
312+
assertEquals(cache.has("a"), false);
313+
});
314+
315+
await t.step("does not reset TTL when slidingExpiration is false", () => {
316+
using time = new FakeTime(0);
317+
const cache = new TtlCache<string, number>(100);
318+
319+
cache.set("a", 1);
320+
321+
time.now = 80;
322+
assertEquals(cache.get("a"), 1);
323+
324+
time.now = 100;
325+
assertEquals(cache.get("a"), undefined);
326+
});
327+
328+
await t.step("absoluteExpiration caps sliding extension", () => {
329+
using time = new FakeTime(0);
330+
const cache = new TtlCache<string, number>(100, {
331+
slidingExpiration: true,
332+
});
333+
334+
cache.set("a", 1, { absoluteExpiration: 150 });
335+
336+
time.now = 80;
337+
assertEquals(cache.get("a"), 1);
338+
339+
time.now = 140;
340+
assertEquals(cache.get("a"), 1);
341+
342+
// Absolute deadline is t=150; sliding cannot extend past it
343+
time.now = 150;
344+
assertEquals(cache.get("a"), undefined);
345+
});
346+
347+
await t.step("absoluteExpiration throws without slidingExpiration", () => {
348+
using cache = new TtlCache<string, number>(100);
349+
assertThrows(
350+
() => cache.set("a", 1, { absoluteExpiration: 50 }),
351+
TypeError,
352+
"absoluteExpiration requires slidingExpiration to be enabled",
353+
);
354+
});
355+
356+
await t.step("per-entry TTL works with sliding expiration", () => {
357+
using time = new FakeTime(0);
358+
const cache = new TtlCache<string, number>(100, {
359+
slidingExpiration: true,
360+
});
361+
362+
cache.set("a", 1, { ttl: 50 });
363+
364+
time.now = 40;
365+
assertEquals(cache.get("a"), 1);
366+
367+
// TTL reset to 50ms at t=40, so alive until t=90
368+
time.now = 80;
369+
assertEquals(cache.get("a"), 1);
370+
371+
// TTL reset to 50ms at t=80, so alive until t=130
372+
time.now = 130;
373+
assertEquals(cache.get("a"), undefined);
374+
});
375+
376+
await t.step("sliding expiration calls onEject on expiry", () => {
377+
using time = new FakeTime(0);
378+
const ejected: [string, number][] = [];
379+
const cache = new TtlCache<string, number>(100, {
380+
slidingExpiration: true,
381+
onEject: (k, v) => ejected.push([k, v]),
382+
});
383+
384+
cache.set("a", 1);
385+
386+
time.now = 80;
387+
cache.get("a");
388+
389+
time.now = 180;
390+
assertEquals(ejected, [["a", 1]]);
391+
});
392+
393+
await t.step("overwriting entry resets sliding metadata", () => {
394+
using time = new FakeTime(0);
395+
const cache = new TtlCache<string, number>(100, {
396+
slidingExpiration: true,
397+
});
398+
399+
cache.set("a", 1, { ttl: 50, absoluteExpiration: 200 });
400+
401+
time.now = 40;
402+
cache.get("a");
403+
404+
// Overwrite with different TTL and no absoluteExpiration
405+
cache.set("a", 2, { ttl: 30 });
406+
407+
time.now = 60;
408+
assertEquals(cache.get("a"), 2);
409+
410+
// TTL reset to 30ms at t=60, alive until t=90
411+
time.now = 90;
412+
assertEquals(cache.get("a"), undefined);
413+
});
414+
415+
await t.step("set() rejects negative absoluteExpiration", () => {
416+
using cache = new TtlCache<string, number>(1000, {
417+
slidingExpiration: true,
418+
});
419+
assertThrows(
420+
() => cache.set("a", 1, { absoluteExpiration: -1 }),
421+
RangeError,
422+
"absoluteExpiration must be a finite, non-negative number",
423+
);
424+
});
425+
426+
await t.step("set() rejects NaN absoluteExpiration", () => {
427+
using cache = new TtlCache<string, number>(1000, {
428+
slidingExpiration: true,
429+
});
430+
assertThrows(
431+
() => cache.set("a", 1, { absoluteExpiration: NaN }),
432+
RangeError,
433+
"absoluteExpiration must be a finite, non-negative number",
434+
);
435+
});
436+
});
437+
268438
Deno.test("TtlCache clear() calls all onEject callbacks even if one throws", () => {
269439
const ejected: string[] = [];
270440
using cache = new TtlCache<string, number>(1000, {

0 commit comments

Comments
 (0)