Skip to content

Send each node only the fields it uses, and get the initial chunk under budget - #421

Merged
dovvnloading merged 3 commits into
mainfrom
perf/node-payload-and-bundle
Sep 19, 2026
Merged

dovvnloading merged 3 commits into
mainfrom
perf/node-payload-and-bundle

Conversation

@dovvnloading

@dovvnloading dovvnloading commented Sep 19, 2026 •

Copy link
Copy Markdown
Owner

Two performance budgets that had been missed for weeks, fixed at the cause rather than by moving the thresholds.

Problem

Nightly perf check: red every night since 2026-08-23. Every scene node crossed the wire as the full flat superset of every node kind's fields: 157 keys per node, of which a chat node uses 13. Each new node kind widened every node on the canvas. Against the 2026-08-11 baseline the per-node payload was up 32-47% and publish cost up 40-119%. The row builder also ran ~150 isinstance checks per node on every publish, since take_dirty_patch_ops rebuilds every row to diff it.

Initial JS chunk: 599,947 bytes against ADR-019's 512,000-byte budget. A sourcemap attribution found the largest block of app source in it was the generated wire validators, ~87,000 bytes (47,908 of them scene-state.ts). Codegen emitted one unrolled block of code per field, about 190 bytes each.

Change

Sparse node rows. backend/domain/node_wire.py builds each row from a per-state-class field table and sends the identity keys plus only the fields that differ from the contract default. The comparison is type-strict: 0 is not False, 480 is not 480.0. SceneNodeRow is marked WIRE_OMITS_DEFAULTS. Codegen emits hydrateSceneNodeRow() from the contract's declared defaults. validateSceneState runs it on snapshots and SceneStore runs it on patch upserts, so every reader still sees the full row. An upsert replaces its row, so a field reset to its default comes back as the default, not the old value. graphlink_wire_schema treats a sparse row's defaulted fields as optional, with the restored value as the schema default.

Table-driven validators. Codegen emits each payload shape as a field table (about 25 bytes per field). The new src/lib/bridge-core/wireCheck.ts compiles each table into a checker once, at module load. Scalar checks run inline and build no path string unless they fail. Restored rows share one key order. Scene rows that already passed are not re-checked when the store re-validates the whole scene after each patch; stored rows are replaced, never edited in place.

Deferred chrome. Every surface that only a click or shortcut opens now loads on first open, via the existing LazySurface pattern: About, Builder/Harness launch, command palette, diagnostics, global and knowledge search, quick switcher, View. Each was checked for work it does while closed. OnboardingDialog opens itself on a fresh machine, so it stays eager.

Gates. The largest-chunk ceiling is now the budget itself, 512,000, and the total-JS ceiling moves down to 1,440,000. measure_baselines times the median of 7 publishes instead of a single sample. baseline.json is re-recorded on the nightly's own runner, taking the slowest of three dispatched runs, because spread between runners on one commit was up to 26%. The nightly check fails on any size change over 10% in either direction, so re-recording was required either way.

Also fixes #417's Document View tests. They wait up to 15s for the lazy body, but the 5000ms per-test timeout still applied, so a slow first import failed at 5s and the longer wait never took effect.

Dependencies. pip-audit began failing every PR during this work, main's lockfile included: anyio 4.14.1 has three new advisories (fixed in 4.14.2) and soupsieve 2.8.4 has two (fixed in 2.9.0). Both are bumped with the lockfile's own pip-compile --generate-hashes --upgrade-package, so the diff is two versions and their hashes. 4.14.2 is a patch release on the 4.14 line, not the 4.15 line whose BlockingPortal deprecation breaks starlette's testclient.

Results

before after
Full snapshot, 500 nodes 2,513.7 KiB 679.3 KiB
One node's row (typical) 3.826 KiB 0.168 KiB
Publish cost, 500 nodes (CI runner) 22.72 ms 4.09 ms
Publish cost, 2,000 nodes (CI runner) 112.32 ms 16.10 ms
Client: parse + restore + validate, 500-node snapshot 17.0 ms 8.1 ms
Client: re-validate a stored 500-node scene after a patch 0.86 ms 0.05 ms
Initial JS chunk 599,947 B 495,013 B
Total JS 1,461,180 B 1,397,864 B

Test plan

  • py -3.12 -m pytest -q: 3,362 passed, 20 skipped. Re-run after the dependency bump: 3,362 passed, and pip-audit on the updated lockfile finds no known vulnerabilities. ruff, mypy (355 files), compileall and codegen.py --check are clean.
  • npm run check: 95 files, 2,230 tests; lint 0 errors; build; bundle gate at the new ceilings.
  • Serializer equivalence (one-off, against the previous _node_wire): 22,703 nodes, including the four perf fixtures and 20,000 randomized nodes of every state class with int/bool/float type confusions. The restored row is identical to the old row on every field.
  • Validator equivalence (one-off, against the previous unrolled validators): 52,000 fuzzed payloads across all 13 topics, valid and invalid. Accept/reject and every error message are identical.
  • New coverage: test_node_wire.py pins the defaults to the contract, checks that every field has a route onto the wire, that omission is type-strict, and that a reset-to-default still produces a patch, plus a property test comparing restored rows as JSON text. wireCheck.test.ts, sceneRowHydration.test.ts and the new SceneStore cases cover messages, restoring, identity and the patch path. The type-strictness and patch-path tests were each confirmed to fail against a deliberately broken implementation.
  • Nightly perf dispatched on this branch: run 35465260331, all workloads within threshold.
  • Live app: backend plus the built SPA against an isolated data directory. Loaded the sample workspace, collapsed and re-expanded a node (default-reset round trip), and opened all nine deferred surfaces; the late-subscribing ones (quick switcher, diagnostics, about) received their data. Checked through the DOM and console, not screenshots.

