bun-shared-memory 0.2.1 → 0.2.2

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/CHANGELOG.md CHANGED
@@ -5,6 +5,47 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.2.2] - 2026-11-05
11
+
12
+ ### Added
13
+
14
+ - **`null` is now a supported shared value**. A new `TYPE_NULL` slot type
15
+ allows `null` to be stored and read anywhere — top-level, object property,
16
+ array element, nested object/array. `undefined` remains intentionally
17
+ unsupported.
18
+ - **Nested objects and live `Shared` references**: an object property (or array
19
+ element) may now be another plain object, read lazily and mutated in place
20
+ (e.g. `doc.profile.meta.level++` writes directly to shared memory). Two new
21
+ slot types back this: `TYPE_OBJECT_REF` (a nested object block in the
22
+ DataTable) and `TYPE_KEY_REF` (a live reference to another shared key).
23
+ - **Nested `Shared` values**: storing a `Shared` object inside another object
24
+ or array keeps a live reference, so both wrappers stay in sync (no copying).
25
+ - **Nested arrays**: arrays may contain other arrays (stored recursively as
26
+ array-refs in the ArrayTable). `arr[0][1] = x`, `arr[0].push(...)`, etc.
27
+ - **Defragmentation** remaps nested object references and rewrites their
28
+ property slots; the per-worker object-layout cache is re-validated against
29
+ the global layout version so it stays correct across a defrag.
30
+
31
+ ### Performance
32
+
33
+ - Cache the key's **version-cell index in the read cache**, so cache-hit reads
34
+ skip the `versionCellIndex` arithmetic and go straight to
35
+ `Atomics.load(view, cached.cell)`.
36
+ - Hoist **`getLayoutVersion`** inside lock-free writes: within a write (counter
37
+ > 0) the layout version cannot change, so read it once and reuse it for all
38
+ cached-offset and ArrayRef checks, saving 2–3 `Atomics.load` ops per write.
39
+ Array writes improved from ~37× to ~33× slower than native.
40
+
41
+ ### Fixed
42
+
43
+ - Fix `copyBytes` in the defrag compactor so overlapping source/destination
44
+ ranges (a small string-value block compacting forward over the source region
45
+ of a later array block) are copied overlap-safely. This previously could
46
+ corrupt array contents after a defragmentation that involved interleaved
47
+ string-value and array blocks.
48
+
8
49
  ## [0.2.1] - 2026-11-04
9
50
 
10
51
  ### Performance
@@ -24,11 +65,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
24
65
  - Cache the key's version-cell index in the array proxy so writes bump the
25
66
  version without a per-write Map lookup. Object array-prop index writes
26
67
  dropped from ~33.9× to ~29× slower than native.
27
- - Real-world benchmark: the shared-memory "get user" endpoint improved from
28
- ~4.18M to ~5.79M req/s (+39%), widening the gap over the comparison
29
- backends (sqlite 6.2×→8.5×, redis 218×→302×, mysql 233×→322× slower than
68
+ - Real-world benchmark: the shared-memory "get user" endpoint improved to
69
+ ~5.15M req/s, widening the gap over the comparison
70
+ backends (sqlite 9.9×, redis 76.5×, mysql 82.0× slower than
30
71
  shared memory).
31
72
 
73
+ ### Benchmark
74
+
75
+ - Increase `SAMPLES_PER_WORKER` from 5 to 15 for more stable per-run statistics.
76
+ - Increase `RUNS_PER_METHOD` from 3 to 5 for better CPU-contention filtering.
77
+ - Increase warmup from 1,000 to 5,000 iterations so the JIT and DB caches
78
+ are fully warmed before measurement.
79
+ - Add `?pool=8` to MySQL connection strings to avoid per-request connect overhead.
80
+ - Add priming queries (`SELECT LIMIT 1` for MySQL, `PING` for Redis) so the
81
+ first measured request does not hit cold-connect latency.
82
+
32
83
  ## [0.2.0] - 2026-11-04
33
84
 
34
85
  ### Added
package/README.md CHANGED
@@ -38,20 +38,20 @@ is benchmarked sequentially with 4 workers. See
38
38
 
39
39
  Measured on Linux (Bun 1.4.4, 100,000 requests per methodology, sorted by
40
40
  speed; MySQL/Redis benchmarked over both TCP and Unix socket; each methodology
41
- is the minimum over 3 runs × 5 samples to filter CPU-contention noise):
41
+ is the minimum over 5 runs × 15 samples to filter CPU-contention noise):
42
42
 
