Skip to content

perf(relay): keep 1 s read caches warm when the pool is saturated - #9012

Merged
TheSentinel454 merged 10 commits into
mainfrom
elrond/auth-cache-storm
Nov 4, 2026
Merged

TheSentinel454 merged 10 commits into
mainfrom
elrond/auth-cache-storm

Conversation

@TheSentinel454

@TheSentinel454 TheSentinel454 commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

What

The relay's 1-second read caches (restriction state, tenant host, open channels) now date each entry from when the read checked out its pool connection, not from when the cache miss began. Concurrent misses on the restriction and tenant-host caches now share one database read. The open-channels cache already did this.

Why

Each of these caches serves an entry for 1 s after "the read that produced it began". Today that start time is taken before waiting for a pool connection. Once pool waits reach about a second (for example, many clients reconnecting at once), every entry is already expired by the time it lands. The cache hit rate falls toward zero exactly when the pool is saturated, so every request does its own read. Those reads queue for the same connections, which stretches the waits further. Requests that time out in the pool then fail closed (internal error checking restriction state, community active check failed; refusing the socket), and their clients reconnect and add more load.

In a local synthetic reconnect load test with a 50-connection pool:

  • the restriction cache served fewer than one request in five from cache;
  • the tenant-host cache, which has a single key for the whole deployment, missed more often than it hit;
  • the open-channels cache missed hundreds of thousands of times on expired entries.

How

  • Stamp at checkout. buzz_db::ReadAt<T> carries a value plus the instant its connection was checked out. The statement's snapshot is no older than that instant, so serving the value for 1 s after it keeps the documented staleness bound, however long the read queued. Three *_read_at methods return it: moderation_restriction_state_read_at, lookup_community_by_host_read_at and get_listed_open_channel_ids_read_at. The existing methods now call these and drop the value's timestamp. Each operation keeps one datastore span under its existing name, carried by the *_read_at method.

  • Share concurrent misses. SharedReads<K> is the per-key gate the open-channels cache already used, moved into its own module. The open-channels cache, the restriction cache and the tenant-host cache now all use it. The first miss reads; the others wait for it, then recheck the cache. A waiter waits for at most the writer pool's acquire timeout plus SHARED_READ_STATEMENT_WAIT (1 s). That covers the leader's own wait for a connection, which is what grows in a storm, so waiters keep sharing its read instead of queueing duplicate reads behind it. A failed or cancelled leader releases its waiters at once. A leader whose statement hangs stalls them for at most 1 s past the acquire timeout, never the statement timeout.

  • Reject hosts no community can have. bind_community rejects a host longer than communities.host VARCHAR(255) can hold (buzz_core::tenant::MAX_HOST_LEN, counted in characters) as unmapped, next to the empty-host check. An unauthenticated caller's arbitrarily long host therefore never reaches a resolver, cache, gate or the database.

No staleness bound changes: every entry is still served for at most 1 s after its read's snapshot. Invalidation paths are unchanged. A read in flight at an invalidation can still refill the old row for at most that bound, as documented today.

What got simpler

The open-channels gate's hand-rolled try_lock/timeout block is replaced by SharedReads::turn. One gate implementation now serves three caches instead of one implementation that only one cache had. The gate's wait is set once per gate rather than passed at each call site. Provisioning and host binding share one MAX_HOST_LEN instead of a private constant.

Tests

  • shared_reads::tests: one caller reads while the rest wait; a waiter gives up on a hung read after its bound.
  • concurrent_restriction_and_host_misses_share_the_in_flight_read: waiters on both caches use the leader's entry; with an unreachable database, any waiter that read on its own would fail.
  • postgres_tests::cache_misses_behind_a_held_pool_share_one_fresh_read: with a one-connection pool held for longer than every max age, eight concurrent misses on each of the three caches cost one database read, and each entry still lands fresh.
  • tenant::tests::an_overlong_host_fails_closed_before_the_resolver: a host over the limit fails as unmapped with no gate entry and no lookup; a host at the limit, including one in multibyte characters, is still looked up.
  • Mutation-checked: each of the following turns these tests red: stamping any of the three entries before the pool wait, dropping either new waiter path, letting waiters give up after the cache's max age, dropping the host length check, or counting the host length in bytes.

🤖 Generated with Claude Code

