Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cache/unstable): add memoize() and LruCache #4725

Merged
merged 22 commits into from
Aug 8, 2024
Merged
Show file tree
Hide file tree
Changes from 5 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
184 changes: 184 additions & 0 deletions cache/_serialize_arg_list_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
import { assertEquals } from "@std/assert";
import { _serializeArgList } from "./memoize.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<T> extends FinalizationRegistry<T> {
#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;
}
});
10 changes: 10 additions & 0 deletions cache/deno.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"name": "@std/cache",
"version": "0.224.1",
kt3k marked this conversation as resolved.
Show resolved Hide resolved
"exports": {
".": "./mod.ts",
"./lru-cache": "./lru_cache.ts",
"./memoize": "./memoize.ts"
},
"imports": { "@std/assert": "jsr:@std/assert@^0.225.1" }
iuioiua marked this conversation as resolved.
Show resolved Hide resolved
}
56 changes: 56 additions & 0 deletions cache/lru_cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
import type { MemoizationCache } from "./memoize.ts";

/**
* [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`.
*/
export class LruCache<K, V> extends Map<K, V>
lionel-rowe marked this conversation as resolved.
Show resolved Hide resolved
implements MemoizationCache<K, V> {
constructor(public maxSize: number) {
super();
}

#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);
}
}

override has(key: K): boolean {
const exists = super.has(key);

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

return exists;
}

override get(key: K): V | undefined {
if (super.has(key)) {
const value = super.get(key)!;
this.#setMostRecentlyUsed(key, value);
return value;
}

return undefined;
lionel-rowe marked this conversation as resolved.
Show resolved Hide resolved
}

override set(key: K, value: V): this {
this.#setMostRecentlyUsed(key, value);
this.#pruneToMaxSize();

return this;
}
}
22 changes: 22 additions & 0 deletions cache/lru_cache_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// 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.keys()], [1, 3, 4]);
assertEquals(cache.get(3), "!");
assertEquals(cache.get(1), "updated");

cache.delete(3);
assertEquals(cache.size, 2);
});
Loading