diff --git a/.github/dependency_graph.svg b/.github/dependency_graph.svg index dba8b04d4fa2..241ff2651dbc 100644 --- a/.github/dependency_graph.svg +++ b/.github/dependency_graph.svg @@ -63,32 +63,38 @@ async - + +cache + +cache + + + cli cli - + collections collections - + crypto crypto - + csv csv - + streams streams @@ -106,32 +112,32 @@ - + data-\nstructures data- structures - + datetime datetime - + dotenv dotenv - + encoding encoding - + expect expect @@ -149,20 +155,20 @@ - + fmt fmt - + front-\nmatter front- matter - + toml toml @@ -174,7 +180,7 @@ - + yaml yaml @@ -192,13 +198,13 @@ - + fs fs - + path path @@ -210,13 +216,13 @@ - + html html - + http http @@ -252,7 +258,7 @@ - + media-\ntypes media- @@ -265,7 +271,7 @@ - + net net @@ -277,13 +283,13 @@ - + ini ini - + json json @@ -295,7 +301,7 @@ - + jsonc jsonc @@ -307,7 +313,7 @@ - + log log @@ -331,7 +337,7 @@ - + msgpack msgpack @@ -343,19 +349,19 @@ - + regexp regexp - + semver semver - + testing testing @@ -397,19 +403,19 @@ - + text text - + ulid ulid - + url url @@ -421,7 +427,7 @@ - + uuid uuid @@ -439,7 +445,7 @@ - + webgpu webgpu diff --git a/_tools/check_circular_package_dependencies.ts b/_tools/check_circular_package_dependencies.ts index 79bb26cf6fad..9c7203ca7e36 100644 --- a/_tools/check_circular_package_dependencies.ts +++ b/_tools/check_circular_package_dependencies.ts @@ -39,6 +39,7 @@ type Mod = | "assert" | "async" | "bytes" + | "cache" | "cli" | "collections" | "crypto" @@ -80,6 +81,7 @@ const ENTRYPOINTS: Record = { assert: ["mod.ts"], async: ["mod.ts"], bytes: ["mod.ts"], + cache: ["mod.ts"], cli: ["mod.ts"], collections: ["mod.ts"], crypto: ["mod.ts"], diff --git a/_tools/check_docs.ts b/_tools/check_docs.ts index 46ce52e46edf..e5e83dcfc3b7 100644 --- a/_tools/check_docs.ts +++ b/_tools/check_docs.ts @@ -31,6 +31,7 @@ const ENTRY_POINTS = [ "../assert/mod.ts", "../async/mod.ts", "../bytes/mod.ts", + "../cache/mod.ts", "../cli/mod.ts", "../crypto/mod.ts", "../collections/mod.ts", diff --git a/cache/_serialize_arg_list.ts b/cache/_serialize_arg_list.ts new file mode 100644 index 000000000000..43e0395bfd5d --- /dev/null +++ b/cache/_serialize_arg_list.ts @@ -0,0 +1,87 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. +import type { MemoizationCache } from "./memoize.ts"; + +/** + * Default serialization of arguments list for use as cache keys. Equivalence + * follows [`SameValueZero`](https://tc39.es/ecma262/multipage/abstract-operations.html#sec-samevaluezero) + * reference equality, such that `getKey(x, y) === getKey(x, y)` for all values + * of `x` and `y`, but `getKey({}) !== getKey({})`. + * + * @param cache The cache for which the keys will be used. + * @returns `getKey`, the function for getting cache keys. + */ + +export function _serializeArgList( + cache: MemoizationCache, +): (this: unknown, ...args: unknown[]) => string { + const weakKeyToKeySegmentCache = new WeakMap(); + const weakKeySegmentToKeyCache = new Map(); + let i = 0; + + const registry = new FinalizationRegistry((keySegment) => { + for (const key of weakKeySegmentToKeyCache.get(keySegment) ?? []) { + cache.delete(key); + } + weakKeySegmentToKeyCache.delete(keySegment); + }); + + return function getKey(...args) { + const weakKeySegments: string[] = []; + const keySegments = [this, ...args].map((arg) => { + if (typeof arg === "undefined") return "undefined"; + if (typeof arg === "bigint") return `${arg}n`; + + if (typeof arg === "number") { + return String(arg); + } + + if ( + arg === null || + typeof arg === "string" || + typeof arg === "boolean" + ) { + // This branch will need to be updated if further types are added to + // the language that support value equality, + // e.g. https://github.com/tc39/proposal-record-tuple + return JSON.stringify(arg); + } + + try { + assertWeakKey(arg); + } catch { + if (typeof arg === "symbol") { + return `Symbol.for(${JSON.stringify(arg.description)})`; + } + // Non-weak keys other than `Symbol.for(...)` are handled by the branches above. + throw new Error( + "Should be unreachable. Please open an issue at https://github.com/denoland/std/issues/new", + ); + } + + if (!weakKeyToKeySegmentCache.has(arg)) { + const keySegment = `{${i++}}`; + weakKeySegments.push(keySegment); + registry.register(arg, keySegment); + weakKeyToKeySegmentCache.set(arg, keySegment); + } + + const keySegment = weakKeyToKeySegmentCache.get(arg)!; + weakKeySegments.push(keySegment); + return keySegment; + }); + + const key = keySegments.join(","); + + for (const keySegment of weakKeySegments) { + const keys = weakKeySegmentToKeyCache.get(keySegment) ?? []; + keys.push(key); + weakKeySegmentToKeyCache.set(keySegment, keys); + } + + return key; + }; +} + +function assertWeakKey(arg: unknown): asserts arg is WeakKey { + new WeakRef(arg as WeakKey); +} diff --git a/cache/_serialize_arg_list_test.ts b/cache/_serialize_arg_list_test.ts new file mode 100644 index 000000000000..f44f16b81e0f --- /dev/null +++ b/cache/_serialize_arg_list_test.ts @@ -0,0 +1,184 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. +import { assertEquals } from "@std/assert"; +import { _serializeArgList } from "./_serialize_arg_list.ts"; +import { delay } from "@std/async"; + +Deno.test("_serializeArgList() serializes simple numbers", () => { + const getKey = _serializeArgList(new Map()); + assertEquals(getKey(1), "undefined,1"); + assertEquals(getKey(1, 2), "undefined,1,2"); + assertEquals(getKey(1, 2, 3), "undefined,1,2,3"); +}); + +Deno.test("_serializeArgList() serializes reference types", () => { + const getKey = _serializeArgList(new Map()); + const obj = {}; + const arr: [] = []; + const sym = Symbol("xyz"); + + assertEquals(getKey(obj), "undefined,{0}"); + assertEquals(getKey(obj, obj), "undefined,{0},{0}"); + + assertEquals(getKey(arr), "undefined,{1}"); + assertEquals(getKey(sym), "undefined,{2}"); + assertEquals( + getKey(obj, arr, sym), + "undefined,{0},{1},{2}", + ); +}); + +Deno.test("_serializeArgList() gives same results as SameValueZero algorithm", async (t) => { + /** + * [`SameValueZero`](https://tc39.es/ecma262/multipage/abstract-operations.html#sec-samevaluezero), + * used by [`Set`](https://tc39.es/ecma262/multipage/keyed-collections.html#sec-set-objects): + * + * > Distinct values are discriminated using the SameValueZero comparison algorithm. + */ + const sameValueZero = (x: unknown, y: unknown) => new Set([x, y]).size === 1; + + const getKey = _serializeArgList(new Map()); + + const values = [ + 1, + "1", + '"1"', + 1n, + 0, + -0, + 0n, + true, + "true", + null, + undefined, + Infinity, + -Infinity, + NaN, + {}, + {}, + Symbol("x"), + Symbol.for("x"), + ]; + + await t.step("Serialization of values", () => { + assertEquals( + getKey(...values), + 'undefined,1,"1","\\"1\\"",1n,0,0,0n,true,"true",null,undefined,Infinity,-Infinity,NaN,{0},{1},{2},Symbol.for("x")', + ); + }); + + await t.step("Gives consistent serialization for each value", () => { + for (const x of values) { + assertEquals(getKey(x), getKey(x)); + } + }); + + await t.step("Gives same equivalence for each pair of values", () => { + for (const x of values) { + for (const y of values) { + const expectedEquivalence = sameValueZero(x, y); + const actualEquivalence = getKey(x) === getKey(y); + assertEquals(actualEquivalence, expectedEquivalence); + } + } + }); +}); + +Deno.test("_serializeArgList() discriminates on `this` arg", () => { + const getKey = _serializeArgList(new Map()); + const obj1 = {}; + const obj2 = {}; + + assertEquals(getKey(), "undefined"); + assertEquals(getKey.call(obj1), "{0}"); + assertEquals(getKey.call(obj2), "{1}"); + assertEquals(getKey.call(obj1, obj2), "{0},{1}"); +}); + +Deno.test("_serializeArgList() allows garbage collection for weak keys", async () => { + // @ts-expect-error - Triggering true garbage collection is only available + // with `--v8-flags="--expose-gc"`, so we mock `FinalizationRegistry` with + // `using` and some `Symbol.dispose` trickery if it's not available. Run this + // test with `deno test --v8-flags="--expose-gc"` to test actual gc behavior + // (however, even calling `globalThis.gc` doesn't _guarantee_ garbage + // collection, so this may be flaky between v8 versions etc.) + const gc = globalThis.gc as undefined | (() => void); + + class MockFinalizationRegistry extends FinalizationRegistry { + #cleanupCallback: (heldValue: T) => void; + + constructor(cleanupCallback: (heldValue: T) => void) { + super(cleanupCallback); + this.#cleanupCallback = cleanupCallback; + } + + override register(target: WeakKey, heldValue: T) { + Object.assign(target, { + onCleanup: () => { + this.#cleanupCallback(heldValue); + }, + }); + } + } + + function makeRegisterableObject() { + const onCleanup = null as (() => void) | null; + return { + onCleanup, + [Symbol.dispose]() { + this.onCleanup?.(); + }, + }; + } + + const OriginalFinalizationRegistry = FinalizationRegistry; + + try { + if (!gc) { + globalThis.FinalizationRegistry = MockFinalizationRegistry; + } + + const cache = new Map(); + const getKey = _serializeArgList(cache); + + using outerScopeObj = makeRegisterableObject(); + + const k1 = getKey(outerScopeObj); + const k2 = getKey(globalThis); + const k3 = getKey("primitive"); + const k4 = getKey(globalThis, "primitive"); + const k5 = getKey(globalThis, "primitive", outerScopeObj); + + const persistentKeys = new Set([k1, k2, k3, k4, k5]); + + await (async () => { + using obj1 = makeRegisterableObject(); + using obj2 = makeRegisterableObject(); + + const k6 = getKey(obj1); + const k7 = getKey(obj2); + const k8 = getKey(obj1, obj2); + const k9 = getKey(obj1, globalThis); + const k10 = getKey(obj1, "primitive"); + const k11 = getKey(obj1, outerScopeObj); + + const ephemeralKeys = new Set([k6, k7, k8, k9, k10, k11]); + + const keys = new Set([...ephemeralKeys, ...persistentKeys]); + for (const [idx, key] of [...keys].entries()) { + cache.set(key, idx + 1); + } + + gc?.(); + // wait for gc to run + await delay(0); + assertEquals(cache.size, keys.size); + })(); + + gc?.(); + // wait for gc to run + await delay(0); + assertEquals(cache.size, persistentKeys.size); + } finally { + globalThis.FinalizationRegistry = OriginalFinalizationRegistry; + } +}); diff --git a/cache/deno.json b/cache/deno.json new file mode 100644 index 000000000000..1685e5bfd0ed --- /dev/null +++ b/cache/deno.json @@ -0,0 +1,9 @@ +{ + "name": "@std/cache", + "version": "0.1.0", + "exports": { + ".": "./mod.ts", + "./lru-cache": "./lru_cache.ts", + "./memoize": "./memoize.ts" + } +} diff --git a/cache/lru_cache.ts b/cache/lru_cache.ts new file mode 100644 index 000000000000..d5e2791aa06e --- /dev/null +++ b/cache/lru_cache.ts @@ -0,0 +1,151 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. +import type { MemoizationCache } from "./memoize.ts"; +export type { MemoizationCache }; + +/** + * [Least-recently-used]( + * https://en.wikipedia.org/wiki/Cache_replacement_policies#LRU + * ) cache. + * + * Automatically removes entries above the max size based on when they were + * last accessed with `get`, `set`, or `has`. + * + * @typeParam K The type of the cache keys. + * @typeParam V The type of the cache values. + * + * @example Basic usage + * ```ts + * import { LruCache } from "@std/cache"; + * import { assert, assertEquals } from "@std/assert"; + * + * const MAX_SIZE = 3; + * const cache = new LruCache(MAX_SIZE); + * + * cache.set("a", 1); + * cache.set("b", 2); + * cache.set("c", 3); + * cache.set("d", 4); + * + * // most recent values are stored up to `MAX_SIZE` + * assertEquals(cache.get("b"), 2); + * assertEquals(cache.get("c"), 3); + * assertEquals(cache.get("d"), 4); + * + * // less recent values are removed + * assert(!cache.has("a")); + * ``` + */ +export class LruCache extends Map + implements MemoizationCache { + /** + * The maximum number of entries to store in the cache. + * + * @example Max size + * ```ts no-assert + * import { LruCache } from "@std/cache"; + * import { assertEquals } from "@std/assert"; + * + * const cache = new LruCache(100); + * assertEquals(cache.maxSize, 100); + * ``` + */ + maxSize: number; + + /** + * Constructs a new `LruCache`. + * + * @param maxSize The maximum number of entries to store in the cache. + */ + constructor(maxSize: number) { + super(); + this.maxSize = 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); + } + } + + /** + * Checks whether an element with the specified key exists or not. + * + * @param key The key to check. + * @returns `true` if the cache contains the specified key, otherwise `false`. + * + * @example Checking for the existence of a key + * ```ts + * import { LruCache } from "@std/cache"; + * import { assert } from "@std/assert"; + * + * const cache = new LruCache(100); + * + * cache.set("a", 1); + * assert(cache.has("a")); + * ``` + */ + override has(key: K): boolean { + const exists = super.has(key); + + if (exists) { + this.#setMostRecentlyUsed(key, super.get(key)!); + } + + return exists; + } + + /** + * 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. + * + * @example Getting a value from the cache + * ```ts + * import { LruCache } from "@std/cache"; + * import { assertEquals } from "@std/assert"; + * + * const cache = new LruCache(100); + * + * cache.set("a", 1); + * assertEquals(cache.get("a"), 1); + * ``` + */ + override get(key: K): V | undefined { + if (super.has(key)) { + const value = super.get(key)!; + this.#setMostRecentlyUsed(key, value); + return value; + } + + return undefined; + } + + /** + * Sets the specified key to the specified value. + * + * @param key The key to set the value for. + * @param value The value to set. + * @returns `this` for chaining. + * + * @example Setting a value in the cache + * ```ts no-assert + * import { LruCache } from "@std/cache"; + * + * const cache = new LruCache(100); + * cache.set("a", 1); + * ``` + */ + override set(key: K, value: V): this { + this.#setMostRecentlyUsed(key, value); + this.#pruneToMaxSize(); + + return this; + } +} diff --git a/cache/lru_cache_test.ts b/cache/lru_cache_test.ts new file mode 100644 index 000000000000..ea5908e3efd5 --- /dev/null +++ b/cache/lru_cache_test.ts @@ -0,0 +1,24 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. +import { assert, assertEquals } from "@std/assert"; +import { LruCache } from "./lru_cache.ts"; + +Deno.test("LruCache deletes least-recently-used", () => { + const cache = new LruCache(3); + + cache.set(1, "!"); + cache.set(2, "!"); + cache.set(1, "updated"); + cache.set(3, "!"); + cache.set(4, "!"); + + assertEquals(cache.size, 3); + assert(!cache.has(2)); + assertEquals(cache.get(2), undefined); + assertEquals([...cache.keys()], [1, 3, 4]); + assertEquals(cache.get(3), "!"); + assertEquals(cache.get(1), "updated"); + + cache.delete(3); + assertEquals(cache.size, 2); + assertEquals(cache.get(3), undefined); +}); diff --git a/cache/memoize.ts b/cache/memoize.ts new file mode 100644 index 000000000000..29ca4593ed7e --- /dev/null +++ b/cache/memoize.ts @@ -0,0 +1,144 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +// deno-lint-ignore no-unused-vars +import type { LruCache } from "./lru_cache.ts"; +import { _serializeArgList } from "./_serialize_arg_list.ts"; + +/** + * A cache suitable for use with {@linkcode memoize}. + */ +export type MemoizationCache = { + has: (key: K) => boolean; + get: (key: K) => V | undefined; + set: (key: K, val: V) => unknown; + delete: (key: K) => unknown; +}; + +/** + * Options for {@linkcode memoize}. + * + * @typeParam Fn The type of the function to memoize. + * @typeParam Key The type of the cache key. + * @typeParam Cache The type of the cache. + */ +export type MemoizeOptions< + Fn extends (...args: never[]) => unknown, + Key, + Cache extends MemoizationCache>, +> = { + /** + * Provide a custom cache for getting previous results. By default, a new + * {@linkcode Map} object is instantiated upon memoization and used as a cache, with no + * limit on the number of results to be cached. + * + * Alternatively, you can supply a {@linkcode LruCache} with a specified max + * size to limit memory usage. + */ + cache?: Cache; + /** + * Function to get a unique cache key from the function's arguments. By + * default, a composite key is created from all the arguments plus the `this` + * value, using reference equality to check for equivalence. + * + * @example + * ```ts + * import { memoize } from "@std/cache"; + * import { assertEquals } from "@std/assert"; + * + * const fn = memoize(({ value }: { cacheKey: number; value: number }) => { + * return value; + * }, { getKey: ({ cacheKey }) => cacheKey }); + * + * assertEquals(fn({ cacheKey: 1, value: 2 }), 2); + * assertEquals(fn({ cacheKey: 1, value: 99 }), 2); + * assertEquals(fn({ cacheKey: 2, value: 99 }), 99); + * ``` + */ + getKey?: (this: ThisParameterType, ...args: Parameters) => Key; +}; + +/** + * Cache the results of a function based on its arguments. + * + * @typeParam Fn The type of the function to memoize. + * @typeParam Key The type of the cache key. + * @typeParam Cache The type of the cache. + * @param fn The function to memoize + * @param options Options for memoization + * + * @returns The memoized function. + * + * @example Basic usage + * ```ts + * import { memoize } from "@std/cache"; + * import { assertEquals } from "@std/assert"; + * + * // fibonacci function, which is very slow for n > ~30 if not memoized + * const fib = memoize((n: bigint): bigint => { + * return n <= 2n ? 1n : fib(n - 1n) + fib(n - 2n); + * }); + * + * assertEquals(fib(100n), 354224848179261915075n); + * ``` + * + * > [!NOTE] + * > * By default, memoization is on the basis of all arguments passed to the + * > function, with equality determined by reference. This means that, for + * > example, passing a memoized function as `arr.map(func)` will not use the + * > cached results, as the index is implicitly passed as an argument. To + * > avoid this, you can pass a custom `getKey` option or use the memoized + * > function inside an anonymous callback like `arr.map((x) => func(x))`. + * > * Memoization will not cache thrown errors and will eject promises from + * > the cache upon rejection. If you want to retain errors or rejected + * > promises in the cache, you will need to catch and return them. + */ +export function memoize< + Fn extends (...args: never[]) => unknown, + Key = string, + Cache extends MemoizationCache> = Map< + Key, + ReturnType + >, +>( + fn: Fn, + options?: MemoizeOptions, +): Fn { + const cache = options?.cache ?? new Map(); + const getKey = options?.getKey ?? + _serializeArgList( + cache as MemoizationCache, + ) as unknown as ( + (this: ThisParameterType, ...args: Parameters) => Key + ); + const memoized = function ( + this: ThisParameterType, + ...args: Parameters + ): ReturnType { + const key = getKey.apply(this, args) as Key; + + if (cache.has(key)) { + return cache.get(key)!; + } + + let val = fn.apply(this, args) as ReturnType; + + if (val instanceof Promise) { + val = val.catch((reason) => { + cache.delete(key); + throw reason; + }) as typeof val; + } + + cache.set(key, val); + + return val; + } as Fn; + + return Object.defineProperties( + memoized, + { + length: { value: fn.length }, + name: { value: fn.name }, + }, + ); +} diff --git a/cache/memoize_test.ts b/cache/memoize_test.ts new file mode 100644 index 000000000000..7e162b9c1f90 --- /dev/null +++ b/cache/memoize_test.ts @@ -0,0 +1,561 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. +import { + assert, + assertAlmostEquals, + assertEquals, + assertRejects, +} from "@std/assert"; +import { delay } from "@std/async"; +import { memoize } from "./memoize.ts"; +import { LruCache } from "./lru_cache.ts"; + +Deno.test( + "memoize() memoizes nullary function (lazy/singleton)", + async (t) => { + await t.step("async function", async () => { + let numTimesCalled = 0; + + const db = { + connect() { + ++numTimesCalled; + return Promise.resolve({}); + }, + }; + + const getConn = memoize(async () => await db.connect()); + const conn = await getConn(); + assertEquals(numTimesCalled, 1); + const conn2 = await getConn(); + // equal by reference + assert(conn2 === conn); + assertEquals(numTimesCalled, 1); + }); + + await t.step("sync function", async () => { + const firstHitDate = memoize(() => new Date()); + + const date = firstHitDate(); + + await delay(10); + + const date2 = firstHitDate(); + + assertEquals(date, date2); + }); + }, +); + +Deno.test("memoize() allows simple memoization with primitive arg", () => { + let numTimesCalled = 0; + const fn = memoize((n: number) => { + ++numTimesCalled; + return 0 - n; + }); + + assertEquals(fn(42), -42); + assertEquals(numTimesCalled, 1); + assertEquals(fn(42), -42); + assertEquals(numTimesCalled, 1); + assertEquals(fn(888), -888); + assertEquals(numTimesCalled, 2); +}); + +Deno.test("memoize() is performant for expensive fibonacci function", () => { + const fib = memoize((n: bigint): bigint => + n <= 2n ? 1n : fib(n - 1n) + fib(n - 2n) + ); + + const startTime = Date.now(); + assertEquals(fib(100n), 354224848179261915075n); + + assertAlmostEquals(Date.now(), startTime, 10); +}); + +Deno.test("memoize() allows multiple primitive args", () => { + let numTimesCalled = 0; + const fn = memoize((a: number, b: number) => { + ++numTimesCalled; + return a + b; + }); + + assertEquals(fn(7, 8), 15); + assertEquals(numTimesCalled, 1); + assertEquals(fn(7, 8), 15); + assertEquals(numTimesCalled, 1); + assertEquals(fn(7, 9), 16); + assertEquals(numTimesCalled, 2); + assertEquals(fn(8, 7), 15); + assertEquals(numTimesCalled, 3); +}); + +Deno.test("memoize() allows ...spread primitive args", () => { + let numTimesCalled = 0; + const fn = memoize((...ns: number[]) => { + ++numTimesCalled; + return ns.reduce((total, val) => total + val, 0); + }); + + assertEquals(fn(), 0); + assertEquals(fn(), 0); + assertEquals(numTimesCalled, 1); + assertEquals(fn(7), 7); + assertEquals(fn(7), 7); + assertEquals(numTimesCalled, 2); + assertEquals(fn(7, 8), 15); + assertEquals(fn(7, 8), 15); + assertEquals(numTimesCalled, 3); + assertEquals(fn(7, 8, 9), 24); + assertEquals(fn(7, 8, 9), 24); + assertEquals(numTimesCalled, 4); +}); + +Deno.test( + "memoize() caches unary function by all passed args by default (implicit extra args as array callback)", + () => { + let numTimesCalled = 0; + const fn = memoize((n: number) => { + ++numTimesCalled; + return 0 - n; + }); + + assertEquals([1, 1, 2, 2].map(fn), [-1, -1, -2, -2]); + assertEquals(numTimesCalled, 4); + }, +); + +Deno.test("memoize() preserves `this` binding`", () => { + class X { + readonly key = "CONSTANT"; + timesCalled = 0; + + #method() { + return 1; + } + + method() { + ++this.timesCalled; + return this.#method(); + } + } + + const x = new X(); + + const method = x.method.bind(x); + + const fn = memoize(method); + assertEquals(fn(), 1); + + const fn2 = memoize(x.method).bind(x); + assertEquals(fn2(), 1); +}); + +// based on https://github.com/lodash/lodash/blob/4.17.15/test/test.js#L14704-L14716 +Deno.test("memoize() uses `this` binding of function for `getKey`", () => { + type Obj = { b: number; c: number; memoized: (a: number) => number }; + + let numTimesCalled = 0; + + const fn = function (this: Obj, a: number) { + ++numTimesCalled; + return a + this.b + this.c; + }; + const getKey = function (this: Obj, a: number) { + return JSON.stringify([a, this.b, this.c]); + }; + + const memoized = memoize(fn, { getKey }); + + const obj: Obj = { memoized, "b": 2, "c": 3 }; + assertEquals(obj.memoized(1), 6); + assertEquals(numTimesCalled, 1); + + assertEquals(obj.memoized(1), 6); + assertEquals(numTimesCalled, 1); + + obj.b = 3; + obj.c = 5; + assertEquals(obj.memoized(1), 9); + assertEquals(numTimesCalled, 2); +}); + +Deno.test("memoize() allows reference arg with default caching", () => { + let numTimesCalled = 0; + const fn = memoize((sym: symbol) => { + ++numTimesCalled; + return sym; + }); + const sym1 = Symbol(); + const sym2 = Symbol(); + + fn(sym1); + assertEquals(numTimesCalled, 1); + fn(sym1); + assertEquals(numTimesCalled, 1); + fn(sym2); + assertEquals(numTimesCalled, 2); +}); + +Deno.test("memoize() allows multiple reference args with default caching", () => { + let numTimesCalled = 0; + const fn = memoize((obj1: unknown, obj2: unknown) => { + ++numTimesCalled; + return { obj1, obj2 }; + }); + const obj1 = {}; + const obj2 = {}; + + fn(obj1, obj1); + assertEquals(numTimesCalled, 1); + fn(obj1, obj1); + assertEquals(numTimesCalled, 1); + fn(obj1, obj2); + assertEquals(numTimesCalled, 2); + fn(obj2, obj2); + assertEquals(numTimesCalled, 3); + fn(obj2, obj1); + assertEquals(numTimesCalled, 4); +}); + +Deno.test("memoize() allows non-primitive arg with `getKey`", () => { + let numTimesCalled = 0; + const fn = memoize((d: Date) => { + ++numTimesCalled; + return new Date(0 - d.valueOf()); + }, { getKey: (n) => n.valueOf() }); + const date1 = new Date(42); + const date2 = new Date(888); + + assertEquals(fn(date1), new Date(-42)); + assertEquals(numTimesCalled, 1); + assertEquals(fn(date1), new Date(-42)); + assertEquals(numTimesCalled, 1); + assertEquals(fn(date2), new Date(-888)); + assertEquals(numTimesCalled, 2); +}); + +Deno.test("memoize() allows non-primitive arg with `getKey`", () => { + const fn = memoize(({ value }: { cacheKey: number; value: number }) => { + return value; + }, { getKey: ({ cacheKey }) => cacheKey }); + + assertEquals(fn({ cacheKey: 1, value: 2 }), 2); + assertEquals(fn({ cacheKey: 1, value: 99 }), 2); + assertEquals(fn({ cacheKey: 2, value: 99 }), 99); +}); + +Deno.test( + "memoize() allows multiple non-primitive args with `getKey` returning primitive", + () => { + let numTimesCalled = 0; + + const fn = memoize((...args: { val: number }[]) => { + ++numTimesCalled; + return args.reduce((total, { val }) => total + val, 0); + }, { getKey: (...args) => JSON.stringify(args) }); + + assertEquals(fn({ val: 1 }, { val: 2 }), 3); + assertEquals(numTimesCalled, 1); + assertEquals(fn({ val: 1 }, { val: 2 }), 3); + assertEquals(numTimesCalled, 1); + assertEquals(fn({ val: 2 }, { val: 1 }), 3); + assertEquals(numTimesCalled, 2); + }, +); + +Deno.test( + "memoize() allows multiple non-primitive args with `getKey` returning stringified array of primitives", + () => { + let numTimesCalled = 0; + + const fn = memoize((...args: { val: number }[]) => { + ++numTimesCalled; + return args.reduce((total, { val }) => total + val, 0); + }, { getKey: (...args) => JSON.stringify(args.map((arg) => arg.val)) }); + + assertEquals(fn({ val: 1 }, { val: 2 }), 3); + assertEquals(numTimesCalled, 1); + assertEquals(fn({ val: 1 }, { val: 2 }), 3); + assertEquals(numTimesCalled, 1); + assertEquals(fn({ val: 2 }, { val: 1 }), 3); + assertEquals(numTimesCalled, 2); + }, +); + +Deno.test( + "memoize() allows multiple non-primitive args of different types, `getKey` returning custom string from props", + () => { + let numTimesCalled = 0; + + const fn = memoize((one: { one: number }, two: { two: number }) => { + ++numTimesCalled; + return one.one + two.two; + }, { getKey: (one, two) => `${one.one},${two.two}` }); + + assertEquals(fn({ one: 1 }, { two: 2 }), 3); + assertEquals(numTimesCalled, 1); + assertEquals(fn({ one: 1 }, { two: 2 }), 3); + assertEquals(numTimesCalled, 1); + assertEquals(fn({ one: 2 }, { two: 1 }), 3); + assertEquals(numTimesCalled, 2); + }, +); + +Deno.test("memoize() allows primitive arg with `getKey`", () => { + let numTimesCalled = 0; + const fn = memoize((arg: string | number | boolean) => { + ++numTimesCalled; + + try { + return JSON.parse(String(arg)) as string | number | boolean; + } catch { + return arg; + } + }, { getKey: (arg) => String(arg) }); + + assertEquals(fn("true"), true); + assertEquals(numTimesCalled, 1); + assertEquals(fn(true), true); + assertEquals(numTimesCalled, 1); + + assertEquals(fn("42"), 42); + assertEquals(numTimesCalled, 2); + assertEquals(fn(42), 42); + assertEquals(numTimesCalled, 2); +}); + +Deno.test("memoize() works with async functions", async () => { + // wait time per call of the original (un-memoized) function + const DELAY_MS = 100; + // max amount of execution time per call of the memoized function + const TOLERANCE_MS = 5; + + const startTime = Date.now(); + const fn = memoize(async (n: number) => { + await delay(DELAY_MS); + return 0 - n; + }); + + const nums = [42, 888, 42, 42, 42, 42, 888, 888, 888, 888]; + const expected = [-42, -888, -42, -42, -42, -42, -888, -888, -888, -888]; + const results: number[] = []; + + // call in serial to test time elapsed + for (const num of nums) { + results.push(await fn(num)); + } + + assertEquals(results, expected); + + const numUnique = new Set(nums).size; + + assertAlmostEquals( + Date.now() - startTime, + numUnique * DELAY_MS, + nums.length * TOLERANCE_MS, + ); +}); + +Deno.test( + "memoize() doesn’t cache rejected promises for future function calls", + async () => { + let rejectNext = true; + const fn = memoize(async (n: number) => { + await Promise.resolve(); + const thisCallWillReject = rejectNext; + rejectNext = !rejectNext; + if (thisCallWillReject) { + throw new Error(); + } + return 0 - n; + }); + + // first call rejects + await assertRejects(() => fn(42)); + // second call succeeds (rejected response is discarded) + assertEquals(await fn(42), -42); + // subsequent calls also succeed (successful response from cache is used) + assertEquals(await fn(42), -42); + }, +); + +Deno.test( + "memoize() causes async functions called in parallel to return the same promise (even if rejected)", + async () => { + let rejectNext = true; + const fn = memoize(async (n: number) => { + await Promise.resolve(); + if (rejectNext) { + rejectNext = false; + throw new Error(`Rejected ${n}`); + } + return 0 - n; + }); + + const promises = [42, 42, 888, 888].map((x) => fn(x)); + + const results = await Promise.allSettled(promises); + + assert(promises[1] === promises[0]); + assert(results[1]!.status === "rejected"); + assert(results[1]!.reason.message === "Rejected 42"); + + assert(promises[3] === promises[2]); + assert(results[3]!.status === "fulfilled"); + assert(results[3]!.value === -888); + }, +); + +Deno.test("memoize() allows passing a `Map` as a cache", () => { + let numTimesCalled = 0; + const cache = new Map(); + const fn = memoize((n: number) => { + ++numTimesCalled; + return 0 - n; + }, { cache }); + + assertEquals(fn(42), -42); + assertEquals(numTimesCalled, 1); + assertEquals(fn(42), -42); + assertEquals(numTimesCalled, 1); +}); + +Deno.test("memoize() allows passing a custom cache object", () => { + let numTimesCalled = 0; + + const uselessCache = { + has: () => false, + get: () => { + throw new Error("`has` is always false, so `get` is never called"); + }, + set: () => {}, + delete: () => {}, + keys: () => [], + }; + + const fn = memoize((n: number) => { + ++numTimesCalled; + return 0 - n; + }, { cache: uselessCache }); + + assertEquals(fn(42), -42); + assertEquals(numTimesCalled, 1); + assertEquals(fn(42), -42); + assertEquals(numTimesCalled, 2); +}); + +Deno.test("memoize() deletes stale entries of passed `LruCache`", () => { + let numTimesCalled = 0; + + const MAX_SIZE = 5; + + const fn = memoize((n: number) => { + ++numTimesCalled; + return 0 - n; + }, { cache: new LruCache(MAX_SIZE) }); + + assertEquals(fn(0), 0); + assertEquals(fn(0), 0); + assertEquals(numTimesCalled, 1); + + for (let i = 1; i < MAX_SIZE; ++i) { + assertEquals(fn(i), 0 - i); + assertEquals(fn(i), 0 - i); + assertEquals(numTimesCalled, i + 1); + } + + assertEquals(fn(MAX_SIZE), 0 - MAX_SIZE); + assertEquals(fn(MAX_SIZE), 0 - MAX_SIZE); + assertEquals(numTimesCalled, MAX_SIZE + 1); + + assertEquals(fn(0), 0); + assertEquals(fn(0), 0); + assertEquals(numTimesCalled, MAX_SIZE + 2); +}); + +Deno.test("memoize() only caches single latest result with a `LruCache` of maxSize=1", () => { + let numTimesCalled = 0; + + const fn = memoize((n: number) => { + ++numTimesCalled; + return 0 - n; + }, { cache: new LruCache(1) }); + + assertEquals(fn(0), 0); + assertEquals(fn(0), 0); + assertEquals(numTimesCalled, 1); + + assertEquals(fn(1), -1); + assertEquals(numTimesCalled, 2); +}); + +Deno.test("memoize() preserves function length", () => { + assertEquals(memoize.length, 2); + + assertEquals(memoize(() => {}).length, 0); + assertEquals(memoize((_arg) => {}).length, 1); + assertEquals(memoize((_1, _2) => {}).length, 2); + assertEquals(memoize((..._args) => {}).length, 0); + assertEquals(memoize((_1, ..._args) => {}).length, 1); +}); + +Deno.test("memoize() preserves function name", () => { + assertEquals(memoize.name, "memoize"); + + const fn1 = () => {}; + function fn2() {} + const obj = { ["!"]: () => {} }; + + assertEquals(memoize(() => {}).name, ""); + assertEquals(memoize(fn1).name, "fn1"); + assertEquals(memoize(fn1.bind({})).name, "bound fn1"); + assertEquals(memoize(fn2).name, "fn2"); + assertEquals(memoize(function fn3() {}).name, "fn3"); + assertEquals(memoize(obj["!"]).name, "!"); +}); + +Deno.test("memoize() has correct TS types", async (t) => { + await t.step("simple types", () => { + // no need to run, only for type checking + void (() => { + const fn: (this: number, x: number) => number = (_) => 1; + const memoized = memoize(fn); + + const _fn2: typeof fn = memoized; + const _fn3: Omit = fn; + + const _t1: ThisParameterType = 1; + // @ts-expect-error Type 'string' is not assignable to type 'number'. + const _t2: ThisParameterType = "1"; + + const _a1: Parameters[0] = 1; + // @ts-expect-error Type 'string' is not assignable to type 'number'. + const _a2: Parameters[0] = "1"; + // @ts-expect-error Tuple type '[x: number]' of length '1' has no element at index '1'. + const _a3: Parameters[1] = {} as never; + + const _r1: ReturnType = 1; + // @ts-expect-error Type 'string' is not assignable to type 'number'. + const _r2: ReturnType = "1"; + }); + }); + + await t.step("memoize() correctly preserves generic types", () => { + // no need to run, only for type checking + void (() => { + const fn = (x: T): T => x; + const memoized = memoize(fn); + + const _fn2: typeof fn = memoized; + const _fn3: Omit = fn; + + const _r1: number = fn(1); + const _r2: string = fn("1"); + // @ts-expect-error Type 'string' is not assignable to type 'number'. + const _r3: number = fn("1"); + + const _fn4: typeof fn = (n: number) => n; + // @ts-expect-error Type 'string' is not assignable to type 'number'. + const _fn5: typeof fn = (n: number) => n; + }); + }); +}); diff --git a/cache/mod.ts b/cache/mod.ts new file mode 100644 index 000000000000..96384df880a4 --- /dev/null +++ b/cache/mod.ts @@ -0,0 +1,26 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. +// This module is browser compatible. + +/** + * In-memory cache utilities, such as memoization and caches with different + * expiration policies. + * + * ```ts + * import { memoize, LruCache } from "@std/cache"; + * import { assertEquals } from "@std/assert"; + * + * const cache = new LruCache(1000); + * + * // fibonacci function, which is very slow for n > ~30 if not memoized + * const fib = memoize((n: bigint): bigint => { + * return n <= 2n ? 1n : fib(n - 1n) + fib(n - 2n); + * }, { cache }); + * + * assertEquals(fib(100n), 354224848179261915075n); + * ``` + * + * @module + */ + +export * from "./memoize.ts"; +export * from "./lru_cache.ts"; diff --git a/deno.json b/deno.json index 3bb4531dc7cb..2b6b3d6430e3 100644 --- a/deno.json +++ b/deno.json @@ -97,6 +97,7 @@ "./assert", "./async", "./bytes", + "./cache", "./cli", "./collections", "./crypto",