feat(scaling): serialize per-socket fan-out + NEW_CHANGES_BATCH (#7756 lever 3)

Draft
JO
10d ago
+157-32

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):

Stepdevelopthis PRtakeaway
10028/38/3839/40/47within noise (PR slightly worse)
20030/37/5137/50/59within noise (PR slightly worse)
30038/45/7140/77/119PR worse
35039/39/12263/109/131PR worse
4001758/2275/24631350/2373/3065overlapping

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 exported buildNewChangesEmits function in src/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