diff --git a/cache/ttl_cache.ts b/cache/ttl_cache.ts index de3bceba144d..58dcc70cdbc8 100644 --- a/cache/ttl_cache.ts +++ b/cache/ttl_cache.ts @@ -3,6 +3,63 @@ import type { MemoizationCache } from "./memoize.ts"; +/** + * Options for {@linkcode TtlCache.prototype.set} when + * {@linkcode TtlCacheOptions.slidingExpiration | slidingExpiration} is + * disabled (the default). + * + * @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 {@linkcode TtlCache.prototype.set} when + * {@linkcode TtlCacheOptions.slidingExpiration | slidingExpiration} is + * enabled. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + */ +export interface TtlCacheSlidingSetOptions extends TtlCacheSetOptions { + /** + * A maximum lifetime in milliseconds for this entry, measured from the + * time it is set. The sliding window cannot extend past this duration. + */ + absoluteExpiration?: number; +} + +/** + * Options for the {@linkcode TtlCache} constructor. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @typeParam Sliding Whether sliding expiration is enabled. + */ +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; + /** + * When `true`, each {@linkcode TtlCache.prototype.get | get()} or + * {@linkcode TtlCache.prototype.has | has()} call resets the entry's TTL. + * + * If both `slidingExpiration` and + * {@linkcode TtlCacheSlidingSetOptions.absoluteExpiration | absoluteExpiration} + * are set on an entry, the sliding window cannot extend past the absolute + * expiration. + * + * @default {false} + */ + slidingExpiration?: Sliding; +} + /** * Time-to-live cache. * @@ -12,6 +69,7 @@ import type { MemoizationCache } from "./memoize.ts"; * * @typeParam K The type of the cache keys. * @typeParam V The type of the cache values. + * @typeParam Sliding Whether sliding expiration is enabled. * * @example Usage * ```ts @@ -27,32 +85,34 @@ import type { MemoizationCache } from "./memoize.ts"; * assertEquals(cache.size, 0); * ``` * - * @example Adding a onEject function. + * @example Sliding expiration * ```ts * import { TtlCache } from "@std/cache/ttl-cache"; - * import { delay } from "@std/async/delay"; * import { assertEquals } from "@std/assert/equals"; + * import { FakeTime } from "@std/testing/time"; * - * const cache = new TtlCache(100, { onEject: (key, value) => { - * console.log("Revoking: ", key) - * URL.revokeObjectURL(value) - * }}) + * using time = new FakeTime(0); + * const cache = new TtlCache(100, { + * slidingExpiration: true, + * }); * - * cache.set( - * "fast-url", - * URL.createObjectURL(new Blob(["Hello, World"], { type: "text/plain" })) - * ); - * - * await delay(200) // "Revoking: fast-url" - * assertEquals(cache.get("fast-url"), undefined) + * cache.set("a", 1); + * time.now = 80; + * assertEquals(cache.get("a"), 1); // resets TTL + * time.now = 160; + * assertEquals(cache.get("a"), 1); // still alive, TTL was reset at t=80 + * time.now = 260; + * assertEquals(cache.get("a"), undefined); // expired * ``` */ -export class TtlCache extends Map +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; + #slidingExpiration: Sliding; + #entryTtls = new Map(); + #absoluteDeadlines = new Map(); /** * Constructs a new instance. @@ -60,17 +120,23 @@ 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; + this.#slidingExpiration = (options?.slidingExpiration ?? false) as Sliding; } /** @@ -78,12 +144,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,13 +164,100 @@ 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?: Sliding extends true ? TtlCacheSlidingSetOptions + : 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)); + + if (this.#slidingExpiration) { + const slidingOptions = options as TtlCacheSlidingSetOptions | undefined; + this.#entryTtls.set(key, ttl); + if (slidingOptions?.absoluteExpiration !== undefined) { + const abs = slidingOptions.absoluteExpiration; + if (!(abs >= 0) || !Number.isFinite(abs)) { + throw new RangeError( + `Cannot set entry in TtlCache: absoluteExpiration must be a finite, non-negative number: received ${abs}`, + ); + } + this.#absoluteDeadlines.set(key, Date.now() + abs); + } else { + this.#absoluteDeadlines.delete(key); + } + } + return this; } + /** + * Gets the value associated with the specified key. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * When {@linkcode TtlCacheOptions.slidingExpiration | slidingExpiration} is + * enabled, accessing an entry resets its TTL. + * + * @param key The key to get the value for. + * @returns The value associated with the specified key, or `undefined` if + * the key is not present in the cache. + * + * @example Usage + * ```ts + * import { TtlCache } from "@std/cache/ttl-cache"; + * import { assertEquals } from "@std/assert/equals"; + * + * using cache = new TtlCache(1000); + * + * cache.set("a", 1); + * assertEquals(cache.get("a"), 1); + * ``` + */ + override get(key: K): V | undefined { + if (!super.has(key)) return undefined; + if (this.#slidingExpiration) this.#resetTtl(key); + return super.get(key); + } + + /** + * Checks whether an element with the specified key exists. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * When {@linkcode TtlCacheOptions.slidingExpiration | slidingExpiration} is + * enabled, checking an entry resets its TTL. + * + * @param key The key to check. + * @returns `true` if the cache contains the specified key, otherwise `false`. + * + * @example Usage + * ```ts + * import { TtlCache } from "@std/cache/ttl-cache"; + * import { assert } from "@std/assert"; + * + * using cache = new TtlCache(1000); + * + * cache.set("a", 1); + * assert(cache.has("a")); + * ``` + */ + override has(key: K): boolean { + const exists = super.has(key); + if (exists && this.#slidingExpiration) this.#resetTtl(key); + return exists; + } + /** * Deletes the value associated with the given key. * @@ -129,12 +279,17 @@ 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.#entryTtls.delete(key); + this.#absoluteDeadlines.delete(key); + this.#eject?.(key, value!); + return true; } /** @@ -160,7 +315,19 @@ export class TtlCache extends Map clearTimeout(timeout); } this.#timeouts.clear(); + this.#entryTtls.clear(); + this.#absoluteDeadlines.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; } /** @@ -186,4 +353,21 @@ export class TtlCache extends Map [Symbol.dispose](): void { this.clear(); } + + #resetTtl(key: K): void { + const ttl = this.#entryTtls.get(key); + if (ttl === undefined) return; + + const deadline = this.#absoluteDeadlines.get(key); + const effectiveTtl = deadline !== undefined + ? Math.min(ttl, Math.max(0, deadline - Date.now())) + : ttl; + + const existing = this.#timeouts.get(key); + if (existing !== undefined) clearTimeout(existing); + this.#timeouts.set( + key, + setTimeout(() => this.delete(key), effectiveTtl), + ); + } } diff --git a/cache/ttl_cache_test.ts b/cache/ttl_cache_test.ts index 9b868d9d2d21..8c87d890b25a 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,321 @@ 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); + }); + + await t.step("set() rejects negative absoluteExpiration", () => { + using cache = new TtlCache(1000, { + slidingExpiration: true, + }); + assertThrows( + () => cache.set("a", 1, { absoluteExpiration: -1 }), + RangeError, + "absoluteExpiration must be a finite, non-negative number", + ); + }); + + await t.step("set() rejects NaN absoluteExpiration", () => { + using cache = new TtlCache(1000, { + slidingExpiration: true, + }); + assertThrows( + () => cache.set("a", 1, { absoluteExpiration: NaN }), + RangeError, + "absoluteExpiration must be a finite, non-negative number", + ); + }); +}); + +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); +}); + +Deno.test("TtlCache get() returns undefined for missing key with sliding expiration", () => { + using cache = new TtlCache(100, { + slidingExpiration: true, + }); + assertEquals(cache.get("missing"), undefined); +}); + +Deno.test("TtlCache sliding expiration", async (t) => { + await t.step("get() resets TTL", () => { + using time = new FakeTime(0); + const cache = new TtlCache(100, { + slidingExpiration: true, + }); + + cache.set("a", 1); + + time.now = 80; + assertEquals(cache.get("a"), 1); + + // TTL was reset at t=80, so entry lives until t=180 + time.now = 160; + assertEquals(cache.get("a"), 1); + + // TTL was reset at t=160, so entry lives until t=260 + time.now = 250; + assertEquals(cache.get("a"), 1); + + time.now = 350; + assertEquals(cache.get("a"), undefined); + }); + + await t.step("has() resets TTL", () => { + using time = new FakeTime(0); + const cache = new TtlCache(100, { + slidingExpiration: true, + }); + + cache.set("a", 1); + + time.now = 80; + assertEquals(cache.has("a"), true); + + time.now = 160; + assertEquals(cache.has("a"), true); + + time.now = 260; + assertEquals(cache.has("a"), false); + }); + + await t.step("does not reset TTL when slidingExpiration is false", () => { + using time = new FakeTime(0); + const cache = new TtlCache(100); + + cache.set("a", 1); + + time.now = 80; + assertEquals(cache.get("a"), 1); + + time.now = 100; + assertEquals(cache.get("a"), undefined); + }); + + await t.step("absoluteExpiration caps sliding extension", () => { + using time = new FakeTime(0); + const cache = new TtlCache(100, { + slidingExpiration: true, + }); + + cache.set("a", 1, { absoluteExpiration: 150 }); + + time.now = 80; + assertEquals(cache.get("a"), 1); + + time.now = 140; + assertEquals(cache.get("a"), 1); + + // Absolute deadline is t=150; sliding cannot extend past it + time.now = 150; + assertEquals(cache.get("a"), undefined); + }); + + await t.step( + "absoluteExpiration is a type error without slidingExpiration", + () => { + using time = new FakeTime(0); + const cache = new TtlCache(100); + + // @ts-expect-error absoluteExpiration requires slidingExpiration: true + cache.set("a", 1, { absoluteExpiration: 50 }); + + time.now = 80; + assertEquals(cache.get("a"), 1); + + time.now = 100; + assertEquals(cache.get("a"), undefined); + }, + ); + + await t.step("per-entry TTL works with sliding expiration", () => { + using time = new FakeTime(0); + const cache = new TtlCache(100, { + slidingExpiration: true, + }); + + cache.set("a", 1, { ttl: 50 }); + + time.now = 40; + assertEquals(cache.get("a"), 1); + + // TTL reset to 50ms at t=40, so alive until t=90 + time.now = 80; + assertEquals(cache.get("a"), 1); + + // TTL reset to 50ms at t=80, so alive until t=130 + time.now = 130; + assertEquals(cache.get("a"), undefined); + }); + + await t.step("sliding expiration calls onEject on expiry", () => { + using time = new FakeTime(0); + const ejected: [string, number][] = []; + const cache = new TtlCache(100, { + slidingExpiration: true, + onEject: (k, v) => ejected.push([k, v]), + }); + + cache.set("a", 1); + + time.now = 80; + cache.get("a"); + + time.now = 180; + assertEquals(ejected, [["a", 1]]); + }); + + await t.step("overwriting entry resets sliding metadata", () => { + using time = new FakeTime(0); + const cache = new TtlCache(100, { + slidingExpiration: true, + }); + + cache.set("a", 1, { ttl: 50, absoluteExpiration: 200 }); + + time.now = 40; + cache.get("a"); + + // Overwrite with different TTL and no absoluteExpiration + cache.set("a", 2, { ttl: 30 }); + + time.now = 60; + assertEquals(cache.get("a"), 2); + + // TTL reset to 30ms at t=60, alive until t=90 + time.now = 90; + assertEquals(cache.get("a"), undefined); + }); });