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
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:
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user