镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

fix(relay): deliver multi-node fan-out concurrently and never drop silently - #9195

Merged
TheSentinel454 merged 7 commits into
mainfrom
elrond/multinode-fanout-workers
Nov 6, 2026
Merged

TheSentinel454 merged 7 commits into
mainfrom
elrond/multinode-fanout-workers

Conversation

@TheSentinel454

@TheSentinel454 TheSentinel454 commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Each relay node runs one consumer for events that other nodes publish to Redis. It delivered them one at a time and awaited the access gate (visibility and membership lookups) for each event. Under load it fell more than 4,096 events behind the pub/sub broadcast. Each Lagged(n) then skipped n events for good: nothing replayed them, and plain subscribers were never told.

In a 15-node load test, about 50% of cross-node events were skipped at 10x load and about 75% at 13x. Throughput at 13x was also lower than at 10x. Loadgen didn't show this, because it only times events that arrive.

Change

  • Concurrent delivery. A dispatcher reads the broadcast and only routes. It drops local echoes and queues each event on one of 16 workers by (community, topic). Each worker delivers its queue in order, so a channel's events keep their order while different channels deliver concurrently. handlers/multinode_fanout.rs
  • Known loss closes the affected connections. An event is lost when its worker's bounded queue (1,024 events) is full, or when it isn't delivered within 2 s of this node reading it from Redis, whether it waited in the broadcast, behind a backlog, or in a stalled delivery. ChannelEvent now carries received_at, stamped by the pub/sub subscriber. For a lost event, the relay sends CLOSED "resync: live delivery lagged" to every sync the event would have reached (NIP-AR Sync rule 5). It also closes every connection the event would have reached, so those clients reconnect and catch up from their cursor. Matching runs before the access gate, so a recipient that would have been filtered out may be closed too. That costs a reconnect, never a leak.
  • A lost ephemeral event is only counted. Presence, typing and observer frames are never stored, so no reconnect can replay them. Losing one fences and closes nothing; it is still never delivered past its deadline. Without this, one stale global presence event would close nearly every mobile connection in the community, since every mobile client subscribes to presence.
  • Unknown loss closes every subscribed connection. If the dispatcher itself lags the broadcast, it can't know which events it skipped. It resyncs every sync (unchanged) and closes every connection that has a subscription.
  • Immediate fence. Each affected connection is fenced as soon as the loss is detected. The live fan-out send path (send_to_text_bytes, local and cross-node) delivers it nothing from then on. The fence is written under the connection map's write guard, and the send path checks it and queues its frame under one read guard, so no frame slips in between the check and the send.
  • Why 2 s. The mobile client resumes from the newest created_at it received, minus 5 s of slack. The desktop app (block/buzz-app) resumes 960 s before its connection last heard the relay, which already covers the full 900 s created_at drift. While an event waits, local fan-out and other workers keep moving that cursor. The fence stops the cursor, and the deadline means no withheld event is more than 2 s older than the fence, so a reconnect replays it. Without the deadline, an event stuck behind a long backlog could be withheld past the slack and never replayed.
  • Paced closes. Only the close is delayed. One closer task closes at most 50 connections per 10 ms (5,000/s per node). A drop in a hot channel therefore can't become a reconnect storm (Review-Proven Rule 9).
  • fan_out_pubsub_event is now split into take_local_echo and deliver_pubsub_event, so the dispatcher can check echoes before queueing. Its behavior is unchanged.
  • Limits (mobile): replay works while the 2 s deadline, plus the writer's clock skew and its publish delay, stays under the client's 5 s slack. The relay accepts created_at up to 15 min off, so a writer clock more than about 3 s slow can still put an event behind the cursor. An ordinary reconnect has the same limit today; closing it needs a client rewind instruction, which is out of scope here. The desktop app's 960 s resume window has no such gap. A dispatcher that falls behind without overflowing the broadcast still never delivers a late event, but it detects the loss, and fences, late. Events skipped by dispatcher lag are never seen. Neither case is bounded by the deadline, which is a cutoff for attempting delivery, not a guaranteed fence time.
  • What a load-test rerun should watch: under sustained overload the deadline turns backlog into steady closes (up to 5,000/s per node), and reconnecting clients replay from the same database pool. Watch buzz_multinode_fanout_dropped_total{reason="stale"}, buzz_multinode_fanout_lag_closed_total and the reconnect REQ rate next to pool wait. On dispatcher lag, the dispatcher scans the subscription registry and fences each subscribed connection synchronously, so also watch how long it stays behind after a Lagged.

