authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-09-04 23:44:36-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-14 02:40:47-07:00
log3f7ee731b61bebfb8d577c2cc80b97211691b31d
tree16910a0ba6bc224b96ed527a55e199d447889566
parentfc2505c256e9e0b1daa77481cc6ca104cf5a3a5d
signature Commit is signed but in an unrecognized format.

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.

3 files changed, 282 insertions(+), 29 deletions(-)

flake.nix+6-6
......@@ -15,12 +15,12 @@
1515 pkgs.python3 # for font subsetting
1616
1717 # paperclover.net
18 (pkgs.ffmpeg.override {
19 withOpus = true;
20 withSvtav1 = true;
21 withJxl = true;
22 withWebp = true;
23 })
18 # (pkgs.ffmpeg.override {
19 # withOpus = true;
20 # withSvtav1 = true;
21 # withJxl = true;
22 # withWebp = true;
23 # })
2424 pkgs.rsync
2525 ];
2626 };
framework/lib/lru.test.ts created+137
......@@ -0,0 +1,137 @@
1import { test } from "node:test";
2import { Lru } from "./lru.ts";
3
4test("lru basic", (t) => {
5 const lru = new Lru({ capacity: 3 });
6 lru.set("a", 1);
7 lru.set("b", 2);
8 lru.set("c", 3);
9 t.assert.deepEqual(lru.serialize(), [
10 ["c", 3, 1],
11 ["b", 2, 1],
12 ["a", 1, 1],
13 ]);
14 lru.set("d", 4);
15 t.assert.deepEqual(lru.serialize(), [
16 ["d", 4, 1],
17 ["c", 3, 1],
18 ["b", 2, 1],
19 ]);
20 lru.set("c", 5);
21 lru.get("b");
22 lru.set("e", 6);
23 t.assert.deepEqual(lru.serialize(), [
24 ["e", 6, 1],
25 ["b", 2, 1],
26 ["c", 5, 1],
27 ]);
28 lru.capacity = 10;
29 lru.set("a", 0);
30 lru.set("z", 1);
31 lru.set("x", 1);
32 lru.set("w", 1);
33 t.assert.deepEqual(lru.serialize(), [
34 ["w", 1, 1],
35 ["x", 1, 1],
36 ["z", 1, 1],
37 ["a", 0, 1],
38 ["e", 6, 1],
39 ["b", 2, 1],
40 ["c", 5, 1],
41 ]);
42 lru.delete("a");
43 t.assert.deepEqual(lru.serialize(), [
44 ["w", 1, 1],
45 ["x", 1, 1],
46 ["z", 1, 1],
47 ["e", 6, 1],
48 ["b", 2, 1],
49 ["c", 5, 1],
50 ]);
51 lru.capacity = 2;
52 t.assert.deepEqual(lru.serialize(), [
53 ["w", 1, 1],
54 ["x", 1, 1],
55 ]);
56 lru.ensureUnusedCapacity(1);
57 t.assert.deepEqual(lru.serialize(), [
58 ["w", 1, 1],
59 ]);
60 lru.ensureUnusedCapacity(1.2);
61 t.assert.deepEqual(lru.serialize(), []);
62 lru.ensureUnusedCapacity(2);
63});
64test("lru sized elements", (t) => {
65 const lru = new Lru<string, number>({ capacity: 10, sizeFn: (v) => v });
66 lru.set("a", 3);
67 lru.set("b", 3);
68 lru.set("c", 3);
69 t.assert.deepEqual(lru.serialize(), [
70 ["c", 3, 3],
71 ["b", 3, 3],
72 ["a", 3, 3],
73 ]);
74 // hitting exact capacity
75 lru.set("d", 1);
76 lru.set("d", 1);
77 t.assert.deepEqual(lru.serialize(), [
78 ["d", 1, 1],
79 ["c", 3, 3],
80 ["b", 3, 3],
81 ["a", 3, 3],
82 ]);
83 lru.set("e", 1);
84 t.assert.deepEqual(lru.serialize(), [
85 ["e", 1, 1],
86 ["d", 1, 1],
87 ["c", 3, 3],
88 ["b", 3, 3],
89 ]);
90 lru.clear();
91 lru.set("a", 3);
92 lru.set("b", 3);
93 lru.set("c", 3);
94 lru.set("d", 1);
95 lru.get("a");
96 lru.get("b");
97 lru.get("c");
98 lru.set("e", 1);
99 t.assert.deepEqual(lru.serialize(), [
100 ["e", 1, 1],
101 ["c", 3, 3],
102 ["b", 3, 3],
103 ["a", 3, 3],
104 ]);
105});
106test("lru throw on exceeding capacity", (t) => {
107 const lru = new Lru<string, number>({ capacity: 1, sizeFn: (v) => v });
108 lru.set("e", 1);
109 lru.set("e", 1);
110 t.assert.throws(() => lru.set("e", 2), RangeError);
111 t.assert.throws(() => lru.set("d", 2), RangeError);
112 lru.set("d", 1);
113});
114test("locked items cannot be evicted", (t) => {
115 const lru = new Lru<string, number>({ capacity: 3, sizeFn: (v) => v });
116 lru.set("e", 1);
117 lru.set("d", 1);
118 lru.set("f", 1);
119
120 const unlock = lru.lock("e");
121 lru.ensureUnusedCapacity(2);
122 t.assert.deepEqual(lru.serialize(), [
123 ["e", 1, 1],
124 ]);
125
126 unlock();
127
128 lru.ensureUnusedCapacity(3);
129
130 t.assert.deepEqual(lru.serialize(), []);
131});
132test("throws on evicting a locked item", (t) => {
133 const lru = new Lru<string, number>({ capacity: 3, sizeFn: (v) => v });
134 lru.set("e", 1);
135 void lru.lock("e");
136 t.assert.throws(() => lru.ensureUnusedCapacity(3), RangeError);
137});
framework/lib/lru.ts+139-23
......@@ -19,6 +19,7 @@ export class Lru<K, V> extends Map<K, V> {
1919 /** The least recently used */
2020 #tail: K | none = none;
2121 #used: number = 0;
22 #locked: number = 0;
2223 #capacity: number;
2324 #sizeFn: (value: V, key: K) => number;
2425 #deleteFn: null | ((value: V, key: K) => void);
......@@ -34,14 +35,16 @@ export class Lru<K, V> extends Map<K, V> {
3435 }
3536
3637 override clear() {
37 this.clear();
38 super.clear();
3839 this.#order.clear();
3940 this.#head = none;
4041 this.#tail = none;
4142 this.#used = 0;
4243 }
4344 override delete(key: K) {
44 if (!this.#evict(key)) return false;
45 const entry = this.#evict(key);
46 if (!entry) return false;
47 this.#used -= entry[0];
4548 ASSERT(this.#order.delete(key));
4649 ASSERT(super.delete(key));
4750 return true;
......@@ -57,46 +60,103 @@ export class Lru<K, V> extends Map<K, V> {
5760 return super.get(key) as V;
5861 }
5962 override set(key: K, value: V) {
63 const size = this.#sizeFn(value, key);
64 {
65 const uc = this.unlockedCapacity;
66 if (size > uc) {
67 const label = uc === this.#capacity ? " " : " unlocked ";
68 const msg = `Object of size ${size} exceeds${label}capacity ${uc}`;
69 throw new RangeError(msg);
70 }
71 }
72
6073 const entry = this.#evict(key);
61 const valueSize = this.#sizeFn(value, key);
6274 if (entry) {
6375 // relocate
6476 this.#used -= entry[0];
65 entry[0] = valueSize, entry[1] = none, entry[2] = this.#head;
66 UNWRAP(this.#order.get(this.#head as K))[1] = key;
77 const head = this.#head;
78 ASSERT(head !== key);
79 entry[0] = size, entry[1] = none, entry[2] = this.#head;
80 if (head !== none) UNWRAP(this.#order.get(this.#head as K))[1] = key;
81 if (this.#tail === none) this.#tail = key;
6782 } else {
6883 // insertion
69 const { size } = this;
70 const order: Order<K> = [valueSize, none, size > 0 ? this.#head : none];
84 const { size: mapSize } = this;
85 const order: Order<K> = [size, none, mapSize > 0 ? this.#head : none, 0];
7186 this.#order.set(key, order);
72 if (size === 0) this.#tail = key;
73 else UNWRAP(this.#order.get(this.#head as K))[1] = key;
87 if (mapSize === 0) this.#tail = key;
88 else if (this.#head !== none) {
89 UNWRAP(this.#order.get(this.#head as K))[1] = key;
90 }
7491 }
92
7593 this.#head = key;
76 this.#used += valueSize;
77 this.ensureUnusedCapacity(0);
94 this.#used += size;
95 try {
96 this.ensureUnusedCapacity(0);
97 } catch {
98 ASSERT(false, "capacity is already verified");
99 }
78100 super.set(key, value);
79101 return this;
80102 }
81103
104 /** Prevent an item from being automatically evicted */
105 lock(key: K): Lock {
106 const order = this.#order.get(key);
107 if (!order) throw new Error(`Key ${key} not in Lru`);
108 const count = order[3] += 1;
109 if (count === 1) this.#locked += order[0];
110 const unlock = () => {
111 const count = order![3] -= 1;
112 if (count === 0) this.#locked -= order[0];
113 };
114 unlock[Symbol.dispose] = unlock;
115 return unlock;
116 }
117
82118 /** Evict items until there are `unused` capacity slots. */
83119 ensureUnusedCapacity(unused: number) {
120 const uc = this.unlockedCapacity;
121 if (unused > uc) {
122 const label = uc === this.#capacity ? "capacity" : "unlocked capacity";
123 const msg = `Can't ensure ${unused} unused capacity with ${label} ${uc}`;
124 throw new RangeError(msg);
125 }
84126 const needed = this.#used - this.#capacity + unused;
127 if (needed <= 0) return;
85128 let remain = needed;
86129 const evictFn = this.#evictFn;
87130 const deleteFn = this.#deleteFn;
88 while (remain > 0) {
89 const key = this.#tail as K;
90 const tail = UNWRAP(this.#evict(key));
91 if (evictFn) evictFn(super.get(key) as V, key);
92 if (deleteFn) deleteFn(super.get(key) as V, key);
93 ASSERT(this.#order.delete(key));
94 ASSERT(super.delete(key));
95 remain -= tail[0];
131 const order = this.#order;
132 let key = this.#tail;
133 loop: {
134 let hadLocks = false;
135 while (key !== none) {
136 const entry = UNWRAP(order.get(key));
137 if (entry[3] > 0) {
138 key = entry[1];
139 hadLocks = true;
140 continue;
141 }
142 ASSERT(this.#evict(key) === entry);
143 if (evictFn) evictFn(super.get(key) as V, key);
144 if (deleteFn) deleteFn(super.get(key) as V, key);
145 ASSERT(this.#order.delete(key));
146 ASSERT(super.delete(key));
147 remain -= entry[0];
148 if (remain <= 0) break loop;
149 key = entry[1];
150 }
151 ASSERT(hadLocks);
152 const msg = `Too many locked objects, cannot free ${unused} capacity`;
153 throw new RangeError(msg);
96154 }
97155 this.#used -= needed - remain;
98156 }
99157
158 hasEvictableCapacity() {}
159
100160 /** Unlink an entry without removing it. */
101161 #evict(key: K) {
102162 const entry = this.#order.get(key);
......@@ -104,12 +164,10 @@ export class Lru<K, V> extends Map<K, V> {
104164 const [, before, after] = entry;
105165 if (before !== none) {
106166 UNWRAP(this.#order.get(before as K))[2] = after;
107 if (after === none) this.#tail = before;
108 }
167 } else this.#head = after;
109168 if (after !== none) {
110169 UNWRAP(this.#order.get(after as K))[1] = before;
111 if (before === none) this.#head = after;
112 }
170 } else this.#tail = before;
113171 return entry;
114172 }
115173
......@@ -120,6 +178,58 @@ export class Lru<K, V> extends Map<K, V> {
120178 this.#capacity = capacity;
121179 this.ensureUnusedCapacity(0);
122180 }
181 get remaining() {
182 return this.#capacity - this.#used;
183 }
184 get unlockedCapacity() {
185 return this.#capacity - this.#locked;
186 }
187 get used() {
188 return this.#used;
189 }
190
191 serialize(): Serialized<K, V>;
192 serialize<OK, OV>(o: SerializeOptions<K, V, OK, OV>): Serialized<OK, OV>;
193 serialize<OK, OV>(
194 opts: SerializeOptions<K, V, OK, OV> = {},
195 ): Serialized<OK, OV> {
196 const { map = (k, v) => [k as unknown as OK, v as unknown as OV] } = opts;
197 const result: Serialized<OK, OV> = [];
198 const order = this.#order;
199 let head = this.#head;
200 while (head !== none) {
201 const entry = UNWRAP(order.get(head), head);
202 const [key, value] = map(head, super.get(head) as V);
203 result.push([key, value, entry[0]]);
204 head = entry[2];
205 }
206 return result;
207 }
208 revive<IK, IV>(
209 entries: Serialized<IK, IV>,
210 opts: SerializeOptions<IK, IV, K, V> = {},
211 ) {
212 const { map = (k, v) => [k as unknown as K, v as unknown as V] } = opts;
213 const order = this.#order;
214 let prev: Order<K> | null = null;
215 let pk!: K;
216 for (let i = entries.length - 1; i > 0; i -= 1) {
217 const [ik, iv, size] = UNWRAP(entries[i]);
218 const [k, v] = map(ik, iv);
219 super.set(k, v);
220 const current: Order<K> = [size, none, prev ? pk : none, 0];
221 if (prev) prev[1] = k;
222 order.set(k, prev = current);
223 pk = k;
224 }
225 }
226}
227
228/** The first item is the most recently used. */
229export type Serialized<K, V> = Array<[K, V, number]>;
230
231interface SerializeOptions<IK, IV, OK = IK, OV = IV> {
232 map?: (key: IK, value: IV) => [OK, OV];
123233}
124234
125235/**
......@@ -133,7 +243,13 @@ type Order<K> = [
133243 before: K | none,
134244 /** Towards least recently used */
135245 after: K | none,
246 /** Locking prevents automatic eviction */
247 locks: number,
136248];
137249
250interface Lock extends Disposable {
251 (): void;
252}
253
138254const none: unique symbol = Symbol();
139255type none = typeof none;