Skip to main content

ipfrs_network/
topic_router.rs

1//! TopicRouter — Intelligent GossipSub topic management with priority-based message queuing.
2//!
3//! This module provides a production-grade topic router that sits above the raw GossipSub layer
4//! and adds:
5//!
6//! - Per-topic configuration (queue depth, priority threshold, TTL)
7//! - Lock-free per-topic message counters via `Arc<AtomicU64>`
8//! - Priority queues backed by `BinaryHeap` — highest-priority messages dequeued first
9//! - Automatic drop of messages below a topic's `priority_threshold`
10//! - Bounded queues — messages rejected with `QueueFull` when `max_queue_depth` is exceeded
11//! - Aggregate statistics with atomic accumulators and a `snapshot()` API
12
13use std::cmp::Ordering;
14use std::collections::{BinaryHeap, HashMap};
15use std::sync::{
16    atomic::{AtomicU64, Ordering as AOrdering},
17    Arc, Mutex,
18};
19use std::time::{Duration, Instant};
20
21use thiserror::Error;
22
23// ─── Errors ──────────────────────────────────────────────────────────────────
24
25/// Errors that can occur in `TopicRouter` operations.
26#[derive(Error, Debug, Clone, PartialEq, Eq)]
27pub enum TopicError {
28    /// The requested topic has not been registered.
29    #[error("Topic not found: {0}")]
30    TopicNotFound(String),
31
32    /// A topic with the same name is already registered.
33    #[error("Topic already registered: {0}")]
34    AlreadyRegistered(String),
35
36    /// The topic's queue is at capacity.
37    #[error("Queue full for topic '{topic}' (max {max})")]
38    QueueFull { topic: String, max: usize },
39
40    /// The message priority is below the configured threshold for the topic.
41    #[error("Message priority {priority} is below threshold for topic '{topic}'")]
42    BelowThreshold { topic: String, priority: u8 },
43}
44
45// ─── TopicConfig ─────────────────────────────────────────────────────────────
46
47/// Per-topic routing configuration.
48#[derive(Debug, Clone)]
49pub struct TopicConfig {
50    /// Maximum number of messages that may reside in the queue at once.
51    /// Messages enqueued beyond this limit are rejected with `TopicError::QueueFull`.
52    pub max_queue_depth: usize,
53
54    /// Minimum priority required for a message to be accepted.
55    /// Messages strictly below this value are silently dropped and counted as dropped.
56    pub priority_threshold: u8,
57
58    /// Maximum age a message may reach before `PrioritizedMessage::is_expired` returns `true`.
59    pub ttl: Duration,
60}
61
62impl Default for TopicConfig {
63    fn default() -> Self {
64        Self {
65            max_queue_depth: 1000,
66            priority_threshold: 0,
67            ttl: Duration::from_secs(60),
68        }
69    }
70}
71
72// ─── PrioritizedMessage ───────────────────────────────────────────────────────
73
74/// A message that can be stored in a per-topic `BinaryHeap`.
75///
76/// Ordering is based purely on `priority`: higher numerical value → greater ordering,
77/// so the binary heap (a max-heap) will always pop the highest-priority message first.
78/// Ties are broken by *earlier* `enqueued_at` (FIFO within the same priority).
79#[derive(Debug, Clone)]
80pub struct PrioritizedMessage {
81    /// Raw message bytes.
82    pub payload: Vec<u8>,
83
84    /// Message priority (0 = lowest, 255 = highest).
85    pub priority: u8,
86
87    /// Monotonic timestamp recorded at enqueue time.
88    pub enqueued_at: Instant,
89
90    /// The topic this message belongs to.
91    pub topic: String,
92}
93
94impl PrioritizedMessage {
95    /// Returns `true` when the message has lived longer than `ttl`.
96    #[must_use]
97    pub fn is_expired(&self, ttl: Duration) -> bool {
98        self.enqueued_at.elapsed() >= ttl
99    }
100}
101
102impl PartialEq for PrioritizedMessage {
103    fn eq(&self, other: &Self) -> bool {
104        self.priority == other.priority && self.enqueued_at == other.enqueued_at
105    }
106}
107
108impl Eq for PrioritizedMessage {}
109
110impl PartialOrd for PrioritizedMessage {
111    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
112        Some(self.cmp(other))
113    }
114}
115
116impl Ord for PrioritizedMessage {
117    fn cmp(&self, other: &Self) -> Ordering {
118        // Higher priority → greater. Tie-break: earlier enqueued_at → greater (FIFO).
119        self.priority
120            .cmp(&other.priority)
121            .then_with(|| other.enqueued_at.cmp(&self.enqueued_at))
122    }
123}
124
125// ─── TopicRouterStats ─────────────────────────────────────────────────────────
126
127/// A point-in-time snapshot of aggregate router statistics.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct TopicRouterStatsSnapshot {
130    /// Number of currently registered topics.
131    pub total_topics: usize,
132    /// Total messages ever successfully enqueued (not dropped).
133    pub total_enqueued: u64,
134    /// Total messages dropped (below threshold or queue full).
135    pub total_dropped: u64,
136    /// Total messages dequeued.
137    pub total_dequeued: u64,
138}
139
140/// Atomic accumulator for aggregate router statistics.
141///
142/// Call `snapshot()` to get a plain `TopicRouterStatsSnapshot` suitable for logging/export.
143#[derive(Debug)]
144pub struct TopicRouterStats {
145    total_enqueued: Arc<AtomicU64>,
146    total_dropped: Arc<AtomicU64>,
147    total_dequeued: Arc<AtomicU64>,
148}
149
150impl Default for TopicRouterStats {
151    fn default() -> Self {
152        Self {
153            total_enqueued: Arc::new(AtomicU64::new(0)),
154            total_dropped: Arc::new(AtomicU64::new(0)),
155            total_dequeued: Arc::new(AtomicU64::new(0)),
156        }
157    }
158}
159
160impl TopicRouterStats {
161    /// Create a new zeroed stats accumulator.
162    #[must_use]
163    pub fn new() -> Self {
164        Self::default()
165    }
166
167    /// Returns a cloned handle that shares the same atomic counters.
168    #[must_use]
169    pub fn clone_handle(&self) -> Self {
170        Self {
171            total_enqueued: Arc::clone(&self.total_enqueued),
172            total_dropped: Arc::clone(&self.total_dropped),
173            total_dequeued: Arc::clone(&self.total_dequeued),
174        }
175    }
176
177    /// Record one successfully enqueued message.
178    pub(crate) fn record_enqueue(&self) {
179        self.total_enqueued.fetch_add(1, AOrdering::Relaxed);
180    }
181
182    /// Record one dropped message.
183    pub(crate) fn record_drop(&self) {
184        self.total_dropped.fetch_add(1, AOrdering::Relaxed);
185    }
186
187    /// Record one dequeued message.
188    pub(crate) fn record_dequeue(&self) {
189        self.total_dequeued.fetch_add(1, AOrdering::Relaxed);
190    }
191
192    /// Take an instantaneous snapshot of all counters.
193    #[must_use]
194    pub fn snapshot(&self, total_topics: usize) -> TopicRouterStatsSnapshot {
195        TopicRouterStatsSnapshot {
196            total_topics,
197            total_enqueued: self.total_enqueued.load(AOrdering::Relaxed),
198            total_dropped: self.total_dropped.load(AOrdering::Relaxed),
199            total_dequeued: self.total_dequeued.load(AOrdering::Relaxed),
200        }
201    }
202}
203
204// ─── TopicRouter ─────────────────────────────────────────────────────────────
205
206/// Intelligent GossipSub topic manager with priority-based message queuing.
207///
208/// `TopicRouter` is `Send + Sync` and designed for concurrent use behind an `Arc`.
209///
210/// # Example
211///
212/// ```rust
213/// use ipfrs_network::topic_router::{TopicRouter, TopicConfig, PrioritizedMessage};
214/// use std::time::{Duration, Instant};
215///
216/// let router = TopicRouter::new();
217/// router.register_topic("blocks", TopicConfig::default()).unwrap();
218///
219/// let msg = PrioritizedMessage {
220///     payload: b"hello".to_vec(),
221///     priority: 200,
222///     enqueued_at: Instant::now(),
223///     topic: "blocks".to_string(),
224/// };
225/// router.enqueue("blocks", msg).unwrap();
226/// let popped = router.dequeue("blocks");
227/// assert!(popped.is_some());
228/// ```
229pub struct TopicRouter {
230    /// Registered topic configurations.
231    topics: Mutex<HashMap<String, TopicConfig>>,
232
233    /// Per-topic message counter (messages ever enqueued, not dropped).
234    message_counts: Mutex<HashMap<String, Arc<AtomicU64>>>,
235
236    /// Per-topic priority queues.
237    priority_queues: Mutex<HashMap<String, BinaryHeap<PrioritizedMessage>>>,
238
239    /// Aggregate statistics.
240    stats: TopicRouterStats,
241}
242
243impl Default for TopicRouter {
244    fn default() -> Self {
245        Self::new()
246    }
247}
248
249impl TopicRouter {
250    /// Create a new, empty `TopicRouter`.
251    #[must_use]
252    pub fn new() -> Self {
253        Self {
254            topics: Mutex::new(HashMap::new()),
255            message_counts: Mutex::new(HashMap::new()),
256            priority_queues: Mutex::new(HashMap::new()),
257            stats: TopicRouterStats::new(),
258        }
259    }
260
261    // ── Registration ─────────────────────────────────────────────────────────
262
263    /// Register a new topic with the given configuration.
264    ///
265    /// # Errors
266    ///
267    /// Returns `TopicError::AlreadyRegistered` if the topic name has already been registered.
268    pub fn register_topic(&self, name: &str, config: TopicConfig) -> Result<(), TopicError> {
269        let mut topics = self.topics.lock().unwrap_or_else(|e| e.into_inner());
270
271        if topics.contains_key(name) {
272            return Err(TopicError::AlreadyRegistered(name.to_string()));
273        }
274
275        topics.insert(name.to_string(), config);
276        drop(topics);
277
278        // Initialise per-topic structures.
279        self.message_counts
280            .lock()
281            .unwrap_or_else(|e| e.into_inner())
282            .insert(name.to_string(), Arc::new(AtomicU64::new(0)));
283
284        self.priority_queues
285            .lock()
286            .unwrap_or_else(|e| e.into_inner())
287            .insert(name.to_string(), BinaryHeap::new());
288
289        Ok(())
290    }
291
292    /// Unregister a topic and discard its queue and counters.
293    ///
294    /// # Errors
295    ///
296    /// Returns `TopicError::TopicNotFound` if the topic has not been registered.
297    pub fn unregister_topic(&self, name: &str) -> Result<(), TopicError> {
298        let removed = self
299            .topics
300            .lock()
301            .unwrap_or_else(|e| e.into_inner())
302            .remove(name)
303            .is_some();
304
305        if !removed {
306            return Err(TopicError::TopicNotFound(name.to_string()));
307        }
308
309        self.message_counts
310            .lock()
311            .unwrap_or_else(|e| e.into_inner())
312            .remove(name);
313
314        self.priority_queues
315            .lock()
316            .unwrap_or_else(|e| e.into_inner())
317            .remove(name);
318
319        Ok(())
320    }
321
322    // ── Queuing ───────────────────────────────────────────────────────────────
323
324    /// Enqueue a message on the given topic.
325    ///
326    /// # Errors
327    ///
328    /// - `TopicError::TopicNotFound` — topic not registered
329    /// - `TopicError::BelowThreshold` — message priority below topic threshold (also counts as dropped)
330    /// - `TopicError::QueueFull` — queue at capacity (also counts as dropped)
331    pub fn enqueue(&self, topic: &str, msg: PrioritizedMessage) -> Result<(), TopicError> {
332        // Retrieve config under a short-lived lock to avoid holding it across queue operations.
333        let (max_depth, threshold) = {
334            let topics = self.topics.lock().unwrap_or_else(|e| e.into_inner());
335            match topics.get(topic) {
336                Some(cfg) => (cfg.max_queue_depth, cfg.priority_threshold),
337                None => return Err(TopicError::TopicNotFound(topic.to_string())),
338            }
339        };
340
341        // Drop messages below the priority threshold.
342        if msg.priority < threshold {
343            self.stats.record_drop();
344            return Err(TopicError::BelowThreshold {
345                topic: topic.to_string(),
346                priority: msg.priority,
347            });
348        }
349
350        // Push into the priority queue, enforcing depth limit.
351        let mut queues = self
352            .priority_queues
353            .lock()
354            .unwrap_or_else(|e| e.into_inner());
355
356        let queue = queues.get_mut(topic).ok_or_else(|| {
357            // Should be impossible if registration succeeded, but handle gracefully.
358            TopicError::TopicNotFound(topic.to_string())
359        })?;
360
361        if queue.len() >= max_depth {
362            drop(queues);
363            self.stats.record_drop();
364            return Err(TopicError::QueueFull {
365                topic: topic.to_string(),
366                max: max_depth,
367            });
368        }
369
370        queue.push(msg);
371        drop(queues);
372
373        // Update per-topic counter and aggregate stats.
374        if let Some(counter) = self
375            .message_counts
376            .lock()
377            .unwrap_or_else(|e| e.into_inner())
378            .get(topic)
379        {
380            counter.fetch_add(1, AOrdering::Relaxed);
381        }
382
383        self.stats.record_enqueue();
384        Ok(())
385    }
386
387    /// Pop and return the highest-priority message from the topic's queue.
388    ///
389    /// Returns `None` if the topic does not exist or the queue is empty.
390    #[must_use]
391    pub fn dequeue(&self, topic: &str) -> Option<PrioritizedMessage> {
392        let msg = self
393            .priority_queues
394            .lock()
395            .unwrap_or_else(|e| e.into_inner())
396            .get_mut(topic)?
397            .pop()?;
398
399        self.stats.record_dequeue();
400        Some(msg)
401    }
402
403    // ── Inspection ────────────────────────────────────────────────────────────
404
405    /// Returns the number of messages currently in the topic's queue.
406    ///
407    /// Returns `0` for unregistered topics.
408    #[must_use]
409    pub fn queue_depth(&self, topic: &str) -> usize {
410        self.priority_queues
411            .lock()
412            .unwrap_or_else(|e| e.into_inner())
413            .get(topic)
414            .map(|q| q.len())
415            .unwrap_or(0)
416    }
417
418    /// Returns the total number of messages ever successfully enqueued on the topic
419    /// (messages that were dropped are not counted here).
420    ///
421    /// Returns `0` for unregistered topics.
422    #[must_use]
423    pub fn message_count(&self, topic: &str) -> u64 {
424        self.message_counts
425            .lock()
426            .unwrap_or_else(|e| e.into_inner())
427            .get(topic)
428            .map(|c| c.load(AOrdering::Relaxed))
429            .unwrap_or(0)
430    }
431
432    /// Returns a sorted list of all registered topic names.
433    #[must_use]
434    pub fn all_topics(&self) -> Vec<String> {
435        let mut names: Vec<String> = self
436            .topics
437            .lock()
438            .unwrap_or_else(|e| e.into_inner())
439            .keys()
440            .cloned()
441            .collect();
442        names.sort();
443        names
444    }
445
446    /// Returns a point-in-time statistics snapshot.
447    #[must_use]
448    pub fn stats(&self) -> TopicRouterStatsSnapshot {
449        let total_topics = self.topics.lock().unwrap_or_else(|e| e.into_inner()).len();
450        self.stats.snapshot(total_topics)
451    }
452}
453
454// ─── Tests ────────────────────────────────────────────────────────────────────
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459    use std::time::{Duration, Instant};
460
461    fn make_msg(topic: &str, priority: u8, payload: &[u8]) -> PrioritizedMessage {
462        PrioritizedMessage {
463            payload: payload.to_vec(),
464            priority,
465            enqueued_at: Instant::now(),
466            topic: topic.to_string(),
467        }
468    }
469
470    fn default_router_with_topic(topic: &str) -> TopicRouter {
471        let r = TopicRouter::new();
472        r.register_topic(topic, TopicConfig::default())
473            .expect("test: register_topic should succeed for new topic");
474        r
475    }
476
477    // ── 1. Register a topic successfully ─────────────────────────────────────
478    #[test]
479    fn test_register_topic_ok() {
480        let router = TopicRouter::new();
481        let result = router.register_topic("alpha", TopicConfig::default());
482        assert!(result.is_ok(), "Expected successful registration");
483        assert!(router.all_topics().contains(&"alpha".to_string()));
484    }
485
486    // ── 2. Duplicate registration is rejected ─────────────────────────────────
487    #[test]
488    fn test_register_topic_duplicate() {
489        let router = TopicRouter::new();
490        router
491            .register_topic("beta", TopicConfig::default())
492            .expect("test: first registration of beta should succeed");
493        let err = router
494            .register_topic("beta", TopicConfig::default())
495            .expect_err("test: duplicate registration should fail");
496        assert!(
497            matches!(err, TopicError::AlreadyRegistered(ref n) if n == "beta"),
498            "Expected AlreadyRegistered, got {err:?}"
499        );
500    }
501
502    // ── 3. Unregister removes the topic ───────────────────────────────────────
503    #[test]
504    fn test_unregister_topic() {
505        let router = default_router_with_topic("gamma");
506        router
507            .unregister_topic("gamma")
508            .expect("test: unregister_topic should succeed for registered topic");
509        assert!(!router.all_topics().contains(&"gamma".to_string()));
510    }
511
512    // ── 4. Unregister unknown topic returns TopicNotFound ─────────────────────
513    #[test]
514    fn test_unregister_nonexistent_topic() {
515        let router = TopicRouter::new();
516        let err = router
517            .unregister_topic("ghost")
518            .expect_err("test: unregister_topic should fail for unknown topic");
519        assert!(
520            matches!(err, TopicError::TopicNotFound(ref n) if n == "ghost"),
521            "Expected TopicNotFound"
522        );
523    }
524
525    // ── 5. Enqueue on unknown topic returns TopicNotFound ─────────────────────
526    #[test]
527    fn test_enqueue_unknown_topic() {
528        let router = TopicRouter::new();
529        let msg = make_msg("unknown", 100, b"payload");
530        let err = router
531            .enqueue("unknown", msg)
532            .expect_err("test: enqueue on unknown topic should fail");
533        assert!(matches!(err, TopicError::TopicNotFound(_)));
534    }
535
536    // ── 6. Messages dequeued in priority order (highest first) ───────────────
537    #[test]
538    fn test_priority_ordering() {
539        let router = default_router_with_topic("prio");
540        router
541            .enqueue("prio", make_msg("prio", 10, b"low"))
542            .expect("test: enqueue should succeed for valid priority message");
543        router
544            .enqueue("prio", make_msg("prio", 200, b"high"))
545            .expect("test: enqueue should succeed for valid priority message");
546        router
547            .enqueue("prio", make_msg("prio", 100, b"mid"))
548            .expect("test: enqueue should succeed for valid priority message");
549
550        let first = router
551            .dequeue("prio")
552            .expect("test: dequeue should return a message from non-empty queue");
553        let second = router
554            .dequeue("prio")
555            .expect("test: dequeue should return a message from non-empty queue");
556        let third = router
557            .dequeue("prio")
558            .expect("test: dequeue should return a message from non-empty queue");
559
560        assert_eq!(
561            first.payload, b"high",
562            "First dequeued should be highest priority"
563        );
564        assert_eq!(second.payload, b"mid");
565        assert_eq!(third.payload, b"low");
566    }
567
568    // ── 7. Dequeue returns None on empty queue ───────────────────────────────
569    #[test]
570    fn test_dequeue_empty() {
571        let router = default_router_with_topic("empty");
572        assert!(router.dequeue("empty").is_none());
573    }
574
575    // ── 8. Queue depth limit is enforced ─────────────────────────────────────
576    #[test]
577    fn test_queue_depth_limit() {
578        let router = TopicRouter::new();
579        let config = TopicConfig {
580            max_queue_depth: 3,
581            ..Default::default()
582        };
583        router
584            .register_topic("bounded", config)
585            .expect("test: register bounded topic should succeed");
586
587        for i in 0_u8..3 {
588            router
589                .enqueue("bounded", make_msg("bounded", i, b"x"))
590                .expect("test: enqueue should succeed when queue is not full");
591        }
592        assert_eq!(router.queue_depth("bounded"), 3);
593
594        let err = router
595            .enqueue("bounded", make_msg("bounded", 1, b"overflow"))
596            .expect_err("test: enqueue beyond max_queue_depth should fail");
597        assert!(
598            matches!(err, TopicError::QueueFull { ref topic, max: 3 } if topic == "bounded"),
599            "Expected QueueFull"
600        );
601    }
602
603    // ── 9. Priority threshold drops low-priority messages ────────────────────
604    #[test]
605    fn test_priority_threshold_drop() {
606        let router = TopicRouter::new();
607        let config = TopicConfig {
608            priority_threshold: 100,
609            ..Default::default()
610        };
611        router
612            .register_topic("thresh", config)
613            .expect("test: register thresh topic should succeed");
614
615        let err = router
616            .enqueue("thresh", make_msg("thresh", 50, b"low"))
617            .expect_err("test: enqueue below priority threshold should fail");
618        assert!(
619            matches!(err, TopicError::BelowThreshold { ref topic, priority: 50 } if topic == "thresh"),
620            "Expected BelowThreshold"
621        );
622
623        // Message at exact threshold should be accepted.
624        router
625            .enqueue("thresh", make_msg("thresh", 100, b"ok"))
626            .expect("test: enqueue at exact priority threshold should succeed");
627        assert_eq!(router.queue_depth("thresh"), 1);
628    }
629
630    // ── 10. Stats accumulate correctly ───────────────────────────────────────
631    #[test]
632    fn test_stats_accumulation() {
633        let router = TopicRouter::new();
634        let config = TopicConfig {
635            priority_threshold: 50,
636            max_queue_depth: 2,
637            ..Default::default()
638        };
639        router
640            .register_topic("stats_topic", config)
641            .expect("test: register stats_topic should succeed");
642
643        // 2 successful enqueues
644        router
645            .enqueue("stats_topic", make_msg("stats_topic", 100, b"a"))
646            .expect("test: enqueue into stats_topic should succeed");
647        router
648            .enqueue("stats_topic", make_msg("stats_topic", 200, b"b"))
649            .expect("test: enqueue into stats_topic should succeed");
650        // 1 drop: below threshold
651        let _ = router.enqueue("stats_topic", make_msg("stats_topic", 10, b"c"));
652        // 1 drop: queue full
653        let _ = router.enqueue("stats_topic", make_msg("stats_topic", 200, b"d"));
654
655        // 1 dequeue
656        let _ = router.dequeue("stats_topic");
657
658        let snap = router.stats();
659        assert_eq!(snap.total_enqueued, 2);
660        assert_eq!(snap.total_dropped, 2);
661        assert_eq!(snap.total_dequeued, 1);
662        assert_eq!(snap.total_topics, 1);
663    }
664
665    // ── 11. TTL expiry check on PrioritizedMessage ───────────────────────────
666    #[test]
667    fn test_ttl_expiry() {
668        // A message created with an artificially old enqueued_at should be expired.
669        let old = PrioritizedMessage {
670            payload: vec![],
671            priority: 100,
672            // Pretend the message was enqueued 2 seconds ago by lying about enqueued_at.
673            // We do this by creating a fresh Instant and noting it predates the TTL check.
674            enqueued_at: Instant::now(),
675            topic: "ttl_topic".to_string(),
676        };
677        // With a 10s TTL a brand-new message should NOT be expired.
678        assert!(!old.is_expired(Duration::from_secs(10)));
679
680        // With a 0-nanosecond TTL any message should immediately be expired.
681        assert!(old.is_expired(Duration::from_nanos(0)));
682    }
683
684    // ── 12. all_topics() returns all registered topics sorted ────────────────
685    #[test]
686    fn test_all_topics_listing() {
687        let router = TopicRouter::new();
688        router
689            .register_topic("zebra", TopicConfig::default())
690            .expect("test: register_topic should succeed for unique topic name");
691        router
692            .register_topic("apple", TopicConfig::default())
693            .expect("test: register_topic should succeed for unique topic name");
694        router
695            .register_topic("mango", TopicConfig::default())
696            .expect("test: register_topic should succeed for unique topic name");
697
698        let topics = router.all_topics();
699        assert_eq!(topics, vec!["apple", "mango", "zebra"]);
700    }
701
702    // ── 13. message_count reflects enqueued (not dropped) messages ───────────
703    #[test]
704    fn test_message_count_excludes_drops() {
705        let router = TopicRouter::new();
706        let config = TopicConfig {
707            priority_threshold: 100,
708            ..Default::default()
709        };
710        router
711            .register_topic("cnt", config)
712            .expect("test: register cnt topic should succeed");
713
714        router
715            .enqueue("cnt", make_msg("cnt", 200, b"good"))
716            .expect("test: enqueue good message into cnt should succeed");
717        let _ = router.enqueue("cnt", make_msg("cnt", 50, b"bad")); // dropped
718
719        assert_eq!(router.message_count("cnt"), 1);
720    }
721
722    // ── 14. queue_depth tracks enqueue and dequeue correctly ─────────────────
723    #[test]
724    fn test_queue_depth_tracking() {
725        let router = default_router_with_topic("depth");
726        assert_eq!(router.queue_depth("depth"), 0);
727
728        router
729            .enqueue("depth", make_msg("depth", 1, b"a"))
730            .expect("test: enqueue into depth topic should succeed");
731        router
732            .enqueue("depth", make_msg("depth", 2, b"b"))
733            .expect("test: enqueue into depth topic should succeed");
734        assert_eq!(router.queue_depth("depth"), 2);
735
736        let _ = router.dequeue("depth");
737        assert_eq!(router.queue_depth("depth"), 1);
738
739        let _ = router.dequeue("depth");
740        assert_eq!(router.queue_depth("depth"), 0);
741    }
742
743    // ── 15. Stats total_topics reflects current registration count ───────────
744    #[test]
745    fn test_stats_total_topics() {
746        let router = TopicRouter::new();
747        assert_eq!(router.stats().total_topics, 0);
748
749        router
750            .register_topic("t1", TopicConfig::default())
751            .expect("test: register topic t1 should succeed");
752        assert_eq!(router.stats().total_topics, 1);
753
754        router
755            .register_topic("t2", TopicConfig::default())
756            .expect("test: register topic t2 should succeed");
757        assert_eq!(router.stats().total_topics, 2);
758
759        router
760            .unregister_topic("t1")
761            .expect("test: unregister_topic t1 should succeed");
762        assert_eq!(router.stats().total_topics, 1);
763    }
764}