bun-shared-memory 0.1.8 → 0.2.0

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 CHANGED
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  GrowableBuffer,
3
+ MIN_BLOCK_SIZE,
3
4
  SharedBudget,
4
5
  nextPowerOfTwo,
5
6
  } from "./growable-buffer.ts";
@@ -48,6 +49,13 @@ export const TYPE_STRING_REF = 3;
48
49
  export const TYPE_ARRAY_REF = 4;
49
50
  export const TYPE_OBJECT = 5;
50
51
  export const TYPE_BIGINT_STR = 6;
52
+ /** A string value stored in the ArrayTable (slab-allocated, refcounted). */
53
+ export const TYPE_STRING_VALUE_REF = 7;
54
+ /**
55
+ * A short string stored inline in the slot itself (up to 12 UTF-8 bytes in
56
+ * the a/b/c fields), avoiding a heap allocation and the string-table write.
57
+ */
58
+ export const TYPE_INLINE_STRING = 8;
51
59
 
52
60
  const SLOT_SIZE = 16;
53
61
  const OBJECT_HEADER = 16;
@@ -149,6 +157,11 @@ export function stringRefSlot(ref: StringRef): ValueSlot {
149
157
  return { type: TYPE_STRING_REF, a: ref.offset, b: ref.length, c: 0 };
150
158
  }
151
159
 
160
+ /** Encode a string value (in the ArrayTable) into a ValueSlot. */
161
+ export function stringValueRefSlot(ref: StringRef): ValueSlot {
162
+ return { type: TYPE_STRING_VALUE_REF, a: ref.offset, b: ref.length, c: 0 };
163
+ }
164
+
152
165
  /** Encode a bigint stored as a decimal string into a ValueSlot. */
153
166
  export function bigintStrSlot(ref: StringRef): ValueSlot {
154
167
  return { type: TYPE_BIGINT_STR, a: ref.offset, b: ref.length, c: 0 };
@@ -176,6 +189,8 @@ export function decodeSlot(slot: ValueSlot): unknown {
176
189
  }
177
190
  case TYPE_STRING_REF:
178
191
  return { offset: slot.a, length: slot.b };
192
+ case TYPE_STRING_VALUE_REF:
193
+ return { offset: slot.a, length: slot.b };
179
194
  case TYPE_ARRAY_REF:
180
195
  return { offset: slot.a, count: slot.b, capacity: slot.c };
181
196
  default:
@@ -217,6 +232,11 @@ export interface SharedMemoryOptions {
217
232
  stringTable?: SharedArrayBuffer;
218
233
  arrayTable?: SharedArrayBuffer;
219
234
  };
235
+ freeLists?: {
236
+ dataTable?: SharedArrayBuffer;
237
+ stringTable?: SharedArrayBuffer;
238
+ arrayTable?: SharedArrayBuffer;
239
+ };
220
240
  };
221
241
  }
222
242
 