dovvnloading and others added 3 commits September 19, 2026 15:26
Every scene node crossed the wire as the full flat superset of every node
kind's fields: 157 keys per node, of which a chat node uses 13. Each new
node kind widened every node on the canvas. Against the 2026-08-11 perf
baseline the per-node payload had grown 32-47%, publish cost 40-119%, and
the nightly perf check had failed every night since 2026-08-23.

A node now sends its five identity keys plus only the fields that differ
from the contract default. The client restores the rest from the same
defaults, so every reader still sees the full row:

- backend/domain/node_wire.py builds the row from one field table per
  node state class instead of ~150 isinstance checks per node. Omission is
  type-strict (0 is not False, 480 is not 480.0), so restoring never
  changes a value.
- SceneNodeRow is marked WIRE_OMITS_DEFAULTS. codegen emits
  hydrateSceneNodeRow() from its declared defaults; validateSceneState
  restores snapshots before validating, and SceneStore restores patch
  upserts, which replace the row, so a field reset to its default comes
  back as the default rather than the old value.
- graphlink_wire_schema's schema and validator treat a sparse row's
  defaulted fields as optional, with the restored value as `default`.

Checked against the previous serializer: 22,703 nodes (the four perf
fixtures plus 20,000 randomized nodes of every state class, including
int/bool/float type confusions) restore to exactly the row it produced.
Measured locally, median of 7: publish cost -73% to -80% versus the
August baseline, full snapshot -64% to -80%, one node -65% to -94%.

Also: perf timings are now the median of 7 publishes rather than one
sample, and the Document View tests get a test timeout above the 15s
lazy-body wait #417 added. The per-test 5000ms limit still applied, so
that longer wait could never actually be used.

Co-Authored-By: Claude <noreply@anthropic.com>
…f baseline

The initial JS chunk was 599,947 bytes against ADR-019's 512,000-byte budget.
A sourcemap attribution found the largest block of app source in it was the
generated wire validators: ~87,000 bytes, 47,908 of them scene-state.ts, because
codegen emitted one unrolled block of code per field (~190 bytes each).

- codegen now emits each payload's shape as a field table (~25 bytes a
  field), compiled into a checker once at load by the new
  src/lib/bridge-core/wireCheck.ts. Checked against the unrolled
  validators on 52,000 fuzzed payloads across all 13 topics: identical
  accept/reject and identical error messages. Initial chunk 599,947 ->
  529,180.
- Scalar fields are checked inline without building a path string, and
  sparse rows are restored in table order so every restored row shares one
  shape. Scene rows that already passed are not re-checked; the store
  re-validates the whole scene after every patch, and stored rows are
  replaced, never edited. Re-validating a stored 500-node scene: 0.86 ms
  with the old validator -> 0.05 ms. Client-side cost of a 500-node
  snapshot (parse + restore + validate): 17.0 ms -> 8.1 ms.
- Every chrome surface that only a click or shortcut opens (About,
  Builder/Harness launch, command palette, diagnostics, global and
  knowledge search, quick switcher, View) now loads on first open, on the
  existing LazySurface terms. Each was checked for work it does while
  closed; OnboardingDialog does some (it opens itself on a fresh machine)
  and stays eager. 529,180 -> 495,013.
- check-bundle-size: the largest-chunk ceiling is now the budget itself,
  512,000 (3.3% headroom). The total ceiling moves down to 1,440,000.

Perf baseline: re-recorded after the sparse node rows landed, because the
nightly check fails on any size change over 10% in either direction. Values
are from the nightly's own runner, not a dev machine. Each timing is the
slowest of three dispatched runs of the same commit, because runner-to-runner
spread was up to 26%. Against the 2026-08-11 baseline: publish cost -71% to
-79%, full snapshot -64% to -80%.

Co-Authored-By: Claude <noreply@anthropic.com>
pip-audit now fails every PR, main's lockfile included: anyio 4.14.1 has
three published advisories (CVE-2026-63374, CVE-2026-64847, CVE-2026-63349;
fixed in 4.14.2) and soupsieve 2.8.4 has two (CVE-2026-85999,
CVE-2026-86000; fixed in 2.9.0).

Regenerated with the lockfile's own command, upgrading only those two
packages (pip-compile --generate-hashes --upgrade-package ...), so the
diff is two versions and their hashes. 4.14.2 is a patch release on the
4.14 line, not the 4.15 line whose BlockingPortal deprecation breaks
starlette's testclient.

pip-audit on the updated lockfile: no known vulnerabilities. Full suite
on the new versions: 3,362 passed.

Co-Authored-By: Claude <noreply@anthropic.com>
@dovvnloading
dovvnloading merged commit beb3c70 into main Sep 19, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant