Files
buzz/crates/buzz-pubsub
Luke Tornquist 744f3946f6 perf(relay): serve restriction rows for 10 s with cross-pod invalidation (#9199)
## Summary

The restriction cache (`AppState::restriction_state_cached`) is the
relay's hottest auth read. With a 1 s max age, most per-request
ban/timeout checks miss and hit Postgres. This change serves cached rows
for **10 s** and makes every restriction write invalidate them on every
pod, so the longer age only bounds a pod that misses the notification.
That's the same bound the membership cache already uses.

### What changes

- **`RESTRICTION_CACHE_MAX_AGE` goes from 1 s to 10 s.**
- **Cross-pod invalidation for every restriction writer.**
`AppState::invalidate_restrictions(tenant)` drops the community's rows
locally and publishes a new `CacheInvalidation::Restrictions` over the
existing Redis cache-invalidation channel. The writers that call it:
- kind 9040 ban, 9041 unban, 9042 timeout, 9043 untimeout
(`moderation_commands.rs`)
- admin HTTP `DELETE /members/{pubkey}/ban` and `/timeout`, which
invalidated nothing before
- report-resolution and direct ban/timeout, at the enforcement
convergence point so crash recovery repeats it. Timeout invalidated
nothing before.
- recording an agent's owner (HTTP token/NIP-OA owner link and both
invite claims, through `disconnect_unowned_agent_clusterwide`). The
agent's cached row predates the link and lacks the owner's restriction;
the old disconnect-path drop used to cover this.

Before this change, unban, timeout and untimeout cleared only the
handling pod, and bans cleared other pods only through the disconnect
path.
- **Community-wide drop.** An agent's row also carries its owner's
restriction (`restriction_state` reads `users.agent_owner_pubkey`), and
the cache can't tell which agents a target owns. Dropping the
community's rows covers owner→agent without a lookup. Restriction writes
are rare next to the reads the cache absorbs.
- **Stale-refill fence.** Before, a read in flight when a ban landed
could refill the old "allowed" row. The 1 s age bounded that; at 10 s it
needs a fence. The restriction cache now uses the epoch-fenced fill the
community-active cache already had: a fill is published only if no
invalidation happened since its read began.

### What got simpler

- That shared-read, epoch-fence and max-age logic is now one generic
`FencedCache<K, V>` (`fenced_cache.rs`). `CommunityActiveCache` becomes
a type alias over it.
- The restriction cache drops its hand-rolled fill in `state.rs`, plus
`CachedRestriction` and the separate `restriction_reads` gate.
- `disconnect_pubkey_local` no longer touches the restriction cache, so
roster removal and self-leave stop flushing it.

Net +146 lines, tests included (new writer assertions plus the generic
cache tests, moved from `community_active.rs`).

### Accepted bounds

- A pod that misses the Redis notification (subscriber reconnect window,
or an older pod mid-rollout) serves a stale row for at most 10 s. Older
pods log and skip the unknown `op`. Bans still close sockets clusterwide
through conn-control, and socket admission and moderation-command actors
still read fresh.
- `banned` is computed at read time, so an expiring ban can outlast its
expiry by up to 10 s. That fails closed. Timeouts still lift on time,
because the cached row carries `muted_until`.
- An owner link drops the whole community's rows, not just the agent's,
because the cross-pod message is community-scoped. A link happens once
per agent, so the extra refills are negligible.

### Identified debt (not in this PR)

- `open_channels_cache` and `HostCache` hand-roll similar read_at/epoch
logic. `open_channels_cache` could move onto `FencedCache`.
- Concurrent waiters whose shared read *fails* each re-read on their own
(`SharedReads::turn`). Sharing the failure is a separate change.

## Test plan

- `fenced_cache` unit tests cover:
  - concurrent misses sharing one read
  - entries served inside the max age and expired past it
  - invalidation fencing an in-flight fill and its waiters
- scoped invalidation dropping only matching keys while also fencing
fills
  - errors not being cached
- `state::tests::restriction_invalidation_drops_the_community_rows`
checks that local and received drops remove the target's and an owned
agent's rows, and that another community's rows stay.
- `owner_link_drops_the_agents_cached_restriction_row`: a timed-out
owner's fresh agent, admitted over HTTP with that owner, is refused its
first write. Removing the owner-link invalidation fails it.
- Postgres tests now assert that the cached row is dropped by:
- kinds 9040–9043
(`restriction_commands_drop_the_cached_restriction_row`)
  - admin HTTP unban and untimeout, with other-community isolation
  - direct ban and timeout through the enforcement convergence point
- Mutants (epoch check, scoped epoch bump, `Restrictions` arm, admin
unban invalidate, convergence invalidate, 9040 invalidate) each fail
their test.
- `stored_writes_honor_cached_ban_until_it_ages_out` pins the 10 s bound
with literal ages: a 9 s old cached ban still refuses a write, and a 10
s old one is reread. Raising `RESTRICTION_CACHE_MAX_AGE` to 11 s fails
it.
- On a cloud workstation: fmt, file-size, clippy (`buzz-pubsub`,
`buzz-relay`), the nextest unit suites, and the full `buzz-relay`
Postgres lane. In the full lane, 622/626 passed. The other 4 are tests
this change doesn't touch, and each passes in isolation at both the base
commit and this head.
- Two relays sharing one Postgres and Redis: a timeout, untimeout and
admin-HTTP lift on one relay take effect on the other's next write, a
few ms after the first relay acknowledges.

🤖 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>
2026-11-06 13:13:38 -05:00
..