feat(scaling): serialize per-socket fan-out + NEW_CHANGES_BATCH (#7756 lever 3)
Summary
Honest re-evaluation after N=3 scoring: this PR's perf claim was wrong twice. The original "70% p95 drop at step 200" was a single-run outlier. The subsequent "tighter envelope" claim was a cross-matrix-entry comparison confounded by the runner-noise envelope (see scaling-dive doc for the methodology trap). Like-for-like comparison (develop-baseline vs this-PR-baseline, N=3 each):
| Step | develop | this PR | takeaway |
|---|---|---|---|
| 100 | 28/38/38 | 39/40/47 | within noise (PR slightly worse) |
| 200 | 30/37/51 | 37/50/59 | within noise (PR slightly worse) |
| 300 | 38/45/71 | 40/77/119 | PR worse |
| 350 | 39/39/122 | 63/109/131 | PR worse |
| 400 | 1758/2275/2463 | 1350/2373/3065 | overlapping |
The performance benefit isn't there. Recommendation now stands on the correctness fix only, not perf.
What this PR is for
Two bundled changes; only change A is justified by what we measured:
Change A — per-socket fan-out serialization (correctness fix). updatePadClients previously advanced sessioninfo.rev inside the collect phase, before the emit. Under concurrency that allowed overlapping fan-outs on the same socket; if the emit on one branch threw after the other branch had already advanced rev, revisions could be silently lost (the client enforces newRev === rev + 1 and stops applying on mismatch).
The fix snapshots startRev and headRev once and writes sessioninfo.rev = headRev immediately. A concurrent second run sees the bumped rev and skips the range; on emit failure sessioninfo.rev rolls back to startRev. At most one fan-out per socket per pad at a time, with retry semantics preserved. This is a real race the previous code exhibited.
Change B — NEW_CHANGES_BATCH wire format. When a recipient is more than one rev behind, the server packs queued revs into one NEW_CHANGES_BATCH emit. Single-rev fan-outs stay as NEW_CHANGES. Feature-flagged behind settings.newChangesBatch: false default; clients are forward-compatible.
In the dive, change B was dormant — steady-state catch-up is 1 rev per recipient per fan-out, so the batching branch never fired. It would fire under server slowness (GC pauses, disk hiccups, sustained delays inside updatePadClients). Useful forward-compat groundwork; not contributing to current measured numbers.
Tests
src/tests/backend-new/specs/new-changes-batch.test.ts(4/4) — pins the wire-format decision via the exportedbuildNewChangesEmitsfunction insrc/node/handler/NewChangesPacker.ts(extracted so the test exercises the production code path).src/tests/backend-new/specs/prom-instruments.test.ts(5/5) — no regression on #7762.- All 33 CI checks pass.
Recommendation
Merge for the correctness fix. The previous race was real and could lose revisions under concurrent commits. The perf benefit I claimed wasn't real — runner noise dominated my earlier measurements.
For the perf direction, the actually-measured win is #7774 (engine.io flush deferral), which tightens the tail at mid-load with N=3 confirmation. That PR doesn't depend on this one.
🤖 Generated with Claude Code