diff --git a/cache/ttl_cache.ts b/cache/ttl_cache.ts index 0ab0a8e2c3f0..7a5d86e5831f 100644 --- a/cache/ttl_cache.ts +++ b/cache/ttl_cache.ts @@ -14,6 +14,14 @@ export interface TtlCacheSetOptions { * overrides the cache's default TTL. Must be a finite, non-negative number. */ ttl?: number; + /** + * A maximum lifetime in milliseconds for this entry, measured from the + * time it is set. When + * {@linkcode TtlCacheOptions.slidingExpiration | slidingExpiration} is + * enabled, the sliding window cannot extend past this duration. Throws + * if `slidingExpiration` is not enabled. + */ + absoluteExpiration?: number; } /** @@ -27,6 +35,16 @@ export interface TtlCacheOptions { * manual deletion, or clearing the cache. */ onEject?: (ejectedKey: K, ejectedValue: V) => void; + /** + * When `true`, each {@linkcode TtlCache.prototype.get | get()} call resets + * the entry's TTL. + * + * If both `slidingExpiration` and `absoluteExpiration` are set on an entry, + * the sliding window cannot extend past the absolute expiration. + * + * @default {false} + */ + slidingExpiration?: boolean; } /** @@ -38,7 +56,6 @@ export interface TtlCacheOptions { * * @typeParam K The type of the cache keys. * @typeParam V The type of the cache values. - * * @example Usage * ```ts * import { TtlCache } from "@std/cache/ttl-cache"; @@ -53,24 +70,24 @@ export interface TtlCacheOptions { * assertEquals(cache.size, 0); * ``` * - * @example Adding an onEject callback + * @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) - * }}) - * - * cache.set( - * "fast-url", - * URL.createObjectURL(new Blob(["Hello, World"], { type: "text/plain" })) - * ); + * using time = new FakeTime(0); + * const cache = new TtlCache(100, { + * slidingExpiration: true, + * }); * - * 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 @@ -78,6 +95,9 @@ export class TtlCache extends Map #defaultTtl: number; #timeouts = new Map(); #eject?: ((ejectedKey: K, ejectedValue: V) => void) | undefined; + #slidingExpiration: boolean; + #entryTtls?: Map; + #absoluteDeadlines?: Map; /** * Constructs a new instance. @@ -101,6 +121,11 @@ export class TtlCache extends Map } this.#defaultTtl = defaultTtl; this.#eject = options?.onEject; + this.#slidingExpiration = options?.slidingExpiration ?? false; + if (this.#slidingExpiration) { + this.#entryTtls = new Map(); + this.#absoluteDeadlines = new Map(); + } } /** @@ -128,7 +153,17 @@ export class TtlCache extends Map * assertEquals(cache.get("a"), undefined); * ``` */ - override set(key: K, value: V, options?: TtlCacheSetOptions): this { + override set( + key: K, + value: V, + options?: TtlCacheSetOptions, + ): this { + if (options?.absoluteExpiration !== undefined && !this.#slidingExpiration) { + throw new TypeError( + "Cannot set entry in TtlCache: absoluteExpiration requires slidingExpiration to be enabled", + ); + } + const ttl = options?.ttl ?? this.#defaultTtl; if (!(ttl >= 0) || !Number.isFinite(ttl)) { throw new RangeError( @@ -140,9 +175,54 @@ export class TtlCache extends Map if (existing !== undefined) clearTimeout(existing); super.set(key, value); this.#timeouts.set(key, setTimeout(() => this.delete(key), ttl)); + + if (this.#slidingExpiration) { + this.#entryTtls!.set(key, ttl); + if (options?.absoluteExpiration !== undefined) { + const abs = options.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); + } + /** * Deletes the value associated with the given key. * @@ -171,6 +251,8 @@ export class TtlCache extends Map const timeout = this.#timeouts.get(key); if (timeout !== undefined) clearTimeout(timeout); this.#timeouts.delete(key); + this.#entryTtls?.delete(key); + this.#absoluteDeadlines?.delete(key); this.#eject?.(key, value!); return true; } @@ -198,6 +280,8 @@ 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; @@ -234,4 +318,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 481d33415a8c..c5c0267c026d 100644 --- a/cache/ttl_cache_test.ts +++ b/cache/ttl_cache_test.ts @@ -265,6 +265,176 @@ Deno.test("TtlCache validates TTL", async (t) => { }); }); +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() does not reset 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); + + // has() did not reset the TTL, so the entry still expires at t=100 + time.now = 100; + 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 throws without slidingExpiration", () => { + using cache = new TtlCache(100); + assertThrows( + () => cache.set("a", 1, { absoluteExpiration: 50 }), + TypeError, + "absoluteExpiration requires slidingExpiration to be enabled", + ); + }); + + 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); + }); + + 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, {