@TheSentinel454
TheSentinel454 force-pushed the elrond/auth-cache-storm branch from 186487e to b422af0 Compare November 3, 2026 19:36
TheSentinel454 and others added 3 commits November 3, 2026 14:39
…urrent misses

Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Luke Tornquist <[email protected]>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Luke Tornquist <[email protected]>
The checkout-stamped read variants each carried the same span name as
the method they extend, so a read through the plain method and one
through the variant were the same operation with two span owners, which
community.rs's single-owner test rejects. The plain methods now call
their variant and drop their own span: every read still emits one span
under the existing name.

Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Luke Tornquist <[email protected]>
@TheSentinel454
TheSentinel454 force-pushed the elrond/auth-cache-storm branch from b422af0 to a6c09c9 Compare November 3, 2026 19:39
@TheSentinel454
TheSentinel454 marked this pull request as ready for review November 3, 2026 20:09
@TheSentinel454
TheSentinel454 requested a review from a team as a code owner November 3, 2026 20:09
…he cache

The shared-read test yielded once and then filled the cache, so a waiter
spawned late could take the initial cache-hit path and pass without
exercising the waiter branch. Each waiter holds a clone of the leader's
gate while it waits; the test now waits for the gate's count to show all
eight before it fills the cache.

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

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

🔐 Codex Security Review

Note: This is an automated, security-focused review generated by Codex.
Use it as a supplement to human review; false positives are possible.

Scope

  • Exact PR diff: 865b150371d4e15562d63f0e9837bca0c32540d7...23735c0590caf21db6c4087c88f08f5449773c3a
  • Model: gpt-6.1-sol

💡 Click "edited" above to see earlier reviews for this PR.


Review Summary

Overall Risk: NONE

No concrete security, correctness, or reliability regressions found in the authorized PR range.

Findings

No concrete security, correctness, or reliability findings were identified.

Notes

  • Review used read-only source and history inspection. Builds and tests were not executed, as requested.

Generated by Codex Security Review |
Requested by: @TheSentinel454 |
Workflow run

@wpfleger96 wpfleger96 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Requesting changes. The checkout-time stamping is correct: each read_at is taken after the writer-pool checkout and before the SELECT, and the writer pool runs at READ COMMITTED, so the 1 s staleness bound holds no matter how long the read queued. The non-_read_at methods, the datastore spans and the test-runner entries also check out. Two problems with the new shared-read gates block this, though.

1. Waiters stop sharing the read in exactly the saturated case this PR targets. SharedReads::turn (crates/buzz-relay/src/shared_reads.rs:44-52) is given the cache max age (1 s) as its wait at all three call sites (state.rs restriction_state_cached and open_channels, tenant.rs resolve_host). Once the leader has been waiting more than 1 s for a pool connection, every waiter times out, finds no entry, and calls acquire_writer itself. SQLx 0.9 hands out connections first come, first served by default, so those reads queue behind the leader. In the ordinary pool-wait case they can't finish sooner; they only add reads to the saturated pool. Nothing rechecks the cache after they get a connection, so the leader's entry landing later doesn't cancel them either. With pool waits over a second (the default writer acquire_timeout is 3 s), N concurrent misses for one key still issue about N reads. The open-channels gate already had this 1 s fallback; this PR extends it to the restriction and tenant-host caches. The old reason for tying the wait to the max age, that the leader's value would be stale on arrival, no longer applies with checkout stamping, which is why the doc comment on open_channels changed.

Fix: give the gate its own wait budget, separate from the cache max age and long enough to cover the intended pool wait, and keep a bounded fallback. Be careful about using the full statement timeout as that budget: these reads inherit a 120 s statement_timeout, which would turn a 1 s stall behind a hung leader into about two minutes. One option is to wait out the leader's pool wait but not its statement time. Please add a test where the leader waits on a held connection for longer than the max age while several misses for the same key are in flight, and assert one database read.

2. The tenant-host gate now keeps memory for host names that map to no community, chosen by unauthenticated callers. tenant_host_reads.turn(normalized_host.to_owned(), …) (crates/buzz-relay/src/tenant.rs:172-178) creates a gate entry for every miss before the lookup, and neither an unmapped result nor an error removes it. Before this PR, only mapped hosts were stored (tenant_host_cache). Unauthenticated paths reach this resolver (for example api/nip05.rs:39 via bind_community), and normalize_host (crates/buzz-core/src/tenant.rs:121) puts no limit on length. The gate cache counts entries without a weigher (10,000 entries, 60 s idle expiry), so each pod can keep up to 10,000 attacker-chosen host strings. Each one can be as long as Hyper's defaults allow: about 400 KB for an HTTP/1 connection buffer, 16 KiB for an HTTP/2 header list. The relay uses default axum::serve, and nothing in the router layers or the checked-in ingress templates sets a tighter host limit.

