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
244 changes: 199 additions & 45 deletions cache/lru_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,45 @@
import type { MemoizationCache } from "./memoize.ts";
export type { MemoizationCache };

/**
* The reason an entry was removed from the cache.
*
* @experimental **UNSTABLE**: New API, yet to be vetted.
*
* - `"evicted"` — removed automatically because the cache exceeded
* {@linkcode LruCache.prototype.maxSize | maxSize}.
* - `"deleted"` — removed by an explicit
* {@linkcode LruCache.prototype.delete | delete()} call.
* - `"cleared"` — removed by
* {@linkcode LruCache.prototype.clear | clear()}.
*/
export type LruCacheEjectionReason = "evicted" | "deleted" | "cleared";

/**
* Options for the {@linkcode LruCache} constructor.
*
* @experimental **UNSTABLE**: New API, yet to be vetted.
*/
export interface LruCacheOptions<K, V> {
/**
* Callback invoked when an entry is removed, whether by eviction,
* manual deletion, or clearing the cache. The entry is already removed
* from the cache when this callback fires. Overwriting an existing key
* via {@linkcode LruCache.prototype.set | set()} does **not** trigger
* this callback. The cache is not re-entrant during this callback:
* calling `set`, `delete`, or `clear` will throw.
*
* @param ejectedKey The key of the removed entry.
* @param ejectedValue The value of the removed entry.
* @param reason Why the entry was removed.
*/
onEject?: (
ejectedKey: K,
ejectedValue: V,
reason: LruCacheEjectionReason,
) => void;
}

