bun-shared-memory 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/memory.ts ADDED
@@ -0,0 +1,1016 @@
1
+ import { GrowableBuffer, SharedBudget } from "./growable-buffer.ts";
2
+ import { Mutex } from "./mutex.ts";
3
+
4
+ /**
5
+ * The 4-buffer shared memory architecture.
6
+ *
7
+ * 1) Index Table — maps object keys → DataTable offsets
8
+ * 2) DataTable — stores objects, arrays, strings as references
9
+ * 3) StringTable — append-only storage for strings
10
+ * 4) ArrayTable — append-only storage for arrays
11
+ *
12
+ * Value slot layout (16 bytes):
13
+ * [0..3] type (Int32): 0=number, 1=boolean, 2=bigint, 3=string-ref, 4=array-ref, 6=bigint-str
14
+ * [4..7] payload A
15
+ * [8..11] payload B
16
+ * [12..15] payload C
17
+ *
18
+ * number -> float64 in [4..11]
19
+ * boolean -> [4] = 0/1
20
+ * bigint -> int64 in [4..11] (only values that fit in 64 bits)
21
+ * bigint-str -> [4..7]=offset, [8..11]=length (into StringTable); for bigints
22
+ * too large for 64 bits, stored as their decimal string
23
+ * string-ref -> [4..7]=offset, [8..11]=length (into StringTable)
24
+ * array-ref -> [4..7]=offset, [8..11]=count (into ArrayTable)
25
+ *
26
+ * Object block layout in DataTable:
27
+ * [0..3] type = OBJECT (5)
28
+ * [4..7] property count
29
+ * [8..11] reserved
30
+ * [12..15] reserved
31
+ * then propertyCount * (nameRef(8 bytes) + valueSlot(16 bytes))
32
+ *
33
+ * Index Table entry (12 bytes):
34
+ * [0..3] key string offset
35
+ * [4..7] key string length
36
+ * [8..11] data offset (into DataTable)
37
+ */
38
+
39
+ export const TYPE_NUMBER = 0;
40
+ export const TYPE_BOOLEAN = 1;
41
+ export const TYPE_BIGINT = 2;
42
+ export const TYPE_STRING_REF = 3;
43
+ export const TYPE_ARRAY_REF = 4;
44
+ export const TYPE_OBJECT = 5;
45
+ export const TYPE_BIGINT_STR = 6;
46
+
47
+ const SLOT_SIZE = 16;
48
+ const OBJECT_HEADER = 16;
49
+ const NAME_REF_SIZE = 8;
50
+ const INDEX_ENTRY_SIZE = 16;
51
+ const INDEX_HEADER = 4;
52
+ /** Byte offset of the version field within an index entry. */
53
+ const INDEX_VERSION_OFFSET = 12;
54
+ /** Size of the objectId header at the start of each array block. */
55
+ const ARRAY_HEADER = 4;
56
+
57
+ const DEFAULT_INITIAL = 1024;
58
+ const DEFAULT_MAX = 64 * 1024 * 1024; // 64 MiB
59
+ const DEFAULT_INTERN_CAPACITY = 256;
60
+ const DEFAULT_INTERN_MAX = 1 << 16; // 65536 entries
61
+
62
+ /** Int32Array index of a key's version cell within the Index Table buffer. */
63
+ function versionCellIndex(index: number): number {
64
+ return (INDEX_HEADER + index * INDEX_ENTRY_SIZE + INDEX_VERSION_OFFSET) / 4;
65
+ }
66
+
67
+ /**
68
+ * Reusable DataView for packing/unpacking 64-bit values (number, bigint) into
69
+ * the two 32-bit halves of a ValueSlot. Each worker has its own module
70
+ * instance, and all uses happen under the shared lock (or on a single thread),
71
+ * so reusing one view is safe and avoids allocating a buffer per call.
72
+ */
73
+ const slotView = new DataView(new ArrayBuffer(8));
74
+
75
+ export interface StringRef {
76
+ offset: number;
77
+ length: number;
78
+ }
79
+
80
+ export interface ArrayRef {
81
+ /**
82
+ * Byte offset of the array block in the ArrayTable, pointing at the 4-byte
83
+ * objectId header. The value slots start at `offset + ARRAY_HEADER`.
84
+ */
85
+ offset: number;
86
+ count: number;
87
+ /** Number of slots reserved for this array (>= count). */
88
+ capacity: number;
89
+ }
90
+
91
+ export interface ValueSlot {
92
+ type: number;
93
+ a: number;
94
+ b: number;
95
+ c: number;
96
+ }
97
+
98
+ /**
99
+ * The stable layout of an object value in the DataTable: its total offset and
100
+ * the slot offset of each property. Static for the lifetime of the object.
101
+ */
102
+ export interface ObjectLayout {
103
+ totalOffset: number;
104
+ properties: Map<string, number>;
105
+ }
106
+
107
+ /** Encode a number (float64) into a ValueSlot. */
108
+ export function numberSlot(value: number): ValueSlot {
109
+ slotView.setFloat64(0, value, true);
110
+ return {
111
+ type: TYPE_NUMBER,
112
+ a: slotView.getInt32(0, true),
113
+ b: slotView.getInt32(4, true),
114
+ c: 0,
115
+ };
116
+ }
117
+
118
+ /** Encode a boolean into a ValueSlot. */
119
+ export function booleanSlot(value: boolean): ValueSlot {
120
+ return { type: TYPE_BOOLEAN, a: value ? 1 : 0, b: 0, c: 0 };
121
+ }
122
+
123
+ /** Encode a bigint (int64) into a ValueSlot. */
124
+ export function bigintSlot(value: bigint): ValueSlot {
125
+ slotView.setBigInt64(0, value, true);
126
+ return {
127
+ type: TYPE_BIGINT,
128
+ a: slotView.getInt32(0, true),
129
+ b: slotView.getInt32(4, true),
130
+ c: 0,
131
+ };
132
+ }
133
+
134
+ /** Encode a string reference into a ValueSlot. */
135
+ export function stringRefSlot(ref: StringRef): ValueSlot {
136
+ return { type: TYPE_STRING_REF, a: ref.offset, b: ref.length, c: 0 };
137
+ }
138
+
139
+ /** Encode a bigint stored as a decimal string into a ValueSlot. */
140
+ export function bigintStrSlot(ref: StringRef): ValueSlot {
141
+ return { type: TYPE_BIGINT_STR, a: ref.offset, b: ref.length, c: 0 };
142
+ }
143
+
144
+ /** Encode an array reference into a ValueSlot. */
145
+ export function arrayRefSlot(ref: ArrayRef): ValueSlot {
146
+ return { type: TYPE_ARRAY_REF, a: ref.offset, b: ref.count, c: ref.capacity };
147
+ }
148
+
149
+ /** Decode a ValueSlot into a JS value (primitives) or a ref object. */
150
+ export function decodeSlot(slot: ValueSlot): unknown {
151
+ switch (slot.type) {
152
+ case TYPE_NUMBER: {
153
+ slotView.setInt32(0, slot.a, true);
154
+ slotView.setInt32(4, slot.b, true);
155
+ return slotView.getFloat64(0, true);
156
+ }
157
+ case TYPE_BOOLEAN:
158
+ return slot.a === 1;
159
+ case TYPE_BIGINT: {
160
+ slotView.setInt32(0, slot.a, true);
161
+ slotView.setInt32(4, slot.b, true);
162
+ return slotView.getBigInt64(0, true);
163
+ }
164
+ case TYPE_STRING_REF:
165
+ return { offset: slot.a, length: slot.b };
166
+ case TYPE_ARRAY_REF:
167
+ return { offset: slot.a, count: slot.b, capacity: slot.c };
168
+ default:
169
+ throw new Error(`Unknown slot type ${slot.type}`);
170
+ }
171
+ }
172
+
173
+ export interface SharedMemoryOptions {
174
+ initialSize?: number;
175
+ maxByteLength?: number;
176
+ /** Total memory budget shared across all buffers (see below). */
177
+ maxTotalBytes?: number;
178
+ lock?: Mutex;
179
+ internCapacity?: number;
180
+ internMax?: number;
181
+ buffers?: {
182
+ indexTable?: SharedArrayBuffer;
183
+ dataTable?: SharedArrayBuffer;
184
+ stringTable?: SharedArrayBuffer;
185
+ arrayTable?: SharedArrayBuffer;
186
+ layoutVersion?: SharedArrayBuffer;
187
+ refCount?: SharedArrayBuffer;
188
+ };
189
+ }
190
+
191
+ /**
192
+ * Manages the four shared buffers and provides operations to store and read
193
+ * strings, arrays, and objects.
194
+ */
195
+ export class SharedMemory {
196
+ readonly indexTable: GrowableBuffer;
197
+ readonly dataTable: GrowableBuffer;
198
+ readonly stringTable: GrowableBuffer;
199
+ readonly arrayTable: GrowableBuffer;
200
+ /**
201
+ * Cached Int32Array view over the Index Table, used to read/increment each
202
+ * key's version cell (stored as the 4th field of each index entry). Rebuilt
203
+ * on growth. The version is incremented on every write so workers can
204
+ * invalidate their read caches cross-worker.
205
+ */
206
+ private versionView: Int32Array;
207
+ /**
208
+ * Global layout version cell. Bumped by the main thread after a
209
+ * defragmentation pass so workers can invalidate cached offsets (indexCache,
210
+ * layoutCache, propCache, readCache, internCache).
211
+ */
212
+ readonly layoutVersion: Int32Array;
213
+ /** Cross-worker mutex protecting all read/write operations. */
214
+ readonly lock: Mutex;
215
+ /**
216
+ * The 5th shared buffer: reference-counting ring buffer + heartbeat +
217
+ * global refcount table. Shared with workers so they can report acquire/
218
+ * release events to the main thread's coordinator. Optional; created lazily
219
+ * by the coordinator when auto-GC is enabled.
220
+ */
221
+ readonly refCountBuffer: SharedArrayBuffer | null;
222
+
223
+ private readonly encoder = new TextEncoder();
224
+ private readonly decoder = new TextDecoder();
225
+
226
+ /**
227
+ * LRU cache mapping interned strings to their stable StringTable refs.
228
+ * Per-worker (each worker has its own module instance). Refs are stable
229
+ * because the StringTable is append-only, so a cached ref stays valid.
230
+ * Grows dynamically up to `internMax`; evicts least-recently-used entries
231
+ * beyond `internCapacity`.
232
+ */
233
+ private readonly internCache = new Map<string, StringRef>();
234
+ private internCapacity: number;
235
+ private readonly internMax: number;
236
+ /** Fast path: the most recently interned string and its ref. */
237
+ private lastInterned: string | undefined;
238
+ private lastInternedRef: StringRef | undefined;
239
+
240
+ /**
241
+ * Monotonic counter for stable object/array identity. Each object and array
242
+ * gets a unique id that is independent of its DataTable/ArrayTable offset
243
+ * (which changes during defragmentation). Used to key the refcount table.
244
+ */
245
+ private nextObjectId = 1;
246
+
247
+ constructor(options?: SharedMemoryOptions) {
248
+ const initial = options?.initialSize ?? DEFAULT_INITIAL;
249
+ // A single SharedArrayBuffer can be at most 4 GiB. `maxTotalBytes` is a
250
+ // *total* budget shared across all buffers: each buffer's own maxByteLength
251
+ // is capped at min(4 GiB, maxTotalBytes), and the sum of all buffers'
252
+ // physical sizes is additionally capped at maxTotalBytes. Without
253
+ // maxTotalBytes, each buffer simply grows up to its own maxByteLength.
254
+ const maxTotal = options?.maxTotalBytes;
255
+ const perBufferMax = Math.min(
256
+ 4 * 1024 * 1024 * 1024,
257
+ maxTotal ?? options?.maxByteLength ?? DEFAULT_MAX,
258
+ );
259
+ const budget =
260
+ maxTotal !== undefined ? new SharedBudget(maxTotal) : undefined;
261
+ const buffers = options?.buffers;
262
+ this.indexTable = new GrowableBuffer(
263
+ buffers?.indexTable ??
264
+ new SharedArrayBuffer(initial, { maxByteLength: perBufferMax }),
265
+ perBufferMax,
266
+ budget,
267
+ );
268
+ this.dataTable = new GrowableBuffer(
269
+ buffers?.dataTable ??
270
+ new SharedArrayBuffer(initial, { maxByteLength: perBufferMax }),
271
+ perBufferMax,
272
+ budget,
273
+ );
274
+ this.stringTable = new GrowableBuffer(
275
+ buffers?.stringTable ??
276
+ new SharedArrayBuffer(initial, { maxByteLength: perBufferMax }),
277
+ perBufferMax,
278
+ budget,
279
+ );
280
+ this.arrayTable = new GrowableBuffer(
281
+ buffers?.arrayTable ??
282
+ new SharedArrayBuffer(initial, { maxByteLength: perBufferMax }),
283
+ perBufferMax,
284
+ budget,
285
+ );
286
+ this.versionView = new Int32Array(this.indexTable.buffer);
287
+ this.layoutVersion = new Int32Array(
288
+ buffers?.layoutVersion ??
289
+ new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT),
290
+ );
291
+ this.lock = options?.lock ?? new Mutex();
292
+ this.refCountBuffer = buffers?.refCount ?? null;
293
+ this.internCapacity = options?.internCapacity ?? DEFAULT_INTERN_CAPACITY;
294
+ this.internMax = options?.internMax ?? DEFAULT_INTERN_MAX;
295
+ }
296
+
297
+ /** Run `fn` while holding the shared lock. */
298
+ withLock<T>(fn: () => T): T {
299
+ return this.lock.withLock(fn);
300
+ }
301
+
302
+ /**
303
+ * Attach the reference-counting buffer (5th SAB) to this memory so it is
304
+ * included in `exportBuffers()`. No-op if a buffer is already attached.
305
+ */
306
+ attachRefCountBuffer(sab: SharedArrayBuffer): void {
307
+ if (this.refCountBuffer === null) {
308
+ (this as { refCountBuffer: SharedArrayBuffer | null }).refCountBuffer =
309
+ sab;
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Export all shared buffers (including the lock) so they can be sent to a
315
+ * worker via `postMessage` and reconstructed there with `fromBuffers()`.
316
+ */
317
+ exportBuffers(): {
318
+ indexTable: SharedArrayBuffer;
319
+ dataTable: SharedArrayBuffer;
320
+ stringTable: SharedArrayBuffer;
321
+ arrayTable: SharedArrayBuffer;
322
+ layoutVersion: SharedArrayBuffer;
323
+ lock: SharedArrayBuffer;
324
+ refCount: SharedArrayBuffer | null;
325
+ } {
326
+ return {
327
+ indexTable: this.indexTable.buffer,
328
+ dataTable: this.dataTable.buffer,
329
+ stringTable: this.stringTable.buffer,
330
+ arrayTable: this.arrayTable.buffer,
331
+ layoutVersion: this.layoutVersion.buffer as SharedArrayBuffer,
332
+ lock: this.lock.buffer as SharedArrayBuffer,
333
+ refCount: this.refCountBuffer,
334
+ };
335
+ }
336
+
337
+ /**
338
+ * Reconstruct a SharedMemory instance from buffers exported by
339
+ * `exportBuffers()` (e.g. received from the main thread in a worker).
340
+ */
341
+ static fromBuffers(buffers: {
342
+ indexTable: SharedArrayBuffer;
343
+ dataTable: SharedArrayBuffer;
344
+ stringTable: SharedArrayBuffer;
345
+ arrayTable: SharedArrayBuffer;
346
+ layoutVersion: SharedArrayBuffer;
347
+ lock: SharedArrayBuffer;
348
+ refCount?: SharedArrayBuffer | null;
349
+ }): SharedMemory {
350
+ return new SharedMemory({
351
+ buffers: {
352
+ indexTable: buffers.indexTable,
353
+ dataTable: buffers.dataTable,
354
+ stringTable: buffers.stringTable,
355
+ arrayTable: buffers.arrayTable,
356
+ layoutVersion: buffers.layoutVersion,
357
+ refCount: buffers.refCount ?? undefined,
358
+ },
359
+ lock: new Mutex(buffers.lock),
360
+ });
361
+ }
362
+
363
+ // ---------------------------------------------------------------------------
364
+ // StringTable
365
+ // ---------------------------------------------------------------------------
366
+
367
+ /**
368
+ * Append a string to the StringTable and return its reference.
369
+ *
370
+ * Interns the string in a per-worker LRU cache: repeated writes of the same
371
+ * string reuse the existing StringTable ref instead of re-encoding and
372
+ * re-copying the bytes. The cache grows dynamically up to `internMax` and
373
+ * evicts the least-recently-used entry once it exceeds `internCapacity`.
374
+ */
375
+ storeString(value: string): StringRef {
376
+ // Fast path: the same string as the previous call reuses its ref without
377
+ // touching the LRU map (avoids the expensive delete+set on every hit).
378
+ if (value === this.lastInterned) {
379
+ return this.lastInternedRef!;
380
+ }
381
+
382
+ const cached = this.internCache.get(value);
383
+ if (cached !== undefined) {
384
+ // Refresh LRU position (Map preserves insertion order; re-insert moves
385
+ // the entry to the most-recently-used end).
386
+ this.internCache.delete(value);
387
+ this.internCache.set(value, cached);
388
+ this.lastInterned = value;
389
+ this.lastInternedRef = cached;
390
+ return cached;
391
+ }
392
+
393
+ const bytes = this.encoder.encode(value);
394
+ const offset = this.stringTable.byteLength;
395
+ this.stringTable.ensureCapacity(offset + bytes.byteLength);
396
+ const view = this.stringTable.getView();
397
+ for (let i = 0; i < bytes.byteLength; i++) {
398
+ view.setUint8(offset + i, bytes[i]!);
399
+ }
400
+ this.stringTable.advance(bytes.byteLength);
401
+ const ref: StringRef = { offset, length: bytes.byteLength };
402
+
403
+ // Insert into the cache, growing capacity up to `internMax` before evicting.
404
+ if (this.internCache.size >= this.internCapacity) {
405
+ if (this.internCapacity < this.internMax) {
406
+ this.internCapacity = Math.min(this.internCapacity * 2, this.internMax);
407
+ } else {
408
+ // Evict the least-recently-used entry (first in insertion order).
409
+ const oldest = this.internCache.keys().next().value;
410
+ if (oldest !== undefined) this.internCache.delete(oldest);
411
+ }
412
+ }
413
+ this.internCache.set(value, ref);
414
+ this.lastInterned = value;
415
+ this.lastInternedRef = ref;
416
+ return ref;
417
+ }
418
+
419
+ /**
420
+ * Clear the per-worker string interning cache.
421
+ *
422
+ * Call this if the StringTable is ever compacted/defragmented (which would
423
+ * invalidate cached refs). The cache is repopulated lazily on the next
424
+ * `storeString` calls.
425
+ */
426
+ clearInternCache(): void {
427
+ this.internCache.clear();
428
+ this.lastInterned = undefined;
429
+ this.lastInternedRef = undefined;
430
+ }
431
+
432
+ /** Read a string from the StringTable by reference. */
433
+ readString(ref: StringRef): string {
434
+ const view = this.stringTable.getView();
435
+ const bytes = new Uint8Array(
436
+ this.stringTable.buffer,
437
+ ref.offset,
438
+ ref.length,
439
+ );
440
+ return this.decoder.decode(bytes);
441
+ }
442
+
443
+ // ---------------------------------------------------------------------------
444
+ // ArrayTable
445
+ // ---------------------------------------------------------------------------
446
+
447
+ /**
448
+ * Append an array of value slots to the ArrayTable and return its reference.
449
+ *
450
+ * Each array block starts with a 4-byte objectId header (for refcounting),
451
+ * followed by `capacity` value slots. `capacity` is the number of slots
452
+ * reserved for the array (>= slots.length). The extra slots are padding so
453
+ * `push` can grow the array in place without overwriting the next array.
454
+ * When the padding is exhausted, the array must be moved (see `moveArray`).
455
+ */
456
+ storeArray(slots: ValueSlot[], capacity?: number): ArrayRef {
457
+ const count = slots.length;
458
+ const cap = Math.max(capacity ?? 0, count, 1);
459
+ const offset = this.arrayTable.byteLength;
460
+ this.arrayTable.ensureCapacity(offset + ARRAY_HEADER + cap * SLOT_SIZE);
461
+ const view = this.arrayTable.getView();
462
+ const objectId = this.nextObjectId++;
463
+ view.setInt32(offset, objectId, true);
464
+ for (let i = 0; i < count; i++) {
465
+ writeSlot(view, offset + ARRAY_HEADER + i * SLOT_SIZE, slots[i]!);
466
+ }
467
+ this.arrayTable.advance(ARRAY_HEADER + cap * SLOT_SIZE);
468
+ return { offset, count, capacity: cap };
469
+ }
470
+
471
+ /** Read an array of value slots from the ArrayTable by reference. */
472
+ readArray(ref: ArrayRef): ValueSlot[] {
473
+ const view = this.arrayTable.getView();
474
+ const slots: ValueSlot[] = [];
475
+ for (let i = 0; i < ref.count; i++) {
476
+ slots.push(readSlot(view, ref.offset + ARRAY_HEADER + i * SLOT_SIZE));
477
+ }
478
+ return slots;
479
+ }
480
+
481
+ /** Update a single value slot of an array in the ArrayTable in place. */
482
+ updateArraySlot(ref: ArrayRef, index: number, slot: ValueSlot): void {
483
+ writeSlot(
484
+ this.arrayTable.getView(),
485
+ ref.offset + ARRAY_HEADER + index * SLOT_SIZE,
486
+ slot,
487
+ );
488
+ }
489
+
490
+ /** Read a single value slot of an array from the ArrayTable. */
491
+ readArraySlot(ref: ArrayRef, index: number): ValueSlot {
492
+ return readSlot(
493
+ this.arrayTable.getView(),
494
+ ref.offset + ARRAY_HEADER + index * SLOT_SIZE,
495
+ );
496
+ }
497
+
498
+ /** Read the stable objectId of an array block in the ArrayTable. */
499
+ getArrayObjectId(offset: number): number {
500
+ return this.arrayTable.getView().getInt32(offset, true);
501
+ }
502
+
503
+ /**
504
+ * Move an array to a new location with a larger capacity, copying its slots.
505
+ * Returns the new reference. The caller must update the array-ref slot in
506
+ * the DataTable to point at the new location.
507
+ */
508
+ moveArray(ref: ArrayRef, newCapacity: number): ArrayRef {
509
+ const newOffset = this.arrayTable.byteLength;
510
+ this.arrayTable.ensureCapacity(
511
+ newOffset + ARRAY_HEADER + newCapacity * SLOT_SIZE,
512
+ );
513
+ const view = this.arrayTable.getView();
514
+ const objectId = this.getArrayObjectId(ref.offset);
515
+ view.setInt32(newOffset, objectId, true);
516
+ for (let i = 0; i < ref.count; i++) {
517
+ writeSlot(
518
+ view,
519
+ newOffset + ARRAY_HEADER + i * SLOT_SIZE,
520
+ readSlot(view, ref.offset + ARRAY_HEADER + i * SLOT_SIZE),
521
+ );
522
+ }
523
+ this.arrayTable.advance(ARRAY_HEADER + newCapacity * SLOT_SIZE);
524
+ return { offset: newOffset, count: ref.count, capacity: newCapacity };
525
+ }
526
+
527
+ /**
528
+ * Append a slot to an array, growing it in place if capacity allows, or
529
+ * moving it to a new location with doubled capacity otherwise. Returns the
530
+ * (possibly new) reference; the caller must update the array-ref slot in the
531
+ * DataTable if the reference changed.
532
+ */
533
+ appendArraySlot(ref: ArrayRef, slot: ValueSlot): ArrayRef {
534
+ if (ref.count < ref.capacity) {
535
+ writeSlot(
536
+ this.arrayTable.getView(),
537
+ ref.offset + ARRAY_HEADER + ref.count * SLOT_SIZE,
538
+ slot,
539
+ );
540
+ return {
541
+ offset: ref.offset,
542
+ count: ref.count + 1,
543
+ capacity: ref.capacity,
544
+ };
545
+ }
546
+ const moved = this.moveArray(ref, Math.max(ref.capacity * 2, 1));
547
+ writeSlot(
548
+ this.arrayTable.getView(),
549
+ moved.offset + ARRAY_HEADER + moved.count * SLOT_SIZE,
550
+ slot,
551
+ );
552
+ return {
553
+ offset: moved.offset,
554
+ count: moved.count + 1,
555
+ capacity: moved.capacity,
556
+ };
557
+ }
558
+
559
+ // ---------------------------------------------------------------------------
560
+ // DataTable
561
+ // ---------------------------------------------------------------------------
562
+
563
+ /** Store a single value slot in the DataTable and return its offset. */
564
+ storeValueSlot(slot: ValueSlot): number {
565
+ const offset = this.dataTable.byteLength;
566
+ this.dataTable.ensureCapacity(offset + SLOT_SIZE);
567
+ writeSlot(this.dataTable.getView(), offset, slot);
568
+ this.dataTable.advance(SLOT_SIZE);
569
+ return offset;
570
+ }
571
+
572
+ /** Read a single value slot from the DataTable. */
573
+ readValueSlot(offset: number): ValueSlot {
574
+ return readSlot(this.dataTable.getView(), offset);
575
+ }
576
+
577
+ /** Update a single value slot in the DataTable. */
578
+ updateValueSlot(offset: number, slot: ValueSlot): void {
579
+ writeSlot(this.dataTable.getView(), offset, slot);
580
+ }
581
+
582
+ /**
583
+ * Create an object in the DataTable.
584
+ *
585
+ * @param properties array of [name, valueSlot] pairs
586
+ * @returns the byte offset of the object block in the DataTable
587
+ */
588
+ createObject(properties: Array<[string, ValueSlot]>): number {
589
+ const blockSize =
590
+ OBJECT_HEADER + properties.length * (NAME_REF_SIZE + SLOT_SIZE);
591
+ const offset = this.dataTable.byteLength;
592
+ this.dataTable.ensureCapacity(offset + blockSize);
593
+ const view = this.dataTable.getView();
594
+
595
+ const objectId = this.nextObjectId++;
596
+ view.setInt32(offset, TYPE_OBJECT, true);
597
+ view.setInt32(offset + 4, properties.length, true);
598
+ view.setInt32(offset + 8, objectId, true);
599
+
600
+ let cursor = offset + OBJECT_HEADER;
601
+ for (const [name, slot] of properties) {
602
+ const nameRef = this.storeString(name);
603
+ view.setInt32(cursor, nameRef.offset, true);
604
+ view.setInt32(cursor + 4, nameRef.length, true);
605
+ cursor += NAME_REF_SIZE;
606
+ writeSlot(view, cursor, slot);
607
+ cursor += SLOT_SIZE;
608
+ }
609
+ this.dataTable.advance(blockSize);
610
+ return offset;
611
+ }
612
+
613
+ /** Read an object's properties from the DataTable. */
614
+ readObject(offset: number): Array<[string, ValueSlot]> {
615
+ const view = this.dataTable.getView();
616
+ const count = view.getInt32(offset + 4, true);
617
+ const properties: Array<[string, ValueSlot]> = [];
618
+ let cursor = offset + OBJECT_HEADER;
619
+ for (let i = 0; i < count; i++) {
620
+ const nameOffset = view.getInt32(cursor, true);
621
+ const nameLength = view.getInt32(cursor + 4, true);
622
+ const name = this.readString({ offset: nameOffset, length: nameLength });
623
+ cursor += NAME_REF_SIZE;
624
+ const slot = readSlot(view, cursor);
625
+ cursor += SLOT_SIZE;
626
+ properties.push([name, slot]);
627
+ }
628
+ return properties;
629
+ }
630
+
631
+ /** Read the stable objectId of an object block in the DataTable. */
632
+ getObjectId(offset: number): number {
633
+ return this.dataTable.getView().getInt32(offset + 8, true);
634
+ }
635
+
636
+ /**
637
+ * Get the stable layout of an object value: its total DataTable offset and
638
+ * the slot offset of each property.
639
+ *
640
+ * Because object properties may not be added or removed, the layout is
641
+ * static for the lifetime of the object (it only changes if the object is
642
+ * moved by a future GC/defragmentation). Callers can cache it and read
643
+ * individual property slots directly without decoding the whole object.
644
+ *
645
+ * Returns null if the key does not exist or is not an object.
646
+ */
647
+ getObjectLayout(key: string): ObjectLayout | null {
648
+ const offset = this.lookup(key);
649
+ if (offset === null) return null;
650
+ const view = this.dataTable.getView();
651
+ if (view.getInt32(offset, true) !== TYPE_OBJECT) return null;
652
+ const count = view.getInt32(offset + 4, true);
653
+ const properties = new Map<string, number>();
654
+ let cursor = offset + OBJECT_HEADER;
655
+ for (let i = 0; i < count; i++) {
656
+ const nameOffset = view.getInt32(cursor, true);
657
+ const nameLength = view.getInt32(cursor + 4, true);
658
+ const name = this.readString({ offset: nameOffset, length: nameLength });
659
+ cursor += NAME_REF_SIZE;
660
+ properties.set(name, cursor);
661
+ cursor += SLOT_SIZE;
662
+ }
663
+ return { totalOffset: offset, properties };
664
+ }
665
+
666
+ /** Update a single value slot of an object in the DataTable. */
667
+ updateObjectSlot(
668
+ offset: number,
669
+ propertyIndex: number,
670
+ slot: ValueSlot,
671
+ ): void {
672
+ const view = this.dataTable.getView();
673
+ const slotOffset =
674
+ offset +
675
+ OBJECT_HEADER +
676
+ propertyIndex * (NAME_REF_SIZE + SLOT_SIZE) +
677
+ NAME_REF_SIZE;
678
+ writeSlot(view, slotOffset, slot);
679
+ }
680
+
681
+ // ---------------------------------------------------------------------------
682
+ // Index Table
683
+ // ---------------------------------------------------------------------------
684
+
685
+ /** Look up a key in the Index Table, returning the DataTable offset or null. */
686
+ lookup(key: string): number | null {
687
+ const index = this.lookupIndex(key);
688
+ if (index === null) return null;
689
+ return this.lookupByIndex(index);
690
+ }
691
+
692
+ /** Read the DataTable offset for a known stable index, or null if absent. */
693
+ lookupByIndex(index: number): number | null {
694
+ const view = this.indexTable.getView();
695
+ const count = view.getInt32(0, true);
696
+ if (index >= count) return null;
697
+ const entryOffset = INDEX_HEADER + index * INDEX_ENTRY_SIZE;
698
+ return view.getInt32(entryOffset + 8, true);
699
+ }
700
+
701
+ /**
702
+ * Look up a key's stable index in the Index Table, or null if absent.
703
+ *
704
+ * The index is the key's position in the Index Table. Keys are never
705
+ * removed, so this index is stable for the lifetime of the key and can be
706
+ * used to address the key's version cell.
707
+ */
708
+ lookupIndex(key: string): number | null {
709
+ const view = this.indexTable.getView();
710
+ const count = view.getInt32(0, true);
711
+ for (let i = 0; i < count; i++) {
712
+ const entryOffset = INDEX_HEADER + i * INDEX_ENTRY_SIZE;
713
+ const keyOffset = view.getInt32(entryOffset, true);
714
+ const keyLength = view.getInt32(entryOffset + 4, true);
715
+ const storedKey = this.readString({
716
+ offset: keyOffset,
717
+ length: keyLength,
718
+ });
719
+ if (storedKey === key) {
720
+ return i;
721
+ }
722
+ }
723
+ return null;
724
+ }
725
+
726
+ /** Read the current version for a key index (lock-free). */
727
+ getVersion(index: number): number {
728
+ return Atomics.load(this.versionView, versionCellIndex(index));
729
+ }
730
+
731
+ /** Increment the version for a key index, invalidating read caches. */
732
+ bumpVersion(index: number): void {
733
+ Atomics.add(this.versionView, versionCellIndex(index), 1);
734
+ }
735
+
736
+ /** Insert a key → dataOffset mapping into the Index Table. */
737
+ insert(key: string, dataOffset: number): void {
738
+ const keyRef = this.storeString(key);
739
+ const view = this.indexTable.getView();
740
+ const count = view.getInt32(0, true);
741
+ const entryOffset = INDEX_HEADER + count * INDEX_ENTRY_SIZE;
742
+ this.indexTable.ensureCapacity(entryOffset + INDEX_ENTRY_SIZE);
743
+ view.setInt32(entryOffset, keyRef.offset, true);
744
+ view.setInt32(entryOffset + 4, keyRef.length, true);
745
+ view.setInt32(entryOffset + 8, dataOffset, true);
746
+ // The 4th field is the key's version cell, initialized to 0.
747
+ view.setInt32(entryOffset + INDEX_VERSION_OFFSET, 0, true);
748
+ view.setInt32(0, count + 1, true);
749
+ this.indexTable.advance(INDEX_ENTRY_SIZE);
750
+
751
+ // Rebuild the version view if the buffer grew (grow preserves the SAB in
752
+ // place, but the Int32Array view must be recreated over the new buffer).
753
+ this.versionView = new Int32Array(this.indexTable.buffer);
754
+ }
755
+
756
+ // ---------------------------------------------------------------------------
757
+ // Layout version (cache invalidation)
758
+ // ---------------------------------------------------------------------------
759
+
760
+ /** Read the current global layout version (lock-free). */
761
+ getLayoutVersion(): number {
762
+ return Atomics.load(this.layoutVersion, 0);
763
+ }
764
+
765
+ /** Bump the global layout version, invalidating all per-worker caches. */
766
+ bumpLayoutVersion(): void {
767
+ Atomics.add(this.layoutVersion, 0, 1);
768
+ }
769
+
770
+ // ---------------------------------------------------------------------------
771
+ // Defragmentation
772
+ // ---------------------------------------------------------------------------
773
+
774
+ /**
775
+ * Compact the DataTable, ArrayTable, and StringTable, reclaiming dead space.
776
+ *
777
+ * Live data is everything reachable from the Index Table (every key's value,
778
+ * its object properties, and the strings/arrays they reference). Dead space
779
+ * (overwritten strings, abandoned array blocks from `moveArray`, and
780
+ * abandoned object blocks) is reclaimed by moving live data to the front of
781
+ * each buffer and resetting the used length.
782
+ *
783
+ * All references (Index Table offsets, object property names, string slots,
784
+ * array slots) are remapped in a single pass. The global layout version is
785
+ * bumped at the end so workers invalidate their cached offsets.
786
+ *
787
+ * Must be called under the shared lock (or on a single thread).
788
+ */
789
+ defragment(): void {
790
+ const stringRemap = new Map<number, number>();
791
+ const arrayRemap = new Map<number, number>();
792
+ const objectRemap = new Map<number, number>();
793
+ const liveStrings = new Set<number>();
794
+ const liveArrays = new Set<number>();
795
+ const liveObjects = new Set<number>();
796
+ const liveDataOffsets = new Set<number>();
797
+
798
+ const markString = (offset: number) => liveStrings.add(offset);
799
+
800
+ const markValueSlot = (slot: ValueSlot) => {
801
+ switch (slot.type) {
802
+ case TYPE_STRING_REF:
803
+ case TYPE_BIGINT_STR:
804
+ liveStrings.add(slot.a);
805
+ break;
806
+ case TYPE_ARRAY_REF:
807
+ markArray(slot.a, slot.b);
808
+ break;
809
+ }
810
+ };
811
+
812
+ const markArray = (offset: number, count: number) => {
813
+ if (liveArrays.has(offset)) return;
814
+ liveArrays.add(offset);
815
+ const view = this.arrayTable.getView();
816
+ for (let i = 0; i < count; i++) {
817
+ markValueSlot(readSlot(view, offset + ARRAY_HEADER + i * SLOT_SIZE));
818
+ }
819
+ };
820
+
821
+ const markObject = (offset: number) => {
822
+ if (liveObjects.has(offset)) return;
823
+ liveObjects.add(offset);
824
+ liveDataOffsets.add(offset);
825
+ const view = this.dataTable.getView();
826
+ const count = view.getInt32(offset + 4, true);
827
+ let cursor = offset + OBJECT_HEADER;
828
+ for (let i = 0; i < count; i++) {
829
+ const nameOffset = view.getInt32(cursor, true);
830
+ const nameLength = view.getInt32(cursor + 4, true);
831
+ liveStrings.add(nameOffset);
832
+ cursor += NAME_REF_SIZE;
833
+ markValueSlot(readSlot(view, cursor));
834
+ cursor += SLOT_SIZE;
835
+ }
836
+ };
837
+
838
+ const markValue = (dataOffset: number) => {
839
+ const view = this.dataTable.getView();
840
+ if (view.getInt32(dataOffset, true) === TYPE_OBJECT) {
841
+ markObject(dataOffset);
842
+ } else {
843
+ liveDataOffsets.add(dataOffset);
844
+ markValueSlot(readSlot(view, dataOffset));
845
+ }
846
+ };
847
+
848
+ // Phase 1: Mark live data by walking the Index Table.
849
+ const indexView = this.indexTable.getView();
850
+ const indexCount = indexView.getInt32(0, true);
851
+ const indexEntries: Array<{ keyRef: StringRef; dataOffset: number }> = [];
852
+ for (let i = 0; i < indexCount; i++) {
853
+ const entryOffset = INDEX_HEADER + i * INDEX_ENTRY_SIZE;
854
+ const keyRef = {
855
+ offset: indexView.getInt32(entryOffset, true),
856
+ length: indexView.getInt32(entryOffset + 4, true),
857
+ };
858
+ const dataOffset = indexView.getInt32(entryOffset + 8, true);
859
+ indexEntries.push({ keyRef, dataOffset });
860
+ liveStrings.add(keyRef.offset);
861
+ markValue(dataOffset);
862
+ }
863
+
864
+ // Phase 2: Compact the DataTable (objects and single value slots).
865
+ const sortedData = [...liveDataOffsets].sort((a, b) => a - b);
866
+ let newDataOffset = 0;
867
+ const dataView = this.dataTable.getView();
868
+ for (const oldOffset of sortedData) {
869
+ const size =
870
+ dataView.getInt32(oldOffset, true) === TYPE_OBJECT
871
+ ? OBJECT_HEADER +
872
+ dataView.getInt32(oldOffset + 4, true) * (NAME_REF_SIZE + SLOT_SIZE)
873
+ : SLOT_SIZE;
874
+ copyBytes(dataView, newDataOffset, oldOffset, size);
875
+ objectRemap.set(oldOffset, newDataOffset);
876
+ newDataOffset += size;
877
+ }
878
+ this.dataTable.reset(newDataOffset);
879
+
880
+ // Phase 3: Compact the ArrayTable.
881
+ const sortedArrays = [...liveArrays].sort((a, b) => a - b);
882
+ let newArrayOffset = 0;
883
+ const arrayView = this.arrayTable.getView();
884
+ for (const oldOffset of sortedArrays) {
885
+ // Determine the array's capacity by scanning forward to the next live
886
+ // array or the end of the used region.
887
+ const next = sortedArrays[sortedArrays.indexOf(oldOffset) + 1];
888
+ const end = next !== undefined ? next : this.arrayTable.byteLength;
889
+ const capacity = (end - oldOffset - ARRAY_HEADER) / SLOT_SIZE;
890
+ copyBytes(
891
+ arrayView,
892
+ newArrayOffset,
893
+ oldOffset,
894
+ ARRAY_HEADER + capacity * SLOT_SIZE,
895
+ );
896
+ arrayRemap.set(oldOffset, newArrayOffset);
897
+ newArrayOffset += ARRAY_HEADER + capacity * SLOT_SIZE;
898
+ }
899
+ this.arrayTable.reset(newArrayOffset);
900
+
901
+ // Phase 4: Compact the StringTable.
902
+ const sortedStrings = [...liveStrings].sort((a, b) => a - b);
903
+ let newStringOffset = 0;
904
+ const stringView = this.stringTable.getView();
905
+ for (const oldOffset of sortedStrings) {
906
+ const next = sortedStrings[sortedStrings.indexOf(oldOffset) + 1];
907
+ const end = next !== undefined ? next : this.stringTable.byteLength;
908
+ const length = end - oldOffset;
909
+ copyBytes(stringView, newStringOffset, oldOffset, length);
910
+ stringRemap.set(oldOffset, newStringOffset);
911
+ newStringOffset += length;
912
+ }
913
+ this.stringTable.reset(newStringOffset);
914
+
915
+ // Phase 5: Rewrite the Index Table with remapped key strings and offsets.
916
+ for (let i = 0; i < indexCount; i++) {
917
+ const entryOffset = INDEX_HEADER + i * INDEX_ENTRY_SIZE;
918
+ const { keyRef, dataOffset } = indexEntries[i]!;
919
+ indexView.setInt32(entryOffset, stringRemap.get(keyRef.offset)!, true);
920
+ indexView.setInt32(entryOffset + 4, keyRef.length, true);
921
+ indexView.setInt32(entryOffset + 8, objectRemap.get(dataOffset)!, true);
922
+ }
923
+
924
+ // Phase 6: Rewrite object property names and value slots, and array slots.
925
+ for (const oldOffset of sortedData) {
926
+ if (dataView.getInt32(oldOffset, true) !== TYPE_OBJECT) continue;
927
+ const newOff = objectRemap.get(oldOffset)!;
928
+ const count = dataView.getInt32(newOff + 4, true);
929
+ let cursor = newOff + OBJECT_HEADER;
930
+ for (let i = 0; i < count; i++) {
931
+ const nameOffset = dataView.getInt32(cursor, true);
932
+ dataView.setInt32(cursor, stringRemap.get(nameOffset)!, true);
933
+ cursor += NAME_REF_SIZE;
934
+ remapSlot(dataView, cursor, stringRemap, arrayRemap);
935
+ cursor += SLOT_SIZE;
936
+ }
937
+ }
938
+ for (const oldOffset of sortedData) {
939
+ if (dataView.getInt32(oldOffset, true) === TYPE_OBJECT) continue;
940
+ const newOff = objectRemap.get(oldOffset)!;
941
+ remapSlot(dataView, newOff, stringRemap, arrayRemap);
942
+ }
943
+ for (const oldOffset of sortedArrays) {
944
+ const newOff = arrayRemap.get(oldOffset)!;
945
+ const next = sortedArrays[sortedArrays.indexOf(oldOffset) + 1];
946
+ const end = next !== undefined ? next : this.arrayTable.byteLength;
947
+ const capacity = (end - oldOffset - ARRAY_HEADER) / SLOT_SIZE;
948
+ for (let i = 0; i < capacity; i++) {
949
+ remapSlot(
950
+ arrayView,
951
+ newOff + ARRAY_HEADER + i * SLOT_SIZE,
952
+ stringRemap,
953
+ arrayRemap,
954
+ );
955
+ }
956
+ }
957
+
958
+ // Phase 7: Invalidate all per-worker caches.
959
+ this.bumpLayoutVersion();
960
+ }
961
+ }
962
+
963
+ function writeSlot(view: DataView, offset: number, slot: ValueSlot): void {
964
+ view.setInt32(offset, slot.type, true);
965
+ view.setInt32(offset + 4, slot.a, true);
966
+ view.setInt32(offset + 8, slot.b, true);
967
+ view.setInt32(offset + 12, slot.c, true);
968
+ }
969
+
970
+ function readSlot(view: DataView, offset: number): ValueSlot {
971
+ return {
972
+ type: view.getInt32(offset, true),
973
+ a: view.getInt32(offset + 4, true),
974
+ b: view.getInt32(offset + 8, true),
975
+ c: view.getInt32(offset + 12, true),
976
+ };
977
+ }
978
+
979
+ /** Copy `len` bytes from `src` to `dst` in the same buffer (dst <= src). */
980
+ function copyBytes(
981
+ view: DataView,
982
+ dst: number,
983
+ src: number,
984
+ len: number,
985
+ ): void {
986
+ for (let i = 0; i < len; i++) {
987
+ view.setUint8(dst + i, view.getUint8(src + i));
988
+ }
989
+ }
990
+
991
+ /**
992
+ * Rewrite a value slot's string/array references after compaction, in place.
993
+ * String refs and bigint-str refs are remapped via `stringRemap`; array refs
994
+ * via `arrayRemap`.
995
+ */
996
+ function remapSlot(
997
+ view: DataView,
998
+ offset: number,
999
+ stringRemap: Map<number, number>,
1000
+ arrayRemap: Map<number, number>,
1001
+ ): void {
1002
+ const type = view.getInt32(offset, true);
1003
+ if (type === TYPE_STRING_REF || type === TYPE_BIGINT_STR) {
1004
+ view.setInt32(
1005
+ offset + 4,
1006
+ stringRemap.get(view.getInt32(offset + 4, true))!,
1007
+ true,
1008
+ );
1009
+ } else if (type === TYPE_ARRAY_REF) {
1010
+ view.setInt32(
1011
+ offset + 4,
1012
+ arrayRemap.get(view.getInt32(offset + 4, true))!,
1013
+ true,
1014
+ );
1015
+ }
1016
+ }