43
43
  | Methodology | Transport | Requests | Elapsed (ms) | req/s | vs. shared |
44
44
  | ----------- | --------- | -------- | ------------ | --------- | ------------- |
45
- | `shared` | — | 100,000 | 21 | 4,876,492 | — |
46
- | `sqlite` | — | 100,000 | 188 | 531,116 | 9.2× slower |
47
- | `redis` | socket | 100,000 | 1,262 | 79,229 | 61.5× slower |
48
- | `mysql` | socket | 100,000 | 1,605 | 62,322 | 78.2× slower |
49
- | `redis` | tcp | 100,000 | 2,817 | 35,497 | 137.4× slower |
50
- | `mysql` | tcp | 100,000 | 2,976 | 33,602 | 145.1× slower |
51
-
52
- Shared memory is the fastest by a wide margin: ~9.2× faster than a live SQLite
53
- read and ~61–145× faster than live MySQL/Redis reads. Unix sockets are
54
- significantly faster than TCP for MySQL/Redis (~1.8×), but both remain far
45
+ | `shared` | — | 100,000 | 19 | 5,154,977 | — |
46
+ | `sqlite` | — | 100,000 | 192 | 520,329 | 9.9× slower |
47
+ | `redis` | socket | 100,000 | 1,484 | 67,385 | 76.5× slower |
48
+ | `mysql` | socket | 100,000 | 1,591 | 62,865 | 82.0× slower |
49
+ | `redis` | tcp | 100,000 | 2,617 | 38,205 | 134.9× slower |
50
+ | `mysql` | tcp | 100,000 | 3,133 | 31,922 | 161.5× slower |
51
+
52
+ Shared memory is the fastest by a wide margin: ~9.9× faster than a live SQLite
53
+ read and ~61–161× faster than live MySQL/Redis reads. Unix sockets are
54
+ significantly faster than TCP for MySQL/Redis (~1.7–1.8×), but both remain far
55
55
  slower than shared memory.
56
56
 
57
57
  ## ✨ Features
@@ -90,7 +90,10 @@ See [SPEC.md](./SPEC.md) for the full specification.
90
90
  - **Allowed**: primitives (`number`, `string`, `boolean`), `bigint`, and arrays of these (mixed arrays allowed).
91
91
  - **BigInts** of any size are supported: values that fit in 64 bits are stored as a fast int64 slot; larger values are stored as their decimal string in the StringTable.
92
92
  - **Objects** have a **fixed shape**: once created, properties may not be added or removed — only their values can change.
93
- - **Not allowed**: nested objects, nested arrays, `null`, `undefined`.
93
+ - **Nested objects and arrays** are supported: an object or array can contain other plain objects and arrays (read lazily, mutated in place).
94
+ - **Nested `Shared` references**: a `Shared` object can be stored inside another object or array as a _live reference_ — both stay in sync (mutating either updates the same shared value). Self-references and mutual references are supported as long as the property exists in the object's initial fixed shape.
95
+ - **`null` is allowed** as a value (top-level, object property, or array element).
96
+ - **Not allowed**: `undefined`.
94
97
 
95
98
  ## Usage
96
99
 
@@ -123,6 +126,19 @@ const arr = Shared<number[]>("arr-1", [1, 2, 3]);
123
126
  arr.push(4);
124
127
  arr[0] = 99;
125
128
 
