diff --git a/cache/ttl_cache.ts b/cache/ttl_cache.ts index de3bceba144d..0ab0a8e2c3f0 100644 --- a/cache/ttl_cache.ts +++ b/cache/ttl_cache.ts @@ -3,6 +3,32 @@ import type { MemoizationCache } from "./memoize.ts"; +/** + * Options for {@linkcode TtlCache.prototype.set}. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + */ +export interface TtlCacheSetOptions { + /** + * A custom time-to-live in milliseconds for this entry. If supplied, + * overrides the cache's default TTL. Must be a finite, non-negative number. + */ + ttl?: number; +} + +/** + * Options for the {@linkcode TtlCache} constructor. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + */ +export interface TtlCacheOptions { + /** + * Callback invoked when an entry is removed, whether by TTL expiry, + * manual deletion, or clearing the cache. + */ + onEject?: (ejectedKey: K, ejectedValue: V) => void; +} + /** * Time-to-live cache. * @@ -27,7 +53,7 @@ import type { MemoizationCache } from "./memoize.ts"; * assertEquals(cache.size, 0); * ``` * - * @example Adding a onEject function. + * @example Adding an onEject callback * ```ts * import { TtlCache } from "@std/cache/ttl-cache"; * import { delay } from "@std/async/delay"; @@ -51,8 +77,7 @@ export class TtlCache extends Map implements MemoizationCache { #defaultTtl: number; #timeouts = new Map(); - - #eject: (ejectedKey: K, ejectedValue: V) => void; + #eject?: ((ejectedKey: K, ejectedValue: V) => void) | undefined; /** * Constructs a new instance. @@ -60,17 +85,22 @@ export class TtlCache extends Map * @experimental **UNSTABLE**: New API, yet to be vetted. * * @param defaultTtl The default time-to-live in milliseconds. This value must - * be equal to or greater than 0. Its limit is determined by the current - * runtime's {@linkcode setTimeout} implementation. + * be a finite, non-negative number. Its upper limit is determined by the + * current runtime's {@linkcode setTimeout} implementation. * @param options Additional options. */ constructor( defaultTtl: number, - options?: { onEject: (ejectedKey: K, ejectedValue: V) => void }, + options?: TtlCacheOptions, ) { super(); + if (!(defaultTtl >= 0) || !Number.isFinite(defaultTtl)) { + throw new RangeError( + `Cannot create TtlCache: defaultTtl must be a finite, non-negative number: received ${defaultTtl}`, + ); + } this.#defaultTtl = defaultTtl; - this.#eject = options?.onEject ?? (() => {}); + this.#eject = options?.onEject; } /** @@ -78,12 +108,9 @@ export class TtlCache extends Map * * @experimental **UNSTABLE**: New API, yet to be vetted. * - * @param key The cache key - * @param value The value to set - * @param ttl A custom time-to-live. If supplied, overrides the cache's - * default TTL for this entry. This value must - * be equal to or greater than 0. Its limit is determined by the current - * runtime's {@linkcode setTimeout} implementation. + * @param key The cache key. + * @param value The value to set. + * @param options Options for this entry. * @returns `this` for chaining. * * @example Usage @@ -101,8 +128,16 @@ export class TtlCache extends Map * assertEquals(cache.get("a"), undefined); * ``` */ - override set(key: K, value: V, ttl: number = this.#defaultTtl): this { - clearTimeout(this.#timeouts.get(key)); + override set(key: K, value: V, options?: TtlCacheSetOptions): this { + const ttl = options?.ttl ?? this.#defaultTtl; + if (!(ttl >= 0) || !Number.isFinite(ttl)) { + throw new RangeError( + `Cannot set entry in TtlCache: ttl must be a finite, non-negative number: received ${ttl}`, + ); + } + + const existing = this.#timeouts.get(key); + if (existing !== undefined) clearTimeout(existing); super.set(key, value); this.#timeouts.set(key, setTimeout(() => this.delete(key), ttl)); return this; @@ -129,12 +164,15 @@ export class TtlCache extends Map * ``` */ override delete(key: K): boolean { - if (super.has(key)) { - this.#eject(key, super.get(key) as V); - } - clearTimeout(this.#timeouts.get(key)); + const value = super.get(key); + const existed = super.delete(key); + if (!existed) return false; + + const timeout = this.#timeouts.get(key); + if (timeout !== undefined) clearTimeout(timeout); this.#timeouts.delete(key); - return super.delete(key); + this.#eject?.(key, value!); + return true; } /** @@ -160,7 +198,17 @@ export class TtlCache extends Map clearTimeout(timeout); } this.#timeouts.clear(); + const entries = [...super.entries()]; super.clear(); + let error: unknown; + for (const [key, value] of entries) { + try { + this.#eject?.(key, value); + } catch (e) { + error ??= e; + } + } + if (error !== undefined) throw error; } /** diff --git a/cache/ttl_cache_test.ts b/cache/ttl_cache_test.ts index 9b868d9d2d21..481d33415a8c 100644 --- a/cache/ttl_cache_test.ts +++ b/cache/ttl_cache_test.ts @@ -1,6 +1,6 @@ // Copyright 2018-2026 the Deno authors. MIT license. import { TtlCache } from "./ttl_cache.ts"; -import { assertEquals } from "@std/assert"; +import { assertEquals, assertThrows } from "@std/assert"; import { FakeTime } from "@std/testing/time"; const UNSET = Symbol("UNSET"); @@ -68,7 +68,7 @@ Deno.test("TtlCache deletes entries", async (t) => { const cache = new TtlCache(10); cache.set(1, "one"); - cache.set(2, "two", 3); + cache.set(2, "two", { ttl: 3 }); time.now = 1; assertEntries(cache, [[1, "one"], [2, "two"]]); @@ -143,4 +143,141 @@ Deno.test("TtlCache onEject()", async (t) => { assertEquals(ejected, [[1, 0], [2, ""], [3, false], [4, null]]); }); + + await t.step("calls onEject on clear()", () => { + const ejected: [number, string][] = []; + using cache = new TtlCache(1000, { + onEject: (k, v) => ejected.push([k, v]), + }); + + cache.set(1, "one"); + cache.set(2, "two"); + cache.set(3, "three"); + cache.clear(); + + assertEquals(ejected, [[1, "one"], [2, "two"], [3, "three"]]); + }); + + await t.step("calls onEject on [Symbol.dispose]()", () => { + const ejected: [number, string][] = []; + { + using cache = new TtlCache(1000, { + onEject: (k, v) => ejected.push([k, v]), + }); + cache.set(1, "one"); + cache.set(2, "two"); + } + + assertEquals(ejected, [[1, "one"], [2, "two"]]); + }); + + await t.step("does not call onEject when overwriting a key", () => { + const ejected: [string, number][] = []; + using cache = new TtlCache(1000, { + onEject: (k, v) => ejected.push([k, v]), + }); + + cache.set("a", 1); + cache.set("a", 2); + + assertEquals(ejected, []); + assertEquals(cache.get("a"), 2); + }); + + await t.step("entry is fully removed before onEject fires", () => { + let sizeInCallback = -1; + let hasInCallback = true; + using cache = new TtlCache(1000, { + onEject: (k) => { + sizeInCallback = cache.size; + hasInCallback = cache.has(k); + }, + }); + + cache.set("a", 1); + cache.delete("a"); + + assertEquals(sizeInCallback, 0); + assertEquals(hasInCallback, false); + }); +}); + +Deno.test("TtlCache validates TTL", async (t) => { + await t.step("constructor rejects negative defaultTtl", () => { + assertThrows( + () => new TtlCache(-1), + RangeError, + "defaultTtl must be a finite, non-negative number", + ); + }); + + await t.step("constructor rejects NaN defaultTtl", () => { + assertThrows( + () => new TtlCache(NaN), + RangeError, + "defaultTtl must be a finite, non-negative number", + ); + }); + + await t.step("constructor rejects Infinity defaultTtl", () => { + assertThrows( + () => new TtlCache(Infinity), + RangeError, + "defaultTtl must be a finite, non-negative number", + ); + }); + + await t.step("constructor accepts 0", () => { + using _cache = new TtlCache(0); + }); + + await t.step("set() rejects negative ttl", () => { + using cache = new TtlCache(1000); + assertThrows( + () => cache.set("a", 1, { ttl: -1 }), + RangeError, + "ttl must be a finite, non-negative number", + ); + }); + + await t.step("set() rejects NaN ttl", () => { + using cache = new TtlCache(1000); + assertThrows( + () => cache.set("a", 1, { ttl: NaN }), + RangeError, + "ttl must be a finite, non-negative number", + ); + }); + + await t.step("set() rejects Infinity ttl", () => { + using cache = new TtlCache(1000); + assertThrows( + () => cache.set("a", 1, { ttl: Infinity }), + RangeError, + "ttl must be a finite, non-negative number", + ); + }); + + await t.step("set() accepts 0 ttl", () => { + using cache = new TtlCache(1000); + cache.set("a", 1, { ttl: 0 }); + assertEquals(cache.get("a"), 1); + }); +}); + +Deno.test("TtlCache clear() calls all onEject callbacks even if one throws", () => { + const ejected: string[] = []; + using cache = new TtlCache(1000, { + onEject: (k) => { + ejected.push(k); + if (k === "a") throw new Error("boom"); + }, + }); + + cache.set("a", 1); + cache.set("b", 2); + cache.set("c", 3); + assertThrows(() => cache.clear(), Error, "boom"); + assertEquals(ejected, ["a", "b", "c"]); + assertEquals(cache.size, 0); });