diff --git a/packages/caches/.gitignore b/packages/caches/.gitignore new file mode 100644 index 00000000..af98a5ea --- /dev/null +++ b/packages/caches/.gitignore @@ -0,0 +1,43 @@ +# Logs +logs +*.log +npm-debug.log* + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules +jspm_packages + +# Optional npm cache directory +.npm + +# Optional REPL history +.node_repl_history + +# Ignore generated TypeScript files. +lib + +# Cache for rollup-plugin-typescript2 +.rpt2_cache diff --git a/packages/caches/.npmignore b/packages/caches/.npmignore new file mode 100644 index 00000000..caed0902 --- /dev/null +++ b/packages/caches/.npmignore @@ -0,0 +1,4 @@ +/node_modules +/lib/tests +tsconfig.json +tsconfig.es5.json diff --git a/packages/caches/README.md b/packages/caches/README.md new file mode 100644 index 00000000..fa0ba635 --- /dev/null +++ b/packages/caches/README.md @@ -0,0 +1,9 @@ +# @wry/caches + +Various cache implementations, including but not limited to + +* `StrongCache`: A standard `Map`-like cache with a least-recently-used (LRU) + eviction policy and a callback hook for removed entries. + +* `WeakCache`: Another LRU cache that holds its keys only weakly, so entries can be removed + once no longer retained elsewhere in the application. diff --git a/packages/caches/package-lock.json b/packages/caches/package-lock.json new file mode 100644 index 00000000..91909d69 --- /dev/null +++ b/packages/caches/package-lock.json @@ -0,0 +1,31 @@ +{ + "name": "@wry/caches", + "version": "0.1.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "@wry/caches", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + } + }, + "dependencies": { + "tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + } + } +} diff --git a/packages/caches/package.json b/packages/caches/package.json new file mode 100644 index 00000000..7425ab6f --- /dev/null +++ b/packages/caches/package.json @@ -0,0 +1,40 @@ +{ + "name": "@wry/caches", + "private": true, + "version": "0.1.0", + "author": "Ben Newman ", + "description": "Various cache implementations", + "license": "MIT", + "type": "module", + "main": "lib/bundle.cjs", + "module": "lib/index.js", + "types": "lib/index.d.ts", + "keywords": [], + "homepage": "https://github.com/benjamn/wryware", + "repository": { + "type": "git", + "url": "git+https://github.com/benjamn/wryware.git" + }, + "bugs": { + "url": "https://github.com/benjamn/wryware/issues" + }, + "scripts": { + "build": "npm run clean:before && npm run tsc && npm run rollup && npm run clean:after", + "clean:before": "rimraf lib", + "tsc": "npm run tsc:es5 && npm run tsc:esm", + "tsc:es5": "tsc -p tsconfig.es5.json", + "tsc:esm": "tsc -p tsconfig.json", + "rollup": "rollup -c rollup.config.js", + "clean:after": "rimraf lib/es5", + "prepare": "npm run build", + "test:cjs": "../../shared/test.sh lib/tests/bundle.cjs", + "test:esm": "../../shared/test.sh lib/tests/bundle.js", + "test": "npm run test:esm && npm run test:cjs" + }, + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } +} diff --git a/packages/caches/rollup.config.js b/packages/caches/rollup.config.js new file mode 100644 index 00000000..ef8e33bb --- /dev/null +++ b/packages/caches/rollup.config.js @@ -0,0 +1 @@ +export { default } from "../../shared/rollup.config.js"; diff --git a/packages/caches/src/common.ts b/packages/caches/src/common.ts new file mode 100644 index 00000000..773d32b9 --- /dev/null +++ b/packages/caches/src/common.ts @@ -0,0 +1,8 @@ +export interface CommonCache { + has(key: K): boolean; + get(key: K): V | undefined; + set(key: K, value: V): V; + delete(key: K): boolean; + clean(): void; + readonly size: number; +} diff --git a/packages/caches/src/index.ts b/packages/caches/src/index.ts new file mode 100644 index 00000000..bddd1925 --- /dev/null +++ b/packages/caches/src/index.ts @@ -0,0 +1,3 @@ +export type { CommonCache } from "./common.js"; +export { StrongCache } from "./strong.js"; +export { WeakCache } from "./weak.js"; diff --git a/packages/caches/src/strong.ts b/packages/caches/src/strong.ts new file mode 100644 index 00000000..a064385f --- /dev/null +++ b/packages/caches/src/strong.ts @@ -0,0 +1,121 @@ +import type { CommonCache } from "./common"; + +interface Node { + key: K; + value: V; + newer: Node | null; + older: Node | null; +} + +function defaultDispose() {} + +export class StrongCache implements CommonCache { + private map = new Map>(); + private newest: Node | null = null; + private oldest: Node | null = null; + + constructor( + private max = Infinity, + public dispose: (value: V, key: K) => void = defaultDispose, + ) {} + + public has(key: K): boolean { + return this.map.has(key); + } + + public get(key: K): V | undefined { + const node = this.getNode(key); + return node && node.value; + } + + public get size() { + return this.map.size; + } + + private getNode(key: K): Node | undefined { + const node = this.map.get(key); + + if (node && node !== this.newest) { + const { older, newer } = node; + + if (newer) { + newer.older = older; + } + + if (older) { + older.newer = newer; + } + + node.older = this.newest; + node.older!.newer = node; + + node.newer = null; + this.newest = node; + + if (node === this.oldest) { + this.oldest = newer; + } + } + + return node; + } + + public set(key: K, value: V): V { + let node = this.getNode(key); + if (node) { + return node.value = value; + } + + node = { + key, + value, + newer: null, + older: this.newest + }; + + if (this.newest) { + this.newest.newer = node; + } + + this.newest = node; + this.oldest = this.oldest || node; + + this.map.set(key, node); + + return node.value; + } + + public clean() { + while (this.oldest && this.map.size > this.max) { + this.delete(this.oldest.key); + } + } + + public delete(key: K): boolean { + const node = this.map.get(key); + if (node) { + if (node === this.newest) { + this.newest = node.older; + } + + if (node === this.oldest) { + this.oldest = node.newer; + } + + if (node.newer) { + node.newer.older = node.older; + } + + if (node.older) { + node.older.newer = node.newer; + } + + this.map.delete(key); + this.dispose(node.value, key); + + return true; + } + + return false; + } +} diff --git a/packages/caches/src/tests/main.ts b/packages/caches/src/tests/main.ts new file mode 100644 index 00000000..87d12056 --- /dev/null +++ b/packages/caches/src/tests/main.ts @@ -0,0 +1,2 @@ +import "./strong.js"; +import "./weak.js"; diff --git a/packages/caches/src/tests/strong.ts b/packages/caches/src/tests/strong.ts new file mode 100644 index 00000000..ba31677a --- /dev/null +++ b/packages/caches/src/tests/strong.ts @@ -0,0 +1,108 @@ +import * as assert from "assert"; +import { StrongCache } from "../strong.js"; + +describe("least-recently-used cache", function () { + it("can hold lots of elements", function () { + const cache = new StrongCache; + const count = 1000000; + + for (let i = 0; i < count; ++i) { + cache.set(i, String(i)); + } + + cache.clean(); + + assert.strictEqual((cache as any).map.size, count); + assert.ok(cache.has(0)); + assert.ok(cache.has(count - 1)); + assert.strictEqual(cache.get(43), "43"); + }); + + it("evicts excess old elements", function () { + const max = 10; + const evicted = []; + const cache = new StrongCache(max, (value, key) => { + assert.strictEqual(String(key), value); + evicted.push(key); + }); + + const count = 100; + const keys = []; + for (let i = 0; i < count; ++i) { + cache.set(i, String(i)); + keys.push(i); + } + + cache.clean(); + + assert.strictEqual((cache as any).map.size, max); + assert.strictEqual(evicted.length, count - max); + + for (let i = count - max; i < count; ++i) { + assert.ok(cache.has(i)); + } + }); + + it("can cope with small max values", function () { + const cache = new StrongCache(2); + + function check(...sequence: number[]) { + cache.clean(); + + let entry = (cache as any).newest; + const forwards = []; + while (entry) { + forwards.push(entry.key); + entry = entry.older; + } + assert.deepEqual(forwards, sequence); + + const backwards = []; + entry = (cache as any).oldest; + while (entry) { + backwards.push(entry.key); + entry = entry.newer; + } + backwards.reverse(); + assert.deepEqual(backwards, sequence); + + sequence.forEach(function (n) { + assert.strictEqual((cache as any).map.get(n).value, n + 1); + }); + + if (sequence.length > 0) { + assert.strictEqual((cache as any).newest.key, sequence[0]); + assert.strictEqual((cache as any).oldest.key, + sequence[sequence.length - 1]); + } + } + + cache.set(1, 2); + check(1); + + cache.set(2, 3); + check(2, 1); + + cache.set(3, 4); + check(3, 2); + + cache.get(2); + check(2, 3); + + cache.set(4, 5); + check(4, 2); + + assert.strictEqual(cache.has(1), false); + assert.strictEqual(cache.get(2), 3); + assert.strictEqual(cache.has(3), false); + assert.strictEqual(cache.get(4), 5); + + cache.delete(2); + check(4); + cache.delete(4); + check(); + + assert.strictEqual((cache as any).newest, null); + assert.strictEqual((cache as any).oldest, null); + }); +}); diff --git a/packages/caches/src/tests/weak.ts b/packages/caches/src/tests/weak.ts new file mode 100644 index 00000000..8c06ae98 --- /dev/null +++ b/packages/caches/src/tests/weak.ts @@ -0,0 +1,170 @@ +import * as assert from "assert"; +import { WeakCache } from "../weak.js"; + +describe("weak least-recently-used cache", function () { + it("can hold lots of elements", async function () { + this.timeout(10000); + const cache = new WeakCache(); + const count = 1000000; + const keys = []; + + for (let i = 0; i < count; ++i) { + const key = {}; + cache.set(key, String(i)); + keys[i] = key; + } + await waitForCache(cache); + + cache.clean(); + + assert.strictEqual(cache.size, count); + assert.ok(cache.has(keys[0])); + assert.ok(cache.has(keys[count - 1])); + assert.strictEqual(cache.get(keys[43]), "43"); + }); + + it("evicts excess old elements", function () { + const max = 10; + const evicted = []; + const cache = new WeakCache(max, (value, key) => { + assert.strictEqual(key.valueOf(), value.valueOf()); + evicted.push(key); + }); + + const count = 100; + const keys = []; + for (let i = 0; i < count; ++i) { + const key = new String(i); + cache.set(key, String(i)); + keys[i] = key; + } + + cache.clean(); + + assert.strictEqual((cache as any).size, max); + assert.strictEqual(evicted.length, count - max); + + for (let i = count - max; i < count; ++i) { + assert.ok(cache.has(keys[i])); + } + }); + + it("evicts elements that are garbage collected", async function () { + const cache = new WeakCache(); + + const count = 100; + const keys: Array = []; + for (let i = 0; i < count; ++i) { + keys[i] = new String(i); + cache.set(keys[i], String(i)); + } + + assert.strictEqual(cache.size, 100); + await waitForCache(cache); + assert.strictEqual(cache.size, 100); + + for (let i = 0; i < 50; ++i) { + keys[i] = null; + } + + return gcPromise(() => { + return cache.size > 50 ? null : () => { + assert.strictEqual(cache.size, 50); + assert.strictEqual(keys.length, 100); + assert.strictEqual(new Set(keys).size, 51); + }; + }); + }); + + function gcPromise(test: () => null | (() => void)) { + return new Promise(function (resolve, reject) { + function pollGC() { + global.gc!(); + const testCallback = test(); + if (!testCallback) { + setTimeout(pollGC, 20); + } else try { + testCallback(); + resolve(); + } catch (e) { + reject(e); + } + } + pollGC(); + }); + } + + it("can cope with small max values", async function () { + const cache = new WeakCache(2); + const keys = Array(10) + .fill(null) + .map((_, i) => new Number(i)); + + async function check(...sequence: number[]) { + await waitForCache(cache); + cache.clean(); + + let entry = cache["newest"]; + const forwards = []; + while (entry) { + forwards.push(entry.keyRef?.deref()); + entry = entry.older; + } + assert.deepEqual(forwards.map(Number), sequence); + + const backwards = []; + entry = cache["oldest"]; + while (entry) { + backwards.push(entry.keyRef?.deref()); + entry = entry.newer; + } + backwards.reverse(); + assert.deepEqual(backwards.map(Number), sequence); + + sequence.forEach(function (n) { + assert.strictEqual(cache["map"].get(keys[n])?.value, n + 1); + }); + + if (sequence.length > 0) { + assert.strictEqual( + cache["oldest"]?.keyRef?.deref().valueOf(), + sequence[sequence.length - 1] + ); + } + } + + cache.set(keys[1], 2); + await check(1); + + cache.set(keys[2], 3); + await check(2, 1); + + cache.set(keys[3], 4); + await check(3, 2); + + cache.get(keys[2]); + await check(2, 3); + + cache.set(keys[4], 5); + await check(4, 2); + + assert.strictEqual(cache.has(keys[1]), false); + assert.strictEqual(cache.get(keys[2]), 3); + assert.strictEqual(cache.has(keys[3]), false); + assert.strictEqual(cache.get(keys[4]), 5); + + cache.delete(keys[2]); + await check(4); + cache.delete(keys[4]); + await check(); + + assert.strictEqual((cache as any).newest, null); + assert.strictEqual((cache as any).oldest, null); + }); +}); + +async function waitForCache(cache: WeakCache) { + while (cache["finalizationScheduled"]) { + await new Promise(queueMicrotask); + } +} diff --git a/packages/caches/src/weak.ts b/packages/caches/src/weak.ts new file mode 100644 index 00000000..d5380c6d --- /dev/null +++ b/packages/caches/src/weak.ts @@ -0,0 +1,200 @@ +import type { CommonCache } from "./common"; + +interface PartialNode { + value: V; + newer: Node | null; + older: Node | null; +} + +interface UnfinalizedNode extends PartialNode { + keyRef?: undefined; + key: K; +} + +interface FullNode extends PartialNode { + keyRef: WeakRef; + key?: undefined; +} + +type Node = FullNode | UnfinalizedNode; + +function noop() {} +const defaultDispose = noop; + +const _WeakRef = + typeof WeakRef !== "undefined" + ? WeakRef + : (function (value: T) { + return { deref: () => value } satisfies Omit< + WeakRef, + typeof Symbol.toStringTag + >; + } as any as typeof WeakRef); +const _WeakMap = typeof WeakMap !== "undefined" ? WeakMap : Map; +const _FinalizationRegistry = + typeof FinalizationRegistry !== "undefined" + ? FinalizationRegistry + : (function () { + return { + register: noop, + unregister: noop, + } satisfies Omit, typeof Symbol.toStringTag>; + } as any as typeof FinalizationRegistry); + +const finalizationBatchSize = 10024; + +export class WeakCache + implements CommonCache +{ + private map = new _WeakMap>(); + private registry: FinalizationRegistry>; + private newest: Node | null = null; + private oldest: Node | null = null; + private unfinalizedNodes: Set> = new Set(); + private finalizationScheduled = false; + public size = 0; + + constructor( + private max = Infinity, + public dispose: (value: V, key?: K) => void = defaultDispose + ) { + this.registry = new _FinalizationRegistry>( + this.deleteNode.bind(this) + ); + } + + public has(key: K): boolean { + return this.map.has(key); + } + + public get(key: K): V | undefined { + const node = this.getNode(key); + return node && node.value; + } + + private getNode(key: K): Node | undefined { + const node = this.map.get(key); + + if (node && node !== this.newest) { + const { older, newer } = node; + + if (newer) { + newer.older = older; + } + + if (older) { + older.newer = newer; + } + + node.older = this.newest; + node.older!.newer = node; + + node.newer = null; + this.newest = node; + + if (node === this.oldest) { + this.oldest = newer; + } + } + + return node; + } + + public set(key: K, value: V): V { + let node = this.getNode(key); + if (node) { + return (node.value = value); + } + + node = { + key, + value, + newer: null, + older: this.newest, + }; + + if (this.newest) { + this.newest.newer = node; + } + + this.newest = node; + this.oldest = this.oldest || node; + + this.scheduleFinalization(node); + this.map.set(key, node); + this.size++; + + return node.value; + } + + public clean() { + while (this.oldest && this.size > this.max) { + this.deleteNode(this.oldest); + } + } + + private deleteNode(node: Node) { + if (node === this.newest) { + this.newest = node.older; + } + + if (node === this.oldest) { + this.oldest = node.newer; + } + + if (node.newer) { + node.newer.older = node.older; + } + + if (node.older) { + node.older.newer = node.newer; + } + + this.size--; + const key = node.key || (node.keyRef && node.keyRef.deref()); + this.dispose(node.value, key); + if (!node.keyRef) { + this.unfinalizedNodes.delete(node); + } else { + this.registry.unregister(node); + } + if (key) this.map.delete(key); + } + + public delete(key: K): boolean { + const node = this.map.get(key); + if (node) { + this.deleteNode(node); + + return true; + } + + return false; + } + + private scheduleFinalization(node: UnfinalizedNode) { + this.unfinalizedNodes.add(node); + if (!this.finalizationScheduled) { + this.finalizationScheduled = true; + queueMicrotask(this.finalize); + } + } + + private finalize = () => { + const iterator = this.unfinalizedNodes.values(); + for (let i = 0; i < finalizationBatchSize; i++) { + const node = iterator.next().value; + if (!node) break; + this.unfinalizedNodes.delete(node); + const key = node.key; + delete (node as unknown as FullNode).key; + (node as unknown as FullNode).keyRef = new _WeakRef(key); + this.registry.register(key, node, node); + } + if (this.unfinalizedNodes.size > 0) { + queueMicrotask(this.finalize); + } else { + this.finalizationScheduled = false; + } + }; +} diff --git a/packages/caches/tsconfig.es5.json b/packages/caches/tsconfig.es5.json new file mode 100644 index 00000000..b63f1a50 --- /dev/null +++ b/packages/caches/tsconfig.es5.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "target": "ES5", + // We want ES2020 module syntax but everything else ES5, so Rollup can still + // perform tree-shaking using the ESM syntax (ES2020 for dynamic import()). + "module": "ES2020", + "outDir": "lib/es5" + } +} diff --git a/packages/caches/tsconfig.json b/packages/caches/tsconfig.json new file mode 100644 index 00000000..d8df61b9 --- /dev/null +++ b/packages/caches/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../shared/tsconfig.json", + "compilerOptions": { + "lib": ["es2015", "ES2021.WeakRef"], + "rootDir": "./src", + "outDir": "./lib" + } +} diff --git a/shared/test.sh b/shared/test.sh index 2b374f8e..b00f7da7 100755 --- a/shared/test.sh +++ b/shared/test.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -node "$(dirname $0)/../node_modules/.bin/mocha" \ +node --expose-gc "$(dirname $0)/../node_modules/.bin/mocha" \ --reporter spec \ --full-trace \ --require source-map-support/register \