UNPKG

@endo/cache-map

Version:

bounded-size caches having WeakMap-compatible methods

318 lines (286 loc) 9.23 kB
// @ts-check /* global globalThis */ /* eslint-disable @endo/no-polymorphic-call */ // eslint-disable-next-line no-restricted-globals const { Error, TypeError, WeakMap } = globalThis; // eslint-disable-next-line no-restricted-globals const { parse, stringify } = JSON; // eslint-disable-next-line no-restricted-globals const { isSafeInteger } = Number; // eslint-disable-next-line no-restricted-globals const { freeze } = Object; // eslint-disable-next-line no-restricted-globals const { toStringTag: toStringTagSymbol } = Symbol; // eslint-disable-next-line no-restricted-globals const UNKNOWN_KEY = Symbol('UNKNOWN_KEY'); /** * @template T * @typedef {T extends object ? { -readonly [K in keyof T]: T[K] } : never} WritableDeep * Intentionally limited to local needs; refer to * https://github.com/sindresorhus/type-fest if insufficient. */ /** * @template T * @param {T} value * @param {<U,>(name: string, value: U) => U} [reviver] * @returns {WritableDeep<T>} */ const deepCopyJsonable = (value, reviver) => { const encoded = stringify(value); const decoded = parse(encoded, reviver); return decoded; }; const freezingReviver = (_name, value) => freeze(value); /** @type {<T,>(value: T) => T} */ const deepCopyAndFreezeJsonable = value => deepCopyJsonable(value, freezingReviver); /** * A cache of bounded size, implementing the WeakMap interface but holding keys * strongly if created with a non-weak `makeMap` option of * {@link makeCacheMapKit}. * * @template K * @template V * @typedef {Pick<Map<K, V>, Exclude<keyof WeakMap<WeakKey, *>, 'set'>> & {set: (key: K, value: V) => WeakMapAPI<K, V>}} WeakMapAPI */ /** * @template K * @template V * @typedef {WeakMapAPI<K, V> & ({clear?: undefined} | Pick<Map<K, V>, 'clear'>)} SingleEntryMap */ /** * A cell of a doubly-linked ring (circular list) for a cache map. * Instances are not frozen, and so should be closely encapsulated. * * @template K * @template V * @typedef {object} CacheMapCell * @property {number} id for debugging * @property {CacheMapCell<K, V>} next * @property {CacheMapCell<K, V>} prev * @property {SingleEntryMap<K, V>} data */ /** * @template K * @template V * @param {CacheMapCell<K, V>} prev * @param {number} id * @param {SingleEntryMap<K, V>} data * @returns {CacheMapCell<K, V>} */ const appendNewCell = (prev, id, data) => { const next = prev?.next; const cell = { id, next, prev, data }; prev.next = cell; next.prev = cell; return cell; }; /** * @template K * @template V * @param {CacheMapCell<K, V>} cell * @param {CacheMapCell<K, V>} prev * @param {CacheMapCell<K, V>} [next] */ const moveCellAfter = (cell, prev, next = prev.next) => { if (cell === prev || cell === next) return; // already in position // Splice out cell. const { prev: oldPrev, next: oldNext } = cell; oldPrev.next = oldNext; oldNext.prev = oldPrev; // Splice in cell after prev. cell.prev = prev; cell.next = next; prev.next = cell; next.prev = cell; }; /** * Clear out a cell to prepare it for future use. Its map is preserved when * possible, but must instead be replaced if the associated key is not known. * * @template K * @template V * @param {CacheMapCell<K, V>} cell * @param {K | UNKNOWN_KEY} oldKey * @param {() => SingleEntryMap<K, V>} [makeMap] required when the key is unknown */ const resetCell = (cell, oldKey, makeMap) => { if (oldKey !== UNKNOWN_KEY) { cell.data.delete(oldKey); return; } if (cell.data.clear) { cell.data.clear(); return; } // WeakMap instances must be replaced when the key is unknown. if (!makeMap) { throw Error('internal: makeMap is required with UNKNOWN_KEY'); } cell.data = makeMap(); }; const zeroMetrics = freeze({ totalQueryCount: 0, totalHitCount: 0, // TODO? // * method-specific counts // * liveTouchStats/evictedTouchStats { count, sum, mean, min, max } // * p50/p90/p95/p99 via Ben-Haim/Tom-Tov streaming histograms }); /** @typedef {typeof zeroMetrics} CacheMapMetrics */ /** * @template {MapConstructor | WeakMapConstructor} [C=WeakMapConstructor] * @template {Parameters<InstanceType<C>['set']>[0]} [K=Parameters<InstanceType<C>['set']>[0]] * @template {unknown} [V=unknown] * @typedef {object} CacheMapKit * @property {WeakMapAPI<K, V>} cache * @property {() => CacheMapMetrics} getMetrics */ /** * Create a bounded-size cache having WeakMap-compatible * `has`/`get`/`set`/`delete` methods, capable of supporting SES (specifically * `assert` error notes). * Key validity, comparison, and referential strength are controlled by the * `makeMap` option, which defaults to `WeakMap` but can be set to any producer * of objects with those methods (e.g., using `Map` allows for arbitrary keys * which will be strongly held). * Cache eviction policy is not currently configurable, but strives for a hit * ratio at least as good as * [LRU](https://en.wikipedia.org/wiki/Cache_replacement_policies#LRU) (e.g., it * might be * [CLOCK](https://en.wikipedia.org/wiki/Page_replacement_algorithm#Clock) * or [SIEVE](https://sievecache.com/)). * * @template {MapConstructor | WeakMapConstructor} [C=WeakMapConstructor] * @template {Parameters<InstanceType<C>['set']>[0]} [K=Parameters<InstanceType<C>['set']>[0]] * @template {unknown} [V=unknown] * @param {number} capacity * @param {object} [options] * @param {C | (() => SingleEntryMap<K, V>)} [options.makeMap] * @returns {CacheMapKit<C, K, V>} */ export const makeCacheMapKit = (capacity, options = {}) => { if (!isSafeInteger(capacity) || capacity < 0) { throw TypeError( 'capacity must be a non-negative safe integer number <= 2**53 - 1', ); } /** * @template V * @type {<V,>() => SingleEntryMap<K, V>} */ const makeMap = (MaybeCtor => { try { // @ts-expect-error MaybeCtor(); return /** @type {any} */ (MaybeCtor); } catch (err) { // @ts-expect-error const constructNewMap = () => new MaybeCtor(); return constructNewMap; } })(options.makeMap ?? WeakMap); const tag = /** @type {any} */ (makeMap()).clear === undefined ? 'WeakCacheMap' : 'CacheMap'; /** @type {WeakMapAPI<K, CacheMapCell<K, V>>} */ const keyToCell = makeMap(); // @ts-expect-error this sentinel head is special const head = /** @type {CacheMapCell<K, V>} */ ({ id: 0, // next and prev are established below as self-referential. next: undefined, prev: undefined, data: { has: () => { throw Error('internal: sentinel head cell has no data'); }, }, }); head.next = head; head.prev = head; let cellCount = 0; const metrics = deepCopyJsonable(zeroMetrics); const getMetrics = () => deepCopyAndFreezeJsonable(metrics); /** * Touching moves a cell to first position so LRU eviction can target the last * cell (`head.prev`). * * @type {(key: K) => (CacheMapCell<K, V> | undefined)} */ const touchKey = key => { metrics.totalQueryCount += 1; const cell = keyToCell.get(key); if (!cell?.data.has(key)) return undefined; metrics.totalHitCount += 1; moveCellAfter(cell, head); return cell; }; /** @type {WeakMapAPI<K, V>['has']} */ const has = key => { const cell = touchKey(key); return cell !== undefined; }; freeze(has); /** @type {WeakMapAPI<K, V>['get']} */ const get = key => { const cell = touchKey(key); return cell?.data.get(key); }; freeze(get); /** @type {WeakMapAPI<K, V>['set']} */ const set = (key, value) => { let cell = touchKey(key); if (cell) { cell.data.set(key, value); // eslint-disable-next-line no-use-before-define return implementation; } if (cellCount < capacity) { // Add and use a new cell at first position. cell = appendNewCell(head, cellCount + 1, makeMap()); cellCount += 1; // intentionally follows cell creation cell.data.set(key, value); } else if (capacity > 0) { // Reuse the current tail, moving it to first position. cell = head.prev; resetCell(/** @type {any} */ (cell), UNKNOWN_KEY, makeMap); cell.data.set(key, value); moveCellAfter(cell, head); } // Don't establish this entry until prior steps succeed. if (cell) keyToCell.set(key, cell); // eslint-disable-next-line no-use-before-define return implementation; }; freeze(set); // "delete" is a keyword. const { delete: deleteEntry } = { /** @type {WeakMapAPI<K, V>['delete']} */ delete: key => { const cell = keyToCell.get(key); if (!cell?.data.has(key)) { keyToCell.delete(key); return false; } moveCellAfter(cell, head.prev); resetCell(cell, key); keyToCell.delete(key); return true; }, }; freeze(deleteEntry); const implementation = /** @type {WeakMapAPI<K, V>} */ ({ has, get, set, delete: deleteEntry, // eslint-disable-next-line jsdoc/check-types [/** @type {typeof Symbol.toStringTag} */ (toStringTagSymbol)]: tag, }); freeze(implementation); const kit = { cache: implementation, getMetrics }; return freeze(kit); }; freeze(makeCacheMapKit);