@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
591 lines (475 loc) • 15.6 kB
JavaScript
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;