Metrics:

  • buzz_multinode_fanout_lag_total (dispatcher lag) and buzz_multinode_fanout_total (delivered) keep their meaning.
  • buzz_multinode_fanout_dropped_total{reason} counts lost events: queue_full, or stale for one not delivered by its deadline. A lost ephemeral event counts as ephemeral instead and closes nothing.
  • buzz_multinode_fanout_lag_closed_total counts close attempts for a loss, one batch at a time.

Rollout

  • Staging first. The load-test rerun at 1x, 10x and 13x happens after merge, with the staging load tests planned once the current relay changes land. At 1x, expect zero dropped_total for stored kinds and zero lag_closed_total. Report delivered %, dropped_total{reason}, lag_closed_total, reconnect REQ rate and pool wait.
  • Then one production cluster before the rest.
  • Rollback trigger: lag_closed_total climbing steadily, or reconnect REQs pushing pool wait up, at normal load. Rollback is a plain revert and redeploy: there are no schema, config or wire changes.

Ordering

Order is kept within a (community, topic). Events on different topics, for example a channel event and a global event, can now reach one subscriber in either order. Before this change they arrived in Redis arrival order. Nothing in the relay depends on order across topics: the access gate re-reads membership and visibility per event. Clients order by created_at.

What got simpler / known debt

  • The fan-out loop in main.rs shrinks to one spawn call, and the lag handling lives next to the delivery code.
  • No new config knobs. The worker count and queue size are constants until a load test shows they need tuning.
  • Known debt, not in this PR: a Redis pub/sub reconnect can still lose events silently for plain subscribers. Syncs are already resynced in that case (artifact_sync::watch_link), but ordinary subscriptions are not.

Tests

handlers::multinode_fanout::tests, each checked with a mutant on Blox:

  • events reach their subscriber through the workers, in publish order
  • a full worker queue closes exactly the dropped event's recipients
  • a lost ephemeral event (full queue or stale) is counted as ephemeral and neither fences nor closes, while a stored event lost on the same queue still closes
  • a local echo on a full queue closes nothing
  • dispatcher lag closes every subscribed connection
  • after a known loss or dispatcher lag, a recipient gets no newer live event while it waits to be closed
  • the fence also withholds relay-local fan-out
  • an event that waited out its deadline before dispatch is lost, never delivered
  • an event behind a backlog is delivered inside its deadline, and past it is lost (its recipient fenced and queued for close), never delivered late (paused clock)
  • a delivery that stalls on a database that never answers is cut off at the deadline and handled as lost
  • the closer cancels in paced batches (paused clock), and counts each batch as it closes

🤖 Generated with Claude Code

…lently

The Redis fan-out consumer delivered every cross-node event serially, awaiting
the access gate for each one. Under load it fell more than 4,096 events behind
the broadcast, and every Lagged(n) skipped n events for good: about half of
cross-pod events at 10x load and three quarters at 13x in staging load tests.
Loadgen did not see it because it only times events that arrive.

A dispatcher now reads the broadcast and only routes: it drops local echoes
and queues each event on one of 16 workers by (community, topic), so a
channel's events keep their order while channels deliver concurrently.

Loss is handled, not hidden. When a worker's bounded queue is full, the
dispatcher drops that event, resyncs the syncs it would have reached, and
closes the connections it would have reached so their clients catch up from
their cursor. When the dispatcher itself lags the broadcast it cannot know
what it skipped, so it does the same for every subscribed connection. One
closer paces closes at 5,000/s per node so a loss cannot become a reconnect
storm.

Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Luke Tornquist <tornquist@squareup.com>
@TheSentinel454
TheSentinel454 marked this pull request as ready for review November 6, 2026 16:34
@TheSentinel454
TheSentinel454 requested a review from a team as a code owner November 6, 2026 16:34
…lose

A connection that lost a live event waited for its paced close while still
receiving newer events. Its client resumes from the newest event it received,
so a close backlog of more than a few seconds moved that cursor past the lost
event for good.

