use std::collections::VecDeque;
use std::sync::Arc;
use crate::util::Counter;
use chrono::{DateTime, Utc};
use dashmap::DashMap;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;
use tracing::trace;
const CHANNEL_CAPACITY: usize = 256;
const MAX_MATCH_DEPTH: usize = 32;
const DEFAULT_CLEANUP_INTERVAL: u64 = 1_000;
#[inline]
fn is_wildcard_pattern(pattern: &str) -> bool {
pattern.contains('*') || pattern.contains('#')
}
const SUBSCRIBER_WARN_THRESHOLD: usize = 40;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum BackpressurePolicy {
DropOldest,
DropNewest,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TopicMessage {
pub topic: String,
pub payload: serde_json::Value,
pub timestamp: DateTime<Utc>,
}
pub struct PubSub {
subscriptions: DashMap<String, broadcast::Sender<TopicMessage>>,
capacity: usize,
messages_published: Counter,
cleanup_interval: u64,
max_subscriptions: usize,
}
impl PubSub {
pub fn new() -> Self {
Self::with_capacity(CHANNEL_CAPACITY)
}
pub fn with_capacity(capacity: usize) -> Self {
Self {
subscriptions: DashMap::new(),
capacity,
messages_published: Counter::new(),
cleanup_interval: DEFAULT_CLEANUP_INTERVAL,
max_subscriptions: 0,
}
}
pub fn set_cleanup_interval(&mut self, interval: u64) {
self.cleanup_interval = interval;
}
pub fn set_max_subscriptions(&mut self, max: usize) {
self.max_subscriptions = max;
}
pub fn subscribe(&self, pattern: &str) -> broadcast::Receiver<TopicMessage> {
self.subscriptions
.entry(pattern.to_string())
.or_insert_with(|| broadcast::channel(self.capacity).0)
.subscribe()
}
pub fn try_subscribe(
&self,
pattern: &str,
) -> crate::error::Result<broadcast::Receiver<TopicMessage>> {
if self.max_subscriptions > 0
&& !self.subscriptions.contains_key(pattern)
&& self.subscriptions.len() >= self.max_subscriptions
{
return Err(crate::error::MajraError::CapacityExceeded(format!(
"max subscription patterns ({}) reached",
self.max_subscriptions
)));
}
Ok(self.subscribe(pattern))
}
pub fn publish(&self, topic: &str, payload: serde_json::Value) -> usize {
let msg = TopicMessage {
topic: topic.to_string(),
payload,
timestamp: Utc::now(),
};
let mut delivered = 0usize;
for entry in self.subscriptions.iter() {
if matches_pattern(entry.key(), topic) {
if entry.value().send(msg.clone()).is_err() {
trace!(
topic,
pattern = entry.key().as_str(),
"no active receivers for pattern"
);
}
delivered += 1;
}
}
self.messages_published.inc();
trace!(topic, delivered, "published");
if self.cleanup_interval > 0
&& self
.messages_published
.get()
.is_multiple_of(self.cleanup_interval)
{
self.cleanup_dead_subscribers();
}
delivered
}
pub fn unsubscribe_all(&self, pattern: &str) {
self.subscriptions.remove(pattern);
}
#[inline]
pub fn pattern_count(&self) -> usize {
self.subscriptions.len()
}
#[inline]
pub fn messages_published(&self) -> u64 {
self.messages_published.get()
}
pub fn cleanup_dead_subscribers(&self) -> usize {
let dead: Vec<String> = self
.subscriptions
.iter()
.filter(|entry| entry.value().receiver_count() == 0)
.map(|entry| entry.key().clone())
.collect();
let count = dead.len();
for pattern in &dead {
self.subscriptions
.remove_if(pattern, |_, tx| tx.receiver_count() == 0);
}
if count > 0 {
trace!(count, "pubsub: cleaned up dead patterns");
}
count
}
}
impl Default for PubSub {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct TypedMessage<T> {
pub topic: String,
pub payload: T,
pub timestamp: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct TypedPubSubConfig {
pub channel_capacity: usize,
pub backpressure: BackpressurePolicy,
pub replay_capacity: usize,
pub cleanup_interval: u64,
pub max_subscriptions: usize,
}
impl Default for TypedPubSubConfig {
fn default() -> Self {
Self {
channel_capacity: CHANNEL_CAPACITY,
backpressure: BackpressurePolicy::DropOldest,
replay_capacity: 0,
cleanup_interval: DEFAULT_CLEANUP_INTERVAL,
max_subscriptions: 0,
}
}
}
type SubscriptionFilter<T> = Arc<dyn Fn(&T) -> bool + Send + Sync>;
struct TypedSubscription<T: Clone + Send + Sync + 'static> {
sender: broadcast::Sender<TypedMessage<T>>,
filter: Option<SubscriptionFilter<T>>,
}
pub struct TypedPubSub<T: Clone + Send + Sync + 'static> {
exact_subscriptions: DashMap<String, Vec<TypedSubscription<T>>>,
pattern_subscriptions: DashMap<String, Vec<TypedSubscription<T>>>,
config: TypedPubSubConfig,
messages_published: Counter,
messages_dropped: Counter,
replay_buffer: DashMap<String, VecDeque<TypedMessage<T>>>,
}
impl<T: Clone + Send + Sync + 'static> TypedPubSub<T> {
pub fn new() -> Self {
Self::with_config(TypedPubSubConfig::default())
}
pub fn with_config(config: TypedPubSubConfig) -> Self {
Self {
exact_subscriptions: DashMap::new(),
pattern_subscriptions: DashMap::new(),
messages_published: Counter::new(),
messages_dropped: Counter::new(),
replay_buffer: DashMap::new(),
config,
}
}
pub fn subscribe(&self, pattern: &str) -> broadcast::Receiver<TypedMessage<T>> {
let (tx, rx) = self.get_or_create_sender(pattern, None);
if self.config.replay_capacity > 0 {
self.replay_to(&tx, pattern);
}
rx
}
pub fn try_subscribe(
&self,
pattern: &str,
) -> crate::error::Result<broadcast::Receiver<TypedMessage<T>>> {
if self.config.max_subscriptions > 0
&& (self.exact_subscriptions.len() + self.pattern_subscriptions.len())
>= self.config.max_subscriptions
{
return Err(crate::error::MajraError::CapacityExceeded(format!(
"max subscription patterns ({}) reached",
self.config.max_subscriptions
)));
}
Ok(self.subscribe(pattern))
}
pub fn subscribe_filtered(
&self,
pattern: &str,
filter: impl Fn(&T) -> bool + Send + Sync + 'static,
) -> broadcast::Receiver<TypedMessage<T>> {
let (_tx, rx) = self.get_or_create_sender(pattern, Some(Arc::new(filter)));
rx
}
fn get_or_create_sender(
&self,
pattern: &str,
filter: Option<SubscriptionFilter<T>>,
) -> (
broadcast::Sender<TypedMessage<T>>,
broadcast::Receiver<TypedMessage<T>>,
) {
let (tx, rx) = broadcast::channel(self.config.channel_capacity);
let sub = TypedSubscription {
sender: tx.clone(),
filter,
};
let map = if is_wildcard_pattern(pattern) {
&self.pattern_subscriptions
} else {
&self.exact_subscriptions
};
let mut entry = map.entry(pattern.to_string()).or_default();
entry.push(sub);
let count = entry.len();
drop(entry);
if count == SUBSCRIBER_WARN_THRESHOLD {
tracing::warn!(
pattern,
count,
"pubsub: subscriber count per pattern exceeds {SUBSCRIBER_WARN_THRESHOLD} \
— tokio::broadcast send latency may degrade quadratically"
);
}
(tx, rx)
}
fn replay_to(&self, tx: &broadcast::Sender<TypedMessage<T>>, pattern: &str) {
if !pattern.contains('*') && !pattern.contains('#') {
if let Some(buf) = self.replay_buffer.get(pattern) {
for msg in buf.value().iter() {
if tx.send(msg.clone()).is_err() {
return;
}
}
}
return;
}
for entry in self.replay_buffer.iter() {
if matches_pattern(pattern, entry.key()) {
for msg in entry.value().iter() {
if tx.send(msg.clone()).is_err() {
trace!(
pattern,
topic = msg.topic.as_str(),
"replay: no active receivers"
);
return;
}
}
}
}
}
pub fn publish(&self, topic: &str, payload: T) -> usize {
let msg = TypedMessage {
topic: topic.to_string(),
payload,
timestamp: Utc::now(),
};
if self.config.replay_capacity > 0 {
let mut buf = self.replay_buffer.entry(msg.topic.clone()).or_default();
buf.push_back(msg.clone());
while buf.len() > self.config.replay_capacity {
buf.pop_front();
}
}
let mut delivered = 0usize;
if let Some(subs) = self.exact_subscriptions.get(topic) {
for sub in subs.value().iter() {
if sub.filter.as_ref().is_some_and(|f| !f(&msg.payload)) {
continue;
}
match self.config.backpressure {
BackpressurePolicy::DropOldest => {
if sub.sender.send(msg.clone()).is_err() {
trace!(topic, "typed: no active receivers");
}
delivered += 1;
}
BackpressurePolicy::DropNewest => {
if sub.sender.len() < self.config.channel_capacity {
if sub.sender.send(msg.clone()).is_err() {
trace!(topic, "typed: no active receivers");
}
delivered += 1;
} else {
self.messages_dropped.inc();
}
}
}
}
}
for entry in self.pattern_subscriptions.iter() {
if matches_pattern(entry.key(), topic) {
for sub in entry.value().iter() {
if sub.filter.as_ref().is_some_and(|f| !f(&msg.payload)) {
continue;
}
match self.config.backpressure {
BackpressurePolicy::DropOldest => {
if sub.sender.send(msg.clone()).is_err() {
trace!(topic, "typed: no active receivers");
}
delivered += 1;
}
BackpressurePolicy::DropNewest => {
if sub.sender.len() < self.config.channel_capacity {
if sub.sender.send(msg.clone()).is_err() {
trace!(topic, "typed: no active receivers");
}
delivered += 1;
} else {
self.messages_dropped.inc();
}
}
}
}
}
}
self.messages_published.inc();
trace!(topic, delivered, "typed: published");
if self.config.cleanup_interval > 0
&& self
.messages_published
.get()
.is_multiple_of(self.config.cleanup_interval)
{
self.cleanup_dead_subscribers();
}
delivered
}
pub fn unsubscribe_all(&self, pattern: &str) {
self.exact_subscriptions.remove(pattern);
self.pattern_subscriptions.remove(pattern);
}
#[inline]
pub fn pattern_count(&self) -> usize {
self.exact_subscriptions.len() + self.pattern_subscriptions.len()
}
#[inline]
pub fn messages_published(&self) -> u64 {
self.messages_published.get()
}
#[inline]
pub fn messages_dropped(&self) -> u64 {
self.messages_dropped.get()
}
pub fn clear_replay(&self) {
self.replay_buffer.clear();
}
pub fn cleanup_dead_subscribers(&self) -> usize {
let mut removed = 0usize;
for map in [&self.exact_subscriptions, &self.pattern_subscriptions] {
let mut empty_patterns = Vec::new();
for mut entry in map.iter_mut() {
let before = entry.value().len();
entry
.value_mut()
.retain(|sub| sub.sender.receiver_count() > 0);
removed += before - entry.value().len();
if entry.value().is_empty() {
empty_patterns.push(entry.key().clone());
}
}
for pattern in &empty_patterns {
map.remove_if(pattern, |_, subs| subs.is_empty());
}
}
if removed > 0 {
trace!(removed, "typed: cleaned up dead subscribers");
}
removed
}
pub fn subscriber_count(&self) -> usize {
self.exact_subscriptions
.iter()
.chain(self.pattern_subscriptions.iter())
.map(|entry| entry.value().len())
.sum()
}
}
impl<T: Clone + Send + Sync + 'static> Default for TypedPubSub<T> {
fn default() -> Self {
Self::new()
}
}
pub struct DirectChannel<T: Clone + Send + 'static> {
tx: broadcast::Sender<T>,
published: Counter,
}
impl<T: Clone + Send + 'static> DirectChannel<T> {
pub fn new(capacity: usize) -> Self {
let (tx, _) = broadcast::channel(capacity);
Self {
tx,
published: Counter::new(),
}
}
pub fn subscribe(&self) -> broadcast::Receiver<T> {
self.tx.subscribe()
}
#[inline]
pub fn publish(&self, value: T) -> usize {
let count = self.tx.send(value).unwrap_or(0);
self.published.inc();
count
}
#[inline]
pub fn subscriber_count(&self) -> usize {
self.tx.receiver_count()
}
#[inline]
pub fn messages_published(&self) -> u64 {
self.published.get()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TopicHash(u64);
impl TopicHash {
#[inline]
#[must_use]
pub fn new(topic: &str) -> Self {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
topic.hash(&mut hasher);
Self(hasher.finish())
}
#[inline]
pub fn value(self) -> u64 {
self.0
}
}
#[derive(Debug, Clone)]
pub struct HashedMessage<T> {
pub topic_hash: TopicHash,
pub timestamp_ns: u64,
pub payload: T,
}
pub struct HashedChannel<T: Clone + Send + 'static> {
subscriptions: DashMap<u64, broadcast::Sender<HashedMessage<T>>>,
epoch: std::time::Instant,
capacity: usize,
published: Counter,
}
impl<T: Clone + Send + 'static> HashedChannel<T> {
pub fn new(capacity: usize) -> Self {
Self {
subscriptions: DashMap::new(),
epoch: std::time::Instant::now(),
capacity,
published: Counter::new(),
}
}
pub fn subscribe(&self, topic: TopicHash) -> broadcast::Receiver<HashedMessage<T>> {
self.subscriptions
.entry(topic.0)
.or_insert_with(|| broadcast::channel(self.capacity).0)
.subscribe()
}
#[inline]
pub fn publish(&self, topic: TopicHash, payload: T) -> usize {
let msg = HashedMessage {
topic_hash: topic,
timestamp_ns: self.epoch.elapsed().as_nanos() as u64,
payload,
};
let delivered = if let Some(tx) = self.subscriptions.get(&topic.0) {
tx.send(msg).unwrap_or(0)
} else {
0
};
self.published.inc();
delivered
}
#[inline]
pub fn topic_count(&self) -> usize {
self.subscriptions.len()
}
#[inline]
pub fn messages_published(&self) -> u64 {
self.published.get()
}
pub fn unsubscribe(&self, topic: TopicHash) {
self.subscriptions.remove(&topic.0);
}
}
impl<T: Clone + Send + Sync + Serialize + 'static> TypedMessage<T> {
pub fn to_untyped(&self) -> Result<TopicMessage, serde_json::Error> {
Ok(TopicMessage {
topic: self.topic.clone(),
payload: serde_json::to_value(&self.payload)?,
timestamp: self.timestamp,
})
}
}
impl<T: Clone + Send + Sync + DeserializeOwned + 'static> TypedMessage<T> {
pub fn from_untyped(msg: &TopicMessage) -> Result<Self, serde_json::Error> {
let payload: T = serde_json::from_value(msg.payload.clone())?;
Ok(Self {
topic: msg.topic.clone(),
payload,
timestamp: msg.timestamp,
})
}
}
#[inline]
#[must_use]
pub fn matches_pattern(pattern: &str, topic: &str) -> bool {
let mut pat = pattern.split('/');
let mut top = topic.split('/');
let mut depth = 0usize;
loop {
match (pat.next(), top.next()) {
(None, None) => return true,
(Some("#"), top_seg) => {
let remaining = if top_seg.is_some() {
1 + top.count()
} else {
0
};
return depth + remaining < MAX_MATCH_DEPTH;
}
(Some("*"), Some(_)) => {
depth += 1;
if depth > MAX_MATCH_DEPTH {
return false;
}
}
(Some(p), Some(t)) if p == t => {
depth += 1;
if depth > MAX_MATCH_DEPTH {
return false;
}
}
_ => return false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exact_match() {
assert!(matches_pattern("a/b/c", "a/b/c"));
}
#[test]
fn no_match() {
assert!(!matches_pattern("a/b/c", "a/b/d"));
}
#[test]
fn star_matches_one() {
assert!(matches_pattern("a/*/c", "a/b/c"));
assert!(!matches_pattern("a/*/c", "a/b/d/c"));
}
#[test]
fn hash_matches_trailing() {
assert!(matches_pattern("a/#", "a/b/c/d"));
assert!(matches_pattern("a/#", "a"));
}
#[test]
fn hash_at_root() {
assert!(matches_pattern("#", "any/topic/at/all"));
}
#[test]
fn depth_limit() {
let deep = (0..MAX_MATCH_DEPTH + 1)
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join("/");
assert!(!matches_pattern("#", &deep));
}
#[tokio::test]
async fn publish_subscribe_roundtrip() {
let hub = PubSub::new();
let mut rx = hub.subscribe("crew/*/status");
hub.publish("crew/abc/status", serde_json::json!({"done": true}));
let msg = rx.recv().await.unwrap();
assert_eq!(msg.topic, "crew/abc/status");
assert_eq!(msg.payload["done"], true);
assert_eq!(hub.messages_published(), 1);
}
#[tokio::test]
async fn no_delivery_on_mismatch() {
let hub = PubSub::new();
let mut rx = hub.subscribe("crew/*/status");
hub.publish("fleet/node-1/heartbeat", serde_json::Value::Null);
assert!(rx.try_recv().is_err());
}
#[test]
fn unsubscribe_removes_pattern() {
let hub = PubSub::new();
let _rx = hub.subscribe("a/b");
assert_eq!(hub.pattern_count(), 1);
hub.unsubscribe_all("a/b");
assert_eq!(hub.pattern_count(), 0);
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct TestEvent {
kind: String,
value: i32,
}
#[tokio::test]
async fn typed_publish_subscribe() {
let hub = TypedPubSub::<TestEvent>::new();
let mut rx = hub.subscribe("events/*");
hub.publish(
"events/progress",
TestEvent {
kind: "progress".into(),
value: 42,
},
);
let msg = rx.recv().await.unwrap();
assert_eq!(msg.topic, "events/progress");
assert_eq!(msg.payload.value, 42);
assert_eq!(hub.messages_published(), 1);
}
#[tokio::test]
async fn typed_no_delivery_on_mismatch() {
let hub = TypedPubSub::<TestEvent>::new();
let mut rx = hub.subscribe("events/progress");
hub.publish(
"events/error",
TestEvent {
kind: "error".into(),
value: 1,
},
);
assert!(rx.try_recv().is_err());
}
#[tokio::test]
async fn typed_subscription_filter() {
let hub = TypedPubSub::<TestEvent>::new();
let mut rx = hub.subscribe_filtered("events/#", |e: &TestEvent| e.value > 10);
hub.publish(
"events/low",
TestEvent {
kind: "low".into(),
value: 5,
},
);
hub.publish(
"events/high",
TestEvent {
kind: "high".into(),
value: 50,
},
);
let msg = rx.recv().await.unwrap();
assert_eq!(msg.payload.value, 50);
assert!(rx.try_recv().is_err());
}
#[tokio::test]
async fn typed_replay_buffer() {
let config = TypedPubSubConfig {
replay_capacity: 3,
..Default::default()
};
let hub = TypedPubSub::<TestEvent>::with_config(config);
for i in 1..=5 {
hub.publish(
"events/data",
TestEvent {
kind: "data".into(),
value: i,
},
);
}
let mut rx = hub.subscribe("events/data");
let m1 = rx.try_recv().unwrap();
let m2 = rx.try_recv().unwrap();
let m3 = rx.try_recv().unwrap();
assert_eq!(m1.payload.value, 3);
assert_eq!(m2.payload.value, 4);
assert_eq!(m3.payload.value, 5);
assert!(rx.try_recv().is_err());
}
#[tokio::test]
async fn typed_backpressure_drop_newest() {
let config = TypedPubSubConfig {
channel_capacity: 2,
backpressure: BackpressurePolicy::DropNewest,
replay_capacity: 0,
..Default::default()
};
let hub = TypedPubSub::<TestEvent>::with_config(config);
let _rx = hub.subscribe("events/#");
hub.publish(
"events/a",
TestEvent {
kind: "a".into(),
value: 1,
},
);
hub.publish(
"events/b",
TestEvent {
kind: "b".into(),
value: 2,
},
);
hub.publish(
"events/c",
TestEvent {
kind: "c".into(),
value: 3,
},
);
assert_eq!(hub.messages_published(), 3);
assert!(hub.messages_dropped() > 0);
}
#[test]
fn typed_unsubscribe() {
let hub = TypedPubSub::<TestEvent>::new();
let _rx = hub.subscribe("events/#");
assert_eq!(hub.pattern_count(), 1);
hub.unsubscribe_all("events/#");
assert_eq!(hub.pattern_count(), 0);
}
#[tokio::test]
async fn typed_message_to_untyped() {
let msg = TypedMessage {
topic: "test".into(),
payload: TestEvent {
kind: "test".into(),
value: 99,
},
timestamp: Utc::now(),
};
let untyped = msg.to_untyped().unwrap();
assert_eq!(untyped.payload["value"], 99);
let back = TypedMessage::<TestEvent>::from_untyped(&untyped)
.expect("deserialization should succeed");
assert_eq!(back.payload.value, 99);
}
#[tokio::test]
async fn typed_clear_replay() {
let config = TypedPubSubConfig {
replay_capacity: 10,
..Default::default()
};
let hub = TypedPubSub::<TestEvent>::with_config(config);
hub.publish(
"events/a",
TestEvent {
kind: "a".into(),
value: 1,
},
);
hub.clear_replay();
let mut rx = hub.subscribe("events/#");
assert!(rx.try_recv().is_err()); }
#[tokio::test]
async fn typed_multiple_subscribers_same_pattern() {
let hub = TypedPubSub::<TestEvent>::new();
let mut rx1 = hub.subscribe("events/#");
let mut rx2 = hub.subscribe("events/#");
hub.publish(
"events/x",
TestEvent {
kind: "x".into(),
value: 7,
},
);
assert_eq!(rx1.recv().await.unwrap().payload.value, 7);
assert_eq!(rx2.recv().await.unwrap().payload.value, 7);
}
#[tokio::test]
async fn typed_wildcard_patterns() {
let hub = TypedPubSub::<TestEvent>::new();
let mut rx_star = hub.subscribe("events/*/done");
let mut rx_hash = hub.subscribe("events/#");
hub.publish(
"events/train/done",
TestEvent {
kind: "done".into(),
value: 1,
},
);
assert_eq!(rx_star.recv().await.unwrap().payload.value, 1);
assert_eq!(rx_hash.recv().await.unwrap().payload.value, 1);
}
#[test]
fn pubsub_default_creates_hub() {
let hub = PubSub::default();
assert_eq!(hub.pattern_count(), 0);
assert_eq!(hub.messages_published(), 0);
}
#[test]
fn typed_pubsub_default_creates_hub() {
let hub = TypedPubSub::<TestEvent>::default();
assert_eq!(hub.pattern_count(), 0);
assert_eq!(hub.messages_published(), 0);
assert_eq!(hub.messages_dropped(), 0);
}
#[test]
fn typed_from_untyped_fails_on_wrong_type() {
let untyped = TopicMessage {
topic: "test".into(),
payload: serde_json::json!("not a TestEvent"),
timestamp: Utc::now(),
};
assert!(TypedMessage::<TestEvent>::from_untyped(&untyped).is_err());
}
#[test]
fn pubsub_cleanup_dead_subscribers() {
let hub = PubSub::new();
let rx1 = hub.subscribe("a/b");
let _rx2 = hub.subscribe("c/d");
assert_eq!(hub.pattern_count(), 2);
drop(rx1);
let removed = hub.cleanup_dead_subscribers();
assert_eq!(removed, 1);
assert_eq!(hub.pattern_count(), 1);
}
#[test]
fn pubsub_cleanup_no_dead() {
let hub = PubSub::new();
let _rx = hub.subscribe("a/b");
assert_eq!(hub.cleanup_dead_subscribers(), 0);
assert_eq!(hub.pattern_count(), 1);
}
#[test]
fn typed_cleanup_dead_subscribers() {
let hub = TypedPubSub::<TestEvent>::new();
let rx1 = hub.subscribe("events/a");
let _rx2 = hub.subscribe("events/b");
let rx3 = hub.subscribe_filtered("events/#", |e: &TestEvent| e.value > 10);
assert_eq!(hub.subscriber_count(), 3);
drop(rx1);
drop(rx3);
let removed = hub.cleanup_dead_subscribers();
assert_eq!(removed, 2);
assert_eq!(hub.subscriber_count(), 1);
}
#[test]
fn typed_cleanup_removes_empty_patterns() {
let hub = TypedPubSub::<TestEvent>::new();
let rx1 = hub.subscribe("events/a");
let _rx2 = hub.subscribe("events/b");
assert_eq!(hub.pattern_count(), 2);
drop(rx1);
hub.cleanup_dead_subscribers();
assert_eq!(hub.pattern_count(), 1);
}
#[test]
fn pubsub_auto_cleanup_on_publish() {
let mut hub = PubSub::new();
hub.set_cleanup_interval(5);
let rx = hub.subscribe("a/b");
drop(rx); assert_eq!(hub.pattern_count(), 1);
for _ in 0..4 {
hub.publish("a/b", serde_json::Value::Null);
}
assert_eq!(hub.pattern_count(), 1);
hub.publish("a/b", serde_json::Value::Null);
assert_eq!(hub.pattern_count(), 0);
}
#[test]
fn typed_auto_cleanup_on_publish() {
let config = TypedPubSubConfig {
cleanup_interval: 3,
..Default::default()
};
let hub = TypedPubSub::<TestEvent>::with_config(config);
let rx = hub.subscribe("events/#");
drop(rx);
assert_eq!(hub.pattern_count(), 1);
for i in 0..2 {
hub.publish(
"events/x",
TestEvent {
kind: "x".into(),
value: i,
},
);
}
assert_eq!(hub.pattern_count(), 1);
hub.publish(
"events/x",
TestEvent {
kind: "x".into(),
value: 3,
},
);
assert_eq!(hub.pattern_count(), 0);
}
#[test]
fn typed_cleanup_partial_pattern() {
let hub = TypedPubSub::<TestEvent>::new();
let rx1 = hub.subscribe("events/#");
let _rx2 = hub.subscribe("events/#"); assert_eq!(hub.subscriber_count(), 2);
assert_eq!(hub.pattern_count(), 1);
drop(rx1);
let removed = hub.cleanup_dead_subscribers();
assert_eq!(removed, 1);
assert_eq!(hub.pattern_count(), 1);
assert_eq!(hub.subscriber_count(), 1);
}
}