@lazily-hub/lazily-js 0.9.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 +28 -0
- package/bench/scale.bench.mjs +176 -0
- package/package.json +2 -1
- package/src/distributed.d.ts +11 -0
- package/src/distributed.js +101 -2
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
|
@@ -40,6 +40,9 @@ notes and platform carve-outs lives in
|
|
|
40
40
|
| --------- | :----: | :------: | :------: | :--: | :----: | :---: | :--: | :---: |
|
|
41
41
|
| Reactive graph — `Cell` / `Slot` / `Signal` / `Effect` / memo / batch | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
42
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`) | ✅ | — | ✅ | ✅ | — | — | — | — |
|
|
43
46
|
| Thread-safe context (lock-backed) | ✅ | ✅ | ✅ | — | — | ✅ | ✅ | ✅ |
|
|
44
47
|
| Async reactive context | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
45
48
|
| Flat state machine | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
@@ -549,6 +552,31 @@ make check # npm run build && npm test
|
|
|
549
552
|
`lake` are present.
|
|
550
553
|
- `npm test` runs the formal check and the Node test suite.
|
|
551
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
|
+
|
|
552
580
|
## See also
|
|
553
581
|
|
|
554
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": {
|
|
@@ -84,6 +84,7 @@
|
|
|
84
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",
|
|
85
85
|
"test:formal": "node scripts/formal-check.mjs",
|
|
86
86
|
"bench": "node bench/context.bench.mjs",
|
|
87
|
+
"bench:scale": "node --max-old-space-size=8192 bench/scale.bench.mjs",
|
|
87
88
|
"benchmark-update": "node scripts/run-benchmarks.mjs",
|
|
88
89
|
"benchmark-check": "node scripts/run-benchmarks.mjs --check",
|
|
89
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 ---
|
package/src/distributed.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
// localUpdate→CrdtOp, ingest→count (op-log dedup by (node, stamp)), and the
|
|
21
21
|
// sync-frame pull protocol.
|
|
22
22
|
|
|
23
|
-
import { CrdtOp, CrdtSync, IpcMessage, WireStamp } from "./index.js";
|
|
23
|
+
import { CrdtOp, CrdtSync, IpcMessage, IpcValue, WireStamp } from "./index.js";
|
|
24
24
|
import { Hlc } from "./seq-crdt.js";
|
|
25
25
|
|
|
26
26
|
// Lexicographic (wall_time, logical, peer) order over two WireStamps.
|
|
@@ -255,6 +255,12 @@ function dedupKey(node, stamp) {
|
|
|
255
255
|
return `${node}|${stamp.wallTime}|${stamp.logical}|${stamp.peer}`;
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
+
// Base node id for entries a family materializes on first remote observation
|
|
259
|
+
// (#lzfamilysync). Family entry nodes are locally-private — keyed ops resolve by
|
|
260
|
+
// key string, never by raw node id — so this only needs to avoid colliding with
|
|
261
|
+
// application-assigned node ids; the runtime skips any id already in use.
|
|
262
|
+
const FAMILY_NODE_BASE = 2 ** 48;
|
|
263
|
+
|
|
258
264
|
/**
|
|
259
265
|
* The live runtime that folds distributed CRDT anti-entropy frames into a set of
|
|
260
266
|
* replicated root cells (port of lazily-rs `CrdtPlaneRuntime`). One runtime per
|
|
@@ -283,6 +289,12 @@ export class CrdtPlaneRuntime {
|
|
|
283
289
|
#frontier = new Map();
|
|
284
290
|
#membership = new Set();
|
|
285
291
|
|
|
292
|
+
// -- Family sync (#lzfamilysync) -----------------------------------------
|
|
293
|
+
#families = new Set();
|
|
294
|
+
#familyMembers = new Map();
|
|
295
|
+
#familyEpoch = 0;
|
|
296
|
+
#nextFamilyNode = FAMILY_NODE_BASE;
|
|
297
|
+
|
|
286
298
|
constructor(peer) {
|
|
287
299
|
this.#peer = assertPeerId(peer);
|
|
288
300
|
this.#hlc = new Hlc(this.#peer);
|
|
@@ -348,6 +360,90 @@ export class CrdtPlaneRuntime {
|
|
|
348
360
|
return this.#cells.get(node)?.value;
|
|
349
361
|
}
|
|
350
362
|
|
|
363
|
+
// -- Family sync (#lzfamilysync) -----------------------------------------
|
|
364
|
+
|
|
365
|
+
// Register a last-writer-wins family under `namespace`. An inbound keyed op whose
|
|
366
|
+
// first key segment matches materializes a fresh entry on `ingest` (instead of
|
|
367
|
+
// being dropped/mis-addressed), so membership propagates and a derived aggregate
|
|
368
|
+
// over the family converges.
|
|
369
|
+
registerFamilyLww(namespace) {
|
|
370
|
+
this.#families.add(namespace);
|
|
371
|
+
if (!this.#familyMembers.has(namespace)) this.#familyMembers.set(namespace, []);
|
|
372
|
+
return this;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// The membership signal (#lzfamilysync), bumped whenever a family entry
|
|
376
|
+
// materializes — a derived aggregate over the family reads it so a remote-added
|
|
377
|
+
// key forces a recompute. A monotonically-increasing counter.
|
|
378
|
+
membershipEpoch() {
|
|
379
|
+
return this.#familyEpoch;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// The materialized keys of family `namespace`, in first-materialization order.
|
|
383
|
+
familyKeys(namespace) {
|
|
384
|
+
return [...(this.#familyMembers.get(namespace) ?? [])];
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// The current converged boolean value of family entry `namespace/keySuffix`.
|
|
388
|
+
familyValueLww(namespace, keySuffix) {
|
|
389
|
+
const node = this.#keyToNode.get(`${namespace}/${keySuffix}`);
|
|
390
|
+
if (node === undefined) return undefined;
|
|
391
|
+
const iv = this.value(node);
|
|
392
|
+
if (iv === undefined || iv.bytes === undefined) return undefined;
|
|
393
|
+
return iv.bytes.length > 0 && iv.bytes[0] !== 0;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Insert or update local LWW family entry `namespace/keySuffix` to boolean
|
|
397
|
+
// `value`, returning the CrdtOp to broadcast (or null for a value-preserving
|
|
398
|
+
// update). Materializes the entry (and bumps membership) on first insert.
|
|
399
|
+
familySetLww(namespace, keySuffix, value, nowMicros) {
|
|
400
|
+
const key = `${namespace}/${keySuffix}`;
|
|
401
|
+
let node = this.#keyToNode.get(key);
|
|
402
|
+
if (node === undefined) {
|
|
403
|
+
node = this.#mintFamilyNode();
|
|
404
|
+
this.register(node, key);
|
|
405
|
+
this.#recordFamilyMember(namespace, key);
|
|
406
|
+
this.#bumpFamilyEpoch();
|
|
407
|
+
}
|
|
408
|
+
return this.localUpdate(node, nowMicros, IpcValue.inline(Uint8Array.of(value ? 1 : 0)));
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
#mintFamilyNode() {
|
|
412
|
+
for (;;) {
|
|
413
|
+
const candidate = this.#nextFamilyNode;
|
|
414
|
+
this.#nextFamilyNode += 1;
|
|
415
|
+
if (!this.#cells.has(candidate)) return candidate;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
#recordFamilyMember(namespace, key) {
|
|
420
|
+
const members = this.#familyMembers.get(namespace) ?? [];
|
|
421
|
+
if (!members.includes(key)) members.push(key);
|
|
422
|
+
this.#familyMembers.set(namespace, members);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
#bumpFamilyEpoch() {
|
|
426
|
+
this.#familyEpoch += 1;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// If `op` is a keyed op for a registered family whose entry is not yet known,
|
|
430
|
+
// materialize it under a fresh locally-private node (indexed by the wire key) and
|
|
431
|
+
// return that node; otherwise fall back to the normal node resolution.
|
|
432
|
+
#resolveNodeForFamily(op) {
|
|
433
|
+
if (op.key !== null && op.key !== undefined && !this.#keyToNode.has(op.key)) {
|
|
434
|
+
const namespace = String(op.key).split("/")[0];
|
|
435
|
+
if (this.#families.has(namespace)) {
|
|
436
|
+
const node = this.#mintFamilyNode();
|
|
437
|
+
this.#keyToNode.set(op.key, node);
|
|
438
|
+
this.#nodeToKey.set(node, op.key);
|
|
439
|
+
this.#recordFamilyMember(namespace, op.key);
|
|
440
|
+
this.#bumpFamilyEpoch();
|
|
441
|
+
return node;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return this.#resolveNode(op);
|
|
445
|
+
}
|
|
446
|
+
|
|
351
447
|
// The winning CrdtOp at `node`, or undefined.
|
|
352
448
|
winningOp(node) {
|
|
353
449
|
const cell = this.#cells.get(node);
|
|
@@ -420,7 +516,10 @@ export class CrdtPlaneRuntime {
|
|
|
420
516
|
this.#hlc.recv(op.stamp, nowMicros);
|
|
421
517
|
}
|
|
422
518
|
this.#observeStamp(op.stamp);
|
|
423
|
-
|
|
519
|
+
// Materialize-on-ingest (#lzfamilysync): a keyed op for a registered family
|
|
520
|
+
// whose entry is not yet known materializes it under a fresh local node
|
|
521
|
+
// instead of being mis-addressed to a colliding local family node.
|
|
522
|
+
const node = this.#resolveNodeForFamily(op);
|
|
424
523
|
this.#cellFor(node).merge(op);
|
|
425
524
|
}
|
|
426
525
|
return applied;
|