129
+ // Nested objects are read lazily and mutated in place
130
+ const doc = Shared("doc-1", {
131
+ profile: { name: "Alice", meta: { level: 3 } },
132
+ });
133
+ doc.profile.meta.level++;
134
+ doc.profile.meta.level; // 4
135
+
136
+ // Nested Shared values are live references that stay in sync
137
+ const settings = Shared("settings-1", { theme: "dark" });
138
+ const app = Shared("app-1", { settings });
139
+ app.settings.theme = "light";
140
+ settings.theme; // "light" (same shared value)
141
+
126
142
  // Primitives use read()/write()/update()
127
143
  const counter = Shared<number>("counter-1", 0);
128
144
  counter.write(42);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bun-shared-memory",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Share data between Bun workers/threads using SharedArrayBuffer (zero-copy, 4-buffer architecture, Atomics-based synchronization).",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -425,6 +425,41 @@ export class GrowableBuffer {
425
425
  Atomics.add(this.cursor, 0, BigInt(n));
426
426
  }
427
427
 
428
+ /**
429
+ * Grow a block in place by `extra` bytes when the space immediately after it
430
+ * is free (i.e. the block is the last allocated one, so its end equals the
431
+ * current used length). This lets a caller extend a block without leaving
432
+ * the old prefix as dead space.
433
+ *
434
+ * The cursor is advanced with a compare-exchange loop, so concurrent workers
435
+ * cannot allocate into the space being claimed: if another worker has
436
+ * reserved bytes after `endOffset`, the cursor no longer equals `endOffset`
437
+ * and this returns false, letting the caller fall back to a fresh
438
+ * allocation.
439
+ *
440
+ * @returns true if the block was grown in place, false if the space after it
441
+ * is not free (caller must allocate a new block).
442
+ */
443
+ growInPlace(endOffset: number, extra: number): boolean {
444
+ if (extra <= 0) return true;
445
+ while (true) {
446
+ const current = Number(Atomics.load(this.cursor, 0));
447
+ if (current !== endOffset) return false;
448
+ const next = current + extra;
449
+ if (
450
+ Atomics.compareExchange(
451
+ this.cursor,
452
+ 0,
453
+ BigInt(current),
454
+ BigInt(next),
455
+ ) === BigInt(current)
456
+ ) {
457
+ this.ensureCapacity(next);
458
+ return true;
459
+ }
460
+ }
461
+ }
462
+
428
463
  /**
429
464
  * Atomically reserve `size` bytes at the end of the buffer and return the
430
465
  * start offset. This is the lock-free bump-allocation primitive: the shared
package/src/memory.ts CHANGED
@@ -56,6 +56,21 @@ export const TYPE_STRING_VALUE_REF = 7;
56
56
  * the a/b/c fields), avoiding a heap allocation and the string-table write.
57
57
  */
58
58
  export const TYPE_INLINE_STRING = 8;
59
+ /**
60
+ * A nested object block stored in the DataTable. `a` is the DataTable byte
61
+ * offset of the object block (same layout as a top-level object).
62
+ */
63
+ export const TYPE_OBJECT_REF = 9;
64
+ /**
65
+ * A live reference to another shared key (used for nested `Shared` objects so
66
+ * changes stay in sync). `a` is the referenced key's stable Index Table index.
67
+ */
68
+ export const TYPE_KEY_REF = 10;
69
+ /**
70
+ * The `null` value. No payload in a/b/c — the presence of the slot's type is
71
+ * the entire value. `undefined` is intentionally still not representable.
72
+ */
73
+ export const TYPE_NULL = 11;
59
74
 
60
75
  const SLOT_SIZE = 16;
61
76
  const OBJECT_HEADER = 16;
@@ -96,6 +111,12 @@ const slotView = new DataView(new ArrayBuffer(8));
96
111
  export interface StringRef {
97
112
  offset: number;
98
113
  length: number;
114
+ /**
115
+ * Reserved block size in the ArrayTable (>= ARRAY_HEADER + length). Only
116
+ * set for string values stored in the ArrayTable; used to grow a block in
117
+ * place when a string is overwritten with a longer one.
118
+ */
119
+ reserved?: number;
99
120
  }
