@lazily-hub/lazily-js 0.23.0 → 0.25.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/.github/workflows/ci.yml +112 -0
- package/.size-limit.json +7 -7
- package/README.md +8 -8
- package/bench/edge-index-load.mjs +320 -0
- package/bench/effect-edge-audit.mjs +404 -0
- package/package.json +2 -2
- package/src/reactive-async.d.ts +55 -0
- package/src/reactive-async.js +326 -0
- package/src/reactive.d.ts +90 -0
- package/src/reactive.js +633 -105
- package/src/teardown-scope.js +172 -0
- package/src/thread-safe.d.ts +33 -0
- package/src/thread-safe.js +120 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
pull_request:
|
|
8
|
+
workflow_dispatch:
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
test:
|
|
12
|
+
name: Node
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
|
|
15
|
+
steps:
|
|
16
|
+
- name: Checkout
|
|
17
|
+
uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
# lazily-spec is the canonical cross-language wire-protocol source. Clone
|
|
20
|
+
# it as a sibling BEFORE any test step so every test file that reads
|
|
21
|
+
# ../lazily-spec/conformance has the canonical fixtures — CI then
|
|
22
|
+
# validates lazily-js against canonical fixtures and fails on drift
|
|
23
|
+
# (#lzspecconf).
|
|
24
|
+
- name: Fetch canonical lazily-spec conformance fixtures (#lzspecconf)
|
|
25
|
+
run: git clone --depth 1 https://github.com/lazily-hub/lazily-spec.git ../lazily-spec
|
|
26
|
+
|
|
27
|
+
- name: Set up Node
|
|
28
|
+
uses: actions/setup-node@v4
|
|
29
|
+
with:
|
|
30
|
+
node-version: 24
|
|
31
|
+
|
|
32
|
+
- name: Install dependencies
|
|
33
|
+
run: npm ci || npm install
|
|
34
|
+
|
|
35
|
+
# `npm run build` is this repo's lint/typecheck equivalent: it syntax-checks
|
|
36
|
+
# every published entry point with `node --check` and regenerates the
|
|
37
|
+
# size-limit budgets. There is no separate lint script.
|
|
38
|
+
- name: Build (syntax check every entry point)
|
|
39
|
+
run: npm run build
|
|
40
|
+
|
|
41
|
+
- name: Assert size budgets are committed and current
|
|
42
|
+
run: npm run size:check
|
|
43
|
+
|
|
44
|
+
# Absence guards. `npm test` must not be reachable with the sibling
|
|
45
|
+
# missing or an area dark — a skipped fixture is not a passing fixture.
|
|
46
|
+
- name: Assert canonical conformance fixtures are present (#lzspecconf)
|
|
47
|
+
run: |
|
|
48
|
+
test -d ../lazily-spec/conformance || {
|
|
49
|
+
echo "::error::../lazily-spec/conformance missing — conformance replay would test nothing"; exit 1; }
|
|
50
|
+
test -d ../lazily-spec/schemas || {
|
|
51
|
+
echo "::error::../lazily-spec/schemas missing — schema-conformance tests cannot run"; exit 1; }
|
|
52
|
+
for area in agent-doc collections coordination crdt-tree lossless-tree materialization \
|
|
53
|
+
membership message-passing presence rateshape reactive-graph \
|
|
54
|
+
reliable-sync resilience service statechart temporal windowing; do
|
|
55
|
+
test -d "../lazily-spec/conformance/$area" || {
|
|
56
|
+
echo "::error::../lazily-spec/conformance/$area missing"; exit 1; }
|
|
57
|
+
done
|
|
58
|
+
|
|
59
|
+
# Shadowing guard. An absence guard cannot catch a bundled copy: the
|
|
60
|
+
# directory is present, the tests pass, and they are replaying a stale
|
|
61
|
+
# local fixture. js carried nine of these until #lzspecconf.
|
|
62
|
+
- name: Assert no bundled fixture copy shadows the canonical spec
|
|
63
|
+
run: |
|
|
64
|
+
if [ -d test/conformance ]; then
|
|
65
|
+
echo "::error::test/conformance/ reintroduced — bundled fixtures shadow ../lazily-spec and hide drift"
|
|
66
|
+
exit 1
|
|
67
|
+
fi
|
|
68
|
+
|
|
69
|
+
# Positive assertion. The guards above prove the fixtures are *available*;
|
|
70
|
+
# this proves they were *read*. Capture the exit code explicitly and grep
|
|
71
|
+
# the summary rather than trusting a clean tail — and require skipped == 0
|
|
72
|
+
# plus a nonzero pass count, so a suite that silently stops replaying
|
|
73
|
+
# fixtures fails instead of reporting green.
|
|
74
|
+
- name: Test (assert fixtures actually ran)
|
|
75
|
+
run: |
|
|
76
|
+
set -o pipefail
|
|
77
|
+
npm test 2>&1 | tee test-output.txt
|
|
78
|
+
status=$?
|
|
79
|
+
echo "npm test exit=$status"
|
|
80
|
+
[ "$status" -eq 0 ] || { echo "::error::test suite failed"; exit 1; }
|
|
81
|
+
|
|
82
|
+
grep -qE '^(ℹ|#) fail 0$' test-output.txt || {
|
|
83
|
+
echo "::error::test summary does not report 'fail 0'"; exit 1; }
|
|
84
|
+
grep -qE '^(ℹ|#) skipped 0$' test-output.txt || {
|
|
85
|
+
echo "::error::tests were skipped — a skipped conformance fixture is not a passing one"
|
|
86
|
+
grep -E '^(ℹ|#) skipped' test-output.txt; exit 1; }
|
|
87
|
+
|
|
88
|
+
pass="$(grep -oE '^(ℹ|#) pass [0-9]+$' test-output.txt | grep -oE '[0-9]+$' | head -1)"
|
|
89
|
+
[ -n "$pass" ] && [ "$pass" -gt 0 ] || {
|
|
90
|
+
echo "::error::no passing tests reported — the suite ran nothing"; exit 1; }
|
|
91
|
+
echo "passed $pass tests with 0 skipped"
|
|
92
|
+
|
|
93
|
+
# Reactive-graph replay must be observable in the output, per model.
|
|
94
|
+
# `fail 0 / skipped 0 / pass > 0` proves the suite ran; it does not
|
|
95
|
+
# prove the reactive-graph corpus was replayed, nor that it was
|
|
96
|
+
# replayed against every context. The dart/go cascade defect was
|
|
97
|
+
# sync-clean and async-broken, so a run covering only the default
|
|
98
|
+
# context is the exact hole this guard closes. Each model prints
|
|
99
|
+
# "reactive-graph[<model>]: replayed N fixtures, ..." and N must be
|
|
100
|
+
# nonzero.
|
|
101
|
+
for model in Context AsyncContext ThreadSafeContext; do
|
|
102
|
+
line="$(grep -oE "reactive-graph\[$model\]: replayed [0-9]+ fixtures" test-output.txt | head -1)"
|
|
103
|
+
[ -n "$line" ] || {
|
|
104
|
+
echo "::error::reactive-graph corpus was not replayed against $model"; exit 1; }
|
|
105
|
+
n="$(echo "$line" | grep -oE '[0-9]+')"
|
|
106
|
+
[ "$n" -gt 0 ] || {
|
|
107
|
+
echo "::error::reactive-graph[$model] replayed 0 fixtures — it tested nothing"; exit 1; }
|
|
108
|
+
echo "reactive-graph[$model]: $n fixtures replayed"
|
|
109
|
+
done
|
|
110
|
+
|
|
111
|
+
- name: Formal model check
|
|
112
|
+
run: npm run test:formal
|
package/.size-limit.json
CHANGED
|
@@ -3,42 +3,42 @@
|
|
|
3
3
|
"name": "reactive: Context",
|
|
4
4
|
"path": "src/reactive.js",
|
|
5
5
|
"import": "{ Context }",
|
|
6
|
-
"limit": "
|
|
6
|
+
"limit": "2900 B"
|
|
7
7
|
},
|
|
8
8
|
{
|
|
9
9
|
"name": "reactive: Context + handles + defaultEqual",
|
|
10
10
|
"path": "src/reactive.js",
|
|
11
11
|
"import": "{ Context, CellHandle, SlotHandle, SignalHandle, EffectHandle, defaultEqual }",
|
|
12
|
-
"limit": "
|
|
12
|
+
"limit": "2920 B"
|
|
13
13
|
},
|
|
14
14
|
{
|
|
15
15
|
"name": "state-machine: StateMachine",
|
|
16
16
|
"path": "src/state-machine.js",
|
|
17
17
|
"import": "{ StateMachine }",
|
|
18
|
-
"limit": "
|
|
18
|
+
"limit": "267 B"
|
|
19
19
|
},
|
|
20
20
|
{
|
|
21
21
|
"name": "sem-tree: SemTree",
|
|
22
22
|
"path": "src/sem-tree.js",
|
|
23
23
|
"import": "{ SemTree }",
|
|
24
|
-
"limit": "
|
|
24
|
+
"limit": "505 B"
|
|
25
25
|
},
|
|
26
26
|
{
|
|
27
27
|
"name": "stable-id: contentHash",
|
|
28
28
|
"path": "src/stable-id.js",
|
|
29
29
|
"import": "{ contentHash }",
|
|
30
|
-
"limit": "
|
|
30
|
+
"limit": "152 B"
|
|
31
31
|
},
|
|
32
32
|
{
|
|
33
33
|
"name": "collections: CellMap + CellTree + reconcileCollections",
|
|
34
34
|
"path": "src/collections.js",
|
|
35
35
|
"import": "{ CellMap, CellTree, reconcileCollections }",
|
|
36
|
-
"limit": "
|
|
36
|
+
"limit": "1645 B"
|
|
37
37
|
},
|
|
38
38
|
{
|
|
39
39
|
"name": "index: PROTOCOL_ID + Snapshot (tree-shaken kitchen sink)",
|
|
40
40
|
"path": "src/index.js",
|
|
41
41
|
"import": "{ PROTOCOL_ID, Snapshot }",
|
|
42
|
-
"limit": "
|
|
42
|
+
"limit": "2409 B"
|
|
43
43
|
}
|
|
44
44
|
]
|
package/README.md
CHANGED
|
@@ -670,7 +670,7 @@ shipped bytes.
|
|
|
670
670
|
|
|
671
671
|
<!-- size-limits:start -->
|
|
672
672
|
|
|
673
|
-
Generated for package `@lazily-hub/lazily-js` version `0.
|
|
673
|
+
Generated for package `@lazily-hub/lazily-js` version `0.24.0`. Every entry is **minified + brotlied, tree-shaken to the named import** (`size-limit` + esbuild, the same pipeline Webpack/Rollup/Vite apply via `"sideEffects": false`).
|
|
674
674
|
|
|
675
675
|
Refresh command:
|
|
676
676
|
|
|
@@ -681,13 +681,13 @@ npm run test:size # gate: fails CI if any entry exceeds its budget
|
|
|
681
681
|
|
|
682
682
|
| Import | Size | Budget |
|
|
683
683
|
|---|---:|---:|
|
|
684
|
-
| reactive: Context | 2.
|
|
685
|
-
| reactive: Context + handles + defaultEqual | 2.
|
|
686
|
-
| state-machine: StateMachine |
|
|
687
|
-
| sem-tree: SemTree | 505 B ✓ |
|
|
688
|
-
| stable-id: contentHash | 152 B ✓ |
|
|
689
|
-
| collections: CellMap + CellTree + reconcileCollections | 1.65 KB ✓ | 1.
|
|
690
|
-
| index: PROTOCOL_ID + Snapshot (tree-shaken kitchen sink) | 2.41 KB ✓ | 2.
|
|
684
|
+
| reactive: Context | 2.88 KB ✓ | 2.90 KB |
|
|
685
|
+
| reactive: Context + handles + defaultEqual | 2.90 KB ✓ | 2.92 KB |
|
|
686
|
+
| state-machine: StateMachine | 267 B ✓ | 267 B |
|
|
687
|
+
| sem-tree: SemTree | 505 B ✓ | 505 B |
|
|
688
|
+
| stable-id: contentHash | 152 B ✓ | 152 B |
|
|
689
|
+
| collections: CellMap + CellTree + reconcileCollections | 1.65 KB ✓ | 1.65 KB |
|
|
690
|
+
| index: PROTOCOL_ID + Snapshot (tree-shaken kitchen sink) | 2.41 KB ✓ | 2.41 KB |
|
|
691
691
|
|
|
692
692
|
<!-- size-limits:end -->
|
|
693
693
|
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// Width-ladder load test for the dependency-edge index (#lzspecedgeindex).
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS. The existing bench suites (`context.bench.mjs`,
|
|
4
|
+
// `scale.bench.mjs`) scale *node count* and hold fan-out fixed at 2, so the
|
|
5
|
+
// width of a single node's dependent list was never a variable — which is
|
|
6
|
+
// exactly why an O(n^2) edge dedup could sit in the hot path unnoticed. This
|
|
7
|
+
// test makes width the independent variable.
|
|
8
|
+
//
|
|
9
|
+
// SHAPE. One source cell with `width` subscriber slots reading it. Building
|
|
10
|
+
// that fan-out registers `width` dependent edges on one list, so a linear-scan
|
|
11
|
+
// dedup costs width^2/2 comparisons to build and the per-subscriber cost grows
|
|
12
|
+
// linearly with width. With the index it stays flat.
|
|
13
|
+
//
|
|
14
|
+
// METHOD: climb, project, refuse. Each rung measures bytes/subscriber, then
|
|
15
|
+
// projects the next rung's footprint from *that measurement* and refuses to
|
|
16
|
+
// climb if the projection would not leave MEMORY_FLOOR_MB of headroom. This is
|
|
17
|
+
// the lazily-rs `examples/pubsub_load.rs` protocol: never allocate a rung you
|
|
18
|
+
// have not first shown you can afford.
|
|
19
|
+
//
|
|
20
|
+
// MANUAL / ON-DEMAND — not part of `make check`. Wide rungs take minutes and
|
|
21
|
+
// need a raised V8 heap.
|
|
22
|
+
//
|
|
23
|
+
// node --expose-gc --max-old-space-size=8192 bench/edge-index-load.mjs
|
|
24
|
+
// node --expose-gc --max-old-space-size=16384 bench/edge-index-load.mjs --max-width=10000000
|
|
25
|
+
// node --expose-gc --max-old-space-size=8192 bench/edge-index-load.mjs --json
|
|
26
|
+
//
|
|
27
|
+
// `--expose-gc` is required: bytes/subscriber is meaningless without a
|
|
28
|
+
// deterministic collection before each sample.
|
|
29
|
+
|
|
30
|
+
import { performance } from "node:perf_hooks";
|
|
31
|
+
import { getHeapStatistics } from "node:v8";
|
|
32
|
+
import { Context } from "../src/reactive.js";
|
|
33
|
+
|
|
34
|
+
// The ladder. The cluster at 128/159/160/161/192 straddles the promote
|
|
35
|
+
// threshold (EDGE_INDEX_PROMOTE = 160): 159 is the last scan-only width, 160
|
|
36
|
+
// the first promoted one, 161 the width where a list that oscillates by one
|
|
37
|
+
// would thrash a naive promote/demote boundary. A thrash bug is invisible at
|
|
38
|
+
// every other rung, so the cluster is the point of the ladder, not padding.
|
|
39
|
+
const LADDER = [
|
|
40
|
+
32, 64, 96, 128, 159, 160, 161, 192, 256, 1024, 4096, 65536, 262144, 1048576,
|
|
41
|
+
4194304, 10000000,
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const MEMORY_FLOOR_MB = 512; // refuse a rung projected to leave less than this
|
|
45
|
+
|
|
46
|
+
function parseArgs(argv) {
|
|
47
|
+
const opts = { json: false, maxWidth: 1048576 };
|
|
48
|
+
for (const a of argv.slice(2)) {
|
|
49
|
+
if (a === "--json") opts.json = true;
|
|
50
|
+
else if (a.startsWith("--max-width=")) opts.maxWidth = Number(a.slice(12));
|
|
51
|
+
}
|
|
52
|
+
return opts;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function gc() {
|
|
56
|
+
if (typeof global.gc !== "function") {
|
|
57
|
+
throw new Error(
|
|
58
|
+
"edge-index-load requires --expose-gc (bytes/subscriber needs a deterministic GC)",
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
// Two passes: the first frees the previous rung's graph, the second collects
|
|
62
|
+
// anything the first promoted rather than freed.
|
|
63
|
+
global.gc();
|
|
64
|
+
global.gc();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function heapBytes() {
|
|
68
|
+
return process.memoryUsage().heapUsed;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// heap_size_limit reflects --max-old-space-size, so the refuse decision is made
|
|
72
|
+
// against the heap this process actually has, not a hardcoded budget.
|
|
73
|
+
function heapLimitBytes() {
|
|
74
|
+
return getHeapStatistics().heap_size_limit;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Build a width-N fan-out and measure it.
|
|
78
|
+
//
|
|
79
|
+
// `build` covers creating the subscribers AND forcing their first read, because
|
|
80
|
+
// the first read is what runs the tracked compute that registers the dependency
|
|
81
|
+
// edge — creating a lazy slot alone registers nothing, so timing creation only
|
|
82
|
+
// would measure nothing relevant.
|
|
83
|
+
function measureRung(width) {
|
|
84
|
+
gc();
|
|
85
|
+
const before = heapBytes();
|
|
86
|
+
|
|
87
|
+
const ctx = Context();
|
|
88
|
+
const source = ctx.cell(0);
|
|
89
|
+
const subs = new Array(width);
|
|
90
|
+
|
|
91
|
+
const tBuild0 = performance.now();
|
|
92
|
+
for (let i = 0; i < width; i++) {
|
|
93
|
+
subs[i] = ctx.memo(() => ctx.getCell(source) + 1);
|
|
94
|
+
}
|
|
95
|
+
// Force the first read: this is the edge-registration pass.
|
|
96
|
+
for (let i = 0; i < width; i++) {
|
|
97
|
+
ctx.get(subs[i]);
|
|
98
|
+
}
|
|
99
|
+
const tBuild1 = performance.now();
|
|
100
|
+
|
|
101
|
+
gc();
|
|
102
|
+
const after = heapBytes();
|
|
103
|
+
|
|
104
|
+
// Notify: one publish, then read every subscriber. Exercises the removal +
|
|
105
|
+
// re-registration path (recomputeSlotNow clears and re-tracks each slot's
|
|
106
|
+
// dependency), which is where an O(n) *removal* would reintroduce O(n^2).
|
|
107
|
+
const tNotify0 = performance.now();
|
|
108
|
+
ctx.setCell(source, 1);
|
|
109
|
+
let observed = 0;
|
|
110
|
+
for (let i = 0; i < width; i++) {
|
|
111
|
+
if (ctx.get(subs[i]) === 2) observed++;
|
|
112
|
+
}
|
|
113
|
+
const tNotify1 = performance.now();
|
|
114
|
+
|
|
115
|
+
// Correctness: every survivor must observe the final publish.
|
|
116
|
+
if (observed !== width) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`width ${width}: only ${observed}/${width} subscribers observed the final publish`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const buildNsPerSub = ((tBuild1 - tBuild0) * 1e6) / width;
|
|
123
|
+
const notifyNsPerSub = ((tNotify1 - tNotify0) * 1e6) / width;
|
|
124
|
+
const bytesPerSub = (after - before) / width;
|
|
125
|
+
|
|
126
|
+
return { width, buildNsPerSub, notifyNsPerSub, bytesPerSub, observed };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Repeat a rung and take the median. Narrow rungs complete in microseconds, so
|
|
130
|
+
// a single run is dominated by timer resolution and by whichever V8 tier the
|
|
131
|
+
// code happens to be in; medians over repeated runs are the only stable figure.
|
|
132
|
+
// Wide rungs are measured once — they take seconds and cost gigabytes, and
|
|
133
|
+
// their per-subscriber cost is large enough to swamp timer noise anyway.
|
|
134
|
+
function measureRungMedian(width) {
|
|
135
|
+
const reps = width <= 65536 ? 7 : 1;
|
|
136
|
+
const samples = [];
|
|
137
|
+
for (let i = 0; i < reps; i++) samples.push(measureRung(width));
|
|
138
|
+
const pick = (key) => {
|
|
139
|
+
const vals = samples.map((s) => s[key]).sort((a, b) => a - b);
|
|
140
|
+
return vals[vals.length >> 1];
|
|
141
|
+
};
|
|
142
|
+
return {
|
|
143
|
+
width,
|
|
144
|
+
reps,
|
|
145
|
+
buildNsPerSub: pick("buildNsPerSub"),
|
|
146
|
+
notifyNsPerSub: pick("notifyNsPerSub"),
|
|
147
|
+
bytesPerSub: pick("bytesPerSub"),
|
|
148
|
+
observed: width,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Tier the reactive core up to TurboFan before the first measured rung, so the
|
|
153
|
+
// narrow rungs are not measuring the interpreter. Uses a width above the
|
|
154
|
+
// promote threshold so BOTH the scan path and the indexed path are warm.
|
|
155
|
+
function warmup() {
|
|
156
|
+
for (let i = 0; i < 200; i++) {
|
|
157
|
+
measureRung(256);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function main() {
|
|
162
|
+
const opts = parseArgs(process.argv);
|
|
163
|
+
const results = [];
|
|
164
|
+
const refusals = [];
|
|
165
|
+
|
|
166
|
+
const limitMB = heapLimitBytes() / (1024 * 1024);
|
|
167
|
+
if (!opts.json) {
|
|
168
|
+
console.log(`node ${process.version}`);
|
|
169
|
+
console.log(`heap limit: ${limitMB.toFixed(0)} MB`);
|
|
170
|
+
console.log(`memory floor: ${MEMORY_FLOOR_MB} MB`);
|
|
171
|
+
console.log("warming up (200 x width-256 build+notify, discarded)...");
|
|
172
|
+
}
|
|
173
|
+
warmup();
|
|
174
|
+
if (!opts.json) console.log("");
|
|
175
|
+
|
|
176
|
+
let prev = null;
|
|
177
|
+
for (const width of LADDER) {
|
|
178
|
+
if (width > opts.maxWidth) {
|
|
179
|
+
refusals.push({ width, reason: `above --max-width=${opts.maxWidth}` });
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
// Climb, project, refuse: project this rung from the previous rung's
|
|
183
|
+
// MEASURED bytes/subscriber, not from a constant.
|
|
184
|
+
if (prev !== null) {
|
|
185
|
+
const projectedMB = (prev.bytesPerSub * width) / (1024 * 1024);
|
|
186
|
+
const freeMB = limitMB - projectedMB;
|
|
187
|
+
if (freeMB < MEMORY_FLOOR_MB) {
|
|
188
|
+
refusals.push({
|
|
189
|
+
width,
|
|
190
|
+
reason:
|
|
191
|
+
`projected ${projectedMB.toFixed(0)} MB from measured ` +
|
|
192
|
+
`${prev.bytesPerSub.toFixed(1)} B/sub at width ${prev.width}; ` +
|
|
193
|
+
`would leave ${freeMB.toFixed(0)} MB < ${MEMORY_FLOOR_MB} MB floor`,
|
|
194
|
+
});
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let r;
|
|
200
|
+
try {
|
|
201
|
+
r = measureRungMedian(width);
|
|
202
|
+
} catch (err) {
|
|
203
|
+
refusals.push({ width, reason: `aborted: ${err.message}` });
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
results.push(r);
|
|
207
|
+
prev = r;
|
|
208
|
+
if (!opts.json) {
|
|
209
|
+
console.log(
|
|
210
|
+
`width ${String(width).padStart(9)} ` +
|
|
211
|
+
`build ${r.buildNsPerSub.toFixed(1).padStart(9)} ns/sub ` +
|
|
212
|
+
`notify ${r.notifyNsPerSub.toFixed(1).padStart(8)} ns/sub ` +
|
|
213
|
+
`${r.bytesPerSub.toFixed(1).padStart(7)} B/sub`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const report = assertLadder(results, refusals);
|
|
219
|
+
|
|
220
|
+
if (opts.json) {
|
|
221
|
+
console.log(JSON.stringify({ results, refusals, report }, null, 2));
|
|
222
|
+
} else {
|
|
223
|
+
console.log("");
|
|
224
|
+
for (const ref of refusals) {
|
|
225
|
+
console.log(`REFUSED width ${ref.width}: ${ref.reason}`);
|
|
226
|
+
}
|
|
227
|
+
console.log("");
|
|
228
|
+
for (const line of report.lines) console.log(line);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
process.exitCode = report.ok ? 0 : 1;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Assertions, not just prints. Each returns a pass/fail line so a run is
|
|
235
|
+
// self-judging.
|
|
236
|
+
function assertLadder(results, refusals) {
|
|
237
|
+
const lines = [];
|
|
238
|
+
let ok = true;
|
|
239
|
+
const at = (w) => results.find((r) => r.width === w);
|
|
240
|
+
|
|
241
|
+
const check = (label, pass, detail) => {
|
|
242
|
+
if (!pass) ok = false;
|
|
243
|
+
lines.push(`${pass ? "PASS" : "FAIL"} ${label}${detail ? ` — ${detail}` : ""}`);
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
// 1. build ns/sub grows < 2x from 1k -> 1M. This is THE assertion: under the
|
|
247
|
+
// linear scan this ratio is ~1000x, since per-subscriber cost is O(width).
|
|
248
|
+
const lo = at(1024);
|
|
249
|
+
const hi = at(1048576);
|
|
250
|
+
if (lo && hi) {
|
|
251
|
+
const ratio = hi.buildNsPerSub / lo.buildNsPerSub;
|
|
252
|
+
check(
|
|
253
|
+
"build ns/sub grows <2x from width 1k to 1M",
|
|
254
|
+
ratio < 2,
|
|
255
|
+
`${lo.buildNsPerSub.toFixed(1)} -> ${hi.buildNsPerSub.toFixed(1)} ns/sub = ${ratio.toFixed(2)}x`,
|
|
256
|
+
);
|
|
257
|
+
} else {
|
|
258
|
+
lines.push("SKIP build 1k->1M growth — ladder did not reach width 1M");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// 2. bytes/sub flat within ~20% across the ladder (ignore the smallest rungs,
|
|
262
|
+
// where fixed per-context overhead dominates the per-subscriber figure).
|
|
263
|
+
const wide = results.filter((r) => r.width >= 1024);
|
|
264
|
+
if (wide.length >= 2) {
|
|
265
|
+
const vals = wide.map((r) => r.bytesPerSub);
|
|
266
|
+
const min = Math.min(...vals);
|
|
267
|
+
const max = Math.max(...vals);
|
|
268
|
+
const spread = (max - min) / min;
|
|
269
|
+
check(
|
|
270
|
+
"bytes/sub flat within 20% across rungs >=1k",
|
|
271
|
+
spread <= 0.2,
|
|
272
|
+
`${min.toFixed(1)}..${max.toFixed(1)} B/sub = ${(spread * 100).toFixed(1)}% spread`,
|
|
273
|
+
);
|
|
274
|
+
} else {
|
|
275
|
+
lines.push("SKIP bytes/sub flatness — fewer than 2 rungs at width >=1k");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// 3. every survivor observed the final publish (measureRung throws otherwise,
|
|
279
|
+
// so reaching here with a full result set is the pass).
|
|
280
|
+
check(
|
|
281
|
+
"every survivor observed the final publish",
|
|
282
|
+
results.every((r) => r.observed === r.width),
|
|
283
|
+
`${results.length} rungs verified`,
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
// 4. notify unchanged by the edge-index change: notify ns/sub must not grow
|
|
287
|
+
// with width either (the removal path is indexed too).
|
|
288
|
+
if (lo && hi) {
|
|
289
|
+
const ratio = hi.notifyNsPerSub / lo.notifyNsPerSub;
|
|
290
|
+
check(
|
|
291
|
+
"notify ns/sub grows <2x from width 1k to 1M",
|
|
292
|
+
ratio < 2,
|
|
293
|
+
`${lo.notifyNsPerSub.toFixed(1)} -> ${hi.notifyNsPerSub.toFixed(1)} ns/sub = ${ratio.toFixed(2)}x`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// 5. no regression at the promote boundary: width 161 (just promoted, and the
|
|
298
|
+
// width a naive promote/demote boundary would thrash) must not cost
|
|
299
|
+
// materially more per subscriber than width 159 (last scan-only width).
|
|
300
|
+
const below = at(159);
|
|
301
|
+
const above = at(161);
|
|
302
|
+
if (below && above) {
|
|
303
|
+
const ratio = above.notifyNsPerSub / below.notifyNsPerSub;
|
|
304
|
+
check(
|
|
305
|
+
"no demotion thrash at the promote boundary (notify 161 vs 159)",
|
|
306
|
+
ratio < 2,
|
|
307
|
+
`${below.notifyNsPerSub.toFixed(1)} -> ${above.notifyNsPerSub.toFixed(1)} ns/sub = ${ratio.toFixed(2)}x`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (refusals.length > 0) {
|
|
312
|
+
lines.push(
|
|
313
|
+
`NOTE ladder stopped at width ${results[results.length - 1]?.width}; ` +
|
|
314
|
+
`${refusals.length} rung(s) refused`,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
return { ok, lines };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
main();
|