Files
buzz/crates/buzz-relay
Logan Johnson 30e1fa47fb fix(relay): fence pending subscriptions on close or replacement (#2382)
## Summary

A REQ handler runs as a spawned task and can pause in its access checks.
Before this change it took its subscription ID only when it registered.
So:

- `REQ x`, then `CLOSE x` while the REQ was still in its access checks:
the relay sent `CLOSED x`, and then the REQ registered `x` anyway and
sent events and `EOSE` under an ID the client had closed.
- `REQ x` (A), then a newer `REQ x` (B): if A finished its checks after
B registered, A replaced B, or A's refusal retired B and sent a `CLOSED`
the client could not tell from B failing.

Now the read loop admits each REQ and sync REQ, in arrival order, before
it spawns the handler: the newest request owns the ID from the moment it
arrives. A `CLOSE` or a newer same-ID request fences an older one that
is still pending. A fenced request does not register, and does not send
`NOTICE`, `CLOSED`, `EVENT` or `EOSE` for the ID.

This builds on the owner token and lifecycle lock that #8522 added. That
PR fenced output after registration; this one moves the token to
admission, so the same fence also covers the time before registration.

The pubsub retain no longer waits on a full subscriber command queue.
Callers hold a connection's lifecycle lock while they retain. A retain
past a full queue now leaves a coalesced wake-up, and the subscriber
subscribes every retained topic it is missing. A retain that fits in the
queue leaves no wake-up, so first retains do not each make the
subscriber rescan the map.

## Behavior

- `close::admit` runs in the read loop before spawn. It records the
request's token in the connection's subscription map, with a flag that
says whether the request has claimed yet. It also applies the
subscription limit, which moves there from the handlers.
- Claims (`claim_admitted_live`, `claim_admitted_search`, the sync
claim) register only while the request still owns its token.
- Refusals after admission go through `close_if_owner`. The two REQ
refusals that also send a `NOTICE` send it in the same critical section.
- Refusals before admission (rate limit, admission quota) still retire
the old holder, as before.
- The old holder stays registered until the newer request claims or is
refused, as before.
- A channel-access eviction that removes an old holder while a newer
same-ID request is still pending retires only the old holder. It sends
no `CLOSED` for the ID, because the client already replaced that holder,
and the newer request still claims.

Client-visible changes to the subscription limit:

- The limit is checked on arrival, before scope, auth and filter checks.
On a full connection, a REQ for a new ID that would also fail those
checks now gets `CLOSED "error: too many subscriptions"` instead of the
other refusal.
- Requests still in their access checks count toward the limit.

## Scope

Live fan-out does not check the token. A same-ID request that has
arrived but not yet claimed does not stop live events to the old holder.
That path is unchanged here.

If a handler panics before it claims, its token stays in the map until a
`CLOSE`, a same-ID REQ, or the disconnect removes it. Until then it
counts toward the subscription limit.

## Testing

- `close_fences_a_req_whose_handler_has_not_run` (REQ and sync REQ):
`REQ x` then `CLOSE x` before the handler runs gives only `CLOSED x` and
no registration.
- `only_the_newest_same_id_req_answers`: of two same-ID REQs, only the
newer one answers.
- `superseded_or_closed_requests_cannot_claim`: an older request cannot
claim after a newer one, and a closed request cannot claim or close.
- `revoke_of_a_replaced_holder_spares_the_pending_replacement`: a revoke
of the old holder sends no `CLOSED`, keeps the pending request's token,
and the pending request then claims its own channel.
- `queue_full_retain_does_not_wait_and_schedules_reconciliation`
(pubsub).
- `live_subscriber_recovers_a_dropped_subscribe_hint` (pubsub, requires
Redis): it runs the real subscriber on a live link and drops a topic's
subscribe hint with a full queue. The topic is still delivered without a
reconnect and without `Confirm`. The same holds after the topic is
released, unsubscribed and retained again.
- Mutations: with no owner check in `close_owned`, both connection tests
fail. With no owner check in `claim_admitted_live`, the claim test
fails. With no `replaced` check in the eviction, the revoke test fails.
With no reconcile arm in the subscriber, or no `notify_one` in
`retain_topic`, the Redis subscriber test fails.
- CI: the four new relay tests and the full-queue pubsub test are added
to `just test-unit` and `scripts/run-tests.sh`. The Redis subscriber
test runs in a new Redis-backed backend-integration step.
- `cargo test -p buzz-relay --lib` (with `--skip config::tests`, which
hangs locally when run in one process), `cargo test -p buzz-pubsub --
--include-ignored` against a local Redis, `cargo clippy -p buzz-relay -p
buzz-pubsub --all-targets -- -D warnings`, `cargo fmt --all -- --check`.

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

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:16:48 -05:00
..