Skip to content

fix: pace Discord member subscriptions so a scroll cannot spend the Gateway budget - #52

Merged
itz4blitz merged 1 commit into
mainfrom
fix/discord-subscription-budget
Oct 20, 2026
Merged

itz4blitz merged 1 commit into
mainfrom
fix/discord-subscription-budget

Conversation

@itz4blitz

Copy link
Copy Markdown
Owner

Cause

SendQueue coalesced subscription snapshots that had not been sent yet, but run drained each snapshot as soon as it was queued, for as long as the rolling SENDS_A_MINUTE = 100 allowance held out. A burst of viewport changes therefore sent one op 37 frame per intermediate position until the allowance ran out. The final viewport then waited up to about 60 s for old frames to age out, and every other queued command waited with it.

Fix

The change is limited to the send queue in crates/chatd/src/adapters/discord/gateway.rs.

  • Rolling window moved into the queue. SendQueue now records when each frame of the last minute went. pop(now) and due(now) take the time as an argument, so tests can drive them with their own clock. The socket loop sleeps until due instead of until the window frees.
  • Commands first. Commands such as voice state and presence are never spaced. They can use the whole 100-frame queued allowance.
  • Subscriptions spaced. The first 8 subscription frames in a minute go at once, so short scrolls see no delay and there is no unconditional two-second wait. After that, each frame waits 60 s / (80 - frames this minute). The wait grows as the allowance runs down. Under a sustained scroll this settles at about 40 frames a minute, and the latest viewport is never more than about 1.3 s behind.
  • Room kept for commands. Subscriptions only go while the minute holds fewer than 80 frames, so at least 20 slots are always left for commands.
  • Navigation and recovery. A guild that has not been subscribed this minute skips the spacing, so opening a server right after a scroll goes at once. It still counts against the caps.
  • Budget unchanged. Heartbeats, identify and resume still go straight to the socket, inside the 20 of Discord's 120 that were already kept for them. Cached member reads still queue nothing.
  • Bounded queue. It holds at most one pending subscription map, the frames of one minute (at most 100), and one timestamp per guild subscribed this minute.

Validation

cargo test -p chatd: 290 passed, 0 failed.

Suite Passed
lib 136
blobs 14
calls 10
discord 30
e2e 25
messages 5
playback 4
polish 12
signal 10
slack 15
teams 7
telegram 17
upload 5

New and updated unit tests in send_queue_tests use a clock the test moves:

  • Sustained scroll: 5 minutes of the viewport moving every 10 ms (30,000 snapshots), with a presence command every 10 s.
    • 200 subscription frames reach the socket, against 30,000 changes.
    • 37 of them fall in the last minute.
    • No rolling minute goes over 100 frames, or over 80 subscription frames.
    • The queue stays at one pending map or fewer and never holds a command waiting.
    • Each command goes the moment it is queued.
    • When the scroll stops, the final viewport goes about 1.27 s later (the test allows up to 2 s).
    • A newly opened server goes at once.
  • Spent allowance: 80 distinct guilds use up the subscription share, and commands then fill the remaining 20 slots and no more. The next frame is due when the oldest frame ages out at 60 s, and commands go before subscriptions.
  • Existing tests, updated: coalescing and the order of unsubscribes and commands are unchanged. A short scroll (up to the burst) does not wait for a timer, and a new connection sends its first subscription at once.

Fixes #44

🤖 Generated with Claude Code

…ateway budget

The send loop drained every coalesced subscription snapshot as soon as it
was queued, so a burst of viewport changes spent the whole rolling
allowance on intermediate positions and the final viewport waited for the
minute to roll over.

The send queue now owns the rolling window. The first eight subscription
frames a minute go at once; past them each waits a spacing of
60 s / (80 - frames this minute), which settles a sustained scroll near
forty frames a minute with the last viewport about a second behind.
Subscriptions stop at 80 frames a minute, leaving twenty of the queued
hundred for commands, and a guild not subscribed this minute skips the
spacing. Heartbeats, identify and resume still go straight to the socket.

Fixes #44

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@itz4blitz

Copy link
Copy Markdown
Owner Author

@vbmithr this one addresses the budget saturation you measured in #44.

What changed (crates/chatd/src/adapters/discord/gateway.rs, in the send queue):

  • Timing lives in the queue. It records when each frame of the last minute was sent. pop(now) and due(now) take the time as an argument, and run sleeps until the next frame is due.
  • Commands go first and are never delayed. Voice joins, presence and the like can use the whole 100-frame allowance.
  • Subscriptions are spaced out. The first 8 in a minute go immediately, so there is no fixed two-second delay. After that, each waits 60 s / (80 − frames sent this minute), so the wait grows as the allowance runs down. Subscriptions stop once the minute holds 80 frames, which always leaves 20 slots for commands.
  • Opening a server isn't delayed. A guild not subscribed in the last minute skips the spacing, though it still counts toward the limits.
  • Unchanged: heartbeats, identify and resume still go straight out, inside the 20 of Discord's 120 already reserved for them. Cached member reads still send nothing.

Tests: these run on a clock the test controls.

  • a_sustained_scroll_keeps_to_the_allowance_and_ends_on_the_last_viewport: 5 minutes of the viewport moving every 10 ms (30,000 changes) plus a presence command every 10 s.
    • 200 subscription frames are sent in total.
    • No rolling minute goes over 100 frames, or over 80 subscription frames.
    • At most one subscription is ever pending, and every command goes the moment it's queued.
    • The final viewport goes about 1.27 s after scrolling stops.
  • a_spent_allowance_waits_for_the_minute_and_keeps_room_for_commands.

Trade-off: if commands themselves run at a high rate, subscriptions get fewer slots. With about 60 commands a minute, I estimate the final viewport could trail by up to about 6 s, but I haven't tested that.

To try it:

git fetch origin pull/52/head:pr-52 && git checkout pr-52
cargo test -p chatd adapters::discord::gateway
cargo test -p chatd                                        # full daemon suite

Your bench_member_subscription_burst_without_gui against the real release chatd is a better real-world check than our simulated clock. I'm most interested in two numbers: frames sent during the 300-change burst, and latency to the final 3900 offset. A run with a steady stream of other commands mixed in would also show whether the ~6 s worst case holds.

@itz4blitz
itz4blitz merged commit 155a73c into main Oct 20, 2026
6 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.

Member viewport bursts exhaust Gateway send budget before the latest window is delivered

1 participant