bun-shared-memory 0.2.0 → 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 +75 -0
- package/README.md +28 -12
- package/package.json +2 -2
- package/src/growable-buffer.ts +35 -0
- package/src/memory.ts +151 -9
- package/src/shared.ts +849 -251
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,81 @@ 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
|
+
|
|
49
|
+
## [0.2.1] - 2026-11-04
|
|
50
|
+
|
|
51
|
+
### Performance
|
|
52
|
+
|
|
53
|
+
- Inline the read-cache fast path in `readProp`/`readValue` instead of calling
|
|
54
|
+
`readCachedValue`, which returned a polymorphic `{hit,value}` union and
|
|
55
|
+
allocated an object on every cache hit. Keeping the read path monomorphic
|
|
56
|
+
lets the JIT optimize it: number reads dropped from ~12× to ~6× slower than
|
|
57
|
+
native, boolean reads from ~8.4× to ~5.5×, and all array index reads
|
|
58
|
+
improved (array-read summary ~9.7× → ~8.7×).
|
|
59
|
+
- Add `updateBigIntSlot`/`updateArrayBigIntSlot` fast paths that write int64
|
|
60
|
+
bigints directly into the slot, avoiding the `ValueSlot` object allocation
|
|
61
|
+
of the generic encode path. `bigint[]` index writes dropped from ~24.8× to
|
|
62
|
+
~18× slower than native.
|
|
63
|
+
- Extract the array-proxy set handler body into a named `writeArrayProp`
|
|
64
|
+
function and inline the `writeLock`, so no closure is allocated per write.
|
|
65
|
+
- Cache the key's version-cell index in the array proxy so writes bump the
|
|
66
|
+
version without a per-write Map lookup. Object array-prop index writes
|
|
67
|
+
dropped from ~33.9× to ~29× slower than native.
|
|
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
|
|
71
|
+
shared memory).
|
|
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
|
+
|
|
8
83
|
## [0.2.0] - 2026-11-04
|
|
9
84
|
|
|
10
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
|
|
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 |
|
|
46
|
-
| `sqlite` | — | 100,000 |
|
|
47
|
-
| `redis` | socket | 100,000 | 1,
|
|
48
|
-
| `mysql` | socket | 100,000 | 1,
|
|
49
|
-
| `redis` | tcp | 100,000 | 2,
|
|
50
|
-
| `mysql` | tcp | 100,000 |
|
|
51
|
-
|
|
52
|
-
Shared memory is the fastest by a wide margin: ~9.
|
|
53
|
-
read and ~61–
|
|
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
|
-
- **
|
|
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.
|
|
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,
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
49
|
"@types/bun": "latest",
|
|
50
|
-
"cofusion": "^0.0.
|
|
50
|
+
"cofusion": "^0.0.2",
|
|
51
51
|
"elysia": "^1.4.30"
|
|
52
52
|
}
|
|
53
53
|
}
|
package/src/growable-buffer.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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. */
|
|
@@ -812,6 +880,20 @@ export class SharedMemory {
|
|
|
812
880
|
buf.setInt32(off + 12, 0);
|
|
813
881
|
}
|
|
814
882
|
|
|
883
|
+
/**
|
|
884
|
+
* Fast-path: write an int64 bigint into an array slot directly, avoiding
|
|
885
|
+
* the ValueSlot object allocation and the 4 separate Int32 writes.
|
|
886
|
+
*/
|
|
887
|
+
updateArrayBigIntSlot(ref: ArrayRef, index: number, value: bigint): void {
|
|
888
|
+
slotView.setBigInt64(0, value, true);
|
|
889
|
+
const buf = this.arrayTable;
|
|
890
|
+
const off = ref.offset + ARRAY_HEADER + index * SLOT_SIZE;
|
|
891
|
+
buf.setInt32(off, TYPE_BIGINT);
|
|
892
|
+
buf.setInt32(off + 4, slotView.getInt32(0, true));
|
|
893
|
+
buf.setInt32(off + 8, slotView.getInt32(4, true));
|
|
894
|
+
buf.setInt32(off + 12, 0);
|
|
895
|
+
}
|
|
896
|
+
|
|
815
897
|
/**
|
|
816
898
|
* Fast-path: write a short string inline into an array slot (up to 11
|
|
817
899
|
* UTF-8 bytes), avoiding a heap allocation and the string-table write.
|
|
@@ -970,6 +1052,19 @@ export class SharedMemory {
|
|
|
970
1052
|
buf.setInt32(offset + 12, 0);
|
|
971
1053
|
}
|
|
972
1054
|
|
|
1055
|
+
/**
|
|
1056
|
+
* Fast-path: write an int64 bigint slot directly into the DataTable,
|
|
1057
|
+
* avoiding the ValueSlot object allocation of the generic path.
|
|
1058
|
+
*/
|
|
1059
|
+
updateBigIntSlot(offset: number, value: bigint): void {
|
|
1060
|
+
slotView.setBigInt64(0, value, true);
|
|
1061
|
+
const buf = this.dataTable;
|
|
1062
|
+
buf.setInt32(offset, TYPE_BIGINT);
|
|
1063
|
+
buf.setInt32(offset + 4, slotView.getInt32(0, true));
|
|
1064
|
+
buf.setInt32(offset + 8, slotView.getInt32(4, true));
|
|
1065
|
+
buf.setInt32(offset + 12, 0);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
973
1068
|
/**
|
|
974
1069
|
* Fast-path: write a short string inline into the slot itself (up to 11
|
|
975
1070
|
* UTF-8 bytes), avoiding a heap allocation and the string-table write.
|
|
@@ -1090,6 +1185,14 @@ export class SharedMemory {
|
|
|
1090
1185
|
getObjectLayout(key: string): ObjectLayout | null {
|
|
1091
1186
|
const offset = this.lookup(key);
|
|
1092
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 {
|
|
1093
1196
|
if (this.dataTable.getInt32(offset) !== TYPE_OBJECT) return null;
|
|
1094
1197
|
const count = this.dataTable.getInt32(offset + 4);
|
|
1095
1198
|
const properties = new Map<string, number>();
|
|
@@ -1162,6 +1265,20 @@ export class SharedMemory {
|
|
|
1162
1265
|
return null;
|
|
1163
1266
|
}
|
|
1164
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
|
+
|
|
1165
1282
|
/**
|
|
1166
1283
|
* Int32Array index of a key's version cell within the Index Table buffer.
|
|
1167
1284
|
* Stable for the lifetime of the key (the Index Table is never reordered or
|
|
@@ -1312,12 +1429,21 @@ export class SharedMemory {
|
|
|
1312
1429
|
stringValueBlocks.add(slot.a);
|
|
1313
1430
|
arrayBlockSizes.set(
|
|
1314
1431
|
slot.a,
|
|
1315
|
-
Math.max(ARRAY_HEADER + slot.b, MIN_BLOCK_SIZE),
|
|
1432
|
+
Math.max(slot.c || ARRAY_HEADER + slot.b, MIN_BLOCK_SIZE),
|
|
1316
1433
|
);
|
|
1317
1434
|
break;
|
|
1318
1435
|
case TYPE_ARRAY_REF:
|
|
1319
1436
|
markArray(slot.a, slot.b, slot.c);
|
|
1320
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;
|
|
1321
1447
|
}
|
|
1322
1448
|
};
|
|
1323
1449
|
|
|
@@ -1433,14 +1559,14 @@ export class SharedMemory {
|
|
|
1433
1559
|
const nameOffset = this.dataTable.getInt32(cursor);
|
|
1434
1560
|
this.dataTable.setInt32(cursor, stringRemap.get(nameOffset)!);
|
|
1435
1561
|
cursor += NAME_REF_SIZE;
|
|
1436
|
-
remapSlot(this.dataTable, cursor, stringRemap, arrayRemap);
|
|
1562
|
+
remapSlot(this.dataTable, cursor, stringRemap, arrayRemap, objectRemap);
|
|
1437
1563
|
cursor += SLOT_SIZE;
|
|
1438
1564
|
}
|
|
1439
1565
|
}
|
|
1440
1566
|
for (const oldOffset of sortedData) {
|
|
1441
1567
|
if (this.dataTable.getInt32(oldOffset) === TYPE_OBJECT) continue;
|
|
1442
1568
|
const newOff = objectRemap.get(oldOffset)!;
|
|
1443
|
-
remapSlot(this.dataTable, newOff, stringRemap, arrayRemap);
|
|
1569
|
+
remapSlot(this.dataTable, newOff, stringRemap, arrayRemap, objectRemap);
|
|
1444
1570
|
}
|
|
1445
1571
|
for (const oldOffset of sortedArrays) {
|
|
1446
1572
|
if (stringValueBlocks.has(oldOffset)) continue;
|
|
@@ -1453,6 +1579,7 @@ export class SharedMemory {
|
|
|
1453
1579
|
newOff + ARRAY_HEADER + i * SLOT_SIZE,
|
|
1454
1580
|
stringRemap,
|
|
1455
1581
|
arrayRemap,
|
|
1582
|
+
objectRemap,
|
|
1456
1583
|
);
|
|
1457
1584
|
}
|
|
1458
1585
|
}
|
|
@@ -1487,15 +1614,25 @@ function readSlot(buf: GrowableBuffer, offset: number): ValueSlot {
|
|
|
1487
1614
|
};
|
|
1488
1615
|
}
|
|
1489
1616
|
|
|
1490
|
-
/** Copy `len` bytes from `src` to `dst` in the same buffer
|
|
1617
|
+
/** Copy `len` bytes from `src` to `dst` in the same buffer, overlap-safe. */
|
|
1491
1618
|
function copyBytes(
|
|
1492
1619
|
buf: GrowableBuffer,
|
|
1493
1620
|
dst: number,
|
|
1494
1621
|
src: number,
|
|
1495
1622
|
len: number,
|
|
1496
1623
|
): void {
|
|
1497
|
-
|
|
1498
|
-
|
|
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
|
+
}
|
|
1499
1636
|
}
|
|
1500
1637
|
}
|
|
1501
1638
|
|
|
@@ -1509,11 +1646,16 @@ function remapSlot(
|
|
|
1509
1646
|
offset: number,
|
|
1510
1647
|
stringRemap: Map<number, number>,
|
|
1511
1648
|
arrayRemap: Map<number, number>,
|
|
1649
|
+
objectRemap?: Map<number, number>,
|
|
1512
1650
|
): void {
|
|
1513
1651
|
const type = buf.getInt32(offset);
|
|
1514
1652
|
if (type === TYPE_STRING_REF || type === TYPE_BIGINT_STR) {
|
|
1515
1653
|
buf.setInt32(offset + 4, stringRemap.get(buf.getInt32(offset + 4))!);
|
|
1516
1654
|
} else if (type === TYPE_ARRAY_REF || type === TYPE_STRING_VALUE_REF) {
|
|
1517
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))!);
|
|
1518
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.
|
|
1519
1661
|
}
|