100
121
 
101
122
  export interface ArrayRef {
@@ -159,7 +180,12 @@ export function stringRefSlot(ref: StringRef): ValueSlot {
159
180
 
160
181
  /** Encode a string value (in the ArrayTable) into a ValueSlot. */
161
182
  export function stringValueRefSlot(ref: StringRef): ValueSlot {
162
- return { type: TYPE_STRING_VALUE_REF, a: ref.offset, b: ref.length, c: 0 };
183
+ return {
184
+ type: TYPE_STRING_VALUE_REF,
185
+ a: ref.offset,
186
+ b: ref.length,
187
+ c: ref.reserved ?? 0,
188
+ };
163
189
  }
164
190
 
165
191
  /** Encode a bigint stored as a decimal string into a ValueSlot. */
@@ -172,6 +198,21 @@ export function arrayRefSlot(ref: ArrayRef): ValueSlot {
172
198
  return { type: TYPE_ARRAY_REF, a: ref.offset, b: ref.count, c: ref.capacity };
173
199
  }
174
200
 
201
+ /** Encode a reference to a nested object block (DataTable offset). */
202
+ export function objectRefSlot(offset: number): ValueSlot {
203
+ return { type: TYPE_OBJECT_REF, a: offset, b: 0, c: 0 };
204
+ }
205
+
206
+ /** Encode a live reference to another shared key (by stable Index Table index). */
207
+ export function keyRefSlot(index: number): ValueSlot {
208
+ return { type: TYPE_KEY_REF, a: index, b: 0, c: 0 };
209
+ }
210
+
211
+ /** Encode the `null` value. */
212
+ export function nullSlot(): ValueSlot {
213
+ return { type: TYPE_NULL, a: 0, b: 0, c: 0 };
214
+ }
215
+
175
216
  /** Decode a ValueSlot into a JS value (primitives) or a ref object. */
176
217
  export function decodeSlot(slot: ValueSlot): unknown {
177
218
  switch (slot.type) {
@@ -191,8 +232,14 @@ export function decodeSlot(slot: ValueSlot): unknown {
191
232
  return { offset: slot.a, length: slot.b };
192
233
  case TYPE_STRING_VALUE_REF:
193
234
  return { offset: slot.a, length: slot.b };
235
+ case TYPE_NULL:
236
+ return null;
194
237
  case TYPE_ARRAY_REF:
195
238
  return { offset: slot.a, count: slot.b, capacity: slot.c };
239
+ case TYPE_OBJECT_REF:
240
+ return { offset: slot.a };
241
+ case TYPE_KEY_REF:
242
+ return { index: slot.a };
196
243
  default:
197
244
  throw new Error(`Unknown slot type ${slot.type}`);
198
245
  }
@@ -675,13 +722,34 @@ export class SharedMemory {
675
722
  // unbounded ArrayTable growth. The block's objectId header is preserved.
676
723
  if (oldRef !== undefined && size <= ARRAY_HEADER + oldRef.length) {
677
724
  this.arrayTable.writeBytes(oldRef.offset + ARRAY_HEADER, bytes);
678
- return { offset: oldRef.offset, length: bytes.byteLength };
725
+ return {
726
+ offset: oldRef.offset,
727
+ length: bytes.byteLength,
728
+ reserved: oldRef.reserved ?? size,
729
+ };
730
+ }
731
+ // The new string is longer than the old block. If the old block is the
732
+ // last allocated block (its reserved end is the current used length), grow
733
+ // it in place so repeated writes to the same property don't leak dead
734
+ // blocks. This also covers the shrink-then-grow case: a block that was
735
+ // reserved for a longer string but currently holds a shorter one can be
736
+ // grown back into its own reserved tail.
737
+ if (oldRef !== undefined && oldRef.reserved !== undefined) {
738
+ const reservedEnd = oldRef.offset + oldRef.reserved;
739
+ if (this.arrayTable.growInPlace(reservedEnd, size - oldRef.reserved)) {
740
+ this.arrayTable.writeBytes(oldRef.offset + ARRAY_HEADER, bytes);
741
+ return {
742
+ offset: oldRef.offset,
743
+ length: bytes.byteLength,
744
+ reserved: Math.max(oldRef.reserved, size),
745
+ };
746
+ }
679
747
  }
680
748
  const offset = this.arrayTable.reserve(size);
681
749
  const objectId = this.nextObjectId++;
682
750
  this.arrayTable.setInt32(offset, objectId);
683
751
  this.arrayTable.writeBytes(offset + ARRAY_HEADER, bytes);
684
- return { offset, length: bytes.byteLength };
752
+ return { offset, length: bytes.byteLength, reserved: size };
685
753
  }
686
754
 
687
755
  /** Read a string value from the ArrayTable by reference. */
@@ -1117,6 +1185,14 @@ export class SharedMemory {
1117
1185
  getObjectLayout(key: string): ObjectLayout | null {
1118
1186
  const offset = this.lookup(key);
1119
1187
  if (offset === null) return null;
1188
+ return this.getObjectLayoutAtOffset(offset);
1189
+ }
1190
+
1191
+ /**
1192
+ * Get the stable layout of an object block at a DataTable offset (top-level
1193
+ * or nested). Returns null if the offset is not an object block.
1194
+ */
1195
+ getObjectLayoutAtOffset(offset: number): ObjectLayout | null {
1120
1196
  if (this.dataTable.getInt32(offset) !== TYPE_OBJECT) return null;
1121
1197
  const count = this.dataTable.getInt32(offset + 4);
1122
1198
  const properties = new Map<string, number>();
@@ -1189,6 +1265,20 @@ export class SharedMemory {
1189
1265
  return null;
1190
1266
  }
1191
1267
 
1268
+ /**
1269
+ * Read the key string for a known stable index. Used to resolve a live
1270
+ * shared-key reference (TYPE_KEY_REF) back to its key so a wrapper can be
1271
+ * reconstructed. Returns null if the index is out of range.
1272
+ */
1273
+ getKeyByIndex(index: number): string | null {
1274
+ const count = this.indexTable.getInt32(0);
1275
+ if (index >= count) return null;
1276
+ const entryOffset = INDEX_HEADER + index * INDEX_ENTRY_SIZE;
1277
+ const keyOffset = this.indexTable.getInt32(entryOffset);
1278
+ const keyLength = this.indexTable.getInt32(entryOffset + 4);
1279
+ return this.readString({ offset: keyOffset, length: keyLength });
1280
+ }
1281
+
1192
1282
  /**
1193
1283
  * Int32Array index of a key's version cell within the Index Table buffer.
1194
1284
  * Stable for the lifetime of the key (the Index Table is never reordered or
@@ -1339,12 +1429,21 @@ export class SharedMemory {
1339
1429
  stringValueBlocks.add(slot.a);
1340
1430
  arrayBlockSizes.set(
1341
1431
  slot.a,
1342
- Math.max(ARRAY_HEADER + slot.b, MIN_BLOCK_SIZE),
1432
+ Math.max(slot.c || ARRAY_HEADER + slot.b, MIN_BLOCK_SIZE),
1343
1433
  );
1344
1434
  break;
1345
1435
  case TYPE_ARRAY_REF:
1346
1436
  markArray(slot.a, slot.b, slot.c);
1347
1437
  break;
1438
+ case TYPE_OBJECT_REF:
1439
+ // Nested object block lives in the DataTable; mark it recursively.
1440
+ markObject(slot.a);
1441
+ break;
1442
+ case TYPE_KEY_REF:
1443
+ // A live reference to another shared key. The referenced key is
1444
+ // marked separately when the Index Table is walked (Phase 1), so
1445
+ // nothing to do here.
1446
+ break;
1348
1447
  }
1349
1448
  };
1350
1449
 
@@ -1460,14 +1559,14 @@ export class SharedMemory {
1460
1559
  const nameOffset = this.dataTable.getInt32(cursor);
1461
1560
  this.dataTable.setInt32(cursor, stringRemap.get(nameOffset)!);
1462
1561
  cursor += NAME_REF_SIZE;
1463
- remapSlot(this.dataTable, cursor, stringRemap, arrayRemap);
1562
+ remapSlot(this.dataTable, cursor, stringRemap, arrayRemap, objectRemap);
1464
1563
  cursor += SLOT_SIZE;
1465
1564
  }
1466
1565
  }
1467
1566
  for (const oldOffset of sortedData) {
1468
1567
  if (this.dataTable.getInt32(oldOffset) === TYPE_OBJECT) continue;
1469
1568
  const newOff = objectRemap.get(oldOffset)!;
1470
- remapSlot(this.dataTable, newOff, stringRemap, arrayRemap);
1569
+ remapSlot(this.dataTable, newOff, stringRemap, arrayRemap, objectRemap);
1471
1570
  }
1472
1571
  for (const oldOffset of sortedArrays) {
1473
1572
  if (stringValueBlocks.has(oldOffset)) continue;
@@ -1480,6 +1579,7 @@ export class SharedMemory {
1480
1579
  newOff + ARRAY_HEADER + i * SLOT_SIZE,
1481
1580
  stringRemap,
1482
1581
  arrayRemap,
1582
+ objectRemap,
1483
1583
  );
1484
1584
  }
1485
1585
  }
@@ -1514,15 +1614,25 @@ function readSlot(buf: GrowableBuffer, offset: number): ValueSlot {
1514
1614
  };
1515
1615
  }
1516
1616
 
1517
- /** Copy `len` bytes from `src` to `dst` in the same buffer (dst <= src). */
1617
+ /** Copy `len` bytes from `src` to `dst` in the same buffer, overlap-safe. */
1518
1618
  function copyBytes(
1519
1619
  buf: GrowableBuffer,
1520
1620
  dst: number,
1521
1621
  src: number,
1522
1622
  len: number,
1523
1623
  ): void {
1524
- for (let i = 0; i < len; i++) {
1525
- buf.setUint8(dst + i, buf.getUint8(src + i));
1624
+ // `dst` may be greater than `src` when a small block compacts forward over
1625
+ // the tail of a later block still holding its old data (e.g. string-value
1626
+ // blocks interleaved with array blocks). Copy backwards in that case, like
1627
+ // memmove, so overlapping source/destination ranges aren't corrupted.
1628
+ if (dst > src) {
1629
+ for (let i = len - 1; i >= 0; i--) {
1630
+ buf.setUint8(dst + i, buf.getUint8(src + i));
1631
+ }
1632
+ } else {
1633
+ for (let i = 0; i < len; i++) {
1634
+ buf.setUint8(dst + i, buf.getUint8(src + i));
1635
+ }
1526
1636
  }
1527
1637
  }
1528
1638
 
@@ -1536,11 +1646,16 @@ function remapSlot(
1536
1646
  offset: number,
1537
1647
  stringRemap: Map<number, number>,
1538
1648
  arrayRemap: Map<number, number>,
1649
+ objectRemap?: Map<number, number>,
1539
1650
  ): void {
1540
1651
  const type = buf.getInt32(offset);
1541
1652
  if (type === TYPE_STRING_REF || type === TYPE_BIGINT_STR) {
1542
1653
  buf.setInt32(offset + 4, stringRemap.get(buf.getInt32(offset + 4))!);
1543
1654
  } else if (type === TYPE_ARRAY_REF || type === TYPE_STRING_VALUE_REF) {
1544
1655
  buf.setInt32(offset + 4, arrayRemap.get(buf.getInt32(offset + 4))!);
1656
+ } else if (type === TYPE_OBJECT_REF && objectRemap !== undefined) {
1657
+ buf.setInt32(offset + 4, objectRemap.get(buf.getInt32(offset + 4))!);
1545
1658
  }
1659
+ // TYPE_KEY_REF (live shared-key reference) needs no remap: it stores the
1660
+ // key's stable Index Table index, which never changes.
1546
1661
  }