Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 68 additions & 20 deletions cache/ttl_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<K, V> {
/**
* 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.
*
Expand All @@ -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";
Expand All @@ -51,39 +77,40 @@ export class TtlCache<K, V> extends Map<K, V>
implements MemoizationCache<K, V> {
#defaultTtl: number;
#timeouts = new Map<K, number>();

#eject: (ejectedKey: K, ejectedValue: V) => void;
#eject?: ((ejectedKey: K, ejectedValue: V) => void) | undefined;

/**
* Constructs a new instance.
*
* @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<K, V>,
) {
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;
}

/**
* Set a value in the cache.
*
* @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
Expand All @@ -101,8 +128,16 @@ export class TtlCache<K, V> extends Map<K, V>
* 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;
Expand All @@ -129,12 +164,15 @@ export class TtlCache<K, V> extends Map<K, V>
* ```
*/
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;
}

/**
Expand All @@ -160,7 +198,17 @@ export class TtlCache<K, V> extends Map<K, V>
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;
}

/**
Expand Down
141 changes: 139 additions & 2 deletions cache/ttl_cache_test.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -68,7 +68,7 @@ Deno.test("TtlCache deletes entries", async (t) => {
const cache = new TtlCache<number, string>(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"]]);
Expand Down Expand Up @@ -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<number, string>(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<number, string>(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<string, number>(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<string, number>(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<string, number>(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<string, number>(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<string, number>(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<string, number>(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<string, number>(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);
});
Loading