feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "buzz-pubsub"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Redis pub/sub fan-out, presence, and typing indicators for Buzz"
[dependencies]
buzz-core = { workspace = true }
buzz-auth = { workspace = true }
redis = { workspace = true }
deadpool-redis = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
tracing = { workspace = true }
thiserror = { workspace = true }
nostr = { workspace = true }
futures-util = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }
@@ -0,0 +1,251 @@
//! Cross-pod cache-key invalidation over Redis pub/sub.
//!
//! Each relay pod keeps in-memory (moka) membership / accessible-channels /
//! visibility caches. A membership or visibility change is applied to the local
//! caches only on the pod that processed the write; other pods would otherwise
//! rely on the 10s TTL 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 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,
}
/// 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::AccessibleAll,
CacheInvalidation::ChannelDeleted,
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
);
}
}
}
+229
View File
@@ -0,0 +1,229 @@
//! Cross-pod connection-control commands over Redis pub/sub.
//!
//! Under horizontal scaling a member's live connections may land on any pod,
//! so a moderation action taken on one pod (a ban) must reach the pod holding
//! the victim's socket. This module carries connection-control intents — today
//! only "disconnect this pubkey" — to every pod, which each apply locally
//! against their own [`crate::ConnectionManager`].
//!
//! This is deliberately a **separate** channel from `cache_invalidation`: a
//! cache-key drop is a pure, idempotent hint (the DB is re-read on the next
//! access), whereas a disconnect is an imperative, non-idempotent action on a
//! live socket. Folding it into the cache-invalidation enum would break that
//! module's stated invariant ("a pure cache-key drop, never an evict payload").
//! The DB ban row remains the durable backstop: even if a disconnect message is
//! dropped, the next auth attempt is refused at the auth seam.
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 connection-control messages.
pub const CONN_CONTROL_SUFFIX: &str = "conn-control";
/// Pattern the subscriber uses to receive connection-control messages for every
/// community this pod may hold connections for.
pub const CONN_CONTROL_PATTERN: &str = "buzz:*:conn-control";
/// Redis pub/sub channel for connection-control messages under `ctx`.
pub fn conn_control_channel(ctx: &TenantContext) -> String {
format!("{BUZZ_PREFIX}:{}:{CONN_CONTROL_SUFFIX}", ctx.community())
}
/// Parse a connection-control Redis channel into its scoped community id.
pub fn parse_conn_control_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()? != CONN_CONTROL_SUFFIX {
return None;
}
if parts.next().is_some() {
return None;
}
Some(CommunityId::from_uuid(community_id))
}
/// A connection-control command to apply on every pod.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "op")]
pub enum ConnControl {
/// Disconnect every live socket bound to the carrying community.
DisconnectCommunity,
/// Disconnect every live connection authenticated as `pubkey` in the
/// carrying community — live ban enforcement. `pubkey` is 32 raw bytes.
/// `event_id` and `reason` reproduce the same NIP-01 `OK` frame the origin
/// pod sent, so a member disconnected on any pod learns why.
DisconnectPubkey {
/// Banned member's pubkey bytes.
pubkey: Vec<u8>,
/// Id echoed in the closing `OK` frame (the ban event's id on origin).
event_id: String,
/// Human-readable close reason for the `OK` frame.
reason: String,
},
}
/// A connection-control command received from a community-scoped Redis channel.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScopedConnControl {
/// Community whose connections the command applies to.
pub community_id: CommunityId,
/// The tenant-local connection-control command.
pub command: ConnControl,
}
/// 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:*:conn-control` and forwards scoped commands to the
/// broadcast. Mirrors [`crate::cache_invalidation::run_cache_invalidation_subscriber`]:
/// a reconnect loop with exponential backoff. Never returns.
pub async fn run_conn_control_subscriber(
redis_url: String,
broadcast_tx: broadcast::Sender<ScopedConnControl>,
) {
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 conn-control stream ended (clean disconnect) — reconnecting in {backoff_secs}s"
);
}
Err(e) => {
tracing::error!("Redis conn-control 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 conn-control...");
}
}
async fn connect_and_subscribe(
redis_url: &str,
broadcast_tx: &broadcast::Sender<ScopedConnControl>,
) -> Result<(), redis::RedisError> {
let client = redis::Client::open(redis_url)?;
let mut conn = client.get_async_pubsub().await?;
conn.psubscribe(CONN_CONTROL_PATTERN).await?;
tracing::info!("Redis conn-control subscriber connected — listening on {CONN_CONTROL_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_conn_control_channel(channel) else {
tracing::warn!("Received conn-control message on unexpected channel: {channel}");
continue;
};
let payload: String = match msg.get_payload() {
Ok(p) => p,
Err(e) => {
tracing::warn!("Failed to get conn-control payload: {e}");
continue;
}
};
let command: ConnControl = match serde_json::from_str(&payload) {
Ok(v) => v,
Err(e) => {
tracing::warn!("Failed to deserialize conn-control message: {e}");
continue;
}
};
let scoped = ScopedConnControl {
community_id,
command,
};
if broadcast_tx.send(scoped).is_err() {
tracing::trace!("No conn-control 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 conn_control_channel_is_community_scoped() {
let a = ctx(0xaaaa, "a.example");
let b = ctx(0xbbbb, "b.example");
assert_eq!(
conn_control_channel(&a),
format!("buzz:{}:conn-control", a.community())
);
assert_ne!(conn_control_channel(&a), conn_control_channel(&b));
}
#[test]
fn parse_round_trips_the_community() {
let a = ctx(0x1234, "a.example");
let channel = conn_control_channel(&a);
assert_eq!(parse_conn_control_channel(&channel), Some(a.community()));
}
#[test]
fn parse_rejects_foreign_channels() {
assert_eq!(
parse_conn_control_channel("buzz:not-a-uuid:conn-control"),
None
);
assert_eq!(parse_conn_control_channel("buzz:*:cache-invalidate"), None);
let a = ctx(0x1234, "a.example");
let extended = format!("{}:extra", conn_control_channel(&a));
assert_eq!(parse_conn_control_channel(&extended), None);
}
#[test]
fn disconnect_community_command_serde_round_trips() {
let cmd = ConnControl::DisconnectCommunity;
let json = serde_json::to_string(&cmd).unwrap();
assert_eq!(serde_json::from_str::<ConnControl>(&json).unwrap(), cmd);
}
#[test]
fn unknown_command_is_rejected_without_affecting_later_messages() {
assert!(serde_json::from_str::<ConnControl>(r#"{"op":"FutureCommand"}"#).is_err());
let known = serde_json::to_string(&ConnControl::DisconnectCommunity).unwrap();
assert_eq!(
serde_json::from_str::<ConnControl>(&known).unwrap(),
ConnControl::DisconnectCommunity
);
}
#[test]
fn disconnect_command_serde_round_trips() {
let cmd = ConnControl::DisconnectPubkey {
pubkey: vec![7u8; 32],
event_id: "abc123".to_string(),
reason: "blocked: you are banned from this community".to_string(),
};
let json = serde_json::to_string(&cmd).unwrap();
assert_eq!(serde_json::from_str::<ConnControl>(&json).unwrap(), cmd);
}
}
+38
View File
@@ -0,0 +1,38 @@
use thiserror::Error;
/// Errors that can occur in pub/sub, presence, and typing operations.
#[derive(Debug, Error)]
pub enum PubSubError {
/// A Redis command failed.
#[error("Redis error: {0}")]
Redis(#[from] redis::RedisError),
/// Failed to acquire a connection from the Redis pool.
#[error("Redis pool error: {0}")]
Pool(#[from] deadpool_redis::PoolError),
/// JSON serialization or deserialization failed.
#[error("Serialization error: {0}")]
Serialization(#[from] serde_json::Error),
/// The broadcast receiver fell behind and dropped messages.
#[error("Broadcast receiver lagged: {0} messages dropped")]
BroadcastLagged(u64),
/// The pub/sub subscriber task has stopped unexpectedly.
#[error("Pub/sub subscriber task stopped")]
SubscriberStopped,
/// A Redis channel key could not be parsed as a valid channel ID.
#[error("Invalid channel key: {0}")]
InvalidChannelKey(String),
}
impl From<tokio::sync::broadcast::error::RecvError> for PubSubError {
fn from(e: tokio::sync::broadcast::error::RecvError) -> Self {
match e {
tokio::sync::broadcast::error::RecvError::Lagged(n) => PubSubError::BroadcastLagged(n),
tokio::sync::broadcast::error::RecvError::Closed => PubSubError::SubscriberStopped,
}
}
}
+629
View File
@@ -0,0 +1,629 @@
#![deny(unsafe_code)]
#![warn(missing_docs)]
//! `buzz-pubsub` — Redis pub/sub fan-out, presence tracking, and typing indicators.
//!
//! # Architecture
//!
//! ```text
//! buzz-relay process
//! │
//! ├── deadpool-redis pool → PUBLISH, SET, ZADD, etc.
//! │
//! └── dedicated redis::aio::PubSub connection (NOT from pool)
//! └── dynamic SUBSCRIBE buzz:{community}:channel:{id} / buzz:{community}:global
//! └── run_subscriber() → broadcast::channel(4096) → N WS receivers
//! ```
//!
//! The subscriber reconnects automatically on Redis disconnect with exponential
//! backoff (1s → 2s → 4s → … → 30s max).
//!
//! Dedicated pub/sub connection is stateful and cannot be shared.
//! Pool connections handle all other commands.
//! Lagged receivers get `RecvError::Lagged`.
/// Cross-pod cache-key invalidation over Redis pub/sub.
pub mod cache_invalidation;
/// Cross-pod connection-control commands over Redis pub/sub.
pub mod conn_control;
/// Error types for pub/sub operations.
pub mod error;
/// Redis-backed NIP-98 replay seen-set.
pub mod nip98_replay;
pub use nip98_replay::RedisNip98ReplayGuard;
/// Online/offline presence tracking in Redis.
pub mod presence;
/// Redis PUBLISH for channel event fan-out.
pub mod publisher;
/// Redis-backed rate limiter (fixed-window INCR + EXPIRE).
pub mod rate_limiter;
/// Redis SUBSCRIBE for channel event delivery.
pub mod subscriber;
/// Community-scoped Redis event topics.
pub mod topic;
/// Typing indicator tracking in Redis.
pub use error::PubSubError;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use buzz_core::TenantContext;
use nostr::PublicKey;
use tokio::sync::{broadcast, mpsc, Mutex};
use crate::cache_invalidation::{
cache_invalidation_channel, CacheInvalidation, ScopedCacheInvalidation,
};
use crate::conn_control::{conn_control_channel, ConnControl, ScopedConnControl};
pub use crate::topic::{channel_key, global_key, EventTopic, EventTopicKey};
/// A Nostr event received on a scoped Redis event topic, broadcast to local subscribers.
#[derive(Debug, Clone)]
pub struct ChannelEvent {
/// Server-resolved community that scoped the Redis topic.
pub community_id: buzz_core::CommunityId,
/// Tenant-local routing scope for this event.
pub topic: EventTopic,
/// The Nostr event payload.
pub event: nostr::Event,
}
/// Configuration for the pub/sub subsystem.
#[derive(Debug, Clone)]
pub struct PubSubConfig {
/// Redis connection URL (e.g. `redis://127.0.0.1:6379`).
pub redis_url: String,
/// Delay before unsubscribing after the last local interest is released.
pub unsubscribe_debounce: Duration,
}
impl PubSubConfig {
/// Default delay before unsubscribing after the last local interest is released.
pub const DEFAULT_UNSUBSCRIBE_DEBOUNCE: Duration = Duration::from_millis(500);
/// Creates a new `PubSubConfig` with the given Redis URL.
pub fn new(redis_url: impl Into<String>) -> Self {
Self {
redis_url: redis_url.into(),
unsubscribe_debounce: Self::DEFAULT_UNSUBSCRIBE_DEBOUNCE,
}
}
/// Override the unsubscribe debounce delay.
pub fn with_unsubscribe_debounce(mut self, debounce: Duration) -> Self {
self.unsubscribe_debounce = debounce;
self
}
}
/// Central pub/sub manager for a Buzz relay instance.
pub struct PubSubManager {
pool: deadpool_redis::Pool,
/// Redis URL used by the reconnect loop to re-establish pub/sub connections.
redis_url: String,
/// Delay before unsubscribing after the last local interest is released.
unsubscribe_debounce: Duration,
/// Local desired topic refcounts; source of truth across Redis reconnects.
desired_topics: subscriber::DesiredTopics,
subscription_tx: mpsc::Sender<subscriber::SubscriptionCommand>,
subscription_rx: Mutex<Option<mpsc::Receiver<subscriber::SubscriptionCommand>>>,
broadcast_tx: broadcast::Sender<ChannelEvent>,
cache_invalidation_tx: broadcast::Sender<ScopedCacheInvalidation>,
conn_control_tx: broadcast::Sender<ScopedConnControl>,
}
impl PubSubManager {
/// Creates a new `PubSubManager` connected to the given Redis URL.
pub async fn new(redis_url: &str, pool: deadpool_redis::Pool) -> Result<Self, PubSubError> {
Self::with_config(PubSubConfig::new(redis_url), pool).await
}
/// Creates a new `PubSubManager` using explicit pub/sub configuration.
pub async fn with_config(
config: PubSubConfig,
pool: deadpool_redis::Pool,
) -> Result<Self, PubSubError> {
let (broadcast_tx, _) = broadcast::channel(4096);
let (cache_invalidation_tx, _) = broadcast::channel(4096);
let (conn_control_tx, _) = broadcast::channel(4096);
let (subscription_tx, subscription_rx) = mpsc::channel(4096);
Ok(Self {
pool,
redis_url: config.redis_url,
unsubscribe_debounce: config.unsubscribe_debounce,
desired_topics: Arc::new(Mutex::new(HashMap::new())),
subscription_tx,
subscription_rx: Mutex::new(Some(subscription_rx)),
broadcast_tx,
cache_invalidation_tx,
conn_control_tx,
})
}
/// Starts the pub/sub fan-out loop with automatic reconnection.
///
/// Runs forever — spawn this in a background task. The loop reconnects
/// with exponential backoff on Redis disconnect (1s → 2s → 4s → … → 30s).
pub async fn run_subscriber(self: Arc<Self>) {
let Some(subscription_rx) = self.subscription_rx.lock().await.take() else {
tracing::error!("Redis pub/sub subscriber already started");
return;
};
subscriber::run_subscriber(
self.redis_url.clone(),
self.broadcast_tx.clone(),
self.desired_topics.clone(),
subscription_rx,
)
.await;
}
/// Starts the cache-invalidation subscriber loop with automatic
/// reconnection. Runs forever — spawn this in a background task.
pub async fn run_cache_invalidation_subscriber(self: Arc<Self>) {
cache_invalidation::run_cache_invalidation_subscriber(
self.redis_url.clone(),
self.cache_invalidation_tx.clone(),
)
.await;
}
/// Starts the connection-control subscriber loop with automatic
/// reconnection. Runs forever — spawn this in a background task.
pub async fn run_conn_control_subscriber(self: Arc<Self>) {
conn_control::run_conn_control_subscriber(
self.redis_url.clone(),
self.conn_control_tx.clone(),
)
.await;
}
/// Returns a new broadcast receiver for locally-published channel events.
pub fn subscribe_local(&self) -> broadcast::Receiver<ChannelEvent> {
self.broadcast_tx.subscribe()
}
/// Retain local interest in a scoped Redis event topic.
///
/// The first retain for a topic asks the subscriber task to `SUBSCRIBE`.
/// Additional retains only increment the local desired refcount.
pub async fn retain_topic(&self, ctx: &TenantContext, topic: EventTopic) {
let topic_key = EventTopicKey::from_context(ctx, topic);
let should_subscribe = {
let mut desired = self.desired_topics.lock().await;
let count = desired.entry(topic_key).or_insert(0);
let was_zero = *count == 0;
*count += 1;
was_zero
};
if should_subscribe {
let _ = self
.subscription_tx
.send(subscriber::SubscriptionCommand::Subscribe(topic_key))
.await;
}
}
/// Release local interest in a scoped Redis event topic.
///
/// When the last retain is released, unsubscribe is delayed by the configured
/// debounce. If another retain arrives during that delay, the pending
/// unsubscribe becomes a no-op.
pub async fn release_topic(&self, ctx: &TenantContext, topic: EventTopic) {
let topic_key = EventTopicKey::from_context(ctx, topic);
let became_zero = {
let mut desired = self.desired_topics.lock().await;
let Some(count) = desired.get_mut(&topic_key) else {
tracing::warn!(?topic_key, "release_topic called for unretained topic");
return;
};
*count -= 1;
if *count == 0 {
desired.remove(&topic_key);
true
} else {
false
}
};
if became_zero {
let tx = self.subscription_tx.clone();
let debounce = self.unsubscribe_debounce;
tokio::spawn(async move {
tokio::time::sleep(debounce).await;
let _ = tx
.send(subscriber::SubscriptionCommand::UnsubscribeIfIdle(
topic_key,
))
.await;
});
}
}
/// Current local desired refcount for tests and metrics.
pub async fn topic_refcount(&self, ctx: &TenantContext, topic: EventTopic) -> usize {
let topic_key = EventTopicKey::from_context(ctx, topic);
self.desired_topics
.lock()
.await
.get(&topic_key)
.copied()
.unwrap_or(0)
}
/// Returns a new broadcast receiver for cross-pod cache-invalidation drops.
pub fn subscribe_cache_invalidations(&self) -> broadcast::Receiver<ScopedCacheInvalidation> {
self.cache_invalidation_tx.subscribe()
}
/// Returns a new broadcast receiver for cross-pod connection-control commands.
pub fn subscribe_conn_control(&self) -> broadcast::Receiver<ScopedConnControl> {
self.conn_control_tx.subscribe()
}
/// Publish a cache-key drop to all pods. Fire-and-forget at the call site:
/// the local cache is already dropped synchronously; this carries the same
/// drop cross-pod. A dropped publish is backstopped by the REQ denial-path
/// DB confirmation, so callers may spawn this without awaiting delivery.
pub async fn publish_cache_invalidation(
&self,
ctx: &TenantContext,
invalidation: &CacheInvalidation,
) -> Result<i64, PubSubError> {
let mut conn = self.pool.get().await?;
let payload = serde_json::to_string(invalidation)?;
let subscriber_count: i64 = redis::cmd("PUBLISH")
.arg(cache_invalidation_channel(ctx))
.arg(&payload)
.query_async(&mut conn)
.await?;
Ok(subscriber_count)
}
/// Publish a connection-control command to all pods. Used for live ban
/// enforcement: the banning pod disconnects any local sockets synchronously
/// and calls this to reach the banned member's sockets on other pods. The DB
/// ban row is the durable backstop, so a dropped publish still refuses the
/// next auth attempt; callers may spawn this without awaiting delivery.
pub async fn publish_conn_control(
&self,
ctx: &TenantContext,
command: &ConnControl,
) -> Result<i64, PubSubError> {
let mut conn = self.pool.get().await?;
let payload = serde_json::to_string(command)?;
let subscriber_count: i64 = redis::cmd("PUBLISH")
.arg(conn_control_channel(ctx))
.arg(&payload)
.query_async(&mut conn)
.await?;
Ok(subscriber_count)
}
/// Publish an event to the Redis channel. Returns subscriber count.
///
/// Routing note (NIP-ER author-private reminders): events are keyed by
/// `buzz:{community}:channel:{id}` / `buzz:{community}:global`, and
/// relay nodes dynamically subscribe only to topics with local interest —
/// so the topic key is a routing label, not an isolation boundary.
/// Author-private reminders (kind:30300, stored under the nil channel
/// sentinel) are therefore NOT protected by per-author Redis routing, and
/// adding it would be pointless: the reminder's author may be connected to
/// any node, so every node must still receive it. The actual author-only
/// delivery boundary is `filter_fanout_by_access` in the relay, which runs
/// on BOTH the in-process and the Redis cross-node (`subscribe_local`)
/// fan-out paths and drops every recipient that is not the event author.
/// Redis only ever carries events between nodes inside the relay trust
/// domain; the ciphertext is NIP-44-encrypted to the author regardless.
pub async fn publish_event(
&self,
ctx: &TenantContext,
topic: EventTopic,
event: &nostr::Event,
) -> Result<i64, PubSubError> {
publisher::publish_event(&self.pool, ctx, topic, event).await
}
/// Set presence with 180s TTL. Call on connect and every 60s heartbeat.
pub async fn set_presence(
&self,
ctx: &TenantContext,
pubkey: &PublicKey,
status: &str,
) -> Result<(), PubSubError> {
presence::set_presence(&self.pool, ctx, pubkey, status).await
}
/// Remove presence for `pubkey`. Call on clean disconnect.
pub async fn clear_presence(
&self,
ctx: &TenantContext,
pubkey: &PublicKey,
) -> Result<(), PubSubError> {
presence::clear_presence(&self.pool, ctx, pubkey).await
}
/// Returns the current presence status for `pubkey`, or `None` if not set.
pub async fn get_presence(
&self,
ctx: &TenantContext,
pubkey: &PublicKey,
) -> Result<Option<String>, PubSubError> {
presence::get_presence(&self.pool, ctx, pubkey).await
}
/// Returns presence statuses for multiple pubkeys as a `pubkey_hex → status` map.
pub async fn get_presence_bulk(
&self,
ctx: &TenantContext,
pubkeys: &[PublicKey],
) -> Result<HashMap<String, String>, PubSubError> {
presence::get_presence_bulk(&self.pool, ctx, pubkeys).await
}
}
#[cfg(test)]
pub(crate) mod test_util {
pub fn make_test_pool() -> deadpool_redis::Pool {
let cfg = deadpool_redis::Config::from_url("redis://127.0.0.1:6379");
cfg.create_pool(Some(deadpool_redis::Runtime::Tokio1))
.expect("Failed to create Redis pool")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_util::make_test_pool;
use buzz_core::{CommunityId, TenantContext};
use nostr::{EventBuilder, Keys, Kind};
use uuid::Uuid;
async fn make_manager() -> Arc<PubSubManager> {
let pool = make_test_pool();
Arc::new(
PubSubManager::new("redis://127.0.0.1:6379", pool)
.await
.expect("Failed to create PubSubManager"),
)
}
fn ctx(id: u128, host: &str) -> TenantContext {
TenantContext::resolved(CommunityId::from_uuid(Uuid::from_u128(id)), host)
}
#[tokio::test]
#[ignore = "requires Redis"]
async fn test_publish_and_subscribe_roundtrip() {
let manager = make_manager().await;
let mut rx = manager.subscribe_local();
let manager_clone = manager.clone();
tokio::spawn(async move { manager_clone.run_subscriber().await });
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
let ctx = ctx(0xaaaa, "a.example");
let channel_id = Uuid::new_v4();
let keys = Keys::generate();
let event = EventBuilder::new(Kind::TextNote, "hello pubsub")
.tags([])
.sign_with_keys(&keys)
.expect("signing failed");
let event_id = event.id;
manager
.retain_topic(&ctx, EventTopic::Channel(channel_id))
.await;
manager
.publish_event(&ctx, EventTopic::Channel(channel_id), &event)
.await
.expect("publish failed");
let received = tokio::time::timeout(tokio::time::Duration::from_secs(2), rx.recv())
.await
.expect("timeout")
.expect("channel closed");
assert_eq!(received.community_id, ctx.community());
assert_eq!(received.topic, EventTopic::Channel(channel_id));
assert_eq!(received.event.id, event_id);
}
#[tokio::test]
#[ignore = "requires Redis"]
async fn test_cache_invalidation_roundtrip() {
let manager = make_manager().await;
let mut rx = manager.subscribe_cache_invalidations();
let manager_clone = manager.clone();
tokio::spawn(async move { manager_clone.run_cache_invalidation_subscriber().await });
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
let channel_id = Uuid::new_v4();
let pubkey = Keys::generate().public_key().to_bytes().to_vec();
let sent = CacheInvalidation::Membership {
channel_id,
pubkey: pubkey.clone(),
};
let ctx = ctx(0xaaaa, "a.example");
manager
.publish_cache_invalidation(&ctx, &sent)
.await
.expect("publish failed");
let received = tokio::time::timeout(tokio::time::Duration::from_secs(2), rx.recv())
.await
.expect("timeout")
.expect("channel closed");
assert_eq!(
received,
ScopedCacheInvalidation {
community_id: ctx.community(),
invalidation: sent,
}
);
}
#[tokio::test]
#[ignore = "requires Redis"]
async fn test_presence_set_and_get() {
let pool = make_test_pool();
let pubkey = Keys::generate().public_key();
let ctx = ctx(0xaaaa, "a.example");
let status = presence::get_presence(&pool, &ctx, &pubkey).await.unwrap();
assert!(status.is_none());
presence::set_presence(&pool, &ctx, &pubkey, "online")
.await
.unwrap();
let status = presence::get_presence(&pool, &ctx, &pubkey).await.unwrap();
assert_eq!(status.as_deref(), Some("online"));
let mut conn = pool.get().await.unwrap();
let ttl: i64 = redis::cmd("TTL")
.arg(presence::presence_key(&ctx, &pubkey))
.query_async(&mut conn)
.await
.unwrap();
assert!(
ttl > 0 && ttl <= presence::PRESENCE_TTL_SECS as i64,
"TTL should be 1-{}s, got {ttl}",
presence::PRESENCE_TTL_SECS
);
presence::clear_presence(&pool, &ctx, &pubkey)
.await
.unwrap();
let status = presence::get_presence(&pool, &ctx, &pubkey).await.unwrap();
assert!(status.is_none());
}
#[tokio::test]
#[ignore = "requires Redis"]
async fn same_channel_id_in_two_communities_release_one_keeps_other_live() {
let pool = make_test_pool();
let manager = Arc::new(
PubSubManager::with_config(
PubSubConfig::new("redis://127.0.0.1:6379")
.with_unsubscribe_debounce(Duration::from_millis(25)),
pool,
)
.await
.expect("Failed to create PubSubManager"),
);
let mut rx = manager.subscribe_local();
let manager_clone = manager.clone();
tokio::spawn(async move { manager_clone.run_subscriber().await });
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
let ctx_a = ctx(0xaaaa, "a.example");
let ctx_b = ctx(0xbbbb, "b.example");
let channel_id = Uuid::from_u128(0xcccc);
let topic = EventTopic::Channel(channel_id);
manager.retain_topic(&ctx_a, topic).await;
manager.retain_topic(&ctx_b, topic).await;
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
assert_eq!(manager.topic_refcount(&ctx_a, topic).await, 1);
assert_eq!(manager.topic_refcount(&ctx_b, topic).await, 1);
let keys = Keys::generate();
let event_before_release = EventBuilder::new(Kind::TextNote, "before A release")
.tags([])
.sign_with_keys(&keys)
.expect("signing failed");
manager
.publish_event(&ctx_b, topic, &event_before_release)
.await
.expect("publish before release failed");
let received_before_release =
tokio::time::timeout(tokio::time::Duration::from_secs(2), rx.recv())
.await
.expect("timeout before release")
.expect("channel closed before release");
assert_eq!(received_before_release.community_id, ctx_b.community());
assert_eq!(received_before_release.topic, topic);
assert_eq!(received_before_release.event.id, event_before_release.id);
manager.release_topic(&ctx_a, topic).await;
assert_eq!(manager.topic_refcount(&ctx_a, topic).await, 0);
assert_eq!(manager.topic_refcount(&ctx_b, topic).await, 1);
// Wait past A's debounce. A buggy implementation that keyed active
// Redis subscriptions by channel id alone would unsubscribe B here too.
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let event_after_release = EventBuilder::new(Kind::TextNote, "after A release")
.tags([])
.sign_with_keys(&keys)
.expect("signing failed");
manager
.publish_event(&ctx_b, topic, &event_after_release)
.await
.expect("publish after release failed");
let received_after_release =
tokio::time::timeout(tokio::time::Duration::from_secs(2), rx.recv())
.await
.expect("timeout after release")
.expect("channel closed after release");
assert_eq!(received_after_release.community_id, ctx_b.community());
assert_eq!(received_after_release.topic, topic);
assert_eq!(received_after_release.event.id, event_after_release.id);
manager.release_topic(&ctx_b, topic).await;
assert_eq!(manager.topic_refcount(&ctx_b, topic).await, 0);
}
#[tokio::test]
async fn retain_release_refcounts_and_debounces_last_release() {
let pool = make_test_pool();
let manager = PubSubManager::with_config(
PubSubConfig::new("redis://127.0.0.1:6379")
.with_unsubscribe_debounce(Duration::from_millis(1)),
pool,
)
.await
.unwrap();
let ctx = ctx(0xaaaa, "a.example");
let topic = EventTopic::Channel(Uuid::from_u128(0xbbbb));
assert_eq!(manager.topic_refcount(&ctx, topic).await, 0);
manager.retain_topic(&ctx, topic).await;
manager.retain_topic(&ctx, topic).await;
assert_eq!(manager.topic_refcount(&ctx, topic).await, 2);
manager.release_topic(&ctx, topic).await;
assert_eq!(manager.topic_refcount(&ctx, topic).await, 1);
manager.release_topic(&ctx, topic).await;
assert_eq!(manager.topic_refcount(&ctx, topic).await, 0);
}
#[test]
fn config_defaults_debounce_but_allows_override() {
let config = PubSubConfig::new("redis://example");
assert_eq!(
config.unsubscribe_debounce,
PubSubConfig::DEFAULT_UNSUBSCRIBE_DEBOUNCE
);
let config = config.with_unsubscribe_debounce(Duration::from_millis(42));
assert_eq!(config.unsubscribe_debounce, Duration::from_millis(42));
}
}
+202
View File
@@ -0,0 +1,202 @@
//! Redis-backed NIP-98 replay seen-set.
//!
//! Implements the [`Nip98ReplayGuard`] trait from `buzz-auth`. Uses Redis
//! `SET NX EX` for an atomic set-if-absent with TTL — the §5 pre-build gate
//! for multi-tenant HA replay protection.
use buzz_auth::{
error::AuthError,
nip98_replay::{
nip98_replay_key_for_scope, Nip98ReplayGuard, DEFAULT_REPLAY_TTL_SECS, MAX_REPLAY_TTL_SECS,
},
};
use nostr::EventId;
/// Redis-backed NIP-98 replay seen-set.
///
/// Each `try_mark(ctx, event_id, ttl)` issues a single
/// `SET buzz:{community}:nip98:{event_id_hex} 1 NX EX <ttl>` against Redis.
/// `NX` makes the operation atomic set-if-absent — the freshness proof comes
/// from Redis returning `OK` only on the first claim. Subsequent claims within
/// the TTL window return `nil`, which we surface as `Ok(false)` so the caller
/// rejects the request as replay.
pub struct RedisNip98ReplayGuard {
pool: deadpool_redis::Pool,
}
impl RedisNip98ReplayGuard {
/// Create a new replay guard backed by the given Redis connection pool.
pub fn new(pool: deadpool_redis::Pool) -> Self {
Self { pool }
}
}
impl Nip98ReplayGuard for RedisNip98ReplayGuard {
fn try_mark_in_scope<'a>(
&'a self,
scope: &'a str,
event_id: &'a EventId,
ttl_secs: u64,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<bool, AuthError>> + Send + 'a>>
{
Box::pin(async move {
// §5 gate floor + safety ceiling. Sub-floor values are lifted to the
// floor (contract permits clamping); above-ceiling values are pushed
// down to MAX_REPLAY_TTL_SECS (contract REQUIRES clamping) so a buggy
// caller cannot send a Redis-incompatible `EX` arg or pin a slot for
// implausibly long.
let ttl = ttl_secs.clamp(DEFAULT_REPLAY_TTL_SECS, MAX_REPLAY_TTL_SECS);
let mut conn = self.pool.get().await.map_err(|e| {
// Structured field for ops; the user-facing AuthError stays a
// bounded category string.
tracing::warn!(
scope = %scope,
error = %e,
"nip98 replay: redis pool acquire failed — caller MUST fail closed"
);
AuthError::Internal(format!("Redis pool: {e}"))
})?;
let key = nip98_replay_key_for_scope(scope, event_id);
// SET key 1 NX EX <ttl>. redis-rs typed return: Some("OK") on first
// claim, None on existing key. Any other value would be a Redis-side
// bug; treat it as internal error.
let result: Option<String> = redis::cmd("SET")
.arg(&key)
.arg("1")
.arg("NX")
.arg("EX")
.arg(ttl)
.query_async(&mut *conn)
.await
.map_err(|e| {
tracing::warn!(
scope = %scope,
error = %e,
"nip98 replay: redis SET NX EX failed — caller MUST fail closed"
);
AuthError::Internal(format!("Redis SET NX EX: {e}"))
})?;
match result.as_deref() {
Some("OK") => Ok(true),
None => Ok(false),
Some(other) => {
tracing::error!(
scope = %scope,
reply = %other,
"nip98 replay: redis SET NX EX returned an unexpected reply — investigate"
);
Err(AuthError::Internal(format!(
"unexpected SET NX EX reply: {other}"
)))
}
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use buzz_core::{CommunityId, TenantContext};
use deadpool_redis::{Config, Runtime};
use nostr::{EventBuilder, Keys, Kind};
use uuid::Uuid;
fn redis_pool() -> deadpool_redis::Pool {
let url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".into());
Config::from_url(url)
.create_pool(Some(Runtime::Tokio1))
.expect("create pool")
}
fn fresh_ctx() -> TenantContext {
TenantContext::resolved(CommunityId::from_uuid(Uuid::new_v4()), "test.example")
}
fn fresh_event_id() -> EventId {
EventBuilder::new(Kind::HttpAuth, "")
.sign_with_keys(&Keys::generate())
.expect("sign")
.id
}
#[tokio::test]
#[ignore = "requires Redis"]
async fn first_claim_succeeds_replay_fails() {
let guard = RedisNip98ReplayGuard::new(redis_pool());
let ctx = fresh_ctx();
let eid = fresh_event_id();
assert!(guard
.try_mark(&ctx, &eid, DEFAULT_REPLAY_TTL_SECS)
.await
.expect("first mark"));
assert!(!guard
.try_mark(&ctx, &eid, DEFAULT_REPLAY_TTL_SECS)
.await
.expect("replay mark"));
}
#[tokio::test]
#[ignore = "requires Redis"]
async fn isolation_between_communities() {
let guard = RedisNip98ReplayGuard::new(redis_pool());
let ctx_a = fresh_ctx();
let ctx_b = fresh_ctx();
let eid = fresh_event_id();
assert!(guard
.try_mark(&ctx_a, &eid, DEFAULT_REPLAY_TTL_SECS)
.await
.expect("mark in A"));
// Same event id under ctx_b is still a first claim — communities are
// independent seen-sets.
assert!(guard
.try_mark(&ctx_b, &eid, DEFAULT_REPLAY_TTL_SECS)
.await
.expect("mark in B"));
}
#[tokio::test]
#[ignore = "requires Redis"]
async fn sub_floor_ttl_is_lifted_to_default() {
let guard = RedisNip98ReplayGuard::new(redis_pool());
let ctx = fresh_ctx();
let eid = fresh_event_id();
// Caller asks for 30s; impl lifts to ≥ DEFAULT_REPLAY_TTL_SECS.
// Smoke-test the path: pass sub-floor TTL, claim once, then expect
// replay rejection — the TTL lift kept the marker alive past 30s and
// the contract holds.
assert!(guard.try_mark(&ctx, &eid, 30).await.expect("mark"));
assert!(!guard.try_mark(&ctx, &eid, 30).await.expect("replay"));
}
#[tokio::test]
#[ignore = "requires Redis"]
async fn above_ceiling_ttl_is_clamped() {
let guard = RedisNip98ReplayGuard::new(redis_pool());
let ctx = fresh_ctx();
let eid = fresh_event_id();
// Caller asks for u64::MAX (well past Redis's i64::MAX `EX` limit).
// Impl MUST clamp down to MAX_REPLAY_TTL_SECS so the SET succeeds; if
// we instead forwarded u64::MAX, Redis would reject the EX arg and
// try_mark would return Err — and per the trait contract, callers
// fail closed on Err. That's correctness-preserving but UX-hostile:
// every nip98 request errors. The clamp turns a foot-gun into a
// contained warning.
assert!(guard
.try_mark(&ctx, &eid, u64::MAX)
.await
.expect("mark with extreme ttl must succeed via clamp"));
assert!(!guard
.try_mark(&ctx, &eid, u64::MAX)
.await
.expect("replay with extreme ttl must succeed via clamp"));
}
}
+215
View File
@@ -0,0 +1,215 @@
//! Presence tracking — online/away status with TTL.
//!
//! Stored as `SET buzz:{community}:presence:{pubkey_hex} "online" EX 180`.
//! TTL is 3x the 60s heartbeat interval so a single missed heartbeat doesn't
//! cause presence flap. Clean disconnect deletes immediately.
use buzz_core::TenantContext;
use deadpool_redis::Pool;
use nostr::PublicKey;
use std::collections::HashMap;
use crate::error::PubSubError;
use crate::topic::BUZZ_PREFIX;
/// 3x the 60s heartbeat — single missed heartbeat won't cause presence flap.
pub const PRESENCE_TTL_SECS: u64 = 180;
/// Returns the Redis key for the presence entry of `pubkey` under `ctx`.
pub fn presence_key(ctx: &TenantContext, pubkey: &PublicKey) -> String {
format!(
"{BUZZ_PREFIX}:{}:presence:{}",
ctx.community(),
pubkey.to_hex()
)
}
/// Sets presence status for `pubkey` with a [`PRESENCE_TTL_SECS`]-second TTL.
pub async fn set_presence(
pool: &Pool,
ctx: &TenantContext,
pubkey: &PublicKey,
status: &str,
) -> Result<(), PubSubError> {
let mut conn = pool.get().await?;
let key = presence_key(ctx, pubkey);
redis::cmd("SET")
.arg(&key)
.arg(status)
.arg("EX")
.arg(PRESENCE_TTL_SECS)
.query_async::<()>(&mut conn)
.await?;
Ok(())
}
/// Removes the presence entry for `pubkey`. Call on clean disconnect.
pub async fn clear_presence(
pool: &Pool,
ctx: &TenantContext,
pubkey: &PublicKey,
) -> Result<(), PubSubError> {
let mut conn = pool.get().await?;
let key = presence_key(ctx, pubkey);
redis::cmd("DEL")
.arg(&key)
.query_async::<()>(&mut conn)
.await?;
Ok(())
}
/// Returns the current presence status for `pubkey`, or `None` if not set or expired.
pub async fn get_presence(
pool: &Pool,
ctx: &TenantContext,
pubkey: &PublicKey,
) -> Result<Option<String>, PubSubError> {
let mut conn = pool.get().await?;
let key = presence_key(ctx, pubkey);
let value: Option<String> = redis::cmd("GET").arg(&key).query_async(&mut conn).await?;
Ok(value)
}
/// Returns `pubkey_hex → status` for all currently-set keys.
pub async fn get_presence_bulk(
pool: &Pool,
ctx: &TenantContext,
pubkeys: &[PublicKey],
) -> Result<HashMap<String, String>, PubSubError> {
if pubkeys.is_empty() {
return Ok(HashMap::new());
}
let mut conn = pool.get().await?;
let keys: Vec<String> = pubkeys
.iter()
.map(|pubkey| presence_key(ctx, pubkey))
.collect();
let values: Vec<Option<String>> = redis::cmd("MGET").arg(&keys).query_async(&mut conn).await?;
let result = pubkeys
.iter()
.zip(values.iter())
.filter_map(|(pk, v)| v.as_ref().map(|s| (pk.to_hex(), s.clone())))
.collect();
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_util::make_test_pool;
use buzz_core::{CommunityId, TenantContext};
use nostr::Keys;
use uuid::Uuid;
fn make_pubkey() -> PublicKey {
Keys::generate().public_key()
}
fn ctx(id: u128, host: &str) -> TenantContext {
TenantContext::resolved(CommunityId::from_uuid(Uuid::from_u128(id)), host)
}
#[test]
fn presence_ttl_is_three_one_minute_heartbeat_windows() {
assert_eq!(PRESENCE_TTL_SECS, 180);
assert_eq!(PRESENCE_TTL_SECS, 3 * 60);
}
#[test]
fn test_presence_key_format() {
let pubkey = make_pubkey();
let ctx = ctx(0xaaaa, "a.example");
let key = presence_key(&ctx, &pubkey);
let prefix = format!("buzz:{}:presence:", ctx.community());
assert!(key.starts_with(&prefix));
let hex_part = key.strip_prefix(&prefix).unwrap();
assert_eq!(hex_part.len(), 64);
assert!(hex_part.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn same_pubkey_in_two_communities_has_different_presence_keys() {
let pubkey = make_pubkey();
let community_a = ctx(0xaaaa, "a.example");
let community_b = ctx(0xbbbb, "b.example");
assert_ne!(
presence_key(&community_a, &pubkey),
presence_key(&community_b, &pubkey)
);
}
#[tokio::test]
#[ignore = "requires Redis"]
async fn test_presence_set_and_get() {
let pool = make_test_pool();
let pubkey = make_pubkey();
let ctx = ctx(0xaaaa, "a.example");
let status = get_presence(&pool, &ctx, &pubkey).await.unwrap();
assert!(status.is_none());
set_presence(&pool, &ctx, &pubkey, "online").await.unwrap();
let status = get_presence(&pool, &ctx, &pubkey).await.unwrap();
assert_eq!(status.as_deref(), Some("online"));
set_presence(&pool, &ctx, &pubkey, "away").await.unwrap();
let status = get_presence(&pool, &ctx, &pubkey).await.unwrap();
assert_eq!(status.as_deref(), Some("away"));
clear_presence(&pool, &ctx, &pubkey).await.unwrap();
let status = get_presence(&pool, &ctx, &pubkey).await.unwrap();
assert!(status.is_none());
}
#[tokio::test]
#[ignore = "requires Redis"]
async fn test_presence_bulk() {
let pool = make_test_pool();
let pk1 = make_pubkey();
let pk2 = make_pubkey();
let pk3 = make_pubkey();
let ctx = ctx(0xaaaa, "a.example");
set_presence(&pool, &ctx, &pk1, "online").await.unwrap();
set_presence(&pool, &ctx, &pk2, "away").await.unwrap();
let result = get_presence_bulk(&pool, &ctx, &[pk1, pk2, pk3])
.await
.unwrap();
assert_eq!(
result.get(&pk1.to_hex()).map(|s| s.as_str()),
Some("online")
);
assert_eq!(result.get(&pk2.to_hex()).map(|s| s.as_str()), Some("away"));
assert!(!result.contains_key(&pk3.to_hex()));
clear_presence(&pool, &ctx, &pk1).await.unwrap();
clear_presence(&pool, &ctx, &pk2).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Redis"]
async fn test_presence_ttl() {
let pool = make_test_pool();
let pubkey = make_pubkey();
let ctx = ctx(0xaaaa, "a.example");
set_presence(&pool, &ctx, &pubkey, "online").await.unwrap();
let mut conn = pool.get().await.unwrap();
let ttl: i64 = redis::cmd("TTL")
.arg(presence_key(&ctx, &pubkey))
.query_async(&mut conn)
.await
.unwrap();
assert!(
ttl > 0 && ttl <= PRESENCE_TTL_SECS as i64,
"TTL should be 1-{PRESENCE_TTL_SECS}s, got {ttl}"
);
clear_presence(&pool, &ctx, &pubkey).await.unwrap();
}
}
+37
View File
@@ -0,0 +1,37 @@
//! Event publishing — PUBLISH to Redis via pool connection.
use buzz_core::TenantContext;
use deadpool_redis::Pool;
use nostr::JsonUtil;
use uuid::Uuid;
use crate::error::PubSubError;
use crate::topic::{self, EventTopic};
/// Returns the Redis pub/sub channel key for `channel_id` under `ctx`.
pub fn channel_key(ctx: &TenantContext, channel_id: Uuid) -> String {
topic::channel_key(ctx, channel_id)
}
/// Returns the Redis pub/sub channel key for community-global events under `ctx`.
pub fn global_key(ctx: &TenantContext) -> String {
topic::global_key(ctx)
}
/// Returns the number of subscribers that received the message.
pub async fn publish_event(
pool: &Pool,
ctx: &TenantContext,
topic: EventTopic,
event: &nostr::Event,
) -> Result<i64, PubSubError> {
let mut conn = pool.get().await?;
let key = crate::topic::EventTopicKey::from_context(ctx, topic).redis_channel();
let payload = event.as_json();
let subscriber_count: i64 = redis::cmd("PUBLISH")
.arg(&key)
.arg(&payload)
.query_async(&mut conn)
.await?;
Ok(subscriber_count)
}
+121
View File
@@ -0,0 +1,121 @@
//! Redis-backed rate limiter using atomic Lua script (INCR + EXPIRE).
//!
//! Implements the [`RateLimiter`] trait from `buzz-auth`.
//! Uses a single Lua script to atomically INCR and conditionally EXPIRE,
//! eliminating the crash window where a key could exist without a TTL.
//!
//! ⚠️ Fixed windows allow up to 2× burst at boundaries. Upgrade to sliding
//! window or token bucket for strict limiting.
use std::net::IpAddr;
use buzz_auth::{
error::AuthError,
rate_limit::{LimitType, RateLimitResult, RateLimiter},
};
use buzz_core::TenantContext;
use nostr::PublicKey;
use redis::Script;
/// Atomically INCR the key, set EXPIRE on first call, and return (count, ttl).
///
/// Using a Lua script ensures INCR and EXPIRE are executed atomically —
/// a crash between them can no longer leave a key without a TTL.
const RATE_LIMIT_SCRIPT: &str = r#"
local count = redis.call('INCR', KEYS[1])
if count == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
local ttl = redis.call('TTL', KEYS[1])
return {count, ttl}
"#;
/// Run the atomic rate-limit Lua script against `key` and return a
/// [`RateLimitResult`].
///
/// If the TTL comes back negative (key exists without expiry — broken state
/// from a prior crash), the key is repaired with a fresh EXPIRE and a warning
/// is logged.
async fn run_rate_limit(
pool: &deadpool_redis::Pool,
key: &str,
window_secs: u64,
limit: u64,
) -> Result<RateLimitResult, AuthError> {
let mut conn = pool
.get()
.await
.map_err(|e| AuthError::Internal(format!("Redis pool: {e}")))?;
let script = Script::new(RATE_LIMIT_SCRIPT);
let (count, ttl): (u64, i64) = script
.key(key)
.arg(window_secs as i64)
.invoke_async(&mut *conn)
.await
.map_err(|e| AuthError::Internal(format!("Redis rate limit script: {e}")))?;
// ttl == -1 means the key exists but has no expiry — broken state from a
// prior crash between INCR and EXPIRE. Repair it now.
let reset_in_secs = if ttl < 0 {
tracing::warn!(key = %key, "rate limit key has no TTL — repairing");
let _: () = redis::cmd("EXPIRE")
.arg(key)
.arg(window_secs as i64)
.query_async(&mut *conn)
.await
.map_err(|e| AuthError::Internal(format!("Redis EXPIRE repair: {e}")))?;
// After repair, the window resets to the full duration.
window_secs
} else {
ttl.max(0) as u64
};
if count <= limit {
Ok(RateLimitResult::allowed(count, limit, reset_in_secs))
} else {
Ok(RateLimitResult::denied(count, limit, reset_in_secs))
}
}
/// Redis-backed rate limiter using fixed-window counters.
///
/// Pubkey keys are community-scoped via `&TenantContext`:
/// `buzz:{community}:ratelimit:{pubkey_hex}:{suffix}`. IP keys remain
/// operator-global: `buzz:ratelimit:ip:{ip}:conn`. The counter and its TTL are
/// managed atomically via a Lua script to prevent keys from persisting without
/// expiry.
pub struct RedisRateLimiter {
pool: deadpool_redis::Pool,
}
impl RedisRateLimiter {
/// Create a new `RedisRateLimiter` backed by the given connection pool.
pub fn new(pool: deadpool_redis::Pool) -> Self {
Self { pool }
}
}
impl RateLimiter for RedisRateLimiter {
async fn check_and_increment(
&self,
ctx: &TenantContext,
pubkey: &PublicKey,
limit_type: LimitType,
window_secs: u64,
limit: u64,
) -> Result<RateLimitResult, AuthError> {
let key = buzz_auth::rate_limit::rate_limit_key(ctx, pubkey, &limit_type);
run_rate_limit(&self.pool, &key, window_secs, limit).await
}
async fn check_ip_connection(
&self,
ip: &IpAddr,
window_secs: u64,
limit: u64,
) -> Result<RateLimitResult, AuthError> {
let key = buzz_auth::rate_limit::ip_rate_limit_key(ip);
run_rate_limit(&self.pool, &key, window_secs, limit).await
}
}
+205
View File
@@ -0,0 +1,205 @@
//! Redis pub/sub subscriber — fans out messages to local WS connections via broadcast.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
use futures_util::StreamExt;
use nostr::JsonUtil;
use tokio::sync::{broadcast, mpsc, Mutex};
use crate::topic::EventTopicKey;
use crate::ChannelEvent;
/// Initial reconnect backoff (1 second).
const BACKOFF_INITIAL_SECS: u64 = 1;
/// Maximum reconnect backoff (30 seconds).
const BACKOFF_MAX_SECS: u64 = 30;
/// Local desired topic refcounts, keyed by fully scoped Redis topic.
pub(crate) type DesiredTopics = Arc<Mutex<HashMap<EventTopicKey, usize>>>;
/// Commands sent from relay subscription registration/removal to the Redis
/// pub/sub task.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SubscriptionCommand {
/// Ensure the topic is subscribed on the current Redis pub/sub connection.
Subscribe(EventTopicKey),
/// Unsubscribe only if the desired refcount is still zero when processed.
UnsubscribeIfIdle(EventTopicKey),
}
/// Runs a dynamically scoped subscriber and forwards events to broadcast.
///
/// The desired refcount map is the source of truth. On every reconnect, this
/// task snapshots topics with count > 0 and subscribes to those exact Redis
/// channels before processing messages.
pub(crate) async fn run_subscriber(
redis_url: String,
broadcast_tx: broadcast::Sender<ChannelEvent>,
desired_topics: DesiredTopics,
mut subscription_rx: mpsc::Receiver<SubscriptionCommand>,
) {
let mut backoff_secs = BACKOFF_INITIAL_SECS;
loop {
match connect_and_subscribe(
&redis_url,
&broadcast_tx,
desired_topics.clone(),
&mut subscription_rx,
)
.await
{
Ok(()) => {
// Stream ended cleanly (Redis returned None). The connection was
// established and ran successfully, so reset backoff to the initial
// value — a brief Redis restart should reconnect quickly.
backoff_secs = BACKOFF_INITIAL_SECS;
tracing::warn!("Redis pub/sub stream ended (clean disconnect) — reconnecting in {backoff_secs}s");
}
Err(e) => {
tracing::error!("Redis pub/sub error: {e} — reconnecting in {backoff_secs}s");
}
}
tokio::time::sleep(Duration::from_secs(backoff_secs)).await;
backoff_secs = (backoff_secs * 2).min(BACKOFF_MAX_SECS);
tracing::info!("Attempting to reconnect to Redis pub/sub...");
}
}
/// Establish a Redis pub/sub connection, subscribe to the current desired-topic
/// snapshot, and run the fan-out / command loop until the connection ends.
async fn connect_and_subscribe(
redis_url: &str,
broadcast_tx: &broadcast::Sender<ChannelEvent>,
desired_topics: DesiredTopics,
subscription_rx: &mut mpsc::Receiver<SubscriptionCommand>,
) -> Result<(), redis::RedisError> {
let client = redis::Client::open(redis_url)?;
let conn = client.get_async_pubsub().await?;
let (mut sink, mut stream) = conn.split();
let mut active_topics = HashSet::new();
let initial_topics: Vec<EventTopicKey> = {
let desired = desired_topics.lock().await;
desired
.iter()
.filter_map(|(topic, count)| (*count > 0).then_some(*topic))
.collect()
};
for topic in initial_topics {
let channel = topic.redis_channel();
sink.subscribe(&channel).await?;
active_topics.insert(channel);
}
tracing::info!(
topic_count = active_topics.len(),
"Redis pub/sub subscriber connected with dynamic scoped subscriptions"
);
loop {
tokio::select! {
Some(command) = subscription_rx.recv() => {
match command {
SubscriptionCommand::Subscribe(topic) => {
let channel = topic.redis_channel();
if active_topics.insert(channel.clone()) {
sink.subscribe(&channel).await?;
}
}
SubscriptionCommand::UnsubscribeIfIdle(topic) => {
if desired_refcount(&desired_topics, topic).await == 0 {
let channel = topic.redis_channel();
if active_topics.remove(&channel) {
sink.unsubscribe(&channel).await?;
}
}
}
}
}
msg = stream.next() => {
let Some(msg) = msg else {
// Stream returned None — Redis connection closed.
return Ok(());
};
let payload: String = match msg.get_payload() {
Ok(p) => p,
Err(e) => {
tracing::warn!("Failed to get pub/sub message payload: {e}");
continue;
}
};
let channel_name = msg.get_channel_name();
let topic_key = match EventTopicKey::parse_redis_channel(channel_name) {
Ok(topic_key) => topic_key,
Err(_) => {
tracing::warn!("Received pub/sub message on unexpected channel: {channel_name}");
continue;
}
};
let event = match nostr::Event::from_json(&payload) {
Ok(e) => e,
Err(e) => {
tracing::warn!("Failed to deserialize event from pub/sub: {e}");
continue;
}
};
let channel_event = ChannelEvent {
community_id: topic_key.community_id,
topic: topic_key.topic,
event,
};
if let Err(_e) = broadcast_tx.send(channel_event) {
tracing::trace!(topic = %channel_name, "No broadcast receivers for topic — message dropped");
}
}
else => {
// Command channel closed and stream ended; let the reconnect loop retry.
return Ok(());
}
}
}
}
async fn desired_refcount(desired_topics: &DesiredTopics, topic: EventTopicKey) -> usize {
desired_topics
.lock()
.await
.get(&topic)
.copied()
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use buzz_core::{CommunityId, TenantContext};
use uuid::Uuid;
fn topic(id: u128) -> EventTopicKey {
let ctx = TenantContext::resolved(CommunityId::from_uuid(Uuid::from_u128(id)), "test");
EventTopicKey::from_context(&ctx, crate::EventTopic::Global)
}
#[tokio::test]
async fn desired_refcount_returns_zero_for_absent_topic() {
let desired = Arc::new(Mutex::new(HashMap::new()));
assert_eq!(desired_refcount(&desired, topic(1)).await, 0);
}
#[tokio::test]
async fn desired_refcount_reads_present_topic() {
let desired = Arc::new(Mutex::new(HashMap::from([(topic(1), 3)])));
assert_eq!(desired_refcount(&desired, topic(1)).await, 3);
}
}
+197
View File
@@ -0,0 +1,197 @@
//! Community-scoped Redis event topics.
//!
//! Pub/sub topics are a routing/performance boundary, not an authorization
//! boundary. Tenant identity still comes from [`TenantContext`] on publish /
//! retain paths, and the relay re-checks access before local fan-out.
use buzz_core::{CommunityId, TenantContext};
use uuid::Uuid;
use crate::error::PubSubError;
/// Redis key prefix for Buzz-scoped pub/sub topics and keys.
pub const BUZZ_PREFIX: &str = "buzz";
/// A tenant-local event routing scope.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EventTopic {
/// Events for one exact channel id.
Channel(Uuid),
/// Community-global events that are not exact-channel routed.
Global,
}
/// A fully qualified event topic, including its server-resolved community.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct EventTopicKey {
/// Server-resolved community id.
pub community_id: CommunityId,
/// Tenant-local routing scope.
pub topic: EventTopic,
}
impl EventTopicKey {
/// Build a topic key from a resolved tenant context.
pub fn from_context(ctx: &TenantContext, topic: EventTopic) -> Self {
Self {
community_id: ctx.community(),
topic,
}
}
/// Redis pub/sub channel name for this topic.
pub fn redis_channel(&self) -> String {
match self.topic {
EventTopic::Channel(channel_id) => {
format!("{BUZZ_PREFIX}:{}:channel:{channel_id}", self.community_id)
}
EventTopic::Global => format!("{BUZZ_PREFIX}:{}:global", self.community_id),
}
}
/// Parse a Redis pub/sub channel name into a scoped event topic.
pub fn parse_redis_channel(channel: &str) -> Result<Self, PubSubError> {
let mut parts = channel.split(':');
let Some(prefix) = parts.next() else {
return Err(PubSubError::InvalidChannelKey(channel.to_string()));
};
if prefix != BUZZ_PREFIX {
return Err(PubSubError::InvalidChannelKey(channel.to_string()));
}
let Some(community) = parts.next() else {
return Err(PubSubError::InvalidChannelKey(channel.to_string()));
};
let community_id = Uuid::parse_str(community)
.map(CommunityId::from_uuid)
.map_err(|_| PubSubError::InvalidChannelKey(channel.to_string()))?;
let Some(scope) = parts.next() else {
return Err(PubSubError::InvalidChannelKey(channel.to_string()));
};
let topic = match scope {
"global" => {
if parts.next().is_some() {
return Err(PubSubError::InvalidChannelKey(channel.to_string()));
}
EventTopic::Global
}
"channel" => {
let Some(channel_id) = parts.next() else {
return Err(PubSubError::InvalidChannelKey(channel.to_string()));
};
if parts.next().is_some() {
return Err(PubSubError::InvalidChannelKey(channel.to_string()));
}
EventTopic::Channel(
Uuid::parse_str(channel_id)
.map_err(|_| PubSubError::InvalidChannelKey(channel.to_string()))?,
)
}
_ => return Err(PubSubError::InvalidChannelKey(channel.to_string())),
};
Ok(Self {
community_id,
topic,
})
}
}
/// Redis channel for exact-channel events under `ctx`.
pub fn channel_key(ctx: &TenantContext, channel_id: Uuid) -> String {
EventTopicKey::from_context(ctx, EventTopic::Channel(channel_id)).redis_channel()
}
/// Redis channel for community-global events under `ctx`.
pub fn global_key(ctx: &TenantContext) -> String {
EventTopicKey::from_context(ctx, EventTopic::Global).redis_channel()
}
#[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 channel_key_includes_community_and_channel() {
let ctx = ctx(0xaaaa, "a.example");
let channel_id = Uuid::from_u128(0xbbbb);
assert_eq!(
channel_key(&ctx, channel_id),
format!("buzz:{}:channel:{channel_id}", ctx.community())
);
}
#[test]
fn global_key_includes_community() {
let ctx = ctx(0xaaaa, "a.example");
assert_eq!(global_key(&ctx), format!("buzz:{}:global", ctx.community()));
}
#[test]
fn same_channel_in_two_communities_has_different_topics() {
let community_a = ctx(0xaaaa, "a.example");
let community_b = ctx(0xbbbb, "b.example");
let channel_id = Uuid::from_u128(0xcccc);
assert_ne!(
channel_key(&community_a, channel_id),
channel_key(&community_b, channel_id)
);
}
#[test]
fn parses_channel_topic() {
let community_id = CommunityId::from_uuid(Uuid::from_u128(0xaaaa));
let channel_id = Uuid::from_u128(0xbbbb);
let raw = format!("buzz:{community_id}:channel:{channel_id}");
assert_eq!(
EventTopicKey::parse_redis_channel(&raw).unwrap(),
EventTopicKey {
community_id,
topic: EventTopic::Channel(channel_id),
}
);
}
#[test]
fn parses_global_topic() {
let community_id = CommunityId::from_uuid(Uuid::from_u128(0xaaaa));
let raw = format!("buzz:{community_id}:global");
assert_eq!(
EventTopicKey::parse_redis_channel(&raw).unwrap(),
EventTopicKey {
community_id,
topic: EventTopic::Global,
}
);
}
#[test]
fn rejects_malformed_or_wrong_prefix_topics() {
for raw in [
"",
"not-buzz:00000000-0000-0000-0000-00000000aaaa:global",
"buzz:not-a-uuid:global",
"buzz:00000000-0000-0000-0000-00000000aaaa",
"buzz:00000000-0000-0000-0000-00000000aaaa:global:extra",
"buzz:00000000-0000-0000-0000-00000000aaaa:channel",
"buzz:00000000-0000-0000-0000-00000000aaaa:channel:not-a-uuid",
"buzz:00000000-0000-0000-0000-00000000aaaa:presence:abc",
] {
assert!(
EventTopicKey::parse_redis_channel(raw).is_err(),
"expected {raw:?} to be rejected"
);
}
}
}