bun-shared-memory 0.1.7 → 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/CHANGELOG.md CHANGED
@@ -5,6 +5,80 @@ 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.0] - 2026-11-04
9
+
10
+ ### Added
11
+
12
+ - Slab allocator for string values and arrays. Overwriting a value no longer
13
+ leaves the old block as dead space: freed blocks are pushed onto a shared
14
+ free list (one per table, in a `SharedArrayBuffer`) and reused by later
15
+ allocations, so the tables grow far less over time and defragmentation runs
16
+ less often. The free list is shared across workers via
17
+ `exportBuffers()`/`fromBuffers()`.
18
+ - Inline short strings. Strings up to 11 UTF-8 bytes are packed directly into
19
+ the 16-byte value slot (type + length in the top 4 bits of `c`, bytes in
20
+ `a`/`b`/`c`), avoiding a heap allocation and the string-table write on every
21
+ write. Longer strings still use the slab-allocated ArrayTable with in-place
22
+ reuse.
23
+ - A complete multi-worker todo-list example (`examples/todo-list/`) that
24
+ serves both the frontend and the API on a single port via Elysia workers.
25
+
26
+ ### Performance
27
+
28
+ - Cache each object property getter's stable version-cell index (resolved
29
+ lazily on first read) and load the version directly via
30
+ `Atomics.load(mem.versionView, cellIndex)`, replacing the per-read
31
+ `index === null` check and the `getVersion` → `versionCellIndex` multiply +
32
+ divide. Object property reads improved a further 3–17%.
33
+ - Cache the top-level array ref and version-cell index on the `SharedRef`
34
+ target so index writes skip the per-write DataTable header reads, Map
35
+ lookups, and the generic encode path. `moveArray` now bumps the layout
36
+ version so the cached ref is invalidated on array growth. Closes the
37
+ remaining array-write gap: number[]/boolean[]/string[] index writes are now
38
+ at or above native speed.
39
+ - Cache the array proxy for an object's array property on the accessor getter
40
+ so it isn't recreated on every write (a write bumps the version and
41
+ invalidates the field cache), and cache the property slot offset + array ref
42
+ in the array proxy so index writes skip the per-write `getObjectLayout` Map
43
+ lookup, `properties.get`, and DataTable header reads. Object array-prop
44
+ index writes dropped from ~254× to ~33× slower than native.
45
+
46
+ ### Changed
47
+
48
+ - Raise the defragmentation trigger: a table now triggers a defrag pass when
49
+ its used length exceeds 80% of capacity (was: when free space fell below
50
+ 10%), and only when at least 64 blocks have been freed. Because writes are
51
+ lock-free bump allocations, the used length grows monotonically even when
52
+ the live data set stays the same size; the new threshold defragments only
53
+ when the buffer is genuinely fragmented. During a defrag pass, lock-free
54
+ writes fall back to the mutex and in-flight writes are awaited before
55
+ compacting.
56
+
57
+ ### Fixed
58
+
59
+ - Fix slab-allocator block corruption in `free()`. `alloc()` padded blocks to
60
+ `MIN_BLOCK_SIZE` internally but callers advanced the cursor by the unpadded
61
+ size, so blocks overlapped and `free()`'s metadata overwrote the neighbour
62
+ block. `alloc()` now returns the actual reserved (padded) size and all
63
+ callers advance by it. Free-list metadata was also reduced from 12 to 8
64
+ bytes (next pointer + size) and the magic marker dropped (double-frees are
65
+ now detected by walking the free list), lowering `MIN_BLOCK_SIZE` from 12 to
66
+ 8 so small string blocks no longer inflate the ArrayTable during
67
+ defragmentation.
68
+
69
+ ## [0.1.8] - 2026-11-02
70
+
71
+ ### Fixed
72
+
73
+ - Share each `GrowableBuffer`'s append cursor across workers. The cursor was a
74
+ private per-instance field, so when a worker reconstructed `SharedMemory`
75
+ via `fromBuffers()` its cursor stayed `0` and the first write in that worker
76
+ wrote at offset `0`, overwriting existing data (e.g. a key string) and
77
+ corrupting the shared value. The cursor now lives in a `SharedArrayBuffer`
78
+ (as a `BigInt64Array`) read/written with `Atomics`, so every worker appends
79
+ at the correct offset. Reported while building the multi-worker todo-list
80
+ example.
81
+
8
82
  ## [0.1.7] - 2026-11-02
