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
131 changes: 116 additions & 15 deletions cache/ttl_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -27,6 +35,16 @@ export interface TtlCacheOptions<K, V> {
* 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;
}

/**
Expand All @@ -38,7 +56,6 @@ export interface TtlCacheOptions<K, V> {
*
* @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";
Expand All @@ -53,31 +70,34 @@ export interface TtlCacheOptions<K, V> {
* 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<string, string>(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<string, number>(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<K, V> extends Map<K, V>
implements MemoizationCache<K, V> {
#defaultTtl: number;
#timeouts = new Map<K, number>();
#eject?: ((ejectedKey: K, ejectedValue: V) => void) | undefined;
#slidingExpiration: boolean;
#entryTtls?: Map<K, number>;
#absoluteDeadlines?: Map<K, number>;

/**
* Constructs a new instance.
Expand All @@ -101,6 +121,11 @@ export class TtlCache<K, V> extends Map<K, V>
}
this.#defaultTtl = defaultTtl;
this.#eject = options?.onEject;
this.#slidingExpiration = options?.slidingExpiration ?? false;
if (this.#slidingExpiration) {
this.#entryTtls = new Map();
this.#absoluteDeadlines = new Map();
}
}

/**
Expand Down Expand Up @@ -128,7 +153,17 @@ export class TtlCache<K, V> extends Map<K, V>
* 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(
Expand All @@ -140,9 +175,54 @@ export class TtlCache<K, V> extends Map<K, V>
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<string, number>(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.
*
Expand Down Expand Up @@ -171,6 +251,8 @@ export class TtlCache<K, V> extends Map<K, V>
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;
}
Expand Down Expand Up @@ -198,6 +280,8 @@ export class TtlCache<K, V> extends Map<K, V>
clearTimeout(timeout);
}
this.#timeouts.clear();
this.#entryTtls?.clear();
this.#absoluteDeadlines?.clear();
const entries = [...super.entries()];
super.clear();
let error: unknown;
Expand Down Expand Up @@ -234,4 +318,21 @@ export class TtlCache<K, V> extends Map<K, V>
[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),
);
}
}
170 changes: 170 additions & 0 deletions cache/ttl_cache_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>(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<string, number>(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<string, number>(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<string, number>(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<string, number>(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<string, number>(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<string, number>(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<string, number>(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<string, number>(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<string, number>(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<string, number>(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<string, number>(1000, {
Expand Down
Loading