mirror of
https://github.com/block/buzz.git
synced 2026-11-10 11:21:05 +00:00
## 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>
265 lines
9.2 KiB
Rust
265 lines
9.2 KiB
Rust
//! Cross-pod cache-key invalidation over Redis pub/sub.
|
|
//!
|
|
//! Each relay pod keeps in-memory (moka) membership / accessible-channels /
|
|
//! visibility / restriction caches. A change is applied to the local caches
|
|
//! only on the pod that processed the write; other pods would otherwise rely
|
|
//! on each cache's max age to expire stale entries. This module carries the
|
|
//! same key drops to every pod immediately.
|
|
//!
|
|
//! The message is a pure cache-key drop — never an "evict these subscriptions"
|
|
//! payload. The per-event access gate (`filter_fanout_by_access`) is the
|
|
//! universal delivery-enforcement point, so dropping the stale key is
|
|
//! sufficient: the next read re-fetches authoritative state from the DB.
|
|
|
|
use buzz_core::{CommunityId, TenantContext};
|
|
use futures_util::StreamExt;
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio::sync::broadcast;
|
|
use uuid::Uuid;
|
|
|
|
use crate::topic::BUZZ_PREFIX;
|
|
|
|
/// Tenant-local Redis pub/sub channel suffix for cache-invalidation messages.
|
|
pub const CACHE_INVALIDATION_SUFFIX: &str = "cache-invalidate";
|
|
|
|
/// Pattern used by the subscriber to receive cache invalidations for all
|
|
/// communities this pod may have cached locally.
|
|
pub const CACHE_INVALIDATION_PATTERN: &str = "buzz:*:cache-invalidate";
|
|
|
|
/// Redis pub/sub channel for cache-invalidation messages under `ctx`.
|
|
pub fn cache_invalidation_channel(ctx: &TenantContext) -> String {
|
|
format!(
|
|
"{BUZZ_PREFIX}:{}:{CACHE_INVALIDATION_SUFFIX}",
|
|
ctx.community()
|
|
)
|
|
}
|
|
|
|
/// Parse a cache-invalidation Redis channel into its scoped community id.
|
|
pub fn parse_cache_invalidation_channel(channel: &str) -> Option<CommunityId> {
|
|
let mut parts = channel.split(':');
|
|
if parts.next()? != BUZZ_PREFIX {
|
|
return None;
|
|
}
|
|
let community_id = Uuid::parse_str(parts.next()?).ok()?;
|
|
if parts.next()? != CACHE_INVALIDATION_SUFFIX {
|
|
return None;
|
|
}
|
|
if parts.next().is_some() {
|
|
return None;
|
|
}
|
|
Some(CommunityId::from_uuid(community_id))
|
|
}
|
|
|
|
/// A cache-key drop to apply on every pod. Each variant mirrors exactly one of
|
|
/// the relay's local `invalidate_*` operations. The community is carried by
|
|
/// [`ScopedCacheInvalidation`], not by the tenant-local operation.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
#[serde(tag = "op")]
|
|
pub enum CacheInvalidation {
|
|
/// Drop a serving-host snapshot after policy or binding/lifecycle changes.
|
|
CommunityHost {
|
|
/// Normalized host whose routing and policy snapshot changed.
|
|
host: String,
|
|
},
|
|
/// Drop the `(channel_id, pubkey)` membership entry and the user's
|
|
/// accessible-channels entry. Mirrors `invalidate_membership`.
|
|
Membership {
|
|
/// Channel whose membership changed.
|
|
channel_id: Uuid,
|
|
/// Affected member's pubkey bytes.
|
|
pubkey: Vec<u8>,
|
|
},
|
|
/// Drop every user's accessible-channels entry. Mirrors
|
|
/// `invalidate_all_accessible_channels` (e.g. a new open channel).
|
|
AccessibleAll,
|
|
/// Drop the cached visibility for a single channel. Mirrors
|
|
/// `invalidate_channel_visibility` (e.g. an open→private flip).
|
|
Visibility {
|
|
/// Channel whose visibility changed.
|
|
channel_id: Uuid,
|
|
},
|
|
/// Drop all membership / accessible / visibility caches. Mirrors
|
|
/// `invalidate_channel_deleted`.
|
|
ChannelDeleted,
|
|
/// Drop every cached ban/timeout row in the community after a
|
|
/// restriction write. Mirrors `invalidate_restrictions`. A pod that
|
|
/// predates this variant skips it and serves its rows until they age out.
|
|
Restrictions,
|
|
}
|
|
|
|
/// A cache invalidation received from a community-scoped Redis channel.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ScopedCacheInvalidation {
|
|
/// Community whose local cache key should be dropped.
|
|
pub community_id: CommunityId,
|
|
/// Tenant-local cache invalidation operation.
|
|
pub invalidation: CacheInvalidation,
|
|
}
|
|
|
|
/// Initial reconnect backoff (1 second).
|
|
const BACKOFF_INITIAL_SECS: u64 = 1;
|
|
/// Maximum reconnect backoff (30 seconds).
|
|
const BACKOFF_MAX_SECS: u64 = 30;
|
|
|
|
/// Subscribes to `buzz:*:cache-invalidate` and forwards scoped drops to the broadcast.
|
|
///
|
|
/// Mirrors `subscriber::run_subscriber`: a reconnect loop with exponential
|
|
/// backoff (1s → 2s → 4s → … → 30s max). Never returns — runs for the lifetime
|
|
/// of the relay.
|
|
pub async fn run_cache_invalidation_subscriber(
|
|
redis_url: String,
|
|
broadcast_tx: broadcast::Sender<ScopedCacheInvalidation>,
|
|
) {
|
|
let mut backoff_secs = BACKOFF_INITIAL_SECS;
|
|
|
|
loop {
|
|
match connect_and_subscribe(&redis_url, &broadcast_tx).await {
|
|
Ok(()) => {
|
|
backoff_secs = BACKOFF_INITIAL_SECS;
|
|
tracing::warn!(
|
|
"Redis cache-invalidation stream ended (clean disconnect) — reconnecting in {backoff_secs}s"
|
|
);
|
|
}
|
|
Err(e) => {
|
|
tracing::error!(
|
|
"Redis cache-invalidation error: {e} — reconnecting in {backoff_secs}s"
|
|
);
|
|
}
|
|
}
|
|
|
|
tokio::time::sleep(tokio::time::Duration::from_secs(backoff_secs)).await;
|
|
backoff_secs = (backoff_secs * 2).min(BACKOFF_MAX_SECS);
|
|
|
|
tracing::info!("Attempting to reconnect to Redis cache-invalidation...");
|
|
}
|
|
}
|
|
|
|
async fn connect_and_subscribe(
|
|
redis_url: &str,
|
|
broadcast_tx: &broadcast::Sender<ScopedCacheInvalidation>,
|
|
) -> Result<(), redis::RedisError> {
|
|
let client = redis::Client::open(redis_url)?;
|
|
let mut conn = client.get_async_pubsub().await?;
|
|
|
|
conn.psubscribe(CACHE_INVALIDATION_PATTERN).await?;
|
|
|
|
tracing::info!(
|
|
"Redis cache-invalidation subscriber connected — listening on {CACHE_INVALIDATION_PATTERN}"
|
|
);
|
|
|
|
let mut stream = conn.on_message();
|
|
while let Some(msg) = stream.next().await {
|
|
let channel = msg.get_channel_name();
|
|
let Some(community_id) = parse_cache_invalidation_channel(channel) else {
|
|
tracing::warn!("Received cache-invalidation message on unexpected channel: {channel}");
|
|
continue;
|
|
};
|
|
|
|
let payload: String = match msg.get_payload() {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
tracing::warn!("Failed to get cache-invalidation payload: {e}");
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let invalidation: CacheInvalidation = match serde_json::from_str(&payload) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
tracing::warn!("Failed to deserialize cache-invalidation message: {e}");
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let scoped = ScopedCacheInvalidation {
|
|
community_id,
|
|
invalidation,
|
|
};
|
|
|
|
if broadcast_tx.send(scoped).is_err() {
|
|
tracing::trace!("No cache-invalidation receivers — message dropped");
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn ctx(id: u128, host: &str) -> TenantContext {
|
|
TenantContext::resolved(CommunityId::from_uuid(Uuid::from_u128(id)), host)
|
|
}
|
|
|
|
#[test]
|
|
fn cache_invalidation_channel_is_community_scoped() {
|
|
let community_a = ctx(0xaaaa, "a.example");
|
|
let community_b = ctx(0xbbbb, "b.example");
|
|
|
|
assert_eq!(
|
|
cache_invalidation_channel(&community_a),
|
|
format!("buzz:{}:cache-invalidate", community_a.community())
|
|
);
|
|
assert_ne!(
|
|
cache_invalidation_channel(&community_a),
|
|
cache_invalidation_channel(&community_b)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_cache_invalidation_channel() {
|
|
let community_id = CommunityId::from_uuid(Uuid::from_u128(0xaaaa));
|
|
let raw = format!("buzz:{community_id}:cache-invalidate");
|
|
|
|
assert_eq!(parse_cache_invalidation_channel(&raw), Some(community_id));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_bad_cache_invalidation_channels() {
|
|
for raw in [
|
|
"buzz:cache-invalidate",
|
|
"buzz:not-a-uuid:cache-invalidate",
|
|
"not-buzz:00000000-0000-0000-0000-00000000aaaa:cache-invalidate",
|
|
"buzz:00000000-0000-0000-0000-00000000aaaa:cache-invalidate:extra",
|
|
"buzz:00000000-0000-0000-0000-00000000aaaa:channel:00000000-0000-0000-0000-00000000bbbb",
|
|
] {
|
|
assert_eq!(parse_cache_invalidation_channel(raw), None);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn membership_roundtrips_through_json() {
|
|
let msg = CacheInvalidation::Membership {
|
|
channel_id: Uuid::from_u128(0x1234),
|
|
pubkey: vec![1, 2, 3, 4],
|
|
};
|
|
let json = serde_json::to_string(&msg).unwrap();
|
|
assert_eq!(
|
|
serde_json::from_str::<CacheInvalidation>(&json).unwrap(),
|
|
msg
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unit_variants_roundtrip_through_json() {
|
|
for msg in [
|
|
CacheInvalidation::CommunityHost {
|
|
host: "a.example".into(),
|
|
},
|
|
CacheInvalidation::AccessibleAll,
|
|
CacheInvalidation::ChannelDeleted,
|
|
CacheInvalidation::Restrictions,
|
|
CacheInvalidation::Visibility {
|
|
channel_id: Uuid::from_u128(0xabcd),
|
|
},
|
|
] {
|
|
let json = serde_json::to_string(&msg).unwrap();
|
|
assert_eq!(
|
|
serde_json::from_str::<CacheInvalidation>(&json).unwrap(),
|
|
msg
|
|
);
|
|
}
|
|
}
|
|
}
|