@lazily-hub/lazily-js 0.7.0 → 0.10.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/BENCHMARKS.md +115 -0
- package/README.md +145 -7
- package/bench/scale.bench.mjs +176 -0
- package/package.json +11 -2
- package/src/distributed.d.ts +11 -0
- package/src/distributed.js +101 -2
- package/src/index.d.ts +12 -0
- package/src/index.js +41 -2
- package/src/reactive-family.d.ts +100 -0
- package/src/reactive-family.js +269 -0
- package/src/shm-backend.js +435 -0
- package/src/transport.d.ts +113 -0
- package/src/transport.js +491 -0
package/BENCHMARKS.md
CHANGED
|
@@ -89,3 +89,118 @@ node scripts/run-benchmarks.mjs # record "before" baseline in BENCHMA
|
|
|
89
89
|
# apply the performance patch
|
|
90
90
|
node scripts/run-benchmarks.mjs # compare against the new numbers
|
|
91
91
|
```
|
|
92
|
+
|
|
93
|
+
## Scale (≥1M cells) — spreadsheet-shaped graph
|
|
94
|
+
|
|
95
|
+
A second suite ([`bench/scale.bench.mjs`](bench/scale.bench.mjs)) replicates the
|
|
96
|
+
lazily-rs [`scale`](https://github.com/lazily-hub/lazily-rs/blob/main/benches/scale.rs)
|
|
97
|
+
group, the lazily-go `scale` group, and lazily-py's
|
|
98
|
+
[`scale_bench.py`](https://github.com/lazily-hub/lazily-py/blob/main/src/lazily/scale_bench.py)
|
|
99
|
+
on a **spreadsheet-shaped** graph: `N` input cells + `N` formula slots where
|
|
100
|
+
`formula[i] = input[i] + input[i - 1]` (local fan-in, like a column of
|
|
101
|
+
`=A_i + A_{i-1}`). With the default `N = 1,000,000` that is **~2,000,000
|
|
102
|
+
reactive nodes**. Four scenarios cover the spreadsheet lifecycle:
|
|
103
|
+
|
|
104
|
+
- `build` — construct all `2N` nodes (formulas lazy, not yet computed).
|
|
105
|
+
- `cold_full_recalc` — first read of every formula (forces every compute + edge-tracking).
|
|
106
|
+
- `viewport_recalc` — edit one input, read only a 1,000-cell viewport (the
|
|
107
|
+
lazy-pull win: off-viewport formulas stay dirty and never recompute).
|
|
108
|
+
- `full_recalc_invalidate_all` — re-set every input, then read every formula
|
|
109
|
+
(worst-case full-sheet edit).
|
|
110
|
+
|
|
111
|
+
> **A "cell count" here counts two cells per row** — the graph models a column of
|
|
112
|
+
> formulas `=A_i + A_{i-1}`, so each row is **one input cell `A_i` plus one
|
|
113
|
+
> formula cell**. `N` rows ⇒ `N` inputs + `N` formulas = `2N` cells.
|
|
114
|
+
|
|
115
|
+
Timings use `performance.now()` (`node:perf_hooks`); single wall-clock run per
|
|
116
|
+
scenario. Lower is better. Treat the absolute numbers as indicative — the shapes
|
|
117
|
+
(relative costs, size-scaling behavior) are what transfer across runs and hosts.
|
|
118
|
+
|
|
119
|
+
### Reproduce
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
make bench-scale # scale suite at N = 1,000,000
|
|
123
|
+
node bench/scale.bench.mjs # same, directly
|
|
124
|
+
|
|
125
|
+
# scale at a specific size / viewport:
|
|
126
|
+
LAZILY_SCALE_N=1000000 node bench/scale.bench.mjs
|
|
127
|
+
LAZILY_SCALE_N=5000000 node bench/scale.bench.mjs # Google Sheets 10M-cell workbook
|
|
128
|
+
LAZILY_SCALE_VIEWPORT=1000 node bench/scale.bench.mjs
|
|
129
|
+
BENCH_FORMAT=json node bench/scale.bench.mjs # machine-readable output
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Large `N` needs headroom for the V8 heap — run with
|
|
133
|
+
`node --max-old-space-size=8192 bench/scale.bench.mjs` at 1M and `16384` at 5M.
|
|
134
|
+
|
|
135
|
+
### Hardware / environment
|
|
136
|
+
|
|
137
|
+
| | |
|
|
138
|
+
|---|---|
|
|
139
|
+
| CPU | AMD Ryzen 9 9950X3D (16 cores / 32 threads) |
|
|
140
|
+
| RAM | 186 GiB |
|
|
141
|
+
| OS | Linux 7.1.3 (CachyOS), x86-64 |
|
|
142
|
+
| Node.js | 26.4.0 (V8) |
|
|
143
|
+
|
|
144
|
+
### 1,000,000 rows (~2M cells / nodes)
|
|
145
|
+
|
|
146
|
+
Peak RSS ~1.0 GiB (~550 B/node).
|
|
147
|
+
|
|
148
|
+
| Benchmark | Time | Per cell | What it measures |
|
|
149
|
+
|-----------|-----:|---------:|------------------|
|
|
150
|
+
| `build` | ~0.90 s | ~450 ns | Construct all 2N nodes (each `Cell`/`Slot` allocates its own dependency structures — allocation- and GC-bound under V8). |
|
|
151
|
+
| `cold_full_recalc` | ~0.65 s | ~650 ns | First read of every formula — forces every compute + edge-tracking. |
|
|
152
|
+
| `viewport_recalc` | **~100 µs** | — | Edit one input, read only a 1,000-cell viewport. ~6,500× cheaper than a full cold recalc. |
|
|
153
|
+
| `full_recalc_invalidate_all` | ~1.30 s | ~1.30 µs | Re-set every input, then recompute the whole sheet (worst-case full-sheet edit). |
|
|
154
|
+
|
|
155
|
+
### 5,000,000 rows (10M cells — a full Google Sheets workbook)
|
|
156
|
+
|
|
157
|
+
Google Sheets caps a workbook at **10,000,000 cells**. Modeled as 5,000,000
|
|
158
|
+
input cells + 5,000,000 formula cells (`LAZILY_SCALE_N=5000000`). This is the
|
|
159
|
+
**largest size actually measured** — no extrapolation. Peak RSS ~5.0 GiB.
|
|
160
|
+
|
|
161
|
+
| Benchmark | Time | Per cell | What it measures |
|
|
162
|
+
|-----------|-----:|---------:|------------------|
|
|
163
|
+
| `build` | ~6.56 s | ~656 ns | Build the full 10M-node workbook (allocation/GC-bound). |
|
|
164
|
+
| `cold_full_recalc` | ~3.37 s | ~674 ns | Compute all 5M formulas cold. |
|
|
165
|
+
| `viewport_recalc` | **~106 µs** | — | Edit one input, read a 1,000-cell viewport. ~31,800× cheaper than a full cold recalc. |
|
|
166
|
+
| `full_recalc_invalidate_all` | ~7.38 s | ~1.48 µs | Re-set every input, recompute the whole workbook. |
|
|
167
|
+
|
|
168
|
+
So lazily-js backs a **full-capacity Google Sheets workbook** on V8: building it
|
|
169
|
+
is the expensive part (~6.6 s, allocation/GC-bound — one JS object graph per
|
|
170
|
+
node), but once built, a full cold recompute is ~3.4 s, and a one-cell edit +
|
|
171
|
+
bounded-viewport read stays in the **~100 µs range**. The lazy pull-based model
|
|
172
|
+
leaves off-viewport formulas dirty and never recomputes them — only ~2 formulas
|
|
173
|
+
actually recompute per edit (the two that read the edited input), regardless of
|
|
174
|
+
sheet size, which is exactly the property a viewport-rendered spreadsheet needs.
|
|
175
|
+
|
|
176
|
+
### Spreadsheet cell-count context
|
|
177
|
+
|
|
178
|
+
| Spreadsheet | Documented limit | Cells |
|
|
179
|
+
|-------------|------------------|------:|
|
|
180
|
+
| Google Sheets | 10,000,000 cells per workbook (18,278 columns max) | 10,000,000 |
|
|
181
|
+
| Microsoft Excel | 1,048,576 rows × 16,384 columns per worksheet | 17,179,869,184 |
|
|
182
|
+
|
|
183
|
+
The `LAZILY_SCALE_N=5000000` run above covers a full Google Sheets workbook. A
|
|
184
|
+
grid-complete Excel worksheet (17 billion cells) is unrepresentative — real
|
|
185
|
+
sheets populate a tiny fraction of the grid, and lazily only stores the cells you
|
|
186
|
+
create, so the `scale` group measures the populated-cell path that matters.
|
|
187
|
+
|
|
188
|
+
### Viewport scaling — flat
|
|
189
|
+
|
|
190
|
+
lazily-js's viewport recalc is **effectively size-independent** (~100 µs at 2M
|
|
191
|
+
nodes, ~106 µs at 10M nodes). The value cache is keyed by node identity, so both
|
|
192
|
+
the ~1,000 viewport cache-hit reads and the ~2 actual recomputes are O(1) map
|
|
193
|
+
operations that don't scale with total sheet size. This matches lazily-rs's and
|
|
194
|
+
lazily-py's flat curves. At 10M cells a one-cell edit + 1,000-cell viewport read
|
|
195
|
+
is ~31,800× cheaper than a full cold recalc and never touches off-viewport
|
|
196
|
+
formulas.
|
|
197
|
+
|
|
198
|
+
### Cross-language honesty
|
|
199
|
+
|
|
200
|
+
V8 is heavier per node than compiled Rust (lazily-rs builds the same 2M-node
|
|
201
|
+
graph in ~0.1 s vs ~0.9 s here) but far lighter than CPython (lazily-py's build
|
|
202
|
+
is ~10.6 s at 1M). The recompute paths (`cold_full_recalc` ~650 ns/formula) are
|
|
203
|
+
JIT-compiled and closer to the compiled bindings. These numbers are reported
|
|
204
|
+
honestly, not to claim parity — the transferable result is the **shape**: the
|
|
205
|
+
lazy-pull viewport property holds identically across all bindings (a one-cell
|
|
206
|
+
edit + bounded-viewport read is microseconds, independent of sheet size).
|
package/README.md
CHANGED
|
@@ -39,6 +39,10 @@ notes and platform carve-outs lives in
|
|
|
39
39
|
| Feature | Rust | Python | Kotlin | JS | Dart | Zig | Go | C++ |
|
|
40
40
|
| --------- | :----: | :------: | :------: | :--: | :----: | :---: | :--: | :---: |
|
|
41
41
|
| Reactive graph — `Cell` / `Slot` / `Signal` / `Effect` / memo / batch | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
42
|
+
| Reactive family (`ReactiveFamily`) — keyed cell/slot family + materialization mode (`#lzmatmode`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
43
|
+
| Thread-safe reactive family (`ThreadSafeReactiveFamily`) — `Send + Sync` keyed family + materialization confluence (`#lzmatmode`) | ✅ | — | — | — | — | — | — | — |
|
|
44
|
+
| Async reactive family (`AsyncReactiveFamily`) — keyed family + eventual transparency (`#lzmatmode`) | ✅ | — | — | — | — | — | — | — |
|
|
45
|
+
| Reactive family sync — membership propagation + materialize-on-ingest + derived-aggregate transparency (`#lzfamilysync`) | ✅ | — | ✅ | ✅ | — | — | — | — |
|
|
42
46
|
| Thread-safe context (lock-backed) | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | ✅ |
|
|
43
47
|
| Async reactive context | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
44
48
|
| Flat state machine | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
@@ -46,21 +50,22 @@ notes and platform carve-outs lives in
|
|
|
46
50
|
| Keyed cell collections (`CellMap` / `CellTree`) + reconcile | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
47
51
|
| Memoized semantic tree (`SemTree`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
48
52
|
| Stable-id alignment (manufactured identity) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
49
|
-
| Reactive queue (`QueueCell` SPSC/MPSC + `QueueStorage` adapter) | ✅ |
|
|
53
|
+
| Reactive queue (`QueueCell` SPSC/MPSC + `QueueStorage` adapter) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
50
54
|
| Free-text character CRDT (`TextCrdt`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
51
55
|
| `TextCrdt` delta sync (`version_vector` / `delta_since` / `apply_delta`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
52
56
|
| Move-aware sequence CRDT (`SeqCrdt`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
53
|
-
| Lossless tree CRDT core (`LosslessTreeCrdt`, M1) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
54
|
-
| Lossless tree — dotted-frontier anti-entropy | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
55
|
-
| Lossless tree — concurrent merge convergence | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
57
|
+
| Lossless tree CRDT core (`LosslessTreeCrdt`, M1) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
58
|
+
| Lossless tree — dotted-frontier anti-entropy | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
59
|
+
| Lossless tree — concurrent merge convergence | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
56
60
|
| Registers (LWW / MV) + `PnCounter` + `CellCrdt` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
57
61
|
| IPC wire — `Snapshot` + `Delta` + `CrdtSync` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
58
|
-
| Shared-memory blob path (`ShmBlobArena`) | ✅ | ✅ | ✅ |
|
|
62
|
+
| Shared-memory blob path (`ShmBlobArena`) | ✅ | ✅ | ✅ | ✅ | ~ | ✅ | ✅ | ✅ |
|
|
63
|
+
| Cross-process zero-copy transport (`BlobBackend` / shm / arrow) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
59
64
|
| Distributed CRDT plane (`CrdtPlaneRuntime` / anti-entropy) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
60
65
|
| Distributed plane — WebRTC transport + signaling | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
61
66
|
| State projection / mirror | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
62
67
|
| Causal receipts (`CausalReceipts` outcome projection) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
63
|
-
| Message-passing + RPC command plane (`command-plane-v1`) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
68
|
+
| Message-passing + RPC command plane (`command-plane-v1`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
64
69
|
| C-ABI FFI boundary | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ |
|
|
65
70
|
| Permission boundary (`PeerPermissions` / `RemoteOp`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
66
71
|
| Capability negotiation (`SessionHandshake`) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
@@ -74,8 +79,10 @@ and JSON Schemas in `lazily-spec` and the Lean models in `lazily-formal`.
|
|
|
74
79
|
| Import | What it is |
|
|
75
80
|
|--------|------------|
|
|
76
81
|
| `@lazily-hub/lazily-js` | `lazily-spec` IPC wire types: `Snapshot`, `Delta`, `DeltaOp`, `IpcMessage` (`Snapshot` / `Delta` / `CrdtSync`), `NodeState`, `IpcValue`, `PeerPermissions`, `SessionHandshake`, `BINDING_CAPABILITIES` |
|
|
82
|
+
| `@lazily-hub/lazily-js/transport` | Cross-process zero-copy transport (`#lzzcpy`): `ShmBlobArena`, `InProcessBackend` / `ArrowBackend`, `BlobRouter`, `spillMessage` / `resolveValue`, and the FFI-gated `createShmBackend` (Node/Bun/Deno). Isomorphic — no FFI import; browser-safe |
|
|
77
83
|
| `@lazily-hub/lazily-js/reactive` | Reactive dependency graph: `Context`, `Cell`, `Slot`, `Signal`, `Effect` |
|
|
78
84
|
| `@lazily-hub/lazily-js/reactive-async` | Async reactive graph: `AsyncContext` — Promise-driven slots/effects with revision-guarded stale-completion discard, in-flight dedup, and cancellation |
|
|
85
|
+
| `@lazily-hub/lazily-js/reactive-family` | Unified keyed reactive family: `ReactiveFamily` (`EntryKind` cell/slot × `MaterializationMode` eager/lazy) + `cellFamily` input-cell specialization (`#lzmatmode`) |
|
|
79
86
|
| `@lazily-hub/lazily-js/state-machine` | Flat finite-state-machine kernel backed by a reactive `Cell` |
|
|
80
87
|
| `@lazily-hub/lazily-js/statechart` | Harel/SCXML chart interpreter plus `ChartBuilder`, `StateBuilder`, `TransitionBuilder` |
|
|
81
88
|
| `@lazily-hub/lazily-js/collections` | `CellMap`, `CellTree`, keyed reconciliation, and LIS move minimization |
|
|
@@ -155,6 +162,44 @@ ctx.setCell(userId, 2); // supersedes any in-flight compute; slot re-resolves
|
|
|
155
162
|
await ctx.getAsync(profile); // the profile for user 2
|
|
156
163
|
```
|
|
157
164
|
|
|
165
|
+
## Reactive family and materialization mode
|
|
166
|
+
|
|
167
|
+
`ReactiveFamily` (from `@lazily-hub/lazily-js/reactive-family`) is the unified
|
|
168
|
+
**keyed reactive family** (`#lzmatmode`): it maps keys to per-entry reactive
|
|
169
|
+
nodes and abstracts over the entry's handle kind. Two orthogonal axes:
|
|
170
|
+
|
|
171
|
+
- **Entry kind** — `EntryKind.Cell` entries are input cells (**always
|
|
172
|
+
materialized**, any mode); `EntryKind.Slot` entries are derived slots (what
|
|
173
|
+
materialization governs). `cellFamily(...)` is the input-cell specialization.
|
|
174
|
+
- **Materialization mode** — `MaterializationMode.Eager` (**default**) allocates
|
|
175
|
+
every derived node up front; `MaterializationMode.Lazy` (opt-in) allocates a
|
|
176
|
+
derived node on its **first read** ("materialize on pull") and caches it.
|
|
177
|
+
|
|
178
|
+
Mode is **observationally transparent**: a read returns the same value under
|
|
179
|
+
either mode — it changes allocation timing and memory, never results. Lazy pays
|
|
180
|
+
off only for sparsely-touched large keyed address spaces.
|
|
181
|
+
|
|
182
|
+
```js
|
|
183
|
+
import { Context } from "@lazily-hub/lazily-js/reactive";
|
|
184
|
+
import { ReactiveFamily } from "@lazily-hub/lazily-js/reactive-family";
|
|
185
|
+
|
|
186
|
+
const ctx = new Context();
|
|
187
|
+
|
|
188
|
+
// A derived (slot) family of key*3 over a large address space, built lazily:
|
|
189
|
+
// nothing is allocated until a key is read.
|
|
190
|
+
const fam = ReactiveFamily.lazy(ctx, range(0, 1_000_000), (k) => k * 3);
|
|
191
|
+
fam.presentCount(); // 0
|
|
192
|
+
|
|
193
|
+
fam.observe(5); // 15 — first read materializes just this entry
|
|
194
|
+
fam.presentCount(); // 1
|
|
195
|
+
fam.isPresent(5); // true
|
|
196
|
+
fam.isPresent(6); // false
|
|
197
|
+
|
|
198
|
+
// Eager builds the same values up front — observationally identical.
|
|
199
|
+
const eager = ReactiveFamily.eager(ctx, [0, 1, 2, 3], (k) => k * 3);
|
|
200
|
+
eager.observe(2) === fam.observe(2); // true
|
|
201
|
+
```
|
|
202
|
+
|
|
158
203
|
## State machine and state charts
|
|
159
204
|
|
|
160
205
|
`StateMachine` is the flat finite-state-machine kernel: a pure
|
|
@@ -344,6 +389,69 @@ signaling, and the WebRTC transport are shipped; C-ABI FFI is `none` because
|
|
|
344
389
|
browser/Worker JS cannot host a native in-process ABI. The same payload types
|
|
345
390
|
can still be carried by any transport a host application owns.
|
|
346
391
|
|
|
392
|
+
## Cross-process zero-copy transport
|
|
393
|
+
|
|
394
|
+
`@lazily-hub/lazily-js/transport` implements the pluggable blob-backend
|
|
395
|
+
transport (`#lzzcpy`). A large payload is not copied through the wire codec: the
|
|
396
|
+
producer **spills** it to a backend (which mints a `ShmBlobRef` descriptor) and
|
|
397
|
+
ships only the descriptor; the receiver **routes** the descriptor by its
|
|
398
|
+
`backend` discriminator and **resolves** it zero-copy — reading the backend's own
|
|
399
|
+
bytes in place. `ShmBlobRef` gained an optional `backend` field (`shm` | `arrow`
|
|
400
|
+
| `in_process`) that defaults to `shm` and is omitted from the wire, so every
|
|
401
|
+
pre-transport descriptor round-trips byte-for-byte.
|
|
402
|
+
|
|
403
|
+
The module is **isomorphic**: it imports no FFI, so it bundles and runs in the
|
|
404
|
+
browser. `InProcessBackend`, `ArrowBackend`, the `BlobRouter`, and the
|
|
405
|
+
spill/resolve policy are pure JS and available everywhere (including a
|
|
406
|
+
main-thread ↔ Web Worker deployment).
|
|
407
|
+
|
|
408
|
+
```js
|
|
409
|
+
import {
|
|
410
|
+
BlobRouter,
|
|
411
|
+
InProcessBackend,
|
|
412
|
+
ArrowBackend,
|
|
413
|
+
spillMessage,
|
|
414
|
+
} from "@lazily-hub/lazily-js/transport";
|
|
415
|
+
import { Delta, DeltaOp, IpcMessage, IpcValue } from "@lazily-hub/lazily-js";
|
|
416
|
+
|
|
417
|
+
const backend = new InProcessBackend(); // or ArrowBackend for columnar payloads
|
|
418
|
+
const big = IpcValue.inline(new Uint8Array(4096));
|
|
419
|
+
const { message, spilledBytes } = spillMessage(
|
|
420
|
+
IpcMessage.delta(new Delta({ baseEpoch: 0, epoch: 1, ops: [DeltaOp.slotValue(7, big)] })),
|
|
421
|
+
backend,
|
|
422
|
+
); // message now carries a small SharedBlob descriptor; spilledBytes === 4096
|
|
423
|
+
|
|
424
|
+
const router = new BlobRouter().register(backend);
|
|
425
|
+
router.resolve(message.delta.ops[0].payload); // Uint8Array view, zero copy
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
The genuine **cross-process** `shm` backend (POSIX `shm_open` + `mmap`) is loaded
|
|
429
|
+
lazily and only where a runtime provides FFI — Node (via `koffi`), Bun (`bun:ffi`),
|
|
430
|
+
or Deno (`Deno.dlopen`, needs `--allow-ffi --unstable-ffi`). A peer process on the
|
|
431
|
+
same host that attaches the same name resolves the descriptor without copying
|
|
432
|
+
across the process boundary. In the browser (or any runtime without FFI)
|
|
433
|
+
`createShmBackend` rejects with `ShmUnavailableError`; guard with `shmSupported()`
|
|
434
|
+
and fall back to `InProcessBackend` / `ArrowBackend`.
|
|
435
|
+
|
|
436
|
+
```js
|
|
437
|
+
import { createShmBackend, shmSupported } from "@lazily-hub/lazily-js/transport";
|
|
438
|
+
|
|
439
|
+
if (shmSupported()) {
|
|
440
|
+
const shm = await createShmBackend("my-session", { capacity: 1 << 20 });
|
|
441
|
+
const ref = shm.write(new Uint8Array([1, 2, 3])); // ref.backend === "shm"
|
|
442
|
+
// ...ship `ref` to a peer; the peer attaches `createShmBackend("my-session",
|
|
443
|
+
// { capacity: 1 << 20, create: false })` and calls `shm.readView(ref)`.
|
|
444
|
+
shm.close();
|
|
445
|
+
}
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
All three runtimes are verified end-to-end, including cross-runtime interop: a
|
|
449
|
+
Node process writes a region that a separate Deno process attaches and resolves,
|
|
450
|
+
proving the layout is byte-identical across FFI implementations. The `shm` region
|
|
451
|
+
is a bump-allocated arena with a fixed header (magic / version / capacity / epoch
|
|
452
|
+
/ generation / cursor) and per-entry `{ generation, epoch, len, checksum }`
|
|
453
|
+
validation.
|
|
454
|
+
|
|
347
455
|
## Distributed plane
|
|
348
456
|
|
|
349
457
|
The `@lazily-hub/lazily-js/signaling` and `@lazily-hub/lazily-js/distributed`
|
|
@@ -384,7 +492,11 @@ lazily-js replays the shared `lazily-spec` fixtures for IPC, agent-doc state,
|
|
|
384
492
|
keyed collections (`CellMap`, `CellTree`, LIS reconciliation), semantic tree,
|
|
385
493
|
sequence and text CRDTs (incl. `TextCrdt` delta sync, `#lztextsync`:
|
|
386
494
|
`textcrdt_convergence.json` + `textcrdt_delta_sync.json`), manufactured text
|
|
387
|
-
identity,
|
|
495
|
+
identity, the reactive family / materialization mode (`#lzmatmode`:
|
|
496
|
+
`materialization/observational_transparency.json`,
|
|
497
|
+
`materialization/deferral_not_deallocation.json`,
|
|
498
|
+
`materialization/entry_kind_orthogonal_to_mode.json`), Harel state charts, the
|
|
499
|
+
signaling protocol (`signaling/frames.json`,
|
|
388
500
|
`signaling/anti_spoof_session.json`), and the distributed CRDT plane
|
|
389
501
|
(`distributed/crdt_sync_frames.json`, `distributed/anti_entropy_converge.json`).
|
|
390
502
|
It also validates generated wire values against the canonical JSON Schemas.
|
|
@@ -404,6 +516,7 @@ names the Lean theorems it mirrors:
|
|
|
404
516
|
| `Reactive` | `reactive-properties.test.js` | `setCell_equal_preserves_graph`, `setCell_different_invalidates_dependents`, `recomputeSlot_equal_preserves_dependents`, `recomputeSlot_different_invalidates_dependents`, `signal_materialized_after_recompute` |
|
|
405
517
|
| `Collection` | `collection-properties.test.js` | `setEntryValue_preserves_{membership,order,siblings}`, `moveKey_preserves_{membership,values}`, `moveKey_advances_order`, `addKey_advances_membership_and_order`, `Family.get_idempotent_after_first` |
|
|
406
518
|
| `Tree` | `tree-properties.test.js` | `setNodeValue_preserves_{other_nodes,node_signals}`, `moveChild_preserves_{non_parent,parent_value}`, `moveChild_advances_order_signal_only` |
|
|
519
|
+
| `Materialization` | `reactive-family.test.js` | `observe_canonical`, `eager_lazy_observationally_equivalent`, `eager_materializes_all`, `lazy_defers_slots`, `materialize_present_monotone`, `lazy_present_subset_eager`, `materialize_preserves_observe`, `cell_entries_materialized_in_every_mode`, `slot_entries_deferred_under_lazy` |
|
|
407
520
|
| `Reconciliation` | `reconciliation-properties.test.js` | `lisBy_longest`, `reconcile_move_minimized`, `reconcile_stable_not_invalidated` |
|
|
408
521
|
| `AsyncSlotState` | `reactive-async.test.js` | `stale_completeOk_discarded`, `current_completeOk_publishes`, `current_completeErr_to_error` |
|
|
409
522
|
| `AsyncEffect` | `reactive-async.test.js` | `fire_blocked_during_cleanup`, `invalidate_from_idle_schedules`, `cleanupDone_resumes_deferred`, `dispose_absorbing`, `disposed_terminal` |
|
|
@@ -439,6 +552,31 @@ make check # npm run build && npm test
|
|
|
439
552
|
`lake` are present.
|
|
440
553
|
- `npm test` runs the formal check and the Node test suite.
|
|
441
554
|
|
|
555
|
+
## Benchmarks
|
|
556
|
+
|
|
557
|
+
Wall-clock benchmarks live in [`BENCHMARKS.md`](BENCHMARKS.md), with two suites
|
|
558
|
+
built on a zero-dependency `node:perf_hooks` harness:
|
|
559
|
+
|
|
560
|
+
- **Micro-benchmarks** ([`bench/context.bench.mjs`](bench/context.bench.mjs)) — a
|
|
561
|
+
1:1 port of the single-threaded `Context` cases in lazily-rs's
|
|
562
|
+
`benches/context.rs` (cached reads, cold first get, dependency fan-out,
|
|
563
|
+
set-cell invalidation, memo equality suppression, effect flushing, batch
|
|
564
|
+
storms, typed cache reads) so JS and Rust numbers are directly comparable.
|
|
565
|
+
- **Scale** ([`bench/scale.bench.mjs`](bench/scale.bench.mjs)) — a
|
|
566
|
+
spreadsheet-shaped graph (`N` input cells + `N` formula slots,
|
|
567
|
+
`formula[i] = input[i] + input[i - 1]`) mirroring the lazily-rs/-go/-py `scale`
|
|
568
|
+
groups. At the default `N = 1,000,000` that is ~2M reactive nodes; the
|
|
569
|
+
`LAZILY_SCALE_N=5000000` run covers a full 10M-cell Google Sheets workbook. A
|
|
570
|
+
one-cell edit + 1,000-cell viewport read stays ~100 µs **independent of sheet
|
|
571
|
+
size** — the lazy-pull property a viewport-rendered spreadsheet needs.
|
|
572
|
+
|
|
573
|
+
```bash
|
|
574
|
+
make bench # micro-suite (prints a markdown table)
|
|
575
|
+
make bench-scale # scale suite at N = 1,000,000
|
|
576
|
+
npm run benchmark-update # refresh BENCHMARKS.md's generated micro-bench table
|
|
577
|
+
npm run benchmark-check # CI gate: exit 1 if the micro-bench row set is stale
|
|
578
|
+
```
|
|
579
|
+
|
|
442
580
|
## See also
|
|
443
581
|
|
|
444
582
|
- [`lazily-spec`][spec] - language-agnostic wire protocol, schemas, and
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Large-graph scale benchmark for lazily-js.
|
|
2
|
+
//
|
|
3
|
+
// Replicates the lazily-rs `scale` group (`benches/scale.rs`), the lazily-go
|
|
4
|
+
// `scale` group, and lazily-py's `scale_bench.py` on a spreadsheet-shaped
|
|
5
|
+
// graph: `N` input cells plus `N` formula slots, where
|
|
6
|
+
// `formula[i] = input[i] + input[i - 1]` (local fan-in, like a column of
|
|
7
|
+
// `=A_i + A_{i-1}`). With the default `N = 1_000_000` that is
|
|
8
|
+
// ~2,000,000 reactive nodes. Four scenarios cover the spreadsheet lifecycle:
|
|
9
|
+
//
|
|
10
|
+
// - build — construct all 2N nodes (formulas lazy, not yet computed).
|
|
11
|
+
// - cold_full_recalc — first read of every formula (forces every compute + edge-tracking).
|
|
12
|
+
// - viewport_recalc — edit one input, read only a bounded viewport (the lazy-pull win:
|
|
13
|
+
// off-viewport formulas stay dirty and never recompute).
|
|
14
|
+
// - full_recalc_invalidate_all — touch every input, then read every formula (worst-case full edit).
|
|
15
|
+
//
|
|
16
|
+
// Run as a script:
|
|
17
|
+
//
|
|
18
|
+
// node bench/scale.bench.mjs
|
|
19
|
+
// LAZILY_SCALE_N=1000000 node bench/scale.bench.mjs
|
|
20
|
+
// LAZILY_SCALE_N=5000000 node bench/scale.bench.mjs # Google Sheets 10M-cell workbook
|
|
21
|
+
// LAZILY_SCALE_VIEWPORT=1000 node bench/scale.bench.mjs
|
|
22
|
+
// BENCH_FORMAT=json node bench/scale.bench.mjs # machine-readable output
|
|
23
|
+
//
|
|
24
|
+
// JS runs on a single event-loop thread; this is a single-threaded benchmark
|
|
25
|
+
// (the concurrency surfaces are correctness-tested, not benchmarked here).
|
|
26
|
+
|
|
27
|
+
import { performance } from "node:perf_hooks";
|
|
28
|
+
import { Context } from "../src/reactive.js";
|
|
29
|
+
import { blackBox } from "./harness.mjs";
|
|
30
|
+
|
|
31
|
+
export function scaleN() {
|
|
32
|
+
const raw = Number(process.env.LAZILY_SCALE_N);
|
|
33
|
+
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 1_000_000;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function scaleViewport(n) {
|
|
37
|
+
const raw = Number(process.env.LAZILY_SCALE_VIEWPORT);
|
|
38
|
+
const vp = Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 1_000;
|
|
39
|
+
return Math.min(vp, n);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Construct N input cells + N formula slots (formulas lazy, not yet computed).
|
|
43
|
+
// formula[i] = input[i] + input[i - 1]; each read goes through ctx.getCell so
|
|
44
|
+
// the running slot is auto-tracked as a dependent (dynamic edge discovery).
|
|
45
|
+
export function buildScaleGraph(n) {
|
|
46
|
+
const ctx = new Context();
|
|
47
|
+
const inputs = new Array(n);
|
|
48
|
+
for (let i = 0; i < n; i++) inputs[i] = ctx.cell(i);
|
|
49
|
+
const formulas = new Array(n);
|
|
50
|
+
for (let i = 0; i < n; i++) {
|
|
51
|
+
const a = inputs[i];
|
|
52
|
+
const b = i > 0 ? inputs[i - 1] : inputs[0];
|
|
53
|
+
formulas[i] = ctx.computed(() => (ctx.getCell(a) + ctx.getCell(b)) >>> 0);
|
|
54
|
+
}
|
|
55
|
+
return { ctx, inputs, formulas };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readAllFormulas(g) {
|
|
59
|
+
const { ctx, formulas } = g;
|
|
60
|
+
let acc = 0;
|
|
61
|
+
for (let i = 0; i < formulas.length; i++) acc = (acc + ctx.get(formulas[i])) >>> 0;
|
|
62
|
+
return acc;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function benchBuild(n) {
|
|
66
|
+
const start = performance.now();
|
|
67
|
+
const g = buildScaleGraph(n);
|
|
68
|
+
const elapsed = (performance.now() - start) / 1000;
|
|
69
|
+
const cells = 2 * n;
|
|
70
|
+
blackBox(g.formulas.length);
|
|
71
|
+
return {
|
|
72
|
+
name: "build",
|
|
73
|
+
n,
|
|
74
|
+
seconds: elapsed,
|
|
75
|
+
cells,
|
|
76
|
+
perCellNs: (elapsed / cells) * 1e9,
|
|
77
|
+
detail: `${cells.toLocaleString("en-US")} nodes`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function benchColdFullRecalc(n) {
|
|
82
|
+
const g = buildScaleGraph(n);
|
|
83
|
+
const start = performance.now();
|
|
84
|
+
const sink = readAllFormulas(g);
|
|
85
|
+
const elapsed = (performance.now() - start) / 1000;
|
|
86
|
+
blackBox(sink);
|
|
87
|
+
return {
|
|
88
|
+
name: "cold_full_recalc",
|
|
89
|
+
n,
|
|
90
|
+
seconds: elapsed,
|
|
91
|
+
cells: n,
|
|
92
|
+
perCellNs: (elapsed / n) * 1e9,
|
|
93
|
+
detail: `${n.toLocaleString("en-US")} formulas`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function benchViewportRecalc(n, samples = 1_000) {
|
|
98
|
+
const vp = scaleViewport(n);
|
|
99
|
+
const g = buildScaleGraph(n);
|
|
100
|
+
readAllFormulas(g); // warm the whole sheet once
|
|
101
|
+
const { ctx, inputs, formulas } = g;
|
|
102
|
+
const mid = Math.floor(n / 2);
|
|
103
|
+
const lo = Math.max(0, mid - Math.floor(vp / 2));
|
|
104
|
+
const hi = Math.min(n, lo + vp);
|
|
105
|
+
const window = formulas.slice(lo, hi);
|
|
106
|
+
const midInput = inputs[mid];
|
|
107
|
+
let acc = 0;
|
|
108
|
+
const start = performance.now();
|
|
109
|
+
for (let i = 0; i < samples; i++) {
|
|
110
|
+
ctx.setCell(midInput, i + 1); // edit one input (monotonic → passes PartialEq guard)
|
|
111
|
+
for (let j = 0; j < window.length; j++) acc = (acc + ctx.get(window[j])) >>> 0;
|
|
112
|
+
}
|
|
113
|
+
const elapsed = performance.now() - start; // ms total
|
|
114
|
+
blackBox(acc); // defeat dead-code elimination of the viewport reads
|
|
115
|
+
const perOpUs = (elapsed / samples) * 1000;
|
|
116
|
+
return {
|
|
117
|
+
name: "viewport_recalc",
|
|
118
|
+
n,
|
|
119
|
+
seconds: elapsed / 1000 / samples,
|
|
120
|
+
cells: vp,
|
|
121
|
+
perCellNs: null,
|
|
122
|
+
detail: `${perOpUs.toFixed(2)} us/edit, viewport=${vp}, ${samples} edits`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function benchFullRecalcInvalidateAll(n) {
|
|
127
|
+
const g = buildScaleGraph(n);
|
|
128
|
+
readAllFormulas(g); // warm once
|
|
129
|
+
const { ctx, inputs } = g;
|
|
130
|
+
const start = performance.now();
|
|
131
|
+
for (let j = 0; j < inputs.length; j++) ctx.setCell(inputs[j], j + 1); // touch every input
|
|
132
|
+
const sink = readAllFormulas(g);
|
|
133
|
+
const elapsed = (performance.now() - start) / 1000;
|
|
134
|
+
blackBox(sink);
|
|
135
|
+
return {
|
|
136
|
+
name: "full_recalc_invalidate_all",
|
|
137
|
+
n,
|
|
138
|
+
seconds: elapsed,
|
|
139
|
+
cells: n,
|
|
140
|
+
perCellNs: (elapsed / n) * 1e9,
|
|
141
|
+
detail: `${n.toLocaleString("en-US")} inputs re-set + ${n.toLocaleString("en-US")} formulas recomputed`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function runScaleBenchmarks(n = scaleN()) {
|
|
146
|
+
return [
|
|
147
|
+
benchBuild(n),
|
|
148
|
+
benchColdFullRecalc(n),
|
|
149
|
+
benchViewportRecalc(n),
|
|
150
|
+
benchFullRecalcInvalidateAll(n),
|
|
151
|
+
];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function fmtCell(perCellNs) {
|
|
155
|
+
return perCellNs == null ? "—" : `${perCellNs.toFixed(1).padStart(8)} ns/cell`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function main() {
|
|
159
|
+
const n = scaleN();
|
|
160
|
+
const results = runScaleBenchmarks(n);
|
|
161
|
+
if (process.env.BENCH_FORMAT === "json") {
|
|
162
|
+
console.log(JSON.stringify({ n, nodes: 2 * n, results }, null, 2));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
console.log(
|
|
166
|
+
`lazily-js scale benchmarks (N=${n.toLocaleString("en-US")}, ${(2 * n).toLocaleString("en-US")} reactive nodes)`,
|
|
167
|
+
);
|
|
168
|
+
for (const r of results) {
|
|
169
|
+
const name = r.name.padEnd(28);
|
|
170
|
+
const size = `N=${n.toLocaleString("en-US").padStart(13)}`;
|
|
171
|
+
const ms = `${(r.seconds * 1000).toFixed(3).padStart(12)} ms`;
|
|
172
|
+
console.log(`${name} ${size} ${ms} ${fmtCell(r.perCellNs)} ${r.detail}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
main();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lazily-hub/lazily-js",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Native JavaScript port of the lazily reactive core: a full reactive graph (Cell/Slot/Signal/Effect), the lazily-spec IPC wire types, keyed cell collections + LIS reconciliation, the memoized semantic tree, the move-aware sequence CRDT, the Fugue/RGA text CRDT, manufactured text identity, full-Harel state charts, and an FFI state-projection consumer.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
"types": "./src/index.d.ts",
|
|
16
16
|
"default": "./src/index.js"
|
|
17
17
|
},
|
|
18
|
+
"./transport": {
|
|
19
|
+
"types": "./src/transport.d.ts",
|
|
20
|
+
"default": "./src/transport.js"
|
|
21
|
+
},
|
|
18
22
|
"./reactive": {
|
|
19
23
|
"types": "./src/reactive.d.ts",
|
|
20
24
|
"default": "./src/reactive.js"
|
|
@@ -23,6 +27,10 @@
|
|
|
23
27
|
"types": "./src/reactive-async.d.ts",
|
|
24
28
|
"default": "./src/reactive-async.js"
|
|
25
29
|
},
|
|
30
|
+
"./reactive-family": {
|
|
31
|
+
"types": "./src/reactive-family.d.ts",
|
|
32
|
+
"default": "./src/reactive-family.js"
|
|
33
|
+
},
|
|
26
34
|
"./state-machine": {
|
|
27
35
|
"types": "./src/state-machine.d.ts",
|
|
28
36
|
"default": "./src/state-machine.js"
|
|
@@ -73,9 +81,10 @@
|
|
|
73
81
|
}
|
|
74
82
|
},
|
|
75
83
|
"scripts": {
|
|
76
|
-
"build": "node --check src/index.js && node --check src/reactive.js && node --check src/reactive-async.js && node --check src/state-machine.js && node --check src/statechart.js && node --check src/collections.js && node --check src/queue.js && node --check src/sem-tree.js && node --check src/stable-id.js && node --check src/seq-crdt.js && node --check src/text-crdt.js && node --check src/utf8-offsets.js && node --check src/lossless-tree-crdt.js && node --check src/state-projection.js && node --check src/signaling.js && node --check src/distributed.js",
|
|
84
|
+
"build": "node --check src/index.js && node --check src/transport.js && node --check src/shm-backend.js && node --check src/reactive.js && node --check src/reactive-async.js && node --check src/reactive-family.js && node --check src/state-machine.js && node --check src/statechart.js && node --check src/collections.js && node --check src/queue.js && node --check src/sem-tree.js && node --check src/stable-id.js && node --check src/seq-crdt.js && node --check src/text-crdt.js && node --check src/utf8-offsets.js && node --check src/lossless-tree-crdt.js && node --check src/state-projection.js && node --check src/signaling.js && node --check src/distributed.js",
|
|
77
85
|
"test:formal": "node scripts/formal-check.mjs",
|
|
78
86
|
"bench": "node bench/context.bench.mjs",
|
|
87
|
+
"bench:scale": "node --max-old-space-size=8192 bench/scale.bench.mjs",
|
|
79
88
|
"benchmark-update": "node scripts/run-benchmarks.mjs",
|
|
80
89
|
"benchmark-check": "node scripts/run-benchmarks.mjs --check",
|
|
81
90
|
"test": "npm run test:formal && node --test test/*.test.js"
|
package/src/distributed.d.ts
CHANGED
|
@@ -103,6 +103,17 @@ export class CrdtPlaneRuntime {
|
|
|
103
103
|
since: Iterable<FrontierEntry | [PeerId, WireStamp | unknown]>,
|
|
104
104
|
): CrdtSync;
|
|
105
105
|
syncReply(request: CrdtSync): CrdtSync;
|
|
106
|
+
// Family sync (#lzfamilysync)
|
|
107
|
+
registerFamilyLww(namespace: string): this;
|
|
108
|
+
membershipEpoch(): number;
|
|
109
|
+
familyKeys(namespace: string): string[];
|
|
110
|
+
familyValueLww(namespace: string, keySuffix: string): boolean | undefined;
|
|
111
|
+
familySetLww(
|
|
112
|
+
namespace: string,
|
|
113
|
+
keySuffix: string,
|
|
114
|
+
value: boolean,
|
|
115
|
+
nowMicros: number,
|
|
116
|
+
): CrdtOp | null;
|
|
106
117
|
}
|
|
107
118
|
|
|
108
119
|
// --- Browser WebRTC platform adapter ---
|