UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

591 lines (475 loc) • 15.6 kB
import { assert } from "../assert.js"; import { collectIteratorValueToArray } from "../collection/collectIteratorValueToArray.js"; import { HashMap } from "../collection/map/HashMap.js"; import Signal from "../events/signal/Signal.js"; import { returnOne } from "../function/returnOne.js"; import { returnZero } from "../function/returnZero.js"; import { invokeObjectEquals } from "../model/object/invokeObjectEquals.js"; import { invokeObjectHash } from "../model/object/invokeObjectHash.js"; import { CacheElement } from "./CacheElement.js"; /** * Hash-based cache, uses LRU (Least-Recently Used) eviction policy. * Make sure that keys being used are truly immutable when it comes to hash and equality calculation; otherwise cache corruption is inevitable. * * @template Key * @template Value * @extends {Map<Key,Value>} * * @example * const cache = new Cache({maxWeight:2}); * * cache.put(key0, "a"); * cache.put(key1, "b"); * cache.put(key2, "c"); // cache has only enough space for 2 elements, key0 will be evicted * * cache.get(key0); // null, was evicted * * @author Alex Goldring * @copyright Company Named Limited (c) 2025 */ export class Cache { #maxWeight = Number.POSITIVE_INFINITY; /** * * @type {number} * @private */ #weight = 0; /** * @param {number} [maxWeight=Number.POSITIVE_INFINITY] * @param {function(key:Key):number} [keyWeigher= key=>0] * @param {function(value:Value):number} [valueWeigher= value=>1] * @param {function(Key):number} [keyHashFunction] * @param {function(Key, Key):boolean} [keyEqualityFunction] * @param {number} [capacity] initial capacity for the cache, helps prevent early resizing * @constructor */ constructor({ maxWeight = Number.POSITIVE_INFINITY, keyWeigher = returnZero, valueWeigher = returnOne, keyHashFunction = invokeObjectHash, keyEqualityFunction = invokeObjectEquals, capacity } = {}) { assert.isNumber(maxWeight, 'maxWeight'); assert.notNaN(maxWeight, 'maxWeight'); assert.greaterThanOrEqual(maxWeight, 0, 'maxWeight < 0'); assert.isFunction(keyWeigher, 'keyWeigher'); assert.isFunction(valueWeigher, 'valueWeigher'); assert.isFunction(keyHashFunction, 'keyHashFunction'); assert.isFunction(keyEqualityFunction, 'keyEqualityFunction'); /** * * @type {number} * @private */ this.#maxWeight = maxWeight; /** * * @type {function(Key): number} * @private */ this.keyWeigher = keyWeigher; /** * * @type {function(Value): number} * @private */ this.valueWeigher = valueWeigher; /** * * @type {CacheElement<Key,Value>|null} * @private */ this.__first = null; /** * * @type {CacheElement<Key,Value>|null} * @private */ this.__last = null; /** * * @type {HashMap<Key, CacheElement<Key,Value>>} * @private */ this.data = new HashMap({ keyHashFunction, keyEqualityFunction, capacity }); /** * * @type {Signal<Key,Value>} */ this.onEvicted = new Signal(); /** * * @type {Signal<Key,Value>} */ this.onRemoved = new Signal(); /** * * @type {Signal<Key,Value>} */ this.onSet = new Signal(); } /** * Marks element as recently used * Moves element to the end of the eviction queue * @param {CacheElement<Key,Value>} element * @private */ __promote(element) { if (element === this.__first) { // already at the front return; } // update end-pointers if (element === this.__last) { this.__last = element.previous; } element.unlink(); element.previous = null; if (this.__first !== null) { // shift previous head element forward element.next = this.__first; this.__first.previous = element; } else { element.next = null; } this.__first = element; } /** * Number of elements stored in cache * @returns {number} */ size() { return this.data.size; } /** * @deprecated use {@link maxWeight} directly instead */ setMaxWeight(value) { throw new Error('setMaxWeight is deprecated, use .maxWeight property instead'); } /** * Total weight of all elements currently in the cache * @returns {number} */ get weight() { return this.#weight; } /** * Will cause evictions if current weight is smaller than what we're setting * @param {number} weight */ set maxWeight(weight) { if (typeof weight !== "number" || weight < 0) { throw new Error(`Weight must be a non-negative number, instead was '${weight}'`); } const old_limit = this.#maxWeight; this.#maxWeight = weight; if (old_limit > weight) { // shrinking, let's make sure we are upholding the constraint this.evictUntilWeight(this.#maxWeight); } } get maxWeight() { return this.#maxWeight; } /** * Forces weight to be recomputed for all elements stored in the cache * Useful when values are mutated without re-insertion. * In general - prefer to treat values as immutable */ recomputeWeight() { let result = 0; for (let [key, record] of this.data) { const weight = this.computeElementWeight(key, record.value); record.weight = weight; result += weight; } this.#weight = result; this.evictUntilWeight(this.#maxWeight); } /** * Useful when working with wrapped value types, where contents can change and affect overall weight * Instead of re-inserting element, we can just update weights * NOTE: this method may trigger eviction * @param {Key} key * @returns {boolean} true when weight successfully updated, false if element was not found in cache */ updateElementWeight(key) { const record = this.data.get(key); if (record === undefined) { return false; } const old_weight = record.weight; const new_weight = this.computeElementWeight(key, record.value); if (new_weight === old_weight) { // we're done, no change return true; } record.weight = new_weight; const delta_weight = new_weight - old_weight; this.#weight += delta_weight; if ( this.#weight > this.#maxWeight && new_weight <= this.#maxWeight //make it less likely to drop entire cache ) { this.evictUntilWeight(this.#maxWeight); } return true; } /** * @private * @param {Key} key * @param {Value} value * @returns {number} */ computeElementWeight(key, value) { const key_weight = this.keyWeigher(key); assert.notNaN(key_weight, 'key_weight'); const value_weight = this.valueWeigher(value); assert.notNaN(value_weight, 'value_weight'); return key_weight + value_weight; } /** * * @returns {CacheElement<Key,Value>|null} */ findEvictionVictim() { return this.__last; } /** * Evicts a single element from the cache * @returns {boolean} true if element was evicted, false otherwise */ evictOne() { //find a victim const victim = this.findEvictionVictim(); if (victim !== null) { const removed_from_hash_table = this.remove(victim.key); assert.ok(removed_from_hash_table, `Failed to remove key '${victim.key}', likely reasons:\n\t1. key was mutated (keys must never be mutated)\n\t2. provided hashing function is unstable\n\t3. provided equality function is inconsistent`); this.onEvicted.send2(victim.key, victim.value); return true; } else { //nothing to remove return false; } } /** * Drop data until weight reduces lower or equal to requested weight * @param {number} targetWeight */ evictUntilWeight(targetWeight) { assert.isNumber(targetWeight, 'targetWeight'); assert.notNaN(targetWeight, 'targetWeight'); const target = Math.max(targetWeight, 0); while (this.#weight > target) { this.evictOne(); } } /** * * @param {Key} key * @param {Value} value */ put(key, value) { let element = this.data.get(key); if (element === undefined) { //compute weight const elementWeight = this.computeElementWeight(key, value); /** * It's possible that element being added is larger than cache's capacity, * in which case entire cache will be evicted, but there still won't be enough space * @type {number} */ const weightTarget = this.#maxWeight - elementWeight; if (weightTarget < 0) { // Special case // element does not fit into cache, attempting to insert it forcibly would result in a full flush and overflow return; } element = new CacheElement(); element.key = key; element.value = value; // patch the element in element.next = this.__first; if (this.__first !== null) { this.__first.previous = element; } this.__first = element; if (this.__last === null) { this.__last = element; } element.weight = elementWeight; //evict elements until there is enough space for the element this.evictUntilWeight(weightTarget); //store element this.data.set(key, element); //update weight this.#weight += elementWeight; } else { // check if value is the same if (value === element.value) { // same value, no action required } else { // replace value, adjust weight this.#weight -= element.weight; const elementWeight = this.computeElementWeight(key, value); this.#weight += elementWeight; // assign new values element.weight = elementWeight; element.value = value; } // element inserted again, promote it to the front of the access queue this.__promote(element); } this.onSet.send2(key, value); } /** * * @param {Key} key * @returns {boolean} */ contains(key) { return this.data.has(key); } /** * * @param {Key} key * @returns {Value|null} value, or null if element was not found */ get(key) { const element = this.data.get(key); if (element === undefined) { return null; } else { this.__promote(element); return element.value; } } /** * Please note that the key will be stored inside the cache, so it must be treated as immutable * @param {Key} key * @param {function(Key):Value} compute * @param {*} [compute_context] * @return {Value} */ getOrCompute(key, compute, compute_context) { const existing = this.get(key); if (existing !== null) { return existing; } const value = compute.call(compute_context, key); this.set(key, value); return value; } /** * * @param {CacheElement<Key,Value>} element * @private */ __removeElement(element) { const value = element.value; // remove from the queue if (element === this.__first) { this.__first = element.next; } if (element === this.__last) { this.__last = element.previous; } element.unlink(); const key = element.key; //remove from cache this.data.delete(key); //update weight this.#weight -= element.weight; } /** * * @param {Key} key * @returns {boolean} true if element was removed, false otherwise */ remove(key) { const element = this.data.get(key); if (element === undefined) { //nothing to do return false; } this.__removeElement(element); //notify this.onRemoved.send2(key, element.value); return true; } /** * Remove without triggering {@link #onRemoved} * NOTE: please be sure you understand what you're doing when you use this method * @param {Key} key * @returns {boolean} true if element was removed, false otherwise */ silentRemove(key) { const element = this.data.get(key); if (element === undefined) { //nothing to do return false; } this.__removeElement(element); return true; } /** * Remove all elements from the cache */ clear() { /** * * @type {Key[]} */ const keys = []; collectIteratorValueToArray(keys, this.data.keys()); const n = keys.length; for (let i = 0; i < n; i++) { const key = keys[i]; this.remove(key); } } /** * Removed all data from cache * NOTE: Does NOT signal via {@link removeListener} */ drop() { this.data.clear(); this.__first = null; this.__last = null; this.#weight = 0; } /** * * @param {function(message:string, key:Key, value:Value)} callback * @param {*} [thisArg] * @returns {boolean} */ validate(callback, thisArg) { return this.data.verifyHashes((msg, key, entry) => { callback.call(thisArg, msg, key, entry.value); }); } } /** * Shortcut for "Map" class spec compliance * @readonly */ Cache.prototype.set = Cache.prototype.put; /** * Shortcut for "Map" class spec compliance * @readonly */ Cache.prototype.delete = Cache.prototype.remove; /** * Shortcut for "Map" class spec compliance * @readonly */ Cache.prototype.has = Cache.prototype.contains;