Fix: provisioning already rejects hosts over 255 bytes (handlers/community_provisioning.rs:39), so a longer host can never map. Reject such hosts as unmapped before the gate is touched, returning the same generic failure, so they cost neither a gate entry nor a database read. A key-byte weigher on the host gate cache would also work, but it still spends a lookup on hosts that can never resolve.

Non-blocking: the docs on CachedRestriction ("when the read that produced it began") and on the tenant_host_cache field ("after its read began") should say connection checkout, as the constants now do.

TheSentinel454 and others added 2 commits November 3, 2026 16:35
Every gate waited at most its cache's 1 s max age for the leader. When
the leader itself queued for a pool connection longer than that, which
is the saturated case these gates target, its waiters gave up, found no
entry and each queued its own read behind it: N misses still cost about
N reads. Checkout stamping already keeps the leader's entry fresh however
long it waited, so the max age no longer needs to bound the wait.

Each gate now waits the writer pool's acquire timeout, which bounds the
leader's pool wait, plus SHARED_READ_STATEMENT_WAIT (1 s) for its
statement. A leader whose statement hangs still stalls waiters only that
long, not the 120 s statement timeout. The wait moves from each call
site into SharedReads, so the three gates cannot drift apart.

The held-pool test now sends eight concurrent misses per cache behind a
connection held past the max age and asserts one database read each, as
well as the checkout stamp. The open-set hung-leader test bounds its pool
acquire timeout so its waiters still give up within the test's window.

Also says "connection checkout" in the cache docs that still said the
read "began".

Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Luke Tornquist <[email protected]>
The tenant-host gate keeps an entry for every missed host, mapped or
not, and unauthenticated paths such as NIP-05 reach the resolver with a
caller-chosen host of any length. The gate cache bounds entries, not
bytes, so invalid traffic could pin up to 10,000 long strings per pod.

communities.host is VARCHAR(255), so a longer host can never map.
bind_community now rejects it as UnmappedHost next to the empty-host
check, before any resolver, cache, gate or lookup sees it. The limit
counts characters, as the column does, so no storable host is refused.
MAX_HOST_LEN moves to buzz-core::tenant and provisioning uses the same
constant.

Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Luke Tornquist <[email protected]>
@github-actions github-actions Bot removed the codex-security-review-current The posted Codex security review matches its recorded range. label Nov 3, 2026
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Luke Tornquist <[email protected]>
@github-actions github-actions Bot added the codex-security-review-current The posted Codex security review matches its recorded range. label Nov 3, 2026
The wait moved into SharedReads; this call site still passed it. Also
applies rustfmt to the reworked call sites and test.

Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Luke Tornquist <[email protected]>
@github-actions github-actions Bot added codex-security-review-current The posted Codex security review matches its recorded range. and removed codex-security-review-current The posted Codex security review matches its recorded range. labels Nov 3, 2026
… count

The restriction/host shared-read test measured the gate's Arc count as
a baseline and waited for eight more. The cache can hold a transient
clone of the gate when the baseline is taken, so the target was one too
high and the test timed out under parallel cargo test (14/30 runs on
Blox at 06850f7). On the current-thread test runtime a waiter's first
poll runs uninterrupted from a start count to its wait on the gate, so
eight starts prove eight waiters at the gate. The 500 ms watchdog is
now 10 s; timing is not what the test checks.

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

Copy link
Copy Markdown
Contributor Author

