bun-shared-memory 0.1.4 → 0.1.5

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.
Files changed (3) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/package.json +4 -5
  3. package/src/shared.ts +246 -192
package/CHANGELOG.md ADDED
@@ -0,0 +1,82 @@
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.5] - 2026-11-02
9
+
10
+ ### Changed
11
+ - Replace the `Proxy` used for object `SharedRef`s with hard-baked
12
+ `Object.defineProperty` accessor getters/setters, so property reads skip the
13
+ Proxy trap on the hot path (~3× faster object property reads). Methods
14
+ (`read`/`write`/`update`/`key`/`buffer`) move to the prototype so the data
15
+ properties are the only own properties, keeping the JIT shape monomorphic
16
+ (~2× faster than having the methods as own properties). Arrays, primitives,
17
+ and unknown shapes still use the Proxy. Fixed shape is enforced via
18
+ `Object.preventExtensions` plus `configurable: false` accessors.
19
+ - Optimize `copyValue` (the `read()` copy) with `Object.assign` + a `for...in`
20
+ array pass (~3.5× faster object `read()`).
21
+ - Pass the initial value into `createSharedRef` so the object shape can be
22
+ baked at creation time.
23
+
24
+ ## [0.1.4] - 2026-11-01
25
+
26
+ ### Changed
27
+ - Declare `"sideEffects": false` in `package.json` so bundlers can tree-shake
28
+ the library (the modules have no side effects on import).
29
+ - Remove the `typescript` peer dependency. The library ships as TypeScript
30
+ source and runs natively in Bun; `bun check` is Bun's own type checker and
31
+ does not require the `typescript` package.
32
+
33
+ ## [0.1.3] - 2026-11-01
34
+
35
+ ### Added
36
+ - Real-world benchmark (`bench/real-world/`) comparing shared memory against
37
+ live SQLite, MySQL, and Redis via an Elysia mini API.
38
+ - Redis as a fourth benchmark methodology (in Docker).
39
+ - Unix-socket transport for MySQL/Redis (Linux only; skipped on macOS).
40
+ - Benchmark results written to `benchlogs/<timestamp>-<commit>-real.md`.
41
+ - Real-world benchmark results and methodology documented in the README.
42
+
43
+ ### Changed
44
+ - MySQL/Redis connections are established before the benchmark (not per
45
+ request) so connection setup is not measured as overhead.
46
+ - The benchmark always runs both TCP and Unix-socket transports; the socket
47
+ run is skipped on macOS without error.
48
+
49
+ ## [0.1.2] - 2026-11-01
50
+
51
+ ### Changed
52
+ - Unref the maintenance timer so the process can exit naturally.
53
+ - Document `init()` as the first call and how to configure memory via `init()`.
54
+
55
+ ## [0.1.1] - 2026-11-01
56
+
57
+ ### Added
58
+ - Repository metadata and npm publishing preparation.
59
+ - README with package name and install instructions.
60
+
61
+ ### Changed
62
+ - Optimize the read hot path: direct cache on the proxy target.
63
+ - Optimize `Mutex`: skip notify when no worker is waiting.
64
+ - Start the refcount coordinator/tracker automatically in `init()`.
65
+ - Document the minimum Bun version (`>= 1.4.0`).
66
+
67
+ ## [0.1.0] - 2026-11-01
68
+
69
+ ### Added
70
+ - Initial release: `Shared<T>` Proxy-based API over a 4-buffer shared memory
71
+ architecture (Index Table, DataTable, append-only StringTable, ArrayTable).
72
+ - Atomics-based synchronization (`Mutex`, `Notifier`).
73
+ - Per-key read cache with cross-worker invalidation and offset cache for
74
+ object property reads.
75
+ - O(1) in-place `push`/`pop` with capacity-based array growth.
76
+ - Benchmarks for all data types vs native.
77
+
78
+ [0.1.4]: https://github.com/Kapsonfire/bun-shared-memory/compare/v0.1.3...v0.1.4
79
+ [0.1.3]: https://github.com/Kapsonfire/bun-shared-memory/compare/v0.1.2...v0.1.3
80
+ [0.1.2]: https://github.com/Kapsonfire/bun-shared-memory/compare/v0.1.1...v0.1.2
81
+ [0.1.1]: https://github.com/Kapsonfire/bun-shared-memory/compare/v0.1.0...v0.1.1
82
+ [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.5",
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
  }
