From 3f7ee731b61bebfb8d577c2cc80b97211691b31d Mon Sep 17 00:00:00 2001 From: clover caruso Date: Thu, 4 Sep 2025 23:44:36 -0700 Subject: [PATCH] lib(lru): add serialization and locking. the from-scratch LRU seems to be a great idea, since we now have a more powerful LRU abstraction than the off the shelf `lru-cache` package. locking allows counting a value towards the used capacity without it being evicted (ex: it is a file currently open being read), and lru's serialization allows comitting state to disk and back. both of these features are used by the cache layer. --- flake.nix | 12 +-- framework/lib/lru.test.ts | 137 ++++++++++++++++++++++++++++++++ framework/lib/lru.ts | 162 ++++++++++++++++++++++++++++++++------ 3 files changed, 282 insertions(+), 29 deletions(-) create mode 100644 framework/lib/lru.test.ts diff --git a/flake.nix b/flake.nix index ece9aca47dae0c9161b47672b88e8792508bc604..58e6021f7d7de97c259b47d64cf5010ce2ab2950 100644 --- a/flake.nix +++ b/flake.nix @@ -15,12 +15,12 @@ pkgs.python3 # for font subsetting # paperclover.net - (pkgs.ffmpeg.override { - withOpus = true; - withSvtav1 = true; - withJxl = true; - withWebp = true; - }) + # (pkgs.ffmpeg.override { + # withOpus = true; + # withSvtav1 = true; + # withJxl = true; + # withWebp = true; + # }) pkgs.rsync ]; }; diff --git a/framework/lib/lru.test.ts b/framework/lib/lru.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0667d7bac1a957ae29a4a431fe61a15e4069a204 --- /dev/null +++ b/framework/lib/lru.test.ts @@ -0,0 +1,137 @@ +import { test } from "node:test"; +import { Lru } from "./lru.ts"; + +test("lru basic", (t) => { + const lru = new Lru({ capacity: 3 }); + lru.set("a", 1); + lru.set("b", 2); + lru.set("c", 3); + t.assert.deepEqual(lru.serialize(), [ + ["c", 3, 1], + ["b", 2, 1], + ["a", 1, 1], + ]); + lru.set("d", 4); + t.assert.deepEqual(lru.serialize(), [ + ["d", 4, 1], + ["c", 3, 1], + ["b", 2, 1], + ]); + lru.set("c", 5); + lru.get("b"); + lru.set("e", 6); + t.assert.deepEqual(lru.serialize(), [ + ["e", 6, 1], + ["b", 2, 1], + ["c", 5, 1], + ]); + lru.capacity = 10; + lru.set("a", 0); + lru.set("z", 1); + lru.set("x", 1); + lru.set("w", 1); + t.assert.deepEqual(lru.serialize(), [ + ["w", 1, 1], + ["x", 1, 1], + ["z", 1, 1], + ["a", 0, 1], + ["e", 6, 1], + ["b", 2, 1], + ["c", 5, 1], + ]); + lru.delete("a"); + t.assert.deepEqual(lru.serialize(), [ + ["w", 1, 1], + ["x", 1, 1], + ["z", 1, 1], + ["e", 6, 1], + ["b", 2, 1], + ["c", 5, 1], + ]); + lru.capacity = 2; + t.assert.deepEqual(lru.serialize(), [ + ["w", 1, 1], + ["x", 1, 1], + ]); + lru.ensureUnusedCapacity(1); + t.assert.deepEqual(lru.serialize(), [ + ["w", 1, 1], + ]); + lru.ensureUnusedCapacity(1.2); + t.assert.deepEqual(lru.serialize(), []); + lru.ensureUnusedCapacity(2); +}); +test("lru sized elements", (t) => { + const lru = new Lru({ capacity: 10, sizeFn: (v) => v }); + lru.set("a", 3); + lru.set("b", 3); + lru.set("c", 3); + t.assert.deepEqual(lru.serialize(), [ + ["c", 3, 3], + ["b", 3, 3], + ["a", 3, 3], + ]); + // hitting exact capacity + lru.set("d", 1); + lru.set("d", 1); + t.assert.deepEqual(lru.serialize(), [ + ["d", 1, 1], + ["c", 3, 3], + ["b", 3, 3], + ["a", 3, 3], + ]); + lru.set("e", 1); + t.assert.deepEqual(lru.serialize(), [ + ["e", 1, 1], + ["d", 1, 1], + ["c", 3, 3], + ["b", 3, 3], + ]); + lru.clear(); + lru.set("a", 3); + lru.set("b", 3); + lru.set("c", 3); + lru.set("d", 1); + lru.get("a"); + lru.get("b"); + lru.get("c"); + lru.set("e", 1); + t.assert.deepEqual(lru.serialize(), [ + ["e", 1, 1], + ["c", 3, 3], + ["b", 3, 3], + ["a", 3, 3], + ]); +}); +test("lru throw on exceeding capacity", (t) => { + const lru = new Lru({ capacity: 1, sizeFn: (v) => v }); + lru.set("e", 1); + lru.set("e", 1); + t.assert.throws(() => lru.set("e", 2), RangeError); + t.assert.throws(() => lru.set("d", 2), RangeError); + lru.set("d", 1); +}); +test("locked items cannot be evicted", (t) => { + const lru = new Lru({ capacity: 3, sizeFn: (v) => v }); + lru.set("e", 1); + lru.set("d", 1); + lru.set("f", 1); + + const unlock = lru.lock("e"); + lru.ensureUnusedCapacity(2); + t.assert.deepEqual(lru.serialize(), [ + ["e", 1, 1], + ]); + + unlock(); + + lru.ensureUnusedCapacity(3); + + t.assert.deepEqual(lru.serialize(), []); +}); +test("throws on evicting a locked item", (t) => { + const lru = new Lru({ capacity: 3, sizeFn: (v) => v }); + lru.set("e", 1); + void lru.lock("e"); + t.assert.throws(() => lru.ensureUnusedCapacity(3), RangeError); +}); diff --git a/framework/lib/lru.ts b/framework/lib/lru.ts index f11d96db969a372cb13e1a268257b4b5f09d4000..c72bc49dd0e948ad359cf13a6c7a36f6ba494df4 100644 --- a/framework/lib/lru.ts +++ b/framework/lib/lru.ts @@ -19,6 +19,7 @@ export class Lru extends Map { /** The least recently used */ #tail: K | none = none; #used: number = 0; + #locked: number = 0; #capacity: number; #sizeFn: (value: V, key: K) => number; #deleteFn: null | ((value: V, key: K) => void); @@ -34,14 +35,16 @@ export class Lru extends Map { } override clear() { - this.clear(); + super.clear(); this.#order.clear(); this.#head = none; this.#tail = none; this.#used = 0; } override delete(key: K) { - if (!this.#evict(key)) return false; + const entry = this.#evict(key); + if (!entry) return false; + this.#used -= entry[0]; ASSERT(this.#order.delete(key)); ASSERT(super.delete(key)); return true; @@ -57,46 +60,103 @@ export class Lru extends Map { return super.get(key) as V; } override set(key: K, value: V) { + const size = this.#sizeFn(value, key); + { + const uc = this.unlockedCapacity; + if (size > uc) { + const label = uc === this.#capacity ? " " : " unlocked "; + const msg = `Object of size ${size} exceeds${label}capacity ${uc}`; + throw new RangeError(msg); + } + } + const entry = this.#evict(key); - const valueSize = this.#sizeFn(value, key); if (entry) { // relocate this.#used -= entry[0]; - entry[0] = valueSize, entry[1] = none, entry[2] = this.#head; - UNWRAP(this.#order.get(this.#head as K))[1] = key; + const head = this.#head; + ASSERT(head !== key); + entry[0] = size, entry[1] = none, entry[2] = this.#head; + if (head !== none) UNWRAP(this.#order.get(this.#head as K))[1] = key; + if (this.#tail === none) this.#tail = key; } else { // insertion - const { size } = this; - const order: Order = [valueSize, none, size > 0 ? this.#head : none]; + const { size: mapSize } = this; + const order: Order = [size, none, mapSize > 0 ? this.#head : none, 0]; this.#order.set(key, order); - if (size === 0) this.#tail = key; - else UNWRAP(this.#order.get(this.#head as K))[1] = key; + if (mapSize === 0) this.#tail = key; + else if (this.#head !== none) { + UNWRAP(this.#order.get(this.#head as K))[1] = key; + } } + this.#head = key; - this.#used += valueSize; - this.ensureUnusedCapacity(0); + this.#used += size; + try { + this.ensureUnusedCapacity(0); + } catch { + ASSERT(false, "capacity is already verified"); + } super.set(key, value); return this; } + /** Prevent an item from being automatically evicted */ + lock(key: K): Lock { + const order = this.#order.get(key); + if (!order) throw new Error(`Key ${key} not in Lru`); + const count = order[3] += 1; + if (count === 1) this.#locked += order[0]; + const unlock = () => { + const count = order![3] -= 1; + if (count === 0) this.#locked -= order[0]; + }; + unlock[Symbol.dispose] = unlock; + return unlock; + } + /** Evict items until there are `unused` capacity slots. */ ensureUnusedCapacity(unused: number) { + const uc = this.unlockedCapacity; + if (unused > uc) { + const label = uc === this.#capacity ? "capacity" : "unlocked capacity"; + const msg = `Can't ensure ${unused} unused capacity with ${label} ${uc}`; + throw new RangeError(msg); + } const needed = this.#used - this.#capacity + unused; + if (needed <= 0) return; let remain = needed; const evictFn = this.#evictFn; const deleteFn = this.#deleteFn; - while (remain > 0) { - const key = this.#tail as K; - const tail = UNWRAP(this.#evict(key)); - if (evictFn) evictFn(super.get(key) as V, key); - if (deleteFn) deleteFn(super.get(key) as V, key); - ASSERT(this.#order.delete(key)); - ASSERT(super.delete(key)); - remain -= tail[0]; + const order = this.#order; + let key = this.#tail; + loop: { + let hadLocks = false; + while (key !== none) { + const entry = UNWRAP(order.get(key)); + if (entry[3] > 0) { + key = entry[1]; + hadLocks = true; + continue; + } + ASSERT(this.#evict(key) === entry); + if (evictFn) evictFn(super.get(key) as V, key); + if (deleteFn) deleteFn(super.get(key) as V, key); + ASSERT(this.#order.delete(key)); + ASSERT(super.delete(key)); + remain -= entry[0]; + if (remain <= 0) break loop; + key = entry[1]; + } + ASSERT(hadLocks); + const msg = `Too many locked objects, cannot free ${unused} capacity`; + throw new RangeError(msg); } this.#used -= needed - remain; } + hasEvictableCapacity() {} + /** Unlink an entry without removing it. */ #evict(key: K) { const entry = this.#order.get(key); @@ -104,12 +164,10 @@ export class Lru extends Map { const [, before, after] = entry; if (before !== none) { UNWRAP(this.#order.get(before as K))[2] = after; - if (after === none) this.#tail = before; - } + } else this.#head = after; if (after !== none) { UNWRAP(this.#order.get(after as K))[1] = before; - if (before === none) this.#head = after; - } + } else this.#tail = before; return entry; } @@ -120,6 +178,58 @@ export class Lru extends Map { this.#capacity = capacity; this.ensureUnusedCapacity(0); } + get remaining() { + return this.#capacity - this.#used; + } + get unlockedCapacity() { + return this.#capacity - this.#locked; + } + get used() { + return this.#used; + } + + serialize(): Serialized; + serialize(o: SerializeOptions): Serialized; + serialize( + opts: SerializeOptions = {}, + ): Serialized { + const { map = (k, v) => [k as unknown as OK, v as unknown as OV] } = opts; + const result: Serialized = []; + const order = this.#order; + let head = this.#head; + while (head !== none) { + const entry = UNWRAP(order.get(head), head); + const [key, value] = map(head, super.get(head) as V); + result.push([key, value, entry[0]]); + head = entry[2]; + } + return result; + } + revive( + entries: Serialized, + opts: SerializeOptions = {}, + ) { + const { map = (k, v) => [k as unknown as K, v as unknown as V] } = opts; + const order = this.#order; + let prev: Order | null = null; + let pk!: K; + for (let i = entries.length - 1; i > 0; i -= 1) { + const [ik, iv, size] = UNWRAP(entries[i]); + const [k, v] = map(ik, iv); + super.set(k, v); + const current: Order = [size, none, prev ? pk : none, 0]; + if (prev) prev[1] = k; + order.set(k, prev = current); + pk = k; + } + } +} + +/** The first item is the most recently used. */ +export type Serialized = Array<[K, V, number]>; + +interface SerializeOptions { + map?: (key: IK, value: IV) => [OK, OV]; } /** @@ -133,7 +243,13 @@ type Order = [ before: K | none, /** Towards least recently used */ after: K | none, + /** Locking prevents automatic eviction */ + locks: number, ]; +interface Lock extends Disposable { + (): void; +} + const none: unique symbol = Symbol(); type none = typeof none; -- 2.54.0