bun-shared-memory 0.1.4 → 0.1.6

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 ADDED
@@ -0,0 +1,129 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.6] - 2026-11-02
9
+
10
+ ### Added
11
+
12
+ - Support shared memory larger than 4 GiB. The single-`SharedArrayBuffer`
13
+ per-buffer architecture is replaced with a chunked `GrowableBuffer`: each
14
+ buffer is backed by multiple equal-size chunks (32 MiB default, power of
15
+ two), so a buffer can exceed the 4 GiB single-SAB limit. Offsets resolve
16
+ O(1) via bit shifts (`offset >> chunkShift`).
17
+ - `SharedMemoryOptions.chunkSize` to configure the chunk size (the minimum
18
+ growth step).
19
+ - Chunks are pre-allocated at construction (4 bytes each, with
20
+ `maxByteLength = chunkSize`) and activated on first access via
21
+ `SharedArrayBuffer.grow()`. Because `grow()` is visible across all workers
22
+ sharing a buffer, a worker can grow a chunk and every other worker sees it
23
+ immediately — no postMessage broadcast is needed for chunk activation.
24
+
25
+ ### Changed
26
+
27
+ - `exportBuffers()`/`fromBuffers()` now pass chunk lists instead of a single
28
+ buffer; `fromBuffers` reuses the shared `SharedArrayBuffer`s directly (no
29
+ copy) so writes stay visible across workers. The chunk size is derived from
30
+ each buffer's `maxByteLength` (preserved across `postMessage`), not its
31
+ current 4-byte length.
32
+ - Remove the 4 GiB cap on the per-buffer maximum in the `SharedMemory`
33
+ constructor (the chunked `GrowableBuffer` already bypasses the single-SAB
34
+ limit).
35
+
36
+ ### Performance
37
+
38
+ - After defragmentation, unused chunks are marked inactive:
39
+ `GrowableBuffer.reset()` deactivates chunks beyond the compacted region so
40
+ the GC can reclaim their storage.
41
+ - The chunked accessor adds an indirect `chunks[index].view` lookup on the
42
+ write hot path (the price of >4 GiB support): writes ~-15%, array writes
43
+ ~-20%. Reads are unchanged (lock-free cache path).
44
+
45
+ ## [0.1.5] - 2026-11-02
46
+
47
+ ### Changed
48
+
49
+ - Replace the `Proxy` used for object `SharedRef`s with hard-baked
50
+ `Object.defineProperty` accessor getters/setters, so property reads skip the
51
+ Proxy trap on the hot path (~3× faster object property reads). Methods
52
+ (`read`/`write`/`update`/`key`/`buffer`) move to the prototype so the data
53
+ properties are the only own properties, keeping the JIT shape monomorphic
54
+ (~2× faster than having the methods as own properties). Arrays, primitives,
55
+ and unknown shapes still use the Proxy. Fixed shape is enforced via
56
+ `Object.preventExtensions` plus `configurable: false` accessors.
57
+ - Optimize `copyValue` (the `read()` copy) with `Object.assign` + a `for...in`
58
+ array pass (~3.5× faster object `read()`).
59
+ - Pass the initial value into `createSharedRef` so the object shape can be
60
+ baked at creation time.
61
+
62
+ ## [0.1.4] - 2026-11-01
63
+
64
+ ### Changed
65
+
66
+ - Declare `"sideEffects": false` in `package.json` so bundlers can tree-shake
67
+ the library (the modules have no side effects on import).
68
+ - Remove the `typescript` peer dependency. The library ships as TypeScript
69
+ source and runs natively in Bun; `bun check` is Bun's own type checker and
70
+ does not require the `typescript` package.
71
+
72
+ ## [0.1.3] - 2026-11-01
73
+
74
+ ### Added
75
+
76
+ - Real-world benchmark (`bench/real-world/`) comparing shared memory against
77
+ live SQLite, MySQL, and Redis via an Elysia mini API.
78
+ - Redis as a fourth benchmark methodology (in Docker).
79
+ - Unix-socket transport for MySQL/Redis (Linux only; skipped on macOS).
80
+ - Benchmark results written to `benchlogs/<timestamp>-<commit>-real.md`.
81
+ - Real-world benchmark results and methodology documented in the README.
82
+
83
+ ### Changed
84
+
85
+ - MySQL/Redis connections are established before the benchmark (not per
86
+ request) so connection setup is not measured as overhead.
87
+ - The benchmark always runs both TCP and Unix-socket transports; the socket
88
+ run is skipped on macOS without error.
89
+
90
+ ## [0.1.2] - 2026-11-01
91
+
92
+ ### Changed
93
+
94
+ - Unref the maintenance timer so the process can exit naturally.
95
+ - Document `init()` as the first call and how to configure memory via `init()`.
96
+
97
+ ## [0.1.1] - 2026-11-01
98
+
99
+ ### Added
100
+
101
+ - Repository metadata and npm publishing preparation.
102
+ - README with package name and install instructions.
103
+
104
+ ### Changed
105
+
106
+ - Optimize the read hot path: direct cache on the proxy target.
107
+ - Optimize `Mutex`: skip notify when no worker is waiting.
108
+ - Start the refcount coordinator/tracker automatically in `init()`.
109
+ - Document the minimum Bun version (`>= 1.4.0`).
110
+
111
+ ## [0.1.0] - 2026-11-01
112
+
113
+ ### Added
114
+
115
+ - Initial release: `Shared<T>` Proxy-based API over a 4-buffer shared memory
116
+ architecture (Index Table, DataTable, append-only StringTable, ArrayTable).
117
+ - Atomics-based synchronization (`Mutex`, `Notifier`).
118
+ - Per-key read cache with cross-worker invalidation and offset cache for
119
+ object property reads.
120
+ - O(1) in-place `push`/`pop` with capacity-based array growth.
121
+ - Benchmarks for all data types vs native.
122
+
123
+ [0.1.6]: https://github.com/Kapsonfire/bun-shared-memory/compare/v0.1.5...v0.1.6
124
+ [0.1.5]: https://github.com/Kapsonfire/bun-shared-memory/compare/v0.1.4...v0.1.5
125
+ [0.1.4]: https://github.com/Kapsonfire/bun-shared-memory/compare/v0.1.3...v0.1.4
126
+ [0.1.3]: https://github.com/Kapsonfire/bun-shared-memory/compare/v0.1.2...v0.1.3
127
+ [0.1.2]: https://github.com/Kapsonfire/bun-shared-memory/compare/v0.1.1...v0.1.2
128
+ [0.1.1]: https://github.com/Kapsonfire/bun-shared-memory/compare/v0.1.0...v0.1.1
129
+ [0.1.0]: https://github.com/Kapsonfire/bun-shared-memory/releases/tag/v0.1.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bun-shared-memory",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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,
@@ -14,7 +14,8 @@
14
14
  },