The closer now fences each connection when it is queued: the live fan-out send
path delivers it nothing from then on. Every event dispatched after the loss
is therefore withheld, and the client's cursor stays before the loss. Only the
socket close stays paced. The close counter now counts each batch as it
closes instead of once the whole backlog drains.

Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Luke Tornquist <tornquist@squareup.com>
A client resumes from the newest event it received minus 5 s of slack.
An event waiting behind a worker backlog, or in a stalled delivery,
could be withheld far longer while local fan-out and other workers
moved that cursor past it, so a reconnect never replayed it.

Give every queued event a 2 s deadline. The worker delivers it by the
deadline or handles it as lost: resync its syncs, fence its recipients,
and queue them for the paced close. Make the fence atomic with the live
send path: it is written under the connection map's write guard, and
the send path checks it and queues the frame under one read guard.

Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Luke Tornquist <tornquist@squareup.com>
The 2 s delivery deadline started at dispatch, so time an event spent
waiting in the local broadcast before dispatch went uncounted. Stamp
`ChannelEvent::received_at` in the pub/sub subscriber and run the
deadline from there.

Document the replay bound: the deadline plus writer clock skew and
publish delay must stay under the client's 5 s resume slack, the same
limit an ordinary reconnect has.

Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Luke Tornquist <tornquist@squareup.com>
The deadline bounds when an event may still be delivered, not when its
fence lands: a dispatcher that falls behind without overflowing the
broadcast detects a loss late. Say so next to the other replay limits.

Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Luke Tornquist <tornquist@squareup.com>
The unit lane selects buzz-relay tests by explicit filter, so the new
handlers::multinode_fanout::tests ran nowhere in CI. Add them to the
test-unit nextest filter and the cargo fallback in run-tests.sh. They
are infra-free.

Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Luke Tornquist <tornquist@squareup.com>
@github-actions github-actions Bot added the codex-security-review-current The posted Codex security review matches its recorded range. label Nov 6, 2026
@github-actions

github-actions Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

🔐 Codex Security Review

Status: review required for the current range.

The current range is 744f3946f6fe1ff7a649c132638d5af608900dbd...d1ba7ee67781064bbd763eba654d8e7776f7192c.
A new review must complete for this exact range. When manual authorization
is required, a user with write access must comment exactly
@buzz-security-review d1ba7ee67781064bbd763eba654d8e7776f7192c to authorize a new review.
Any previous review applies only to its recorded range.

An ephemeral event (presence, typing, observer frames) is never stored,
so a reconnect cannot replay it. Fencing and closing its recipients
recovered nothing, and a global presence event reaches most of a
community's connections, so one stale presence frame closed nearly all
of them. A lost ephemeral event is now only counted, with
reason="ephemeral"; it is still never delivered past its deadline.

The loss tests move to a stored kind. New tests pin that an ephemeral
loss neither fences nor closes while a stored loss on the same queue
still does, and that the fence also withholds relay-local fan-out.

Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Luke Tornquist <tornquist@squareup.com>
@github-actions github-actions Bot removed the codex-security-review-current The posted Codex security review matches its recorded range. label Nov 6, 2026
@TheSentinel454

Copy link
Copy Markdown
Contributor Author

🤖 Addressed in d1ba7ee.

  • Observer frames and other ephemeral kinds: nothing can replay them, so a lost one is now only counted (buzz_multinode_fanout_dropped_total{reason="ephemeral"}). It no longer fences or closes its recipients. Before this PR they were also lost, silently, on Lagged.
  • Desktop archive: the in-repo desktop/ app is no longer used. The shipping desktop client (block/buzz-app) resumes 960 s before its connection last heard the relay, which covers the relay's 900 s created_at drift, so a close is replayed there. No change for this one.

@TheSentinel454
TheSentinel454 merged commit 27ab6a0 into main Nov 6, 2026
108 of 109 checks passed
@TheSentinel454
TheSentinel454 deleted the elrond/multinode-fanout-workers branch November 6, 2026 19:33
bradseiler added a commit that referenced this pull request Nov 6, 2026
* origin/main:
  fix(relay): route live REQ events by each filter's own channel scope (#8519)
  fix(relay): deliver multi-node fan-out concurrently and never drop silently (#9195)
  perf(db): bound since-limited events reads with the month-floor literal (#9197)
  fix(mobile): match Agents sign-in to Home’s reconnect capsule (#9204)
  fix(cli): clearer desktop pairing errors, paired default relay, and auth desktop list (#9196)

Signed-off-by: Brad Seiler <seiler@squareup.com>
TheSentinel454 added a commit that referenced this pull request Nov 6, 2026
…#9214)

## Summary

`buzz-relay` lib tests fail to compile on `main`: `error[E0063]: missing
field received_at in initializer of buzz_pubsub::ChannelEvent` at
`crates/buzz-relay/src/handlers/system_channel_postgres_tests.rs:1549`.

This is a semantic merge conflict. #9195 added
`ChannelEvent::received_at`, and #8519 merged a minute later with a test
helper that builds a `ChannelEvent` without it. Neither PR's CI saw the
other. This sets the field to `tokio::time::Instant::now()`, as every
other initializer does.

It breaks clippy (`Rust Lint`) and the backend-integration test archive
for every open PR, because CI builds the merge with `main`.

### Testing

- `cargo fmt --check` and `cargo clippy -p buzz-relay --all-targets -D
warnings` on a cloud workstation at this head.

## What got simpler

Nothing; a one-field compile fix.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
TheSentinel454 added a commit that referenced this pull request Nov 9, 2026
…ind (#9302)

## Summary

Cross-node fan-out no longer closes connections when it falls behind.

Before this change, an event closed its local recipients when:
- it was not delivered within 30 s of this node reading it from Redis,
- its worker queue was full,
- or the access gate still could not check a recipient by that deadline.

Dispatcher lag also closed every subscribed connection. A rate-limited
closer spread the closes out, at 1% of a node's connections per second
with a floor.

The trouble is that a database stall slows every node's deliveries at
once. So the closes landed on every node together. Each reconnect then
does AUTH, membership reads, NIP-11 and REST catch-up against the
database that was already slow. That turned a short stall into a
reconnect storm and bursts of 503s. On small nodes the closer's floor
dominated, so a node could close all of its connections within tens of
seconds. Review-Proven Rule 9 (design every client-driven path for the
whole fleet running it at once) is the reason: a server-forced close
starts a fleet-wide reconnect.

## What changes

Kept from #9195 and #9235:
- One dispatcher plus 16 workers, routed by `(community, topic)`. Each
channel stays in order and channels deliver concurrently.
- The bulk membership read in the access gate, and denying an event for
a channel that no longer exists.

Removed:
- The delivery deadline and `ChannelEvent.received_at`.
- The live fence (`fence_live_delivery`, `live_fenced`).
- `LagCloser` and its rate limit.
- The queue-full closes and the dispatcher-lag closes.
- The access-gate retry loop
(`buzz_multinode_fanout_gate_retries_total`).

Behavior now:
- A slow delivery holds only its own worker and arrives late.
- A full worker queue or dispatcher lag drops the event on this node.
The drop is counted in
`buzz_multinode_fanout_dropped_total{reason="queue_full"}` or
`buzz_multinode_fanout_lag_total`, and the affected artifact syncs get
`CLOSED "resync: live delivery lagged"` (NIP-AR Sync rule 5). This is
the same as before the closer existed.
- A recipient the access gate cannot check misses that event, and a sync
among them resyncs. Same as the local fan-out path.

Trade-off: a plain subscription that misses a dropped event is not told.
Its client may replay the event on its next reconnect, but not if later
deliveries moved its resume cursor past the event by more than its
replay slack (5 s for the ACP client). That was the behavior before
#9195. Queue-full and lag drops stay visible in the counters, so we can
see whether they actually happen before adding any recovery mechanism.

## What got simpler

Net −700 lines.
- `multinode_fanout.rs` drops from ~940 lines to ~350.
- `deliver_pubsub_event` loses its retry loop and goes back to the
shared `route_fanout_matches` path. The Redis path and the local path
once again apply the access gate the same way.
- `ConnectionManager` loses the fence flag and the split send helper.
`SubscriptionRegistry` loses `subscribed_connections`.

## Testing

- `handlers::multinode_fanout::tests`:
  - in-order delivery through the workers;
- a full queue resyncs only the matched syncs and closes nothing, and a
local echo is not counted as a drop;
  - dispatcher lag resyncs every sync and closes nothing;
- a recipient the gate cannot check misses the event with no retry,
stays connected, and still gets the next event.
- `handlers::event::tests` is unchanged apart from removing
`received_at`.
- Mutants killed: the queue-full resync, the lag resync and the
local-echo skip.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

This branch was successfully deployed

No deployments
codex-review — d1ba7ee6 Deployed Nov 6, 2026 by TheSentinel454 via Run Codex Security Review #13573
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.

2 participants