@@ -230,6 +250,7 @@ function makeBuffer(
230
250
  singleBuffer = false,
231
251
  chargeInitial = true,
232
252
  cursorBuffer?: SharedArrayBuffer,
253
+ freeListBuffer?: SharedArrayBuffer,
233
254
  ): GrowableBuffer {
234
255
  if (buf === undefined) {
235
256
  return new GrowableBuffer(
@@ -240,6 +261,7 @@ function makeBuffer(
240
261
  singleBuffer,
241
262
  chargeInitial,
242
263
  cursorBuffer,
264
+ freeListBuffer,
243
265
  );
244
266
  }
245
267
  return new GrowableBuffer(
@@ -250,6 +272,7 @@ function makeBuffer(
250
272
  singleBuffer,
251
273
  chargeInitial,
252
274
  cursorBuffer,
275
+ freeListBuffer,
253
276
  );
254
277
  }
255
278
 
@@ -274,7 +297,7 @@ export class SharedMemory {
274
297
  * on growth. The version is incremented on every write so workers can
275
298
  * invalidate their read caches cross-worker.
276
299
  */
277
- private versionView: Int32Array;
300
+ versionView: Int32Array;
278
301
  /**
279
302
  * Global layout version cell. Bumped by the main thread after a
280
303
  * defragmentation pass so workers can invalidate cached offsets (indexCache,
@@ -380,6 +403,7 @@ export class SharedMemory {
380
403
  false,
381
404
  chargeInitial,
382
405
  buffers?.cursors?.dataTable,
406
+ buffers?.freeLists?.dataTable,
383
407
  );
384
408
  this.stringTable = makeBuffer(
385
409
  buffers?.stringTable,
@@ -390,6 +414,7 @@ export class SharedMemory {
390
414
  false,
391
415
  chargeInitial,
392
416
  buffers?.cursors?.stringTable,
417
+ buffers?.freeLists?.stringTable,
393
418
  );
394
419
  this.arrayTable = makeBuffer(
395
420
  buffers?.arrayTable,
@@ -400,11 +425,12 @@ export class SharedMemory {
400
425
  false,
401
426
  chargeInitial,
402
427
  buffers?.cursors?.arrayTable,
428
+ buffers?.freeLists?.arrayTable,
403
429
  );
404
430
  this.versionView = new Int32Array(this.indexTable.buffers[0]!);
405
431
  this.layoutVersion = new Int32Array(
406
432
  buffers?.layoutVersion ??
407
- new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT),
433
+ new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * 3),
408
434
  );
409
435
  this.lock = options?.lock ?? new Mutex();
410
436
  this.refCountBuffer = buffers?.refCount ?? null;
@@ -473,6 +499,11 @@ export class SharedMemory {
473
499
  stringTable: SharedArrayBuffer;
474
500
  arrayTable: SharedArrayBuffer;
475
501
  };
502
+ freeLists: {
503
+ dataTable: SharedArrayBuffer;
504
+ stringTable: SharedArrayBuffer;
505
+ arrayTable: SharedArrayBuffer;
506
+ };
476
507
  } {
477
508
  return {
478
509
  indexTable: this.indexTable.buffers,
@@ -492,6 +523,11 @@ export class SharedMemory {
492
523
  stringTable: this.stringTable.cursorBuffer,
493
524
  arrayTable: this.arrayTable.cursorBuffer,
494
525
  },
526
+ freeLists: {
527
+ dataTable: this.dataTable.freeListBuffer,
528
+ stringTable: this.stringTable.freeListBuffer,
529
+ arrayTable: this.arrayTable.freeListBuffer,
530
+ },
495
531
  };
496
532
  }
497
533
 
@@ -517,6 +553,11 @@ export class SharedMemory {
517
553
  stringTable?: SharedArrayBuffer;
518
554
  arrayTable?: SharedArrayBuffer;
519
555
  };
556
+ freeLists?: {
557
+ dataTable?: SharedArrayBuffer;
558
+ stringTable?: SharedArrayBuffer;
559
+ arrayTable?: SharedArrayBuffer;
560
+ };
520
561
  }): SharedMemory {
521
562
  const indexBuffer = Array.isArray(buffers.indexTable)
522
563
  ? buffers.indexTable[0]
@@ -538,6 +579,7 @@ export class SharedMemory {
538
579
  refCount: buffers.refCount ?? undefined,
539
580
  budget: buffers.budget ?? undefined,
540
581
  cursors: buffers.cursors,
582
+ freeLists: buffers.freeLists,
541
583
  },
542
584
  lock: new Mutex(buffers.lock),
543
585
  });
@@ -574,10 +616,8 @@ export class SharedMemory {
574
616
  }
575
617
 
576
618
  const bytes = this.encoder.encode(value);
577
- const offset = this.stringTable.byteLength;
578
- this.stringTable.ensureCapacity(offset + bytes.byteLength);
619
+ const offset = this.stringTable.reserve(bytes.byteLength);
579
620
  this.stringTable.writeBytes(offset, bytes);
580
- this.stringTable.advance(bytes.byteLength);
581
621
  const ref: StringRef = { offset, length: bytes.byteLength };
582
622
 
583
623
  // Insert into the cache, growing capacity up to `internMax` before evicting.
@@ -615,6 +655,86 @@ export class SharedMemory {
615
655
  return this.decoder.decode(bytes);
616
656
  }
617
657
 
658
+ /**
659
+ * Store a string value in the ArrayTable as a slab-allocated block.
660
+ *
661
+ * Unlike `storeString` (which interns into the append-only StringTable for
662
+ * property names and keys), string values are stored in the ArrayTable so
663
+ * they can be freed and reused when their reference is overwritten. Each
664
+ * block starts with a 4-byte objectId header (for refcounting), followed by
665
+ * the UTF-8 bytes of the string.
666
+ *
667
+ * @returns a StringRef pointing into the ArrayTable.
668
+ */
669
+ storeStringValue(value: string, oldRef?: StringRef): StringRef {
670
+ const bytes = this.encoder.encode(value);
671
+ const size = ARRAY_HEADER + bytes.byteLength;
672
+ // In-place update: if the new string fits in the existing block (same
673
+ // length or shorter), overwrite it without allocating a new block. This
674
+ // keeps repeated writes to the same string property cheap and avoids
675
+ // unbounded ArrayTable growth. The block's objectId header is preserved.
676
+ if (oldRef !== undefined && size <= ARRAY_HEADER + oldRef.length) {
677
+ this.arrayTable.writeBytes(oldRef.offset + ARRAY_HEADER, bytes);
678
+ return { offset: oldRef.offset, length: bytes.byteLength };
679
+ }
680
+ const offset = this.arrayTable.reserve(size);
681
+ const objectId = this.nextObjectId++;
682
+ this.arrayTable.setInt32(offset, objectId);
683
+ this.arrayTable.writeBytes(offset + ARRAY_HEADER, bytes);
684
+ return { offset, length: bytes.byteLength };
685
+ }
686
+
687
+ /** Read a string value from the ArrayTable by reference. */
688
+ readStringValue(ref: StringRef): string {
689
+ const bytes = this.arrayTable.readBytes(
690
+ ref.offset + ARRAY_HEADER,
691
+ ref.length,
692
+ );
693
+ return this.decoder.decode(bytes);
694
+ }
695
+
696
+ /** Read a short string stored inline in a DataTable slot. */
697
+ readInlineString(a: number, b: number, c: number): string {
698
+ const length = c >>> 28;
699
+ const bytes = new Uint8Array(length);
700
+ for (let i = 0; i < length; i++) {
701
+ const word = i < 4 ? a : i < 8 ? b : c;
702
+ bytes[i] = (word >>> ((i % 4) * 8)) & 0xff;
703
+ }
704
+ return this.decoder.decode(bytes);
705
+ }
706
+
707
+ /**
708
+ * Free a string value block in the ArrayTable, pushing it onto the free
709
+ * list so it can be reused. Detects double frees.
710
+ *
711
+ * No-op: string-value blocks are no longer freed on the hot path (they are
712
+ * reclaimed by defragmentation instead), so this is kept only for API
713
+ * compatibility.
714
+ */
715
+ freeStringValue(_ref: StringRef): void {
716
+ // Intentionally a no-op; dead string blocks are reclaimed by defrag.
717
+ }
718
+
719
+ /**
720
+ * Release a reference to a string value.
721
+ *
722
+ * No-op: string-value refcounting is disabled. Blocks are never freed on the
723
+ * hot path; dead space is reclaimed by defragmentation.
724
+ */
725
+ releaseStringValue(_ref: StringRef): void {
726
+ // Intentionally a no-op; see freeStringValue.
727
+ }
728
+
729
+ /**
730
+ * Acquire a reference to a string value.
731
+ *
732
+ * No-op: string-value refcounting is disabled.
733
+ */
734
+ acquireStringValue(_ref: StringRef): void {
735
+ // Intentionally a no-op; see freeStringValue.
736
+ }
737
+
618
738
  // ---------------------------------------------------------------------------
619
739
  // ArrayTable
620
740
  // ---------------------------------------------------------------------------
@@ -631,8 +751,8 @@ export class SharedMemory {
631
751
  storeArray(slots: ValueSlot[], capacity?: number): ArrayRef {
632
752
  const count = slots.length;
633
753
  const cap = Math.max(capacity ?? 0, count, 1);
634
- const offset = this.arrayTable.byteLength;
635
- this.arrayTable.ensureCapacity(offset + ARRAY_HEADER + cap * SLOT_SIZE);
754
+ const size = ARRAY_HEADER + cap * SLOT_SIZE;
755
+ const offset = this.arrayTable.reserve(size);
636
756
  const objectId = this.nextObjectId++;
637
757
  this.arrayTable.setInt32(offset, objectId);
638
758
  for (let i = 0; i < count; i++) {
@@ -642,7 +762,6 @@ export class SharedMemory {
642
762
  slots[i]!,
643
763
  );
644
764
  }
645
- this.arrayTable.advance(ARRAY_HEADER + cap * SLOT_SIZE);
646
765
  return { offset, count, capacity: cap };
647
766
  }
648
767
 
@@ -666,6 +785,63 @@ export class SharedMemory {
666
785
  );
667
786
  }
668
787
 
788
+ /**
789
+ * Fast-path: write a float64 number into an array slot directly, avoiding
790
+ * the ValueSlot object allocation and the 4 separate Int32 writes.
791
+ */
792
+ updateArrayNumberSlot(ref: ArrayRef, index: number, value: number): void {
793
+ slotView.setFloat64(0, value, true);
794
+ const buf = this.arrayTable;
795
+ const off = ref.offset + ARRAY_HEADER + index * SLOT_SIZE;
796
+ buf.setInt32(off, TYPE_NUMBER);
797
+ buf.setInt32(off + 4, slotView.getInt32(0, true));
798
+ buf.setInt32(off + 8, slotView.getInt32(4, true));
799
+ buf.setInt32(off + 12, 0);
800
+ }
801
+
802
+ /**
803
+ * Fast-path: write a boolean into an array slot directly, avoiding the
804
+ * ValueSlot object allocation and the 4 separate Int32 writes.
805
+ */
806
+ updateArrayBooleanSlot(ref: ArrayRef, index: number, value: boolean): void {
807
+ const buf = this.arrayTable;
808
+ const off = ref.offset + ARRAY_HEADER + index * SLOT_SIZE;
809
+ buf.setInt32(off, TYPE_BOOLEAN);
810
+ buf.setInt32(off + 4, value ? 1 : 0);
811
+ buf.setInt32(off + 8, 0);
812
+ buf.setInt32(off + 12, 0);
813
+ }
814
+
815
+ /**
816
+ * Fast-path: write a short string inline into an array slot (up to 11
817
+ * UTF-8 bytes), avoiding a heap allocation and the string-table write.
818
+ * Returns true if the string fit inline; false if it must go to the heap.
819
+ */
820
+ updateArrayInlineStringSlot(
821
+ ref: ArrayRef,
822
+ index: number,
823
+ value: string,
824
+ ): boolean {
825
+ const bytes = this.encoder.encode(value);
826
+ if (bytes.byteLength > 11) return false;
827
+ const buf = this.arrayTable;
828
+ const off = ref.offset + ARRAY_HEADER + index * SLOT_SIZE;
829
+ buf.setInt32(off, TYPE_INLINE_STRING);
830
+ let a = 0;
831
+ let b = 0;
832
+ let c = bytes.byteLength << 28;
833
+ for (let i = 0; i < bytes.byteLength; i++) {
834
+ const byte = bytes[i]!;
835
+ if (i < 4) a |= byte << (i * 8);
836
+ else if (i < 8) b |= byte << ((i - 4) * 8);
837
+ else c |= byte << ((i - 8) * 8);
838
+ }
839
+ buf.setInt32(off + 4, a);
840
+ buf.setInt32(off + 8, b);
841
+ buf.setInt32(off + 12, c);
842
+ return true;
843
+ }
844
+
669
845
  /** Read a single value slot of an array from the ArrayTable. */
670
846
  readArraySlot(ref: ArrayRef, index: number): ValueSlot {
671
847
  return readSlot(
@@ -685,10 +861,8 @@ export class SharedMemory {
685
861
  * the DataTable to point at the new location.
686
862
  */
687
863
  moveArray(ref: ArrayRef, newCapacity: number): ArrayRef {
688
- const newOffset = this.arrayTable.byteLength;
689
- this.arrayTable.ensureCapacity(
690
- newOffset + ARRAY_HEADER + newCapacity * SLOT_SIZE,
691
- );
864
+ const size = ARRAY_HEADER + newCapacity * SLOT_SIZE;
865
+ const newOffset = this.arrayTable.reserve(size);
692
866
  const objectId = this.getArrayObjectId(ref.offset);
693
867
  this.arrayTable.setInt32(newOffset, objectId);
694
868
  for (let i = 0; i < ref.count; i++) {
@@ -698,7 +872,13 @@ export class SharedMemory {
698
872
  readSlot(this.arrayTable, ref.offset + ARRAY_HEADER + i * SLOT_SIZE),
699
873
  );
700
874
  }
701
- this.arrayTable.advance(ARRAY_HEADER + newCapacity * SLOT_SIZE);
875
+ // The old block is no longer referenced; it is reclaimed by defrag.
876
+ this.freeArray(ref);
877
+ // The array moved to a new offset, so any cached ArrayRef (e.g. the
878
+ // per-target `__arrayRef` on the write hot path) is now stale. Bump the
879
+ // global layout version so those caches are invalidated, exactly as a
880
+ // defragmentation pass does.
881
+ this.bumpLayoutVersion();
702
882
  return { offset: newOffset, count: ref.count, capacity: newCapacity };
703
883
  }
704
884
 
@@ -734,16 +914,23 @@ export class SharedMemory {
734
914
  };
735
915
  }
736
916
 
917
+ /**
918
+ * Free an array block, pushing it onto the ArrayTable free list so it can
919
+ * be reused. Detects double frees. The block's data is overwritten with
920
+ * free-list metadata, so it must not be read after freeing.
921
+ */
922
+ freeArray(_ref: ArrayRef): void {
923
+ // No-op: array blocks are reclaimed by defrag, not the free list.
924
+ }
925
+
737
926
  // ---------------------------------------------------------------------------
738
927
  // DataTable
739
928
  // ---------------------------------------------------------------------------
740
929
 
741
930
  /** Store a single value slot in the DataTable and return its offset. */
742
931
  storeValueSlot(slot: ValueSlot): number {
743
- const offset = this.dataTable.byteLength;
744
- this.dataTable.ensureCapacity(offset + SLOT_SIZE);
932
+ const offset = this.dataTable.reserve(SLOT_SIZE);
745
933
  writeSlot(this.dataTable, offset, slot);
746
- this.dataTable.advance(SLOT_SIZE);
747
934
  return offset;
748
935
  }
749
936
 
@@ -757,6 +944,62 @@ export class SharedMemory {
757
944
  writeSlot(this.dataTable, offset, slot);
758
945
  }
759
946
 
947
+ /**
948
+ * Fast-path: write a float64 number slot directly into the DataTable,
949
+ * avoiding the ValueSlot object allocation and the 4 separate Int32
950
+ * writes of the generic path.
951
+ */
952
+ updateNumberSlot(offset: number, value: number): void {
953
+ slotView.setFloat64(0, value, true);
954
+ const buf = this.dataTable;
955
+ buf.setInt32(offset, TYPE_NUMBER);
956
+ buf.setInt32(offset + 4, slotView.getInt32(0, true));
957
+ buf.setInt32(offset + 8, slotView.getInt32(4, true));
958
+ buf.setInt32(offset + 12, 0);
959
+ }
960
+
961
+ /**
962
+ * Fast-path: write a boolean slot directly into the DataTable, avoiding
963
+ * the ValueSlot object allocation of the generic path.
964
+ */
965
+ updateBooleanSlot(offset: number, value: boolean): void {
966
+ const buf = this.dataTable;
967
+ buf.setInt32(offset, TYPE_BOOLEAN);
968
+ buf.setInt32(offset + 4, value ? 1 : 0);
969
+ buf.setInt32(offset + 8, 0);
970
+ buf.setInt32(offset + 12, 0);
971
+ }
972
+
973
+ /**
974
+ * Fast-path: write a short string inline into the slot itself (up to 11
975
+ * UTF-8 bytes), avoiding a heap allocation and the string-table write.
976
+ * The bytes are packed into the slot's a/b/c fields (11 bytes) with the
977
+ * length in the top 4 bits of `c`, so they can be read back without
978
+ * touching the DataTable again.
979
+ * Returns true if the string fit inline; false if it must go to the heap.
980
+ */
981
+ updateInlineStringSlot(offset: number, value: string): boolean {
982
+ const bytes = this.encoder.encode(value);
983
+ if (bytes.byteLength > 11) return false;
984
+ const buf = this.dataTable;
985
+ buf.setInt32(offset, TYPE_INLINE_STRING);
986
+ // Pack length into the top 4 bits of `c`; bytes 0-3 in `a`, 4-7 in `b`,
987
+ // 8-10 in the low 28 bits of `c`.
988
+ let a = 0;
989
+ let b = 0;
990
+ let c = bytes.byteLength << 28;
991
+ for (let i = 0; i < bytes.byteLength; i++) {
992
+ const byte = bytes[i]!;
993
+ if (i < 4) a |= byte << (i * 8);
994
+ else if (i < 8) b |= byte << ((i - 4) * 8);
995
+ else c |= byte << ((i - 8) * 8);
996
+ }
997
+ buf.setInt32(offset + 4, a);
998
+ buf.setInt32(offset + 8, b);
999
+ buf.setInt32(offset + 12, c);
1000
+ return true;
1001
+ }
1002
+
760
1003
  /**
761
1004
  * Create an object in the DataTable.
762
1005
  *
@@ -766,8 +1009,7 @@ export class SharedMemory {
766
1009
  createObject(properties: Array<[string, ValueSlot]>): number {
767
1010
  const blockSize =
768
1011
  OBJECT_HEADER + properties.length * (NAME_REF_SIZE + SLOT_SIZE);
769
- const offset = this.dataTable.byteLength;
770
- this.dataTable.ensureCapacity(offset + blockSize);
1012
+ const offset = this.dataTable.reserve(blockSize);
771
1013
 
772
1014
  const objectId = this.nextObjectId++;
773
1015
  this.dataTable.setInt32(offset, TYPE_OBJECT);
@@ -783,7 +1025,6 @@ export class SharedMemory {
783
1025
  writeSlot(this.dataTable, cursor, slot);
784
1026
  cursor += SLOT_SIZE;
785
1027
  }
786
- this.dataTable.advance(blockSize);
787
1028
  return offset;
788
1029
  }
789
1030
 
@@ -809,6 +1050,32 @@ export class SharedMemory {
809
1050
  return this.dataTable.getInt32(offset + 8);
810
1051
  }
811
1052
 
1053
+ /**
1054
+ * Free a single value slot in the DataTable, pushing it onto the free list
1055
+ * so it can be reused. Detects double frees.
1056
+ */
1057
+ freeValueSlot(_offset: number): void {
1058
+ // No-op: value slots are reclaimed by defrag, not the free list.
1059
+ }
1060
+
1061
+ /**
1062
+ * Free an object block in the DataTable, pushing it onto the free list so
1063
+ * it can be reused. Detects double frees. The block's data is overwritten
1064
+ * with free-list metadata, so it must not be read after freeing.
1065
+ */
1066
+ freeObject(_offset: number): void {
1067
+ // No-op: object blocks are reclaimed by defrag, not the free list.
1068
+ }
1069
+
1070
+ /**
1071
+ * Free a string in the StringTable, pushing it onto the free list so it can
1072
+ * be reused. Detects double frees. The string's bytes are overwritten with
1073
+ * free-list metadata, so it must not be read after freeing.
1074
+ */
1075
+ freeString(_ref: StringRef): void {
1076
+ // No-op: string blocks are reclaimed by defrag, not the free list.
1077
+ }
1078
+
812
1079
  /**
813
1080
  * Get the stable layout of an object value: its total DataTable offset and
814
1081
  * the slot offset of each property.
@@ -895,6 +1162,15 @@ export class SharedMemory {
895
1162
  return null;
896
1163
  }
897
1164
 
1165
+ /**
1166
+ * Int32Array index of a key's version cell within the Index Table buffer.
1167
+ * Stable for the lifetime of the key (the Index Table is never reordered or
1168
+ * replaced), so callers may cache it and load the version directly.
1169
+ */
1170
+ getVersionCellIndex(index: number): number {
1171
+ return versionCellIndex(index);
1172
+ }
1173
+
898
1174
  /** Read the current version for a key index (lock-free). */
899
1175
  getVersion(index: number): number {
900
1176
  return Atomics.load(this.versionView, versionCellIndex(index));
@@ -908,7 +1184,13 @@ export class SharedMemory {
908
1184
  /** Insert a key → dataOffset mapping into the Index Table. */
909
1185
  insert(key: string, dataOffset: number): void {
910
1186
  const keyRef = this.storeString(key);
911
- const count = this.indexTable.getInt32(0);
1187
+ // Atomically reserve the next entry index. The count cell (index 0) is
1188
+ // incremented with a single Atomics.add, so concurrent workers each get a
1189
+ // distinct entry slot without a mutex. The Index Table is a single
1190
+ // growable SAB, so a fresh Int32Array view over its current buffer is
1191
+ // correct (insert is rare — only on first write of a key).
1192
+ const view = new Int32Array(this.indexTable.buffers[0]!);
1193
+ const count = Atomics.add(view, 0, 1);
912
1194
  const entryOffset = INDEX_HEADER + count * INDEX_ENTRY_SIZE;
913
1195
  this.indexTable.ensureCapacity(entryOffset + INDEX_ENTRY_SIZE);
914
1196
  this.indexTable.setInt32(entryOffset, keyRef.offset);
@@ -916,8 +1198,6 @@ export class SharedMemory {
916
1198
  this.indexTable.setInt32(entryOffset + 8, dataOffset);
917
1199
  // The 4th field is the key's version cell, initialized to 0.
918
1200
  this.indexTable.setInt32(entryOffset + INDEX_VERSION_OFFSET, 0);
919
- this.indexTable.setInt32(0, count + 1);
920
- this.indexTable.advance(INDEX_ENTRY_SIZE);
921
1201
 
922
1202
  // Rebuild the version view over the first chunk (the Index Table is small
923
1203
  // and stays within the first chunk for typical workloads; version cells
@@ -939,6 +1219,49 @@ export class SharedMemory {
939
1219
  Atomics.add(this.layoutVersion, 0, 1);
940
1220
  }
941
1221
 
1222
+ // ---------------------------------------------------------------------------
1223
+ // Lock-free write coordination (write counter + defrag flag)
1224
+ // ---------------------------------------------------------------------------
1225
+ // Cell 0 of `layoutVersion` is the global layout version. Cells 1 and 2 are
1226
+ // used to coordinate lock-free writes against the (rare) defragmentation
1227
+ // pass: cell 1 is a count of in-flight lock-free writes, cell 2 is a flag
1228
+ // set while a defragmentation pass is running. A write increments the
1229
+ // counter, checks the flag, and if the flag is set it falls back to the
1230
+ // mutex. The defrag sets the flag, waits for the counter to reach zero, then
1231
+ // runs under the mutex.
1232
+
1233
+ /** Enter a lock-free write: increment the in-flight write counter. */
1234
+ beginWrite(): void {
1235
+ Atomics.add(this.layoutVersion, 1, 1);
1236
+ }
1237
+
1238
+ /** Leave a lock-free write: decrement the in-flight write counter. */
1239
+ endWrite(): void {
1240
+ Atomics.sub(this.layoutVersion, 1, 1);
1241
+ }
1242
+
1243
+ /** Whether a defragmentation pass is currently running. */
1244
+ isDefragmenting(): boolean {
1245
+ return Atomics.load(this.layoutVersion, 2) !== 0;
1246
+ }
1247
+
1248
+ /** Set the defragmentation-in-progress flag. */
1249
+ setDefragmenting(): void {
1250
+ Atomics.store(this.layoutVersion, 2, 1);
1251
+ }
1252
+
1253
+ /** Clear the defragmentation-in-progress flag. */
1254
+ clearDefragmenting(): void {
1255
+ Atomics.store(this.layoutVersion, 2, 0);
1256
+ }
1257
+
1258
+ /** Wait until no lock-free writes are in flight. */
1259
+ waitForWritesToFinish(): void {
1260
+ while (Atomics.load(this.layoutVersion, 1) !== 0) {
1261
+ Atomics.wait(this.layoutVersion, 1, Atomics.load(this.layoutVersion, 1));
1262
+ }
1263
+ }
1264
+
942
1265
  // ---------------------------------------------------------------------------
943
1266
  // Defragmentation
944
1267
  // ---------------------------------------------------------------------------
@@ -971,6 +1294,10 @@ export class SharedMemory {
971
1294
  const liveArrays = new Set<number>();
972
1295
  const liveObjects = new Set<number>();
973
1296
  const liveDataOffsets = new Set<number>();
1297
+ /** Byte size of each live ArrayTable block (array or string value). */
1298
+ const arrayBlockSizes = new Map<number, number>();
1299
+ /** Offsets of live string-value blocks (no slots to remap). */
1300
+ const stringValueBlocks = new Set<number>();
974
1301
 
975
1302
  const markString = (offset: number) => liveStrings.add(offset);
976
1303
 
@@ -980,15 +1307,27 @@ export class SharedMemory {
980
1307
  case TYPE_BIGINT_STR:
981
1308
  liveStrings.add(slot.a);
982
1309
  break;
1310
+ case TYPE_STRING_VALUE_REF:
1311
+ liveArrays.add(slot.a);
1312
+ stringValueBlocks.add(slot.a);
1313
+ arrayBlockSizes.set(
1314
+ slot.a,
1315
+ Math.max(ARRAY_HEADER + slot.b, MIN_BLOCK_SIZE),
1316
+ );
1317
+ break;
983
1318
  case TYPE_ARRAY_REF:
984
- markArray(slot.a, slot.b);
1319
+ markArray(slot.a, slot.b, slot.c);
985
1320
  break;
986
1321
  }
987
1322
  };
988
1323
 
989
- const markArray = (offset: number, count: number) => {
1324
+ const markArray = (offset: number, count: number, capacity: number) => {
990
1325
  if (liveArrays.has(offset)) return;
991
1326
  liveArrays.add(offset);
1327
+ arrayBlockSizes.set(
1328
+ offset,
1329
+ Math.max(ARRAY_HEADER + capacity * SLOT_SIZE, MIN_BLOCK_SIZE),
1330
+ );
992
1331
  for (let i = 0; i < count; i++) {
993
1332
  markValueSlot(
994
1333
  readSlot(this.arrayTable, offset + ARRAY_HEADER + i * SLOT_SIZE),
@@ -1055,19 +1394,10 @@ export class SharedMemory {
1055
1394
  const sortedArrays = [...liveArrays].sort((a, b) => a - b);
1056
1395
  let newArrayOffset = 0;
1057
1396
  for (const oldOffset of sortedArrays) {
1058
- // Determine the array's capacity by scanning forward to the next live
1059
- // array or the end of the used region.
1060
- const next = sortedArrays[sortedArrays.indexOf(oldOffset) + 1];
1061
- const end = next !== undefined ? next : this.arrayTable.byteLength;
1062
- const capacity = (end - oldOffset - ARRAY_HEADER) / SLOT_SIZE;
1063
- copyBytes(
1064
- this.arrayTable,
1065
- newArrayOffset,
1066
- oldOffset,
1067
- ARRAY_HEADER + capacity * SLOT_SIZE,
1068
- );
1397
+ const size = arrayBlockSizes.get(oldOffset)!;
1398
+ copyBytes(this.arrayTable, newArrayOffset, oldOffset, size);
1069
1399
  arrayRemap.set(oldOffset, newArrayOffset);
1070
- newArrayOffset += ARRAY_HEADER + capacity * SLOT_SIZE;
1400
+ newArrayOffset += size;
1071
1401
  }
1072
1402
  this.arrayTable.reset(newArrayOffset);
1073
1403
 
@@ -1113,10 +1443,10 @@ export class SharedMemory {
1113
1443
  remapSlot(this.dataTable, newOff, stringRemap, arrayRemap);
1114
1444
  }
1115
1445
  for (const oldOffset of sortedArrays) {
1446
+ if (stringValueBlocks.has(oldOffset)) continue;
1116
1447
  const newOff = arrayRemap.get(oldOffset)!;
1117
- const next = sortedArrays[sortedArrays.indexOf(oldOffset) + 1];
1118
- const end = next !== undefined ? next : this.arrayTable.byteLength;
1119
- const capacity = (end - oldOffset - ARRAY_HEADER) / SLOT_SIZE;
1448
+ const capacity =
1449
+ (arrayBlockSizes.get(oldOffset)! - ARRAY_HEADER) / SLOT_SIZE;
1120
1450
  for (let i = 0; i < capacity; i++) {
1121
1451
  remapSlot(
1122
1452
  this.arrayTable,
@@ -1172,7 +1502,7 @@ function copyBytes(
1172
1502
  /**
1173
1503
  * Rewrite a value slot's string/array references after compaction, in place.
1174
1504
  * String refs and bigint-str refs are remapped via `stringRemap`; array refs
1175
- * via `arrayRemap`.
1505
+ * and string-value refs (which live in the ArrayTable) via `arrayRemap`.
1176
1506
  */
1177
1507
  function remapSlot(
1178
1508
  buf: GrowableBuffer,
@@ -1183,7 +1513,7 @@ function remapSlot(
1183
1513
  const type = buf.getInt32(offset);
1184
1514
  if (type === TYPE_STRING_REF || type === TYPE_BIGINT_STR) {
1185
1515
  buf.setInt32(offset + 4, stringRemap.get(buf.getInt32(offset + 4))!);
1186
- } else if (type === TYPE_ARRAY_REF) {
1516
+ } else if (type === TYPE_ARRAY_REF || type === TYPE_STRING_VALUE_REF) {
1187
1517
  buf.setInt32(offset + 4, arrayRemap.get(buf.getInt32(offset + 4))!);
1188
1518
  }
1189
1519
  }