🤖 Both blockers from this review are addressed as of ea6449dd.

  1. The gate now has its own wait budget (9911c99). Each SharedReads waits for the writer pool's acquire timeout plus SHARED_READ_STATEMENT_WAIT (1 s), which is set once in SharedReads::new instead of at each call site. A leader that is still waiting for a connection gets one or fails within the acquire timeout, so its waiters stay in the shared read. A hung statement stalls them for at most 1 s past that, never the 120 s statement timeout. The held-pool test now sends 8 concurrent misses per cache behind a connection held past the max age, and asserts exactly one database read per cache via the miss counters along with the checkout stamp. On Blox, the mutant that gives waiters only the max-age wait fails this test.
  2. Overlong hosts are rejected before any lookup (11297dd). bind_community rejects hosts longer than MAX_HOST_LEN (255) as UnmappedHost, alongside the existing empty-host check, so they never reach a resolver, cache, gate, or the database. The limit counts characters, as communities.host VARCHAR(255) does, so no storable host is refused. MAX_HOST_LEN now lives in buzz-core::tenant, and provisioning uses it too. The new unit test is registered in the Justfile and scripts/run-tests.sh lists.

The non-blocking doc nit is also fixed: the CachedRestriction, OpenChannels, and cache field docs now say "connection checkout".

One addition: concurrent_restriction_and_host_misses_share_the_in_flight_read flaked under plain parallel cargo test. Its Arc-count baseline sometimes included a transient clone the cache held. It now proves enrollment with a start count instead (ea6449d), and passed 40 of 40 parallel runs.

@github-actions github-actions Bot added the codex-security-review-current The posted Codex security review matches its recorded range. label Nov 3, 2026

@wpfleger96 wpfleger96 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Both blockers from the previous review are fixed at ea6449dd, so I have no blocking findings.

1. Shared-read waiters now cover the leader's pool wait. Each gate waits the writer pool's acquire timeout plus SHARED_READ_STATEMENT_WAIT (state.rs:1710-1711). That is the right pool: all three *_read_at reads go through acquire_writer on Db::pool(), the writer, and the production writer gets its configured acquire timeout there. A waiter that gives up still rechecks the cache, then reads on its own and passes any error on, so a failure never turns into an allow or an empty result. Checkout stamping still bounds staleness at 1 s, however long the wait. In a live run of the relay with a single writer connection held for 2 s, eight concurrent NIP-05 requests for one mapped host took one database read. The held-pool Postgres test asserts the same for all three caches.

2. Overlong hosts are rejected before the gate. Outside tests, AppState::resolve_host is only reached through bind_community, which now rejects hosts over 255 characters as UnmappedHost before the resolver runs. Live, 256- and 1024-character Host headers got the same response as an ordinary unmapped host and caused no resolver misses. That held even while the writer pool was blocked, when they answered in 1 ms. A 255-character host still goes to the lookup.

The flake fix holds: the start count proves every waiter is at the gate on the current-thread runtime, and the test passed 20 of 20 repeated parallel runs. The buzz-relay and buzz-core suites pass at this head. The red Mobile Swift (Native) job fails in iOS tests that this PR doesn't touch, and the same workflow has also been failing on recent main commits.

Non-blocking:

  • crates/buzz-admin/src/communities.rs:12 still has its own MAX_HOST_LEN = 255. buzz-admin already depends on buzz-core, so it can use buzz_core::tenant::MAX_HOST_LEN as community_provisioning.rs now does, and the description's "one MAX_HOST_LEN" will hold everywhere.
  • MAX_HOST_LEN is documented in characters, but both provisioning checks (community_provisioning.rs:84,181) and the admin check count bytes. That's safe, because provisioning only accepts ASCII authorities. A short note on the constant that provisioning counts bytes, which is stricter, would keep a later reader from "fixing" one side to match the other.
  • The reworded doc on get_accessible_channel_ids_cached (state.rs:2527) runs to 98 columns. It needs re-wrapping like the lines around it.

buzz-admin drops its private copy and uses buzz_core::tenant::MAX_HOST_LEN.
The constant's doc notes that provisioning counts bytes (stricter, and equal
for the ASCII authorities it accepts) while lookups count characters.
Re-wraps the get_accessible_channel_ids_cached doc.

Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Luke Tornquist <[email protected]>
@github-actions github-actions Bot removed the codex-security-review-current The posted Codex security review matches its recorded range. label Nov 4, 2026
@github-actions github-actions Bot added the codex-security-review-current The posted Codex security review matches its recorded range. label Nov 4, 2026
@TheSentinel454

Copy link
Copy Markdown
Contributor Author