9
83
 
10
84
  ### Added
@@ -172,6 +246,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
172
246
  - O(1) in-place `push`/`pop` with capacity-based array growth.
173
247
  - Benchmarks for all data types vs native.
174
248
 
249
+ [0.2.0]: https://github.com/Kapsonfire/bun-shared-memory/compare/v0.1.8...v0.2.0
250
+ [0.1.8]: https://github.com/Kapsonfire/bun-shared-memory/compare/v0.1.7...v0.1.8
175
251
  [0.1.7]: https://github.com/Kapsonfire/bun-shared-memory/compare/v0.1.6...v0.1.7
176
252
  [0.1.6]: https://github.com/Kapsonfire/bun-shared-memory/compare/v0.1.5...v0.1.6
177
253
  [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.7",
3
+ "version": "0.2.0",
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.1",
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;
@@ -102,7 +112,24 @@ export class GrowableBuffer {
102
112
  /** log2(chunkSize), used for O(1) offset resolution via bit shifts. */
103
113
  private readonly chunkShift: number;
104
114
  private readonly budget?: SharedBudget;
105
- private used = 0;
115
+ /**
116
+ * Shared append cursor (used length). Stored in a SharedArrayBuffer so all
117
+ * workers that reconstruct this buffer from `fromBuffers()` see the same
118
+ * used length and append at the correct offset instead of overwriting
119
+ * existing data at offset 0.
120
+ */
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;
106
133
  /** Smallest chunk index that is not yet active (see `activate`). */
107
134
  private nextChunkToActivate = 0;
108
135
 
@@ -113,9 +140,21 @@ export class GrowableBuffer {
113
140
  chunkSize = 32 * 1024 * 1024, // 32 MiB per chunk
114
141
  singleBuffer = false,
115
142
  chargeInitial = true,
143
+ cursorBuffer?: SharedArrayBuffer,
144
+ freeListBuffer?: SharedArrayBuffer,
116
145
  ) {
117
146
  this.singleBuffer = singleBuffer;
118
147
  this.budget = budget;
148
+ this.cursor = new BigInt64Array(
149
+ cursorBuffer ?? new SharedArrayBuffer(BigInt64Array.BYTES_PER_ELEMENT),
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
+ }
119
158
  if (singleBuffer && typeof initialSizeOrBuffer !== "number") {
120
159
  const buffer = Array.isArray(initialSizeOrBuffer)
121
160
  ? initialSizeOrBuffer[0]
@@ -267,7 +306,17 @@ export class GrowableBuffer {
267
306
 
268
307
  /** Number of bytes written so far (used length, not buffer capacity). */
269
308
  get byteLength(): number {
270
- return this.used;
309
+ return Number(Atomics.load(this.cursor, 0));
310
+ }
311
+
312
+ /** The shared cursor buffer (for export to workers). */
313
+ get cursorBuffer(): SharedArrayBuffer {
314
+ return this.cursor.buffer as SharedArrayBuffer;
315
+ }
316
+
317
+ /** The shared free-list head buffer (for export to workers). */
318
+ get freeListBuffer(): SharedArrayBuffer {
319
+ return this.freeListHead.buffer as SharedArrayBuffer;
271
320
  }
272
321
 
273
322
  /** Total logical capacity across all chunks (active or not). */
@@ -373,7 +422,149 @@ export class GrowableBuffer {
373
422
 
374
423
  /** Mark `n` bytes as used, after writing them at the current offset. */
375
424
  advance(n: number): void {
376
- this.used += n;
425
+ Atomics.add(this.cursor, 0, BigInt(n));
426
+ }
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 };
377
568
  }
378
569
 
379
570
  /**
@@ -383,7 +574,10 @@ export class GrowableBuffer {
383
574
  * `releaseInactiveChunks()`.
384
575
  */
385
576
  reset(n: number): void {
386
- this.used = n;
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);
387
581
  if (this.singleBuffer) return;
388
582
  const neededChunks = Math.ceil(n / this.chunkSize);
389
583
  for (let i = 0; i < this.chunks.length; i++) {