/**
* Least-recently-used cache.
*
Expand All @@ -12,7 +51,7 @@ export type { MemoizationCache };
* @see {@link https://en.wikipedia.org/wiki/Cache_replacement_policies#LRU | Least-recently-used cache}
*
* Automatically removes entries above the max size based on when they were
* last accessed with `get`, `set`, or `has`.
* last accessed with `get` or `set`.
*
* @typeParam K The type of the cache keys.
* @typeParam V The type of the cache values.
Expand All @@ -39,72 +78,95 @@ export type { MemoizationCache };
* assert(!cache.has("a"));
* ```
*
* @example Adding a onEject function.
* @example Adding an onEject callback
* ```ts
* import { LruCache } from "@std/cache";
* import { assertEquals } from "@std/assert";
*
* const cache = new LruCache<string, string>(3, { onEject: (key, value) => {
* console.log("Revoking: ", key)
* URL.revokeObjectURL(value)
* }});
* const ejected: [string, number, string][] = [];
* const cache = new LruCache<string, number>(2, {
* onEject: (key, value, reason) => ejected.push([key, value, reason]),
* });
*
* cache.set(
* "fast-url",
* URL.createObjectURL(new Blob(["Hello, World"], { type: "text/plain" }))
* );
* cache.set("a", 1);
* cache.set("b", 2);
* cache.set("c", 3);
*
* cache.delete("fast-url") // "Revoking: fast-url"
* assertEquals(cache.get("fast-url"), undefined)
* assertEquals(ejected, [["a", 1, "evicted"]]);
* ```
*/
export class LruCache<K, V> extends Map<K, V>
implements MemoizationCache<K, V> {
#maxSize: number;
#ejecting = false;
#eject?:
| ((ejectedKey: K, ejectedValue: V, reason: LruCacheEjectionReason) => void)
| undefined;

/**
* Constructs a new `LruCache`.
*
* @experimental **UNSTABLE**: New API, yet to be vetted.
*
* @param maxSize The maximum number of entries to store in the cache. Must
* be a positive integer.
* @param options Additional options.
*/
constructor(
maxSize: number,
options?: LruCacheOptions<K, V>,
) {
super();
if (!Number.isInteger(maxSize) || maxSize < 1) {
throw new RangeError(
`Cannot create LruCache: maxSize must be a positive integer: received ${maxSize}`,
);
}
this.#maxSize = maxSize;
this.#eject = options?.onEject;
}

/**
* The maximum number of entries to store in the cache.
*
* @returns The maximum number of entries in the cache.
*
* @example Max size
* ```ts no-assert
* ```ts
* import { LruCache } from "@std/cache";
* import { assertEquals } from "@std/assert";
*
* const cache = new LruCache<string, number>(100);
* assertEquals(cache.maxSize, 100);
* ```
*/
maxSize: number;

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

/**
* Constructs a new `LruCache`.
*
* @param maxSize The maximum number of entries to store in the cache.
* @param options Additional options.
*/
constructor(
maxSize: number,
options?: { onEject: (ejectedKey: K, ejectedValue: V) => void },
) {
super();
this.maxSize = maxSize;
this.#eject = options?.onEject ?? (() => {});
get maxSize(): number {
return this.#maxSize;
}

#setMostRecentlyUsed(key: K, value: V): void {
// delete then re-add to ensure most recently accessed elements are last
super.delete(key);
super.set(key, value);
}

#pruneToMaxSize(): void {
if (this.size > this.maxSize) {
this.delete(this.keys().next().value!);
if (this.size <= this.#maxSize) return;
const key = this.keys().next().value!;
const value = super.get(key)!;
super.delete(key);
if (this.#eject) {
this.#ejecting = true;
try {
this.#eject(key, value, "evicted");
} finally {
this.#ejecting = false;
}
}
}

/**
* Checks whether an element with the specified key exists or not.
* Checks whether an element with the specified key exists or not. Does
* **not** update the entry's position in the eviction order.
*
* @param key The key to check.
* @returns `true` if the cache contains the specified key, otherwise `false`.
Expand All @@ -121,20 +183,15 @@ export class LruCache<K, V> extends Map<K, V>
* ```
*/
override has(key: K): boolean {
const exists = super.has(key);

if (exists) {
this.#setMostRecentlyUsed(key, super.get(key)!);
}

return exists;
return super.has(key);
}

/**
* Gets the element with the specified key.
*
* @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.
* @returns The value associated with the specified key, or `undefined` if
* the key is not present in the cache.
*
* @example Getting a value from the cache
* ```ts
Expand All @@ -157,6 +214,38 @@ export class LruCache<K, V> extends Map<K, V>
return undefined;
}

/**
* Returns the value associated with the given key, or `undefined` if the
* key is not present, **without** updating its position in the eviction
* order.
*
* @experimental **UNSTABLE**: New API, yet to be vetted.
*
* @param key The key to look up.
* @returns The value, or `undefined` if not present.
*
* @example Peeking at a value without promoting it
* ```ts
* import { LruCache } from "@std/cache";
* import { assertEquals } from "@std/assert";
*
* const cache = new LruCache<string, number>(3);
* cache.set("a", 1);
* cache.set("b", 2);
* cache.set("c", 3);
*
* // peek does not promote "a"
* assertEquals(cache.peek("a"), 1);
*
* // "a" is still the least recently used and gets evicted
* cache.set("d", 4);
* assertEquals(cache.peek("a"), undefined);
* ```
*/
peek(key: K): V | undefined {
return super.get(key);
}

/**
* Sets the specified key to the specified value.
*
Expand All @@ -173,6 +262,11 @@ export class LruCache<K, V> extends Map<K, V>
* ```
*/
override set(key: K, value: V): this {
if (this.#ejecting) {
throw new TypeError(
"Cannot set entry in LruCache: cache is not re-entrant during onEject callbacks",
);
}
this.#setMostRecentlyUsed(key, value);
this.#pruneToMaxSize();

Expand Down Expand Up @@ -200,9 +294,69 @@ export class LruCache<K, V> extends Map<K, V>
* ```
*/
override delete(key: K): boolean {
if (super.has(key)) {
this.#eject(key, super.get(key) as V);
if (this.#ejecting) {
throw new TypeError(
"Cannot delete entry in LruCache: cache is not re-entrant during onEject callbacks",
);
}
const value = super.get(key);
const existed = super.delete(key);
if (!existed) return false;

if (this.#eject) {
this.#ejecting = true;
try {
this.#eject(key, value!, "deleted");
} finally {
this.#ejecting = false;
}
}
return true;
}

/**
* Clears the cache.
*
* @experimental **UNSTABLE**: New API, yet to be vetted.
*
* @example Usage
* ```ts
* import { LruCache } from "@std/cache";
* import { assertEquals } from "@std/assert/equals";
*
* const cache = new LruCache<string, number>(100);
*
* cache.set("a", 1);
* cache.set("b", 2);
* cache.clear();
* assertEquals(cache.size, 0);
* ```
*/
override clear(): void {
if (this.#ejecting) {
throw new TypeError(
"Cannot clear LruCache: cache is not re-entrant during onEject callbacks",
);
}
if (!this.#eject) {
super.clear();
return;
}
const entries = [...super.entries()];
super.clear();
this.#ejecting = true;
let error: unknown;
try {
for (const [key, value] of entries) {
try {
this.#eject(key, value, "cleared");
} catch (e) {
error ??= e;
}
}
} finally {
this.#ejecting = false;
}
return super.delete(key);
if (error !== undefined) throw error;
}
}
Loading
Loading