🤖 The three non-blocking items from this review are addressed in 23735c05:

  • buzz-admin no longer has its own MAX_HOST_LEN; communities.rs uses buzz_core::tenant::MAX_HOST_LEN, so there is one constant everywhere.
  • The MAX_HOST_LEN doc now notes that provisioning counts bytes, which is stricter for non-ASCII input and identical for the ASCII authorities it accepts, while the lookup counts characters so it never refuses a storable host.
  • The get_accessible_channel_ids_cached doc is re-wrapped.

No behavior change. fmt, clippy (buzz-admin, buzz-core, buzz-relay) and the buzz-admin, buzz-core and relay tenant/provisioning tests pass at this head.

@wpfleger96 wpfleger96 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 No blocking findings at 23735c05.

The change since my last review (at ea6449dd) is the one commit 23735c05, and it fixes all three non-blocking notes from that review:

  • buzz-admin no longer keeps its own MAX_HOST_LEN. It imports buzz_core::tenant::MAX_HOST_LEN, which buzz-relay's provisioning handler and host binding already use, so there's one source for the limit.
  • The constant's doc now says that provisioning counts bytes while lookups count characters, and why both stay. I checked this against the code. communities.rs:269 and community_provisioning.rs:84,181 use host.len(), and bind_community (buzz-relay/src/tenant.rs:87) uses host.chars().count(). The multibyte test at line 403 pins the lookup side.
  • The get_accessible_channel_ids_cached doc is re-wrapped within the column limit.

Behavior is unchanged: the limit is still 255 everywhere. Everything else is as reviewed and live-tested at ea6449dd.

@TheSentinel454
TheSentinel454 merged commit d21f0c0 into main Nov 4, 2026
200 of 202 checks passed
@TheSentinel454
TheSentinel454 deleted the elrond/auth-cache-storm branch November 4, 2026 14:52
bradseiler pushed a commit that referenced this pull request Nov 5, 2026
…hole fleet (#9106)

## What

Adds Review-Proven Rule 9 to `AGENTS.md`: **design every client-driven
path for the whole fleet running it at once.**

Before an author adds or changes a connect, reconnect, renewal, poll, or
catch-up path, they:
- count the relay reads it costs per client, and keep reconnect to a
delta from the client's cursor
- jitter its start and back off with a cap
- prefer an event-driven refresh with a long backstop over a short poll
- on the relay, check every cache on a connect or auth path at a 0% hit
rate, with single-flight misses that survive a saturated pool

## Why

Relay load tests keep finding the same class of defect. A path is cheap
for one client, but when a rollout drain, node eviction, relay restart,
or network blip makes every client run it in the same few seconds, it
collapses the relay. Each bullet cites a merged PR that fixed an
instance: #4542, #8846, #9012, block/buzz-app#2215 and #2308.

Unlike rules 1–8, this one comes from load tests rather than review
mining, and the rule text says so. The section intro and the Reviewing
bullet now say the rules list includes non-review sources.

## What got simpler

Nothing in code. This is one rule added to the existing list. It turns a
recurring load-test finding into a check made at planning and review
time, so it doesn't keep resurfacing as a late load-test result.

## Validation

Docs-only. I checked the diff, the Markdown rendering, and every cited
PR number and title.

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

---------

Signed-off-by: Luke Tornquist <[email protected]>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <[email protected]>
TheSentinel454 added a commit that referenced this pull request Nov 5, 2026
)

## What

Socket admission (root and audio) and the ephemeral write fence now
share one cache of "is this community active?". An entry is served for 1
s after its read checked out a pool connection, and concurrent misses
for one community share one read. Archive and deletion drop the entry
before they close the community's sockets.

## Why

Every new socket runs `is_community_active` before it can authenticate.
In a reconnect storm that means thousands of identical reads for one
community. A read that times out in the pool refuses the socket
(`community active check failed; refusing the socket`), and the client
then reconnects and adds more load. On a local cold-start reconnect rig,
this refusal count roughly tripled once #9012 freed pool capacity for
other reads. It is now one of the top causes of connect failures.

## How