package/src/shared.ts CHANGED
@@ -189,20 +189,6 @@ function getTopLevelObjectId(mem: SharedMemory, key: string): number | null {
189
189
  return null;
190
190
  }
191
191
 
192
- /**
193
- * Per-worker cache of decoded object properties: key -> { version, props }.
194
- *
195
- * When a property of an object is read via the layout (single-slot decode),
196
- * the decoded value is cached here keyed by the object's version. A hit avoids
197
- * re-decoding the property (e.g. re-reading a string from the StringTable) on
198
- * repeated reads of the same property. Writes bump the object's version, so
199
- * the whole entry is invalidated automatically.
200
- */
201
- const propCache = new Map<
202
- string,
203
- { version: number; index: number; props: Map<string, unknown> }
204
- >();
205
-
206
192
  /**
207
193
  * Per-worker cache of object layouts: key -> stable property slot offsets.
208
194
  *
@@ -222,13 +208,21 @@ function getObjectLayout(mem: SharedMemory, key: string): ObjectLayout | null {
222
208
  return layout;
223
209
  }
224
210
 
225
- /** Deep-ish copy of a decoded value, so callers can't corrupt the cache. */
211
+ /**
212
+ * Deep-ish copy of a decoded value, so callers can't corrupt the cache.
213
+ *
214
+ * Uses `Object.assign` for the fast shallow copy, then a `for...in` pass to
215
+ * copy any nested arrays (so mutating a returned array property cannot corrupt
216
+ * the cache). This is ~4.5× faster than the previous `Object.entries` + spread
217
+ * loop, which allocated an entries array and re-checked `Array.isArray` on
218
+ * every property.
219
+ */
226
220
  function copyValue(value: unknown): unknown {
227
221
  if (Array.isArray(value)) return [...value];
228
222
  if (value !== null && typeof value === "object") {
229
- const copy: Record<string, unknown> = {};
230
- for (const [k, v] of Object.entries(value)) {
231
- copy[k] = Array.isArray(v) ? [...v] : v;
223
+ const copy = Object.assign({} as Record<string, unknown>, value);
224
+ for (const k in copy) {
225
+ if (Array.isArray(copy[k])) copy[k] = [...(copy[k] as unknown[])];
232
226
  }
233
227
  return copy;
234
228
  }
@@ -297,6 +291,7 @@ export function Shared<T>(
297
291
  const ref = createSharedRef<T>(
298
292
  mem,
299
293
  key,
294
+ typeof initialOrLoader === "function" ? undefined : (initialOrLoader as T),
300
295
  options.refCount ?? getDefaultTracker() ?? undefined,
301
296
  options.arrayCapacity,
302
297
  );
@@ -318,10 +313,125 @@ export function Shared<T>(
318
313
  return ref;
319
314
  }
320
315
 
316
+ /**
317
+ * Read a single property of a shared object value.
318
+ *
319
+ * Shared by the proxy `get` handler and the generated accessor getters. Uses
320
+ * the lock-free read-cache fast path and falls back to `readValue` (which
321
+ * fills the cache) on a miss. Array properties are returned as an array proxy.
322
+ */
323
+ function readProp(
324
+ mem: SharedMemory,
325
+ key: string,
326
+ prop: string | symbol,
327
+ arrayCapacity?: number,
328
+ ): unknown {
329
+ const fast = readCachedValue(mem, key);
330
+ const value = fast.hit ? fast.value : readValue(mem, key);
331
+ if (Array.isArray(value)) {
332
+ return getTopLevelArrayMember(mem, key, value, prop, arrayCapacity);
333
+ }
334
+ const propValue = (value as Record<string | symbol, unknown>)[prop];
335
+ if (Array.isArray(propValue)) {
336
+ return createArrayProxy(mem, key, prop as string, arrayCapacity);
337
+ }
338
+ return propValue;
339
+ }
340
+
341
+ /**
342
+ * Write a single property of a shared object value in place.
343
+ *
344
+ * Shared by the proxy `set` handler and the generated accessor setters. Uses
345
+ * the cached layout to update only the affected slot.
346
+ */
347
+ function writeProp(
348
+ mem: SharedMemory,
349
+ key: string,
350
+ prop: string | symbol,
351
+ val: unknown,
352
+ arrayCapacity?: number,
353
+ ): void {
354
+ mem.withLock(() => {
355
+ // Object property: use the cached layout to write only that slot.
356
+ const layout = getObjectLayout(mem, key);
357
+ if (layout !== null) {
358
+ const slotOffset = layout.properties.get(prop as string);
359
+ if (slotOffset === undefined) {
360
+ throw new TypeError(
361
+ `Value for key "${key}" has a fixed shape: properties may not be added or removed`,
362
+ );
363
+ }
364
+ mem.updateValueSlot(slotOffset, encodeValue(mem, val));
365
+ bump(mem, key);
366
+ return;
367
+ }
368
+
369
+ // Top-level array index write: update only that array slot.
370
+ const offset = getOffset(mem, key);
371
+ if (offset === null) {
372
+ throw new Error(`No shared value found for key "${key}"`);
373
+ }
374
+ const slot = mem.readValueSlot(offset);
375
+ if (
376
+ slot.type === TYPE_ARRAY_REF &&
377
+ typeof prop === "string" &&
378
+ /^\d+$/.test(prop)
379
+ ) {
380
+ const index = Number(prop);
381
+ const ref = { offset: slot.a, count: slot.b, capacity: slot.c };
382
+ if (index >= ref.count) {
383
+ throw new RangeError(
384
+ `Index ${index} out of bounds for array of length ${ref.count}`,
385
+ );
386
+ }
387
+ mem.updateArraySlot(ref, index, encodeValue(mem, val));
388
+ bump(mem, key);
389
+ return;
390
+ }
391
+
392
+ // Top-level array length write: shrink/grow the array in place.
393
+ if (slot.type === TYPE_ARRAY_REF && prop === "length") {
394
+ const newLen = Number(val);
395
+ if (!Number.isInteger(newLen) || newLen < 0) {
396
+ throw new RangeError("Invalid array length");
397
+ }
398
+ const ref = { offset: slot.a, count: slot.b, capacity: slot.c };
399
+ if (newLen <= ref.capacity) {
400
+ mem.updateValueSlot(
401
+ offset,
402
+ arrayRefSlot({
403
+ offset: ref.offset,
404
+ count: newLen,
405
+ capacity: ref.capacity,
406
+ }),
407
+ );
408
+ bump(mem, key);
409
+ return;
410
+ }
411
+ }
412
+
413
+ // Fallback: full read-modify-write.
414
+ mutateValue<unknown, boolean>(
415
+ mem,
416
+ key,
417
+ (value) => {
418
+ if (Array.isArray(value)) {
419
+ (value as unknown as Record<string | symbol, unknown>)[prop] = val;
420
+ } else {
421
+ (value as Record<string | symbol, unknown>)[prop] = val;
422
+ }
423
+ return true;
424
+ },
425
+ arrayCapacity,
426
+ );
427
+ });
428
+ }
429
+
321
430
  /** Create a SharedRef proxy backed by a SharedMemory instance. */
322
431
  function createSharedRef<T>(
323
432
  mem: SharedMemory,
324
433
  key: string,
434
+ initialValue: T | undefined,
325
435
  tracker?: RefCountTracker,
326
436
  arrayCapacity?: number,
327
437
  ): SharedRef<T> {
@@ -352,6 +462,102 @@ function createSharedRef<T>(
352
462
  },
353
463
  };
354
464
 
465
+ // Determine the object's property names so we can hard-bake accessor
466
+ // getters/setters instead of routing every access through a Proxy trap.
467
+ // The shape is known from the initial value (if it is an object) or from
468
+ // shared memory (if the key already exists, e.g. in a worker).
469
+ let propNames: string[] | null = null;
470
+ if (
471
+ initialValue !== null &&
472
+ typeof initialValue === "object" &&
473
+ !Array.isArray(initialValue)
474
+ ) {
475
+ propNames = Object.keys(initialValue as Record<string, unknown>);
476
+ } else {
477
+ const layout = mem.withLock(() => mem.getObjectLayout(key));
478
+ if (layout !== null) {
479
+ propNames = [...layout.properties.keys()];
480
+ }
481
+ }
482
+
483
+ // Object values: return a plain object with hard-baked accessor getters and
484
+ // setters (no Proxy). This avoids the Proxy trap overhead on every property
485
+ // read (~2.6× faster) and stays a plain, serializable object.
486
+ if (propNames !== null && propNames.length > 0) {
487
+ // Methods live on the prototype, not on the object itself. Keeping the
488
+ // data properties as the only own properties lets the JIT treat the
489
+ // object as monomorphic, which is ~2× faster for accessor reads than
490
+ // having extra own properties (read/write/update/key/buffer).
491
+ const proto: Record<string | symbol, unknown> = {
492
+ [Symbol.toPrimitive]: () => target.read(),
493
+ };
494
+ Object.defineProperty(proto, "read", {
495
+ enumerable: false,
496
+ configurable: false,
497
+ value: target.read,
498
+ });
499
+ Object.defineProperty(proto, "write", {
500
+ enumerable: false,
501
+ configurable: false,
502
+ value: target.write,
503
+ });
504
+ Object.defineProperty(proto, "update", {
505
+ enumerable: false,
506
+ configurable: false,
507
+ value: target.update,
508
+ });
509
+ // key/buffer are non-enumerable so they don't appear in JSON.stringify /
510
+ // Object.keys (matching the previous Proxy behavior).
511
+ Object.defineProperty(proto, "key", {
512
+ enumerable: false,
513
+ configurable: false,
514
+ value: key,
515
+ });
516
+ Object.defineProperty(proto, "buffer", {
517
+ enumerable: false,
518
+ configurable: false,
519
+ get() {
520
+ return mem.dataTable.buffer;
521
+ },
522
+ });
523
+ const ref = Object.create(proto) as Record<string | symbol, unknown>;
524
+ for (const name of propNames) {
525
+ Object.defineProperty(ref, name, {
526
+ enumerable: true,
527
+ configurable: false,
528
+ get() {
529
+ // Lock-free fast path inlined so the JIT can optimize the hot
530
+ // cache-hit read without a function-call chain.
531
+ const cached = readCache.get(key);
532
+ if (
533
+ cached !== undefined &&
534
+ cached.version === mem.getVersion(cached.index)
535
+ ) {
536
+ const v = cached.value;
537
+ if (Array.isArray(v)) {
538
+ return getTopLevelArrayMember(mem, key, v, name, arrayCapacity);
539
+ }
540
+ const pv = (v as Record<string | symbol, unknown>)[name];
541
+ if (Array.isArray(pv)) {
542
+ return createArrayProxy(mem, key, name, arrayCapacity);
543
+ }
544
+ return pv;
545
+ }
546
+ return readProp(mem, key, name, arrayCapacity);
547
+ },
548
+ set(v: unknown) {
549
+ target.__cache = undefined;
550
+ writeProp(mem, key, name, v, arrayCapacity);
551
+ },
552
+ });
553
+ }
554
+ // Fixed shape: reject adding new properties (throws in strict mode).
555
+ Object.preventExtensions(ref);
556
+ return ref as unknown as SharedRef<T>;
557
+ }
558
+
559
+ // Arrays, primitives, or unknown shape: use a Proxy (index access, array
560
+ // methods, and dynamic properties require interception).
355
561
  return new Proxy(target, {
356
562
  get(t, prop) {
357
563
  if (prop === "read") return t.read;
@@ -371,177 +577,12 @@ function createSharedRef<T>(
371
577
  }
372
578
  }
373
579
 
374
- // Fast path: if the full value is cached and valid, read the property
375
- // from the cached value (no decode, no lock). The cache entry is kept
376
- // directly on the target to avoid a Map lookup on the hot path.
377
- const cached = t.__cache;
378
- if (
379
- cached !== undefined &&
380
- cached.version === mem.getVersion(cached.index)
381
- ) {
382
- const cachedValue = cached.value;
383
- if (Array.isArray(cachedValue)) {
384
- return getTopLevelArrayMember(
385
- mem,
386
- key,
387
- cachedValue,
388
- prop,
389
- arrayCapacity,
390
- );
391
- }
392
- const propValue = (cachedValue as Record<string | symbol, unknown>)[
393
- prop
394
- ];
395
- if (Array.isArray(propValue)) {
396
- return createArrayProxy(mem, key, prop as string, arrayCapacity);
397
- }
398
- return propValue;
399
- }
400
-
401
- // Cache miss: for objects, decode only the requested property's slot via
402
- // the cached layout, avoiding a full-object decode. The decoded value is
403
- // cached per property so repeated reads of the same property are cheap.
404
- const layout = getObjectLayout(mem, key);
405
- if (layout !== null) {
406
- const slotOffset = layout.properties.get(prop as string);
407
- if (slotOffset === undefined) return undefined;
408
-
409
- // Property cache hit: the object's version is unchanged.
410
- const pc = propCache.get(key);
411
- const version = pc !== undefined ? mem.getVersion(pc.index) : -1;
412
- if (pc !== undefined && pc.version === version) {
413
- const cachedProp = pc.props.get(prop as string);
414
- if (cachedProp !== undefined) {
415
- if (Array.isArray(cachedProp)) {
416
- return createArrayProxy(mem, key, prop as string, arrayCapacity);
417
- }
418
- return cachedProp;
419
- }
420
- }
421
-
422
- // Decode the single slot and cache it under the object's version.
423
- // The key's index is stable (keys are never removed), so reuse the
424
- // cached index when available to avoid the linear index-table lookup.
425
- const index =
426
- pc !== undefined
427
- ? pc.index
428
- : (readCache.get(key)?.index ?? getIndex(mem, key));
429
- const slot = mem.readValueSlot(slotOffset);
430
- // Array properties are handled lazily by the array proxy; don't decode
431
- // the whole array here (avoids O(n) work on every access).
432
- if (slot.type === TYPE_ARRAY_REF) {
433
- return createArrayProxy(mem, key, prop as string, arrayCapacity);
434
- }
435
- const propValue = decodeValue(mem, slot);
436
- if (pc === undefined || pc.version !== version) {
437
- propCache.set(key, {
438
- version: mem.getVersion(index),
439
- index,
440
- props: new Map(),
441
- });
442
- }
443
- propCache.get(key)!.props.set(prop as string, propValue);
444
- if (Array.isArray(propValue)) {
445
- return createArrayProxy(mem, key, prop as string, arrayCapacity);
446
- }
447
- return propValue;
448
- }
449
-
450
- // Primitive/array key: use the cached value directly (no copy).
451
- const value = readValue(mem, key, t);
452
- if (Array.isArray(value)) {
453
- return getTopLevelArrayMember(mem, key, value, prop, arrayCapacity);
454
- }
455
- // Object value: property access.
456
- const propValue = (value as Record<string | symbol, unknown>)[prop];
457
- if (Array.isArray(propValue)) {
458
- return createArrayProxy(mem, key, prop as string, arrayCapacity);
459
- }
460
- return propValue;
580
+ return readProp(mem, key, prop, arrayCapacity);
461
581
  },
462
582
  set(t, prop, val) {
463
- // In-place update: write only the affected slot instead of decoding and
464
- // re-encoding the whole value.
465
583
  t.__cache = undefined;
466
- return mem.withLock(() => {
467
- // Object property: use the cached layout to write only that slot.
468
- const layout = getObjectLayout(mem, key);
469
- if (layout !== null) {
470
- const slotOffset = layout.properties.get(prop as string);
471
- if (slotOffset === undefined) {
472
- throw new TypeError(
473
- `Value for key "${key}" has a fixed shape: properties may not be added or removed`,
474
- );
475
- }
476
- mem.updateValueSlot(slotOffset, encodeValue(mem, val));
477
- bump(mem, key);
478
- return true;
479
- }
480
-
481
- // Top-level array index write: update only that array slot.
482
- const offset = getOffset(mem, key);
483
- if (offset === null) {
484
- throw new Error(`No shared value found for key "${key}"`);
485
- }
486
- const slot = mem.readValueSlot(offset);
487
- if (
488
- slot.type === TYPE_ARRAY_REF &&
489
- typeof prop === "string" &&
490
- /^\d+$/.test(prop)
491
- ) {
492
- const index = Number(prop);
493
- const ref = { offset: slot.a, count: slot.b, capacity: slot.c };
494
- if (index >= ref.count) {
495
- throw new RangeError(
496
- `Index ${index} out of bounds for array of length ${ref.count}`,
497
- );
498
- }
499
- mem.updateArraySlot(ref, index, encodeValue(mem, val));
500
- bump(mem, key);
501
- return true;
502
- }
503
-
504
- // Top-level array length write: shrink/grow the array in place without
505
- // re-encoding the whole array (which would leave a dead slot behind).
506
- if (slot.type === TYPE_ARRAY_REF && prop === "length") {
507
- const newLen = Number(val);
508
- if (!Number.isInteger(newLen) || newLen < 0) {
509
- throw new RangeError("Invalid array length");
510
- }
511
- const ref = { offset: slot.a, count: slot.b, capacity: slot.c };
512
- if (newLen <= ref.capacity) {
513
- // Shrinking (or growing within capacity): just update the count.
514
- mem.updateValueSlot(
515
- offset,
516
- arrayRefSlot({
517
- offset: ref.offset,
518
- count: newLen,
519
- capacity: ref.capacity,
520
- }),
521
- );
522
- bump(mem, key);
523
- return true;
524
- }
525
- // Growing beyond capacity falls through to the full read-modify-write.
526
- }
527
-
528
- // Fallback: full read-modify-write (e.g. setting a primitive key's
529
- // property, or a non-index property on an array).
530
- return mutateValue<T, boolean>(
531
- mem,
532
- key,
533
- (value) => {
534
- if (Array.isArray(value)) {
535
- (value as unknown as Record<string | symbol, unknown>)[prop] =
536
- val;
537
- } else {
538
- (value as Record<string | symbol, unknown>)[prop] = val;
539
- }
540
- return true;
541
- },
542
- arrayCapacity,
543
- );
544
- });
584
+ writeProp(mem, key, prop, val, arrayCapacity);
585
+ return true;
545
586
  },
546
587
  deleteProperty() {
547
588
  throw new TypeError(
@@ -551,6 +592,22 @@ function createSharedRef<T>(
551
592
  }) as unknown as SharedRef<T>;
552
593
  }
553
594
 
595
+ /**
596
+ * Lock-free fast path: if the cached version matches the current version cell,
597
+ * the cached value is still valid. Returns the cached value, or a miss marker.
598
+ * Kept small so the JIT can inline it into hot accessor getters.
599
+ */
600
+ function readCachedValue(
601
+ mem: SharedMemory,
602
+ key: string,
603
+ ): { hit: true; value: unknown } | { hit: false } {
604
+ const cached = readCache.get(key);
605
+ if (cached !== undefined && cached.version === mem.getVersion(cached.index)) {
606
+ return { hit: true, value: cached.value };
607
+ }
608
+ return { hit: false };
609
+ }
610
+
554
611
  /**
555
612
  * Read the full value for a key from shared memory, using the read cache.
556
613
  *
@@ -565,15 +622,12 @@ function readValue(
565
622
  __cache?: { version: number; value: unknown; index: number };
566
623
  },
567
624
  ): unknown {
625
+ const fast = readCachedValue(mem, key);
626
+ if (fast.hit) return fast.value;
627
+
568
628
  const cached = readCache.get(key);
569
629
  const index = cached !== undefined ? cached.index : getIndex(mem, key);
570
-
571
- // Lock-free fast path: if the cached version matches the current version
572
- // cell, the cached value is still valid.
573
630
  const version = mem.getVersion(index);
574
- if (cached !== undefined && cached.version === version) {
575
- return cached.value;
576
- }
577
631
 
578
632
  // Cache miss: decode under the lock and cache with the observed version.
579
633
  const value = mem.withLock(() => {