Repository navigation
fix(relay): deliver slow cross-node events late instead of closing their recipients - #9235
Conversation
…eir recipients A database stall of a few seconds made every node's cross-node deliveries miss the 2 s deadline at once, and each miss fenced and closed its recipients. Their reconnects hit the same pool, so more deliveries went stale: a routine burst of /query traffic turned into a reconnect storm. - Deliver a slow event late, up to 30 s, and only then handle it as lost. - Pace closes at 1% of the node's connections per second (at least one per jittered 100 ms tick) instead of 5,000/s. - Check a private-channel fan-out's recipients with one bulk membership read for the users the cache lacks, published to the cache, instead of one pool read per recipient in turn. - A recipient the access gate could not check no longer misses the event silently: it is fenced and closed like any other loss. 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>
Read each pubkey's memberships with a lateral LIMIT, so each user's scan stops at the cap like the single-user read. Pin the cap per user with two users above a positive cap, and state the closer's 10/s floor next to its 1% share. 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>
🔐 Codex Security Review
Review SummaryOverall Risk: MEDIUM
Findings[MEDIUM]
|
|
🤖 Intentional trade-off for this fix-forward, documented in the |
kalvinnchau
left a comment
There was a problem hiding this comment.
🤖 Review of 9f98072. One P2 below, inline. CI is green.
The 1%/s close pacing and the bulk membership read address the storm mechanism from the 2026-11-07 incident: what used to be ~2.9k closes fleet-wide in ~10 s is now capped at about 10/s per pod at current pod sizes (~150 conns/pod). The 30 s deadline does less than the description says for private channels, though. See the inline comment.
Scores: minimalness 8/10 (three fixes plus a new unverified→close behavior in one PR), elegance 9/10, correctness 8/10 (inline P2). Not approving until the P2 is resolved or explicitly accepted.
Trade-off to confirm, not a defect: a fenced connection gets no live EVENT frames until the closer reaches it. At 1% per second, a node with 1,000 or more fenced connections takes up to 100 s to close the last one. During that time the client stays connected but receives nothing live.
A Redis fan-out recipient the access gate could not check (a visibility or membership read failed) was fenced and closed at once. Those reads use the writer pool, which gives up after 3 s, and open-channel visibility is never cached, so any stall past 3 s closed the recipients of almost every channel event and the 30 s delivery deadline only covered shorter stalls. deliver_pubsub_event now delivers to the recipients the gate lets through at once and re-checks the unverified ones with backoff (250 ms doubling to 2 s) until the delivery deadline. Only recipients still unchecked then are resynced and lost. Ephemeral events are not retried, so a typing or presence event never holds its worker. 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>
kalvinnchau
left a comment
There was a problem hiding this comment.
🤖 Re-review of ed64e49. The P2 from my previous review is resolved. deliver_or_lose now re-checks unverified recipients with backoff until the 30 s deadline. A stall shorter than the deadline delivers late and closes nothing (recipient_the_gate_checks_on_a_retry_gets_the_event_late). Ephemeral events are not retried. This also covers the case Elrond raised: public-channel visibility is never cached, so it fails on every pass after 3 s.
One non-blocking P3 inline.
Scores: minimalness 9/10 (the three fixes are all parts of one feedback loop; each is justified in the description). Elegance 9/10 (deliver_pubsub_event now repeats route_fanout_matches' signal/divert/resync steps instead of calling it, and the doc comment points this out). Correctness 9/10 (P3 inline).
I'll approve once Rust / Unit Tests passes on this head.
| stored, | ||
| state.artifact_syncs.divert(stored, access.allowed), | ||
| ); | ||
| if access.unverified.is_empty() || tokio::time::Instant::now() + backoff >= retry_until { |
There was a problem hiding this comment.
🤖 [P3, non-blocking] The retry cutoff counts only the backoff, not how long a check takes. Each pass can block for up to the writer pool's 3 s acquire timeout per read: visibility, then membership. Suppose a pass starts within that window of retry_until, for example a 2 s backoff ending at about 28.5 s. Then timeout_at(deadline, ...) in deliver_or_lose can cancel the pass mid-read. The event then goes through lose(.., "stale"), which closes every fan_out_scoped match, including recipients that already received the event on an earlier pass, and it is counted as stale rather than unverified. This is bounded by pacing and rare (it needs a stall of about 27 s or more), so it isn't blocking. A fix is to start the last pass only while now + backoff + acquire timeout < retry_until.
The access gate counted ChannelNotFound as a failed check, so the new re-check held an event for a deleted channel (such as the deletion event itself) on its worker for 30 s and then closed its recipients. A missing channel grants no one access; the gate now says so, as main did before the unverified path existed. 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>
kalvinnchau
left a comment
There was a problem hiding this comment.
🤖 Approving 3d067a8. CI is green. The P2 is resolved by the gate retry, and the ChannelNotFound handling is correct: get_channel reads from the writer, so a channel that was just created cannot look missing because of replica lag. The P3 about the retry cutoff not counting how long the check takes is non-blocking.
…ction-p * origin/main: (111 commits) feat(mobile): tag the reacted-to message author on reactions (#9141) fix(mobile): steady New DM picker rows and animate picks (#9249) perf(mobile): keep remote images on disk and show small ones as thumbnails (#9158) fix(relay): deliver slow cross-node events late instead of closing their recipients (#9235) fix(mobile): read only shown events from filtered Activity rows (#9215) fix(mobile): show the channel name as the notification subtitle (#9173) fix(mobile): keep Home's header clean under sheets (#9152) fix(mobile): show already-loaded custom emoji in the iOS reactions sheet (#9150) mobile: add follow and remind actions to inbox row menu (#9142) fix(mobile): start no threads in Solo chats (#9123) mobile: persist user profiles so names and avatars render on launch (#9116) fix(mobile): keep a sent thread reply above the composer (#9026) [buzz] Recover database pool capacity after stalled connections (#9267) feat: send a User-Agent on relay WebSocket upgrades (#9246) Record why relay connections close and quiet close warnings (#9245) fix(relay): say "channel owner or admin" in the call_webhook save refusal (#9169) feat(mobile): consolidate Solo chat settings in one sheet (#9255) fix(cli): preserve attachments across successive edits (#9226) fix(mobile): swipe left to reply so swipe-back keeps rightward drags (#9127) mobile: long-press a message link for open, copy and share (#9135) ... Signed-off-by: Matthew Boston <mboston@squareup.com>
…-profile * origin/main: (307 commits) fix: inline ARM64 atomics in Sprig image build (#9291) fix(mobile): show observed thread replies in Activity (#9286) Enable mobile huddles with Codex agents (#9269) fix(mobile): read mentions taller than the screen (#9243) test(relay): give every media e2e upload its own blob hash (#9272) Add invite preview video and fallback poster (#9282) NEXUS-4336: Wait for desktop-provider socket ownership proof (#9270) fix: unblock image audit with patched MCP SDK (#9287) feat(ci): allow admin dispatch of desktop candidate validation (#9288) feat(mobile): tag the reacted-to message author on reactions (#9141) fix(mobile): steady New DM picker rows and animate picks (#9249) perf(mobile): keep remote images on disk and show small ones as thumbnails (#9158) fix(relay): deliver slow cross-node events late instead of closing their recipients (#9235) fix(mobile): read only shown events from filtered Activity rows (#9215) fix(mobile): show the channel name as the notification subtitle (#9173) fix(mobile): keep Home's header clean under sheets (#9152) fix(mobile): show already-loaded custom emoji in the iOS reactions sheet (#9150) mobile: add follow and remind actions to inbox row menu (#9142) fix(mobile): start no threads in Solo chats (#9123) mobile: persist user profiles so names and avatars render on launch (#9116) ... Signed-off-by: Franco Sola <fsola@squareup.com> # Conflicts: # docs/nips/NIP-FA.md # docs/nips/NIP-FI.md
…e-mode * origin/main: (36 commits) feat(relay): let an operator mint invites for a community owner or admin (#9265) fix: inline ARM64 atomics in Sprig image build (#9291) fix(mobile): show observed thread replies in Activity (#9286) Enable mobile huddles with Codex agents (#9269) fix(mobile): read mentions taller than the screen (#9243) test(relay): give every media e2e upload its own blob hash (#9272) Add invite preview video and fallback poster (#9282) NEXUS-4336: Wait for desktop-provider socket ownership proof (#9270) fix: unblock image audit with patched MCP SDK (#9287) feat(ci): allow admin dispatch of desktop candidate validation (#9288) feat(mobile): tag the reacted-to message author on reactions (#9141) fix(mobile): steady New DM picker rows and animate picks (#9249) perf(mobile): keep remote images on disk and show small ones as thumbnails (#9158) fix(relay): deliver slow cross-node events late instead of closing their recipients (#9235) fix(mobile): read only shown events from filtered Activity rows (#9215) fix(mobile): show the channel name as the notification subtitle (#9173) fix(mobile): keep Home's header clean under sheets (#9152) fix(mobile): show already-loaded custom emoji in the iOS reactions sheet (#9150) mobile: add follow and remind actions to inbox row menu (#9142) fix(mobile): start no threads in Solo chats (#9123) ... Signed-off-by: Will Pfleger <wpfleger@block.xyz>
…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>
Summary
Follow-up to #9195. Under its 2 s deadline, a database stall of a few seconds made every node's cross-node deliveries go stale at the same moment, and each stale delivery fenced and closed its recipients. Their reconnects and catch-up reads hit the same database pool, so still more deliveries went stale. A short burst of HTTP
/querytraffic, which the relay had absorbed before #9195, became a reconnect storm.This PR fixes three parts of that loop:
MAX_QUEUE_DELAY(2 s) becomesMAX_DELIVERY_DELAY(30 s). A slow delivery reaches its recipients late, and only one that misses 30 s is lost (fenced, resynced, closed). The queue-full and dispatcher-lag losses still close as before. A recipient the access gate could not check (a failed visibility or membership read) used to miss the event silently unless it was an artifact sync. It is now re-checked with backoff (250 ms doubling to 2 s) until the 30 s deadline, gets the event if a check passes, and is lost the same way (fenced and closed) only if the gate still cannot check it then. Those reads use the writer pool, which gives up after 3 s, and open-channel visibility is never cached, so without the re-check any stall past 3 s would close the recipients of almost every channel event. Ephemeral events are not re-checked. A channel the database no longer has (ChannelNotFound, e.g. the channel's own deletion event) is an answer, not a failed check: it grants no one access, as on main, and is never re-checked.check_fanout_accesscalledis_member_cachedonce per recipient, one after another, and each miss waited on the pool in turn. It now callsMembershipCache::members, which answers fresh users from the cache and loads the rest with one bulk read per 64 users (get_active_membership_ids_bulk_read_at, the same rows as the single-user read with the same per-user cap). The loaded sets are published to the cache under the same gate and invalidation rules ascontains. Oversized users share onemembership_pairspoint read.Trade-off (fix 1)
A client resumes from the newest
created_atit received, minus 5 s. While an event waits, newer events on other channels can move a recipient's cursor past it. If the event then arrives, nothing is lost. If the client disconnects while it waits, or the event misses 30 s, a reconnect replays it only if the cursor moved less than 5 s past it. Before #9195 these events were skipped silently. With #9195 the 2 s deadline closed the recipients, which kept the cursor bound but made a few seconds of stall into a fleet-wide reconnect. The module docs andARCHITECTURE.mddescribe the new bound.Known limits
queue_fullas before, now with paced closes.fan_out_scopedmatched, without the access gate, because the gate needs the database that just failed. Pacing bounds the cost.Review-Proven Rules
contains. A user whose gate another load holds is read again here but not published, so a batch never waits behind a single load.What got simpler
contains' inline load handling is nowMemberships::from_read/contains/publish, which the bulk path shares. The closer's fixed batch constant is gone.Related issue
Follow-up to #9195. The trigger, every desktop client calling
/queryin the same second, is not addressed here.Testing
🤖 Generated with Claude Code