- `buzz_db::Db::is_community_active_read_at` returns the answer together
with the instant its connection was checked out, following the #9012
`ReadAt` pattern. `is_community_active` now calls it, and the datastore
span stays under its existing name.
- `community_active::CommunityActiveCache` holds an entry per community
and a `SharedReads` gate. An entry is served for
`COMMUNITY_ACTIVE_MAX_AGE` (1 s) after its read. Both answers are
cached. A cached "inactive" fails safe and lasts at most 1 s after an
unarchive. Errors are not cached.
- **The archive sweep stays exact.** Admission registers a socket before
it checks, so the sweep closes every socket registered before it. A
socket that registers after the sweep must see the community as
inactive. `apply_community_disconnect` invalidates the entry before the
fence and sweep. Invalidation bumps an epoch, and a fill whose read
began before the bump is not published. A post-sweep socket therefore
reads afresh. It cannot take a pre-archive answer from the cache or from
a shared in-flight read. Host invalidations (archive and unarchive
writes, local and received) also drop the entry.
- **Bound when the notification is missed.** A pod that misses the Redis
disconnect admits sockets from a cached "active" for at most 1 s after
the archive commits. Lifecycle revalidation then closes them. This
matches the 1 s host-routing bound in `docs/community-auth-upgrades.md`
("archive and deletion … cached tenant binding can lag those changes by
up to one second"). Deletion keeps its own write fence
(`guard_community_write`).
- Lifecycle revalidation and persistent ingest still read uncached.

## What got simpler

The ephemeral fence's separate `serving_active_cache` is gone. It ran
the same SQL predicate through `is_serving_active`, used a 10 s TTL and
had no invalidation. The connect path, the WebSocket ephemeral path and
HTTP typing now share one cache with one 1 s bound and one invalidation
path. That leaves one fewer cache concept, and the ephemeral fence goes
from 10 s stale to at most 1 s.

## Tests

- `community_active::tests` (unit):
- 16 concurrent misses cost one read, and an entry past its max age is
re-read.
- An invalidation during an in-flight read fences that fill. A waiter
that joined the read reads afresh and gets "inactive".
  - Errors are not cached.
-
`connection::tests::bootstrap_admits_from_the_cached_community_answer`:
with the database unreachable, a cached "active" admits a socket through
the real `handle_connection`, and the socket gets its AUTH challenge.
- `postgres_tests::cache_misses_behind_a_held_pool_share_one_fresh_read`
now also covers this cache. With a one-connection pool held past every
max age, eight concurrent admission checks cost one DB read, and the
entry lands fresh.
-
`postgres_tests::archived_community_is_refused_after_invalidation_or_max_age`:
after an applied archive, the next check refuses at once. An archive
this pod never hears about refuses once the entry passes 1 s.
- The ephemeral and typing fence tests now seed the new cache.
- Mutation-checked on Blox. Each of these turns a test red: dropping the
epoch fence, the max-age filter, the single-flight gate, the connect
path's cached read, or the disconnect invalidation; stamping `read_at`
before the pool wait; and calling the uncached read on the ephemeral
path.

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

---------

Signed-off-by: Luke Tornquist <[email protected]>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <[email protected]>
ravarora2 added a commit that referenced this pull request Nov 6, 2026
## Summary

A cold client in 280 channels can issue 280 separate membership reads,
each checking out a database connection. Cache the user's complete
active-membership set per community so those channel checks share one
read.

Keep the existing membership rules and 10-second freshness bound,
measured from connection checkout. Moka now accounts for snapshot size
with a 64 MiB weighted budget per relay. Each snapshot holds at most
16,384 UUIDs. The database pool controls query concurrency; there is no
separate 16-load limiter. Channel discovery keeps its separate filtering
rules. No schema change or migration.

### Request flow

1. Look up the community/user entry in Moka. A fresh complete snapshot
answers channel checks by binary search.
2. A fresh `PointChecks` marker sends each check to the database; it
stores no permission decision.
3. On a miss, create a lightweight, weighted loading entry. Requests for
that user share its lock.
4. Recheck after acquiring the lock. If waiting for that user’s lock
times out, use a point check.
5. Fetch at most 16,385 membership IDs. Above 16,384, retain a
`PointChecks` marker instead of a partial list.
6. Otherwise, sort the complete list into a compact immutable UUID
array.
7. Replace the loading entry through Moka. Its weigher now includes the
completed payload plus estimated bookkeeping.
8. Let Moka manage admission, eviction, and 10-second expiry. Membership
changes remove the relevant user or community entries.

Publication and invalidation share a short synchronous lock. A loader
publishes only while its original loading lock still identifies the
current entry. An old load can finish its original request, but cannot
repopulate an invalidated entry. Database failures propagate;
cancellation releases the user lock. Both database-query futures
allocate only when used, which keeps nested handler futures small. Cache
hits allocate neither query.

The budget covers estimated retained cache weight. Moka enforces it on a
best-effort basis; query buffers and snapshots held by active requests
can add memory. This is not a strict process-memory ceiling. Empty
entries and fallback markers have nonzero weight. There is no separate
memory-reservation or eviction implementation.

### Operational notes

- A newly granted membership can be absent from another relay's cached
snapshot until the Redis invalidation arrives, or until the 10-second
freshness bound expires if it is lost. Cached write checks can briefly
deny access; explicit channel subscriptions already confirm missing
membership against the database.
- `buzz_membership_cache_hits_total` counts channel checks served from a
fresh snapshot. `buzz_membership_cache_misses_total` counts bounded
full-set load attempts. Timeouts now use point checks, recorded by
`buzz_membership_cache_fallbacks_total{reason="wait_timeout"}`. The
other fallback reason is `oversize`; the `load_limit` path has been
removed. Before/after hit ratios are not directly comparable.
- Keep `channel_id` in membership invalidation messages for
compatibility with older relay instances during rolling deployments,
even though the new local cache invalidates by user.

### Related issue

Follow-up to #9012.

### Testing

**Current head: `a79a40cc5a6e4c7c611ed1b6bcbdd8b8c0490ef2`, including
main `f82944e3180e28502f5ea599ac9973aa6398ddb3`.** Commit `393b32583`
fixes the large-membership test fixture by refreshing planner statistics
after bulk seeding. Before that fix, the boundary test failed locally
and was the sole PostgreSQL test failure in GitHub CI (1,409/1,410
passed); the repaired test passes in 3.54 seconds including setup and
all checks. An isolated copy of the production schema/query at 16,384
memberships selected a quadratic join without statistics and a hash join
after `ANALYZE`; the latter executed in 10.86 ms. The cap and freshness
rules are unchanged.

At this exact head, the full regular buzz-db/buzz-relay suite passed
1,963/1,963 and the full PostgreSQL suite passed 1,310/1,310. This
includes both previously failing Git-auth cases and the repaired
membership boundary test (2.302 seconds including setup and checks).
Rebuilt signed live checks also passed. Full local `just ci` passed
(exit 0). GitHub CI is green: 79 successful checks and 31 skipped, with
none pending or failed at verification. The [verified matched A/B at
this
head](#9060 (comment))
against main `f82944e31` is complete: four 6,600-client arms in both
orders show 90.98–91.24% fewer membership-cache queries and 73.76–74.23%
fewer authorization checkouts. Auth error rates improved in both orders;
auth p99 and overall operation errors were mixed. Average relay RSS
increased 1.6–4.3%, with flat to slightly lower sampled peaks;
PostgreSQL average PSS decreased 1.7–5.2%, with mixed peaks. The
16,384-row production query executed in 24.953 ms after fixture ANALYZE.
Signed pool-4 and 4,097/16,384/16,385-membership probes passed. See the
report for raw counts, memory, provenance and limitations. The
[completed A/B at the earlier `6f0358875`
head](#9060 (comment))
is reported separately; it does not establish current-head performance.

Users above 16,384 memberships still use uncached authoritative point
checks. The higher cap reduces how many users hit that fallback; it does
not eliminate the cost. Each maximum-size list contains 256 KiB of UUIDs
plus overhead within the unchanged 64 MiB estimated cache budget.
Temporary allocations and retained readers can add memory outside that
budget.

Agent-run signed WebSocket test at
`a79a40cc5a6e4c7c611ed1b6bcbdd8b8c0490ef2`, using the rebuilt relay and
an isolated database: non-members cannot post; grants allow both private
channels; removal blocks writes and delivery from A while B continues;
re-add and resubscription restore A. Delivery checks completed in 1.776
s after removal and 0.466 s after re-add. A user grown above 5,000
memberships used snapshots with no oversize fallback. After growing
beyond 16,384, authoritative point checks still enforced removal and
re-add; four oversize fallbacks were recorded. The relay stopped and all
three test ports closed. Binary SHA-256:
`5b69a5aed07e9beef6bd6d2c5661c07fbdf1901dedb1729d27ab5e86a6b8d6a3`.

Build this head with `cargo build -p buzz-relay --bin buzz-relay` and
start an isolated local relay as described in
[TESTING.md](https://github.com/block/buzz/blob/6f0358875709961e2c694835eda32b0573cce47c/TESTING.md).
Reproduce with two accounts and two private channels: check denial
before membership, grant both, remove one, then re-add. Subscribe to
both channels during the test. At the current head, verify a user with
5,000 memberships still uses snapshots, then give the second account
more than 16,384 memberships and repeat the permission checks through
the fallback path. This was agent verification; human confirmation is
still pending.

#### Historical load test, before the memory-bound refactor

These measurements apply to `519509451`, before weighted eviction, the
per-user cap, and bounded loads. The refactored version was subsequently
measured at `6f0358875`; see the linked report above. The current-head
comparison is complete and reported separately
[above](#9060 (comment)).

Compared base `f9ef721e688847ed0c1512081160b6e42cdf92fd` with head
`519509451c10d94074f597124d19f2254f0b96d1` on a dedicated Linux
workstation. Ran base → fix, then fix → base. Each arm used 6,600
clients, 100 connects/second, six generator shards, and a 300-second
hold. Both versions used pool size 50, the same notices index, CPU
allocation, restored fixture, and load generator
(`4813b8cd15411355f05599e263ab15dd04e7afcc`). All four arms and 24 shard
reports completed; recorded binary hashes, fixture manifests, index DDL,
and shard configurations were verified.

| Metric | First pair: base | First pair: fix | Reverse pair: base |
Reverse pair: fix |
|---|---:|---:|---:|---:|
| Per-channel membership query calls | 1,319,382 | 7,205 | 1,367,525 |
7,465 |
| All membership-related query calls | 1,419,429 | 216,639 | 1,457,453 |
220,253 |
| Membership query execution time | 19.55 s | 10.55 s | 18.89 s | 10.74
s |
| Authorization pool checkouts | 1,667,944 | 457,649 | 1,690,624 |
453,910 |
| Average relay CPU, cores | 3.43 | 2.32 | 2.29 | 2.00 |
| Server authentication timeouts | 5,613 | 3,832 | 3,152 | 3,120 |
| Client authentication p99, highest shard | 8.378 s | 7.010 s | 6.349 s
| 6.606 s |

The cache reduced per-channel membership reads by 99.45%, all
membership-related calls by 84.7–84.9%, and authorization checkouts by
72.6–73.2%. Relay CPU was 12.7–32.4% lower. These runs support an
efficiency improvement, but do not establish a repeatable
authentication-latency or timeout improvement.

The pool still saturated. Mean authorization checkout wait increased
from 176.6 to 486.2 ms in the first pair and 118.6 to 442.6 ms in
reverse order. Removing cheap checkouts changes that average's
denominator; it does not isolate latency changes for equivalent
requests. Both versions retained client errors. Memory usage did not
consistently improve.

Each arm restored the fixture and started with a cold relay cache, but
Linux page cache was not dropped. The first baseline arm performed
materially worse than later arms. Operation counts differed with
reconnects and failures. Reported p99 is the maximum shard p99, not a
merged percentile. No perf profile was collected.

Reproduction with the reviewed harness (build exact commits above as
`base` and `fix`; optional delayed profiler disabled and complete
query/metric collection enabled):

```bash
RUN=pr9060_ab EXTRA_ENVS="BUZZ_DB_POOL_SIZE=50" \
ARMS="current:scen=bbcold.toml:rate=100:relay=base:sql=nidx.sql current:scen=bbcold.toml:rate=100:relay=fix:sql=nidx.sql" \
bash ~/lt-src/bb.sh

RUN=pr9060_ba EXTRA_ENVS="BUZZ_DB_POOL_SIZE=50" \
ARMS="current:scen=bbcold.toml:rate=100:relay=fix:sql=nidx.sql current:scen=bbcold.toml:rate=100:relay=base:sql=nidx.sql" \
bash ~/lt-src/bb.sh
```

Generated with Codex

---------

Signed-off-by: Ravneet Arora <[email protected]>
Co-authored-by: maincourse <37ce551b493d4a998caf030929c47bce0b6a0e43f1178b3a63c4151d7c16b76e@buzz.block.builderlab.xyz>

This branch was previously deployed

1 inactive deployment
codex-review — 23735c05 Deployed Nov 4, 2026 by TheSentinel454 via Run Codex Security Review #12380
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

codex-security-review-current The posted Codex security review matches its recorded range.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants