bun-shared-memory 0.1.8 → 0.2.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/CHANGELOG.md CHANGED
@@ -5,6 +5,104 @@ 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
+ ## [0.2.1] - 2026-11-04
9
+
10
+ ### Performance
11
+
12
+ - Inline the read-cache fast path in `readProp`/`readValue` instead of calling
13
+ `readCachedValue`, which returned a polymorphic `{hit,value}` union and
14
+ allocated an object on every cache hit. Keeping the read path monomorphic
15
+ lets the JIT optimize it: number reads dropped from ~12× to ~6× slower than
16
+ native, boolean reads from ~8.4× to ~5.5×, and all array index reads
17
+ improved (array-read summary ~9.7× → ~8.7×).
18
+ - Add `updateBigIntSlot`/`updateArrayBigIntSlot` fast paths that write int64
19
+ bigints directly into the slot, avoiding the `ValueSlot` object allocation
20
+ of the generic encode path. `bigint[]` index writes dropped from ~24.8× to
21
+ ~18× slower than native.
22
+ - Extract the array-proxy set handler body into a named `writeArrayProp`
23
+ function and inline the `writeLock`, so no closure is allocated per write.
24
+ - Cache the key's version-cell index in the array proxy so writes bump the
25
+ version without a per-write Map lookup. Object array-prop index writes
26
+ 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
30
+ shared memory).
31
+
32
+ ## [0.2.0] - 2026-11-04
33
+
34
+ ### Added
35
+
36
+ - Slab allocator for string values and arrays. Overwriting a value no longer
37
+ leaves the old block as dead space: freed blocks are pushed onto a shared
38
+ free list (one per table, in a `SharedArrayBuffer`) and reused by later
39
+ allocations, so the tables grow far less over time and defragmentation runs
40
+ less often. The free list is shared across workers via
41
+ `exportBuffers()`/`fromBuffers()`.
42
+ - Inline short strings. Strings up to 11 UTF-8 bytes are packed directly into
43
+ the 16-byte value slot (type + length in the top 4 bits of `c`, bytes in
44
+ `a`/`b`/`c`), avoiding a heap allocation and the string-table write on every
45
+ write. Longer strings still use the slab-allocated ArrayTable with in-place
46
+ reuse.
47
+ - A complete multi-worker todo-list example (`examples/todo-list/`) that
48
+ serves both the frontend and the API on a single port via Elysia workers.
49
+
50
+ ### Performance
51
+
52
+ - Cache each object property getter's stable version-cell index (resolved
53
+ lazily on first read) and load the version directly via
54
+ `Atomics.load(mem.versionView, cellIndex)`, replacing the per-read
55
+ `index === null` check and the `getVersion` → `versionCellIndex` multiply +
56
+ divide. Object property reads improved a further 3–17%.
57
+ - Cache the top-level array ref and version-cell index on the `SharedRef`
58
+ target so index writes skip the per-write DataTable header reads, Map
59
+ lookups, and the generic encode path. `moveArray` now bumps the layout
60
+ version so the cached ref is invalidated on array growth. Closes the
61
+ remaining array-write gap: number[]/boolean[]/string[] index writes are now
62
+ at or above native speed.
63
+ - Cache the array proxy for an object's array property on the accessor getter
64
+ so it isn't recreated on every write (a write bumps the version and
65
+ invalidates the field cache), and cache the property slot offset + array ref
66
+ in the array proxy so index writes skip the per-write `getObjectLayout` Map
67
+ lookup, `properties.get`, and DataTable header reads. Object array-prop
68
+ index writes dropped from ~254× to ~33× slower than native.
69
+
70
+ ### Changed
71
+
72
+ - Raise the defragmentation trigger: a table now triggers a defrag pass when
73
+ its used length exceeds 80% of capacity (was: when free space fell below
74
+ 10%), and only when at least 64 blocks have been freed. Because writes are
75
+ lock-free bump allocations, the used length grows monotonically even when
76
+ the live data set stays the same size; the new threshold defragments only
77
+ when the buffer is genuinely fragmented. During a defrag pass, lock-free
78
+ writes fall back to the mutex and in-flight writes are awaited before
79
+ compacting.
80
+
81
+ ### Fixed
82
+
83
+ - Fix slab-allocator block corruption in `free()`. `alloc()` padded blocks to
84
+ `MIN_BLOCK_SIZE` internally but callers advanced the cursor by the unpadded
85
+ size, so blocks overlapped and `free()`'s metadata overwrote the neighbour
86
+ block. `alloc()` now returns the actual reserved (padded) size and all
87
+ callers advance by it. Free-list metadata was also reduced from 12 to 8
88
+ bytes (next pointer + size) and the magic marker dropped (double-frees are
89
+ now detected by walking the free list), lowering `MIN_BLOCK_SIZE` from 12 to
90
+ 8 so small string blocks no longer inflate the ArrayTable during
91
+ defragmentation.
92
+
93
+ ## [0.1.8] - 2026-11-02
94
+
95
+ ### Fixed
96
+
97
+ - Share each `GrowableBuffer`'s append cursor across workers. The cursor was a
98
+ private per-instance field, so when a worker reconstructed `SharedMemory`
99
+ via `fromBuffers()` its cursor stayed `0` and the first write in that worker
100
+ wrote at offset `0`, overwriting existing data (e.g. a key string) and
101
+ corrupting the shared value. The cursor now lives in a `SharedArrayBuffer`
102
+ (as a `BigInt64Array`) read/written with `Atomics`, so every worker appends
103
+ at the correct offset. Reported while building the multi-worker todo-list
104
+ example.
105
+
8
106
  ## [0.1.7] - 2026-11-02
9
107
 
10
108
  ### Added
@@ -172,6 +270,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
172
270
  - O(1) in-place `push`/`pop` with capacity-based array growth.
173
271
  - Benchmarks for all data types vs native.
174
272
 
273
+ [0.2.0]: https://github.com/Kapsonfire/bun-shared-memory/compare/v0.1.8...v0.2.0
274
+ [0.1.8]: https://github.com/Kapsonfire/bun-shared-memory/compare/v0.1.7...v0.1.8
175
275
  [0.1.7]: https://github.com/Kapsonfire/bun-shared-memory/compare/v0.1.6...v0.1.7
176
276
  [0.1.6]: https://github.com/Kapsonfire/bun-shared-memory/compare/v0.1.5...v0.1.6
177
277
  [0.1.5]: https://github.com/Kapsonfire/bun-shared-memory/compare/v0.1.4...v0.1.5
package/README.md CHANGED
@@ -40,14 +40,14 @@ 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
41
  is the minimum over 3 runs × 5 samples to filter CPU-contention noise):
42
42
 
43
- | Methodology | Transport | Requests | Elapsed (ms) | req/s | vs. shared |
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 |
43
+ | Methodology | Transport | Requests | Elapsed (ms) | req/s | vs. shared |
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
51
 
52
52
  Shared memory is the fastest by a wide margin: ~9.2× faster than a live SQLite
53
53
  read and ~61–145× faster than live MySQL/Redis reads. Unix sockets are
@@ -74,16 +74,16 @@ See [SPEC.md](./SPEC.md) for the full specification.
74
74
 
75
75
  ## 📊 Stats
76
76
 
77
- | | |
78
- |---|---|
79
- | **Version** | [![npm version](https://img.shields.io/npm/v/bun-shared-memory?color=cb3837)](https://www.npmjs.com/package/bun-shared-memory) |
80
- | **Downloads / month** | [![npm downloads](https://img.shields.io/npm/dm/bun-shared-memory?color=cb3837)](https://www.npmjs.com/package/bun-shared-memory) |
81
- | **Stars** | [![GitHub stars](https://img.shields.io/github/stars/Kapsonfire/bun-shared-memory?color=181717)](https://github.com/Kapsonfire/bun-shared-memory) |
82
- | **Forks** | [![GitHub forks](https://img.shields.io/github/forks/Kapsonfire/bun-shared-memory?color=181717)](https://github.com/Kapsonfire/bun-shared-memory) |
83
- | **Open issues** | [![GitHub issues](https://img.shields.io/github/issues/Kapsonfire/bun-shared-memory?color=181717)](https://github.com/Kapsonfire/bun-shared-memory/issues) |
84
- | **Open PRs** | [![GitHub pull requests](https://img.shields.io/github/issues-pr/Kapsonfire/bun-shared-memory?color=181717)](https://github.com/Kapsonfire/bun-shared-memory/pulls) |
85
- | **License** | [![License](https://img.shields.io/github/license/Kapsonfire/bun-shared-memory?color=green)](https://github.com/Kapsonfire/bun-shared-memory/blob/main/LICENSE) |
86
- | **Language** | [![TypeScript](https://img.shields.io/badge/TypeScript-7-blue?color=3178c6)](https://www.typescriptlang.org) |
77
+ | | |
78
+ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
79
+ | **Version** | [![npm version](https://img.shields.io/npm/v/bun-shared-memory?color=cb3837)](https://www.npmjs.com/package/bun-shared-memory) |
80
+ | **Downloads / month** | [![npm downloads](https://img.shields.io/npm/dm/bun-shared-memory?color=cb3837)](https://www.npmjs.com/package/bun-shared-memory) |
81
+ | **Stars** | [![GitHub stars](https://img.shields.io/github/stars/Kapsonfire/bun-shared-memory?color=181717)](https://github.com/Kapsonfire/bun-shared-memory) |
82
+ | **Forks** | [![GitHub forks](https://img.shields.io/github/forks/Kapsonfire/bun-shared-memory?color=181717)](https://github.com/Kapsonfire/bun-shared-memory) |
83
+ | **Open issues** | [![GitHub issues](https://img.shields.io/github/issues/Kapsonfire/bun-shared-memory?color=181717)](https://github.com/Kapsonfire/bun-shared-memory/issues) |
84
+ | **Open PRs** | [![GitHub pull requests](https://img.shields.io/github/issues-pr/Kapsonfire/bun-shared-memory?color=181717)](https://github.com/Kapsonfire/bun-shared-memory/pulls) |
85
+ | **License** | [![License](https://img.shields.io/github/license/Kapsonfire/bun-shared-memory?color=green)](https://github.com/Kapsonfire/bun-shared-memory/blob/main/LICENSE) |
86
+ | **Language** | [![TypeScript](https://img.shields.io/badge/TypeScript-7-blue?color=3178c6)](https://www.typescriptlang.org) |
87
87
 
88
88
  ## Data Types
89
89
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bun-shared-memory",
3
- "version": "0.1.8",
3
+ "version": "0.2.1",
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,
@@ -47,6 +47,7 @@
47
47
  },
48
48
  "devDependencies": {
49
49
  "@types/bun": "latest",
50
+ "cofusion": "^0.0.2",
50
51
  "elysia": "^1.4.30"
51
52
  }
52
53
  }
@@ -93,6 +93,16 @@ export function largestPowerOfTwo(n: number): number {
93
93
  return 2 ** Math.floor(Math.log2(n));
94
94
  }
95
95
 
96
+ /**
97
+ * Minimum size of a slab-allocated block. The free-list metadata written by
98
+ * `free` (next pointer + size = 8 bytes) must fit inside the block, otherwise
99
+ * it would overwrite the following block. All blocks are padded up to at
100
+ * least this size.
101
+ */
102
+ const MIN_BLOCK_SIZE = 8;
103
+
104
+ export { MIN_BLOCK_SIZE };
105
+
96
106
  export class GrowableBuffer {
97
107
  private chunks: Chunk[] = [];
98
108
  private readonly maxByteLength: number;
@@ -109,6 +119,17 @@ export class GrowableBuffer {
109
119
  * existing data at offset 0.
110
120
  */
111
121
  private readonly cursor: BigInt64Array;
122
+ /**
123
+ * Shared head of the free list. Stored in a SharedArrayBuffer so all workers
124
+ * that reconstruct this buffer from `fromBuffers()` see the same free list
125
+ * and reuse freed blocks instead of appending at the end.
126
+ *
127
+ * The free list is a singly-linked list of freed blocks. Each freed block
128
+ * stores, in its first 4 bytes, the byte offset of the next free block (or
129
+ * -1 for the end of the list). The head is the offset of the first free
130
+ * block, or -1 when the list is empty.
131
+ */
132
+ private readonly freeListHead: Int32Array;
112
133
  /** Smallest chunk index that is not yet active (see `activate`). */
113
134
  private nextChunkToActivate = 0;
114
135
 
@@ -120,12 +141,20 @@ export class GrowableBuffer {
120
141
  singleBuffer = false,
121
142
  chargeInitial = true,
122
143
  cursorBuffer?: SharedArrayBuffer,
144
+ freeListBuffer?: SharedArrayBuffer,
123
145
  ) {
124
146
  this.singleBuffer = singleBuffer;
125
147
  this.budget = budget;
126
148
  this.cursor = new BigInt64Array(
127
149
  cursorBuffer ?? new SharedArrayBuffer(BigInt64Array.BYTES_PER_ELEMENT),
128
150
  );
151
+ this.freeListHead = new Int32Array(
152
+ freeListBuffer ?? new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT),
153
+ );
154
+ // Initialize the free list head to -1 (empty) on first construction.
155
+ if (freeListBuffer === undefined) {
156
+ Atomics.store(this.freeListHead, 0, -1);
157
+ }
129
158
  if (singleBuffer && typeof initialSizeOrBuffer !== "number") {
130
159
  const buffer = Array.isArray(initialSizeOrBuffer)
131
160
  ? initialSizeOrBuffer[0]
@@ -285,6 +314,11 @@ export class GrowableBuffer {
285
314
  return this.cursor.buffer as SharedArrayBuffer;
286
315
  }
287
316
 
317
+ /** The shared free-list head buffer (for export to workers). */
318
+ get freeListBuffer(): SharedArrayBuffer {
319
+ return this.freeListHead.buffer as SharedArrayBuffer;
320
+ }
321
+
288
322
  /** Total logical capacity across all chunks (active or not). */
289
323
  get capacity(): number {
290
324
  return this.singleBuffer
@@ -391,6 +425,148 @@ export class GrowableBuffer {
391
425
  Atomics.add(this.cursor, 0, BigInt(n));
392
426
  }
393
427
 
428
+ /**
429
+ * Atomically reserve `size` bytes at the end of the buffer and return the
430
+ * start offset. This is the lock-free bump-allocation primitive: the shared
431
+ * cursor is incremented with a single `Atomics.add`, so concurrent workers
432
+ * each get a distinct, non-overlapping range without a mutex. The caller
433
+ * writes its data into `[offset, offset + size)`.
434
+ *
435
+ * Chunk growth is preserved: `ensureCapacity` activates any chunks needed to
436
+ * cover the reserved range. Because `SharedArrayBuffer.grow()` is visible
437
+ * across workers, activation needs no broadcast.
438
+ *
439
+ * Unlike `alloc`, this never consults the free list, so freed blocks are not
440
+ * reused on this path (dead space is reclaimed by defragmentation).
441
+ */
442
+ reserve(size: number): number {
443
+ // Lock-free bump allocation with chunk-boundary alignment. The shared
444
+ // cursor is advanced with a compare-exchange loop so concurrent workers
445
+ // each get a distinct, non-overlapping range without a mutex. If the
446
+ // block would cross a chunk boundary, the offset is aligned up to the next
447
+ // chunk so the caller's typed writes (setInt32 etc.) never straddle two
448
+ // chunks (those accessors only write within a single chunk).
449
+ while (true) {
450
+ const current = Number(Atomics.load(this.cursor, 0));
451
+ let offset = current;
452
+ if (!this.singleBuffer) {
453
+ const within = current & (this.chunkSize - 1);
454
+ if (within + size > this.chunkSize) {
455
+ offset = current + (this.chunkSize - within);
456
+ }
457
+ }
458
+ const next = offset + size;
459
+ if (
460
+ Atomics.compareExchange(
461
+ this.cursor,
462
+ 0,
463
+ BigInt(current),
464
+ BigInt(next),
465
+ ) === BigInt(current)
466
+ ) {
467
+ this.ensureCapacity(next);
468
+ return offset;
469
+ }
470
+ }
471
+ }
472
+
473
+ /**
474
+ * Allocate `size` bytes, reusing a freed block from the free list when one
475
+ * of at least `size` bytes is available (first-fit), otherwise appending at
476
+ * the end of the buffer.
477
+ *
478
+ * Returns the byte offset of the allocated block and the actual reserved
479
+ * (padded) size. The caller must write the block's data and then call
480
+ * `advance(reserved)` only when the block was appended (not reused), because
481
+ * a reused block is already counted as used.
482
+ *
483
+ * @returns the offset, the reserved size, and whether the block was freshly
484
+ * appended (true) or reused from the free list (false).
485
+ */
486
+ alloc(size: number): { offset: number; appended: boolean; size: number } {
487
+ // Pad small blocks up to MIN_BLOCK_SIZE so the free-list metadata written
488
+ // by `free` always fits inside the block and never overwrites a neighbour.
489
+ size = Math.max(size, MIN_BLOCK_SIZE);
490
+ // First-fit search over the free list for a block of at least `size`.
491
+ let prev = -1;
492
+ let cur = Atomics.load(this.freeListHead, 0);
493
+ while (cur !== -1) {
494
+ const next = this.getInt32(cur);
495
+ if (this.blockSize(cur) >= size) {
496
+ // Unlink `cur` from the free list.
497
+ if (prev === -1) {
498
+ Atomics.store(this.freeListHead, 0, next);
499
+ } else {
500
+ this.setInt32(prev, next);
501
+ }
502
+ return { offset: cur, appended: false, size };
503
+ }
504
+ prev = cur;
505
+ cur = next;
506
+ }
507
+ // No reusable block; append at the end.
508
+ const offset = this.byteLength;
509
+ this.ensureCapacity(offset + size);
510
+ return { offset, appended: true, size };
511
+ }
512
+
513
+ /**
514
+ * Free a block previously allocated with `alloc`, pushing it onto the free
515
+ * list so it can be reused. Detects double frees: freeing a block that is
516
+ * already on the free list throws an Error.
517
+ *
518
+ * The block's first 8 bytes are overwritten with the free-list metadata
519
+ * (next pointer + size), so the caller must not read the block's data after
520
+ * freeing it.
521
+ */
522
+ free(offset: number, size: number): void {
523
+ // Pad to MIN_BLOCK_SIZE to match the size `alloc` actually reserved, so
524
+ // `blockSize` reports the real block size for reuse decisions.
525
+ size = Math.max(size, MIN_BLOCK_SIZE);
526
+ // Double-free detection: a block already on the free list must not be
527
+ // freed again. Walk the list (free is not a hot path, so the O(n) scan is
528
+ // acceptable).
529
+ let cur = Atomics.load(this.freeListHead, 0);
530
+ while (cur !== -1) {
531
+ if (cur === offset) {
532
+ throw new Error(
533
+ `Double free detected at offset ${offset} (size ${size})`,
534
+ );
535
+ }
536
+ cur = this.getInt32(cur);
537
+ }
538
+ const head = Atomics.load(this.freeListHead, 0);
539
+ this.setInt32(offset, head);
540
+ this.setInt32(offset + 4, size);
541
+ Atomics.store(this.freeListHead, 0, offset);
542
+ }
543
+
544
+ /**
545
+ * Read the size of a freed block, stored in bytes [4..7] when it was
546
+ * freed. Used by `alloc` to check whether a freed block is large enough.
547
+ */
548
+ private blockSize(offset: number): number {
549
+ return this.getInt32(offset + 4);
550
+ }
551
+
552
+ /**
553
+ * Walk the free list and return the number of freed blocks and the total
554
+ * bytes they occupy. Used to measure fragmentation: many small freed blocks
555
+ * scattered through the buffer indicate high fragmentation, which a
556
+ * defragmentation pass can compact.
557
+ */
558
+ freeListStats(): { count: number; bytes: number } {
559
+ let count = 0;
560
+ let bytes = 0;
561
+ let cur = Atomics.load(this.freeListHead, 0);
562
+ while (cur !== -1) {
563
+ count++;
564
+ bytes += this.blockSize(cur);
565
+ cur = this.getInt32(cur);
566
+ }
567
+ return { count, bytes };
568
+ }
569
+
394
570
  /**
395
571
  * Reset the used length to `n` bytes. Used by defragmentation after
396
572
  * compacting live data to the front of the buffer. Chunks beyond the
@@ -399,6 +575,9 @@ export class GrowableBuffer {
399
575
  */
400
576
  reset(n: number): void {
401
577
  Atomics.store(this.cursor, 0, BigInt(n));
578
+ // After compaction the free list's offsets are invalid (blocks moved), so
579
+ // clear it. Freed blocks are re-added lazily on the next `free`.
580
+ Atomics.store(this.freeListHead, 0, -1);
402
581
  if (this.singleBuffer) return;
403
582
  const neededChunks = Math.ceil(n / this.chunkSize);
404
583
  for (let i = 0; i < this.chunks.length; i++) {