15
15
  "files": [
16
16
  "index.ts",
17
- "src/"
17
+ "src/",
18
+ "CHANGELOG.md"
18
19
  ],
19
20
  "scripts": {
20
21
  "typecheck": "bun check",
@@ -46,8 +47,6 @@
46
47
  },
47
48
  "devDependencies": {
48
49
  "@types/bun": "latest",
49
- "elysia": "^1.4.30",
50
- "ioredis": "^6.0.0",
51
- "mysql2": "^3.24.6"
50
+ "elysia": "^1.4.30"
52
51
  }
53
52
  }
@@ -35,33 +35,111 @@ export class SharedBudget {
35
35
  }
36
36
 
37
37
  /**
38
- * Growable SharedArrayBuffer helper.
38
+ * Growable shared memory backed by one or more SharedArrayBuffers (chunks).
39
39
  *
40
- * SharedArrayBuffer supports `grow(newLength)` when created with
41
- * `maxByteLength`. Growth preserves existing data, can only grow up to
42
- * `maxByteLength`, and cannot shrink. All workers sharing the buffer see the
43
- * growth immediately.
40
+ * A single SharedArrayBuffer can be at most 4 GiB, so a buffer that needs to
41
+ * grow beyond that is split into multiple chunks. Each chunk is its own
42
+ * SharedArrayBuffer (<= `chunkMax` bytes). Offsets are logical and continuous
43
+ * across chunks; access methods resolve an offset into (chunk, chunkOffset).
44
+ *
45
+ * All chunks are pre-allocated at construction with `maxByteLength = chunkSize`
46
+ * but only 4 bytes of actual storage. A chunk is "activated" (grown to its
47
+ * full `chunkSize`) on first access. Because `SharedArrayBuffer.grow()` is
48
+ * visible across all workers sharing the buffer, activation needs no
49
+ * postMessage broadcast.
44
50
  */
51
+ interface Chunk {
52
+ buffer: SharedArrayBuffer;
53
+ view: DataView;
54
+ /** Whether the chunk has been grown to its full size. */
55
+ active: boolean;
56
+ }
57
+
58
+ /** Round `n` up to the next power of two (chunk sizes must be powers of two). */
59
+ function nextPowerOfTwo(n: number): number {
60
+ if (n <= 0) return 1;
61
+ let p = 1;
62
+ while (p < n) p <<= 1;
63
+ return p;
64
+ }
65
+
45
66
  export class GrowableBuffer {
46
- readonly buffer: SharedArrayBuffer;
47
- private view: DataView;
67
+ private chunks: Chunk[] = [];
48
68
  private readonly maxByteLength: number;
69
+ /** Fixed size of every chunk (the minimum growth step). */
70
+ readonly chunkSize: number;
71
+ /** log2(chunkSize), used for O(1) offset resolution via bit shifts. */
72
+ private readonly chunkShift: number;
49
73
  private readonly budget?: SharedBudget;
50
74
  private used = 0;
51
75
 
52
76
  constructor(
53
- initialSizeOrBuffer: number | SharedArrayBuffer,
77
+ initialSizeOrBuffer: number | SharedArrayBuffer | SharedArrayBuffer[],
54
78
  maxByteLength: number,
55
79
  budget?: SharedBudget,
80
+ chunkSize = 32 * 1024 * 1024, // 32 MiB per chunk
56
81
  ) {
57
- this.buffer =
58
- typeof initialSizeOrBuffer === "number"
59
- ? new SharedArrayBuffer(initialSizeOrBuffer, { maxByteLength })
60
- : initialSizeOrBuffer;
61
- this.view = new DataView(this.buffer);
62
82
  this.maxByteLength = maxByteLength;
63
83
  this.budget = budget;
64
- this.budget?.add(this.buffer.byteLength);
84
+ if (typeof initialSizeOrBuffer === "number") {
85
+ this.chunkSize = Math.min(nextPowerOfTwo(chunkSize), maxByteLength);
86
+ this.chunkShift = Math.log2(this.chunkSize);
87
+ // Pre-allocate all chunks (4 bytes each, growable to chunkSize).
88
+ const chunkCount = Math.max(1, Math.ceil(maxByteLength / this.chunkSize));
89
+ for (let i = 0; i < chunkCount; i++) {
90
+ const buffer = new SharedArrayBuffer(4, {
91
+ maxByteLength: this.chunkSize,
92
+ });
93
+ this.chunks.push({
94
+ buffer,
95
+ view: new DataView(buffer),
96
+ active: false,
97
+ });
98
+ }
99
+ } else if (Array.isArray(initialSizeOrBuffer)) {
100
+ // Reconstruct from a list of chunks. Use the buffers directly (they are
101
+ // already shared via postMessage) so writes are visible across workers.
102
+ // All chunks are pre-allocated at 4 bytes with maxByteLength = chunkSize;
103
+ // the chunk size is derived from each buffer's maxByteLength (which is
104
+ // preserved across postMessage), not its current byteLength.
105
+ this.chunkSize = initialSizeOrBuffer[0]?.maxByteLength ?? chunkSize;
106
+ this.chunkShift = Math.log2(this.chunkSize);
107
+ for (const sab of initialSizeOrBuffer) {
108
+ this.chunks.push({
109
+ buffer: sab,
110
+ view: new DataView(sab),
111
+ active: sab.byteLength === this.chunkSize,
112
+ });
113
+ this.budget?.add(sab.byteLength);
114
+ }
115
+ } else {
116
+ // Reconstruct from a single existing buffer. Use it directly so writes
117
+ // are shared. Its size defines the chunk size (prefer maxByteLength so
118
+ // a pre-allocated 4-byte chunk is interpreted at its full size).
119
+ this.chunkSize =
120
+ initialSizeOrBuffer.maxByteLength ?? initialSizeOrBuffer.byteLength;
121
+ this.chunkShift = Math.log2(this.chunkSize);
122
+ this.chunks.push({
123
+ buffer: initialSizeOrBuffer,
124
+ view: new DataView(initialSizeOrBuffer),
125
+ active: initialSizeOrBuffer.byteLength === this.chunkSize,
126
+ });
127
+ this.budget?.add(initialSizeOrBuffer.byteLength);
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Activate the chunk at `index` by growing it to its full size. Because
133
+ * `SharedArrayBuffer.grow()` is visible across all workers, no broadcast is
134
+ * needed. No-op if the chunk is already active.
135
+ */
136
+ private activate(index: number): void {
137
+ const chunk = this.chunks[index];
138
+ if (chunk === undefined || chunk.active) return;
139
+ chunk.buffer.grow(this.chunkSize);
140
+ chunk.view = new DataView(chunk.buffer);
141
+ chunk.active = true;
142
+ this.budget?.add(this.chunkSize);
65
143
  }
66
144
 
67
145
  /** Number of bytes written so far (used length, not buffer capacity). */
@@ -69,24 +147,49 @@ export class GrowableBuffer {
69
147
  return this.used;
70
148
  }
71
149
 
150
+ /** Total logical capacity across all chunks (active or not). */
151
+ get capacity(): number {
152
+ return this.chunks.length * this.chunkSize;
153
+ }
154
+
155
+ /** The list of SharedArrayBuffers backing this buffer (for export). */
156
+ get buffers(): SharedArrayBuffer[] {
157
+ return this.chunks.map((c) => c.buffer);
158
+ }
159
+
160
+ /** Number of chunks that have been activated (grown to full size). */
161
+ get activeChunkCount(): number {
162
+ return this.chunks.reduce((n, c) => n + (c.active ? 1 : 0), 0);
163
+ }
164
+
165
+ /**
166
+ * Resolve a logical offset into (chunk, offset within that chunk). All
167
+ * chunks are the same size, so this is O(1). Activates the chunk if it is
168
+ * not yet active.
169
+ */
170
+ private resolve(offset: number): { chunk: Chunk; off: number } {
171
+ const index = offset >> this.chunkShift;
172
+ const chunk = this.chunks[index];
173
+ if (chunk === undefined) {
174
+ throw new RangeError(`Offset ${offset} is beyond the buffer capacity`);
175
+ }
176
+ this.activate(index);
177
+ return { chunk, off: offset & (this.chunkSize - 1) };
178
+ }
179
+
72
180
  /** Ensure the buffer has room for at least `needed` bytes. */
73
181
  ensureCapacity(needed: number): void {
74
- if (needed <= this.buffer.byteLength) return;
75
182
  if (needed > this.maxByteLength) {
76
183
  throw new RangeError(
77
184
  `Shared buffer capacity exceeded: need ${needed} bytes but max is ${this.maxByteLength}`,
78
185
  );
79
186
  }
80
- let newSize = this.buffer.byteLength * 2;
81
- while (newSize < needed) newSize *= 2;
82
- if (newSize > this.maxByteLength) newSize = this.maxByteLength;
83
- if (this.budget && !this.budget.tryGrow(this.buffer.byteLength, newSize)) {
84
- throw new RangeError(
85
- `Shared memory budget exceeded: growing to ${newSize} bytes would exceed the total budget`,
86
- );
187
+ // All chunks are pre-allocated; just activate the ones needed to cover
188
+ // `needed`. grow() is visible across workers, so no broadcast is required.
189
+ const neededChunks = Math.ceil(needed / this.chunkSize);
190
+ for (let i = 0; i < neededChunks; i++) {
191
+ this.activate(i);
87
192
  }
88
- this.buffer.grow(newSize);
89
- this.view = new DataView(this.buffer);
90
193
  }
91
194
 
92
195
  /** Mark `n` bytes as used, after writing them at the current offset. */
@@ -96,15 +199,80 @@ export class GrowableBuffer {
96
199
 
97
200
  /**
98
201
  * Reset the used length to `n` bytes. Used by defragmentation after
99
- * compacting live data to the front of the buffer. The underlying
100
- * SharedArrayBuffer is not shrunk (it cannot be), but the tail beyond `n`
101
- * is treated as free space for future writes.
202
+ * compacting live data to the front of the buffer. Chunks beyond the
203
+ * compacted region are marked inactive (their storage is released by the
204
+ * GC, which replaces them with fresh chunks).
102
205
  */
103
206
  reset(n: number): void {
104
207
  this.used = n;
208
+ const neededChunks = Math.ceil(n / this.chunkSize);
209
+ for (let i = neededChunks; i < this.chunks.length; i++) {
210
+ this.chunks[i]!.active = false;
211
+ }
212
+ }
213
+
214
+ // -------------------------------------------------------------------------
215
+ // Typed accessors (resolve offset -> chunk)
216
+ // -------------------------------------------------------------------------
217
+
218
+ getInt32(offset: number): number {
219
+ const index = offset >> this.chunkShift;
220
+ this.activate(index);
221
+ return this.chunks[index]!.view.getInt32(
222
+ offset & (this.chunkSize - 1),
223
+ true,
224
+ );
225
+ }
226
+
227
+ setInt32(offset: number, value: number): void {
228
+ const index = offset >> this.chunkShift;
229
+ this.activate(index);
230
+ this.chunks[index]!.view.setInt32(
231
+ offset & (this.chunkSize - 1),
232
+ value,
233
+ true,
234
+ );
105
235
  }
106
236
 
107
- getView(): DataView {
108
- return this.view;
237
+ getUint8(offset: number): number {
238
+ const index = offset >> this.chunkShift;
239
+ this.activate(index);
240
+ return this.chunks[index]!.view.getUint8(offset & (this.chunkSize - 1));
241
+ }
242
+
243
+ setUint8(offset: number, value: number): void {
244
+ const index = offset >> this.chunkShift;
245
+ this.activate(index);
246
+ this.chunks[index]!.view.setUint8(offset & (this.chunkSize - 1), value);
247
+ }
248
+
249
+ /** Read `length` bytes starting at `offset` into a fresh Uint8Array. */
250
+ readBytes(offset: number, length: number): Uint8Array {
251
+ const out = new Uint8Array(length);
252
+ let pos = offset;
253
+ let outPos = 0;
254
+ while (outPos < length) {
255
+ const { chunk, off } = this.resolve(pos);
256
+ const n = Math.min(length - outPos, this.chunkSize - off);
257
+ out.set(new Uint8Array(chunk.buffer, off, n), outPos);
258
+ outPos += n;
259
+ pos += n;
260
+ }
261
+ return out;
262
+ }
263
+
264
+ /** Write `bytes` starting at `offset`. */
265
+ writeBytes(offset: number, bytes: Uint8Array): void {
266
+ let pos = offset;
267
+ let srcPos = 0;
268
+ while (srcPos < bytes.byteLength) {
269
+ const { chunk, off } = this.resolve(pos);
270
+ const n = Math.min(bytes.byteLength - srcPos, this.chunkSize - off);
271
+ new Uint8Array(chunk.buffer, off, n).set(
272
+ bytes.subarray(srcPos, srcPos + n),
273
+ );
274
+ srcPos += n;
275
+ pos += n;
276
+ }
109
277
  }
110
278
  }