Skip to main content

offline_protocol_reliability/
retry_queue.rs

1//! Retry queue with exponential backoff for message retries.
2
3use crate::constants::{
4    DEFAULT_BACKOFF_MULTIPLIER, DEFAULT_INITIAL_DELAY_MS, DEFAULT_MAX_DELAY_MS,
5    DEFAULT_MAX_RETRIES, DEFAULT_OUTBOX_LIFETIME_MS, DEFAULT_PENDING_MESSAGE_LIFETIME_MS,
6};
7use chrono::{DateTime, Utc};
8use offline_protocol_core::{Message, MessagePriority};
9use std::cmp::Ordering;
10use std::collections::{BinaryHeap, HashMap};
11
12/// Configuration for retry behavior.
13#[derive(Debug, Clone)]
14pub struct RetryConfig {
15    /// Maximum number of retries per message.
16    pub max_retries: u32,
17
18    /// Initial retry delay in milliseconds.
19    pub initial_delay_ms: u64,
20
21    /// Maximum retry delay in milliseconds.
22    pub max_delay_ms: u64,
23
24    /// Backoff multiplier for exponential backoff.
25    pub backoff_multiplier: f32,
26
27    /// Maximum lifetime for messages in the outbox (milliseconds).
28    pub outbox_max_lifetime_ms: u64,
29
30    /// Maximum lifetime for messages waiting on MLS session establishment.
31    pub pending_message_max_lifetime_ms: u64,
32
33    /// Opt-in: treat a durably-unreachable direct message as EDGE-DRIVEN once
34    /// its escalating reachability probe has run a few times, instead of the
35    /// default of probing it forever at the 15s->600s cap on every carrier.
36    ///
37    /// Default `false` preserves the documented contract (a parked message
38    /// "never goes fully quiet"; see docs/message-delivery.md) and every
39    /// existing native/third-party app's behaviour is unchanged. When `true`,
40    /// after a bounded number of probes the message stops being timed-probed
41    /// and rests in the outbox, re-driven only when the peer next proves
42    /// reachable (an inbound frame or presence-online edge flushes it). It also
43    /// lets a restart skip re-driving a durably-failing backlog.
44    ///
45    /// This trades the SDK's "self-recovers even for a silent returning peer
46    /// with no presence" guarantee for zero steady-state relay traffic to gone
47    /// peers. It is correct for deployments whose peers always interact or
48    /// advertise presence on return (e.g. a machine-to-machine capability
49    /// exchange), and MUST NOT be enabled for consumers that rely on the timed
50    /// probe as their only recovery path.
51    pub edge_driven_unreachable_dm: bool,
52}
53
54impl Default for RetryConfig {
55    fn default() -> Self {
56        Self {
57            max_retries: DEFAULT_MAX_RETRIES,
58            initial_delay_ms: DEFAULT_INITIAL_DELAY_MS,
59            max_delay_ms: DEFAULT_MAX_DELAY_MS,
60            backoff_multiplier: DEFAULT_BACKOFF_MULTIPLIER,
61            outbox_max_lifetime_ms: DEFAULT_OUTBOX_LIFETIME_MS,
62            pending_message_max_lifetime_ms: DEFAULT_PENDING_MESSAGE_LIFETIME_MS,
63            // Default off: preserve the documented perpetual-probe contract.
64            edge_driven_unreachable_dm: false,
65        }
66    }
67}
68
69impl RetryConfig {
70    /// Calculates the backoff delay for the given retry count:
71    /// `initial_delay_ms * backoff_multiplier^retry_count`, clamped to
72    /// `max_delay_ms`.
73    ///
74    /// Computed in f64 and clamped before the integer cast, so it is total
75    /// over all configs: `f64::min` returns the other operand when one side
76    /// is NaN (NaN/infinity collapse to `max_delay_ms`), and float-to-int
77    /// `as` casts saturate.
78    pub fn delay_for_retry(&self, retry_count: u32) -> u64 {
79        let exponent = i32::try_from(retry_count).unwrap_or(i32::MAX);
80        let factor = f64::from(self.backoff_multiplier).powi(exponent);
81        let delay_ms = self.initial_delay_ms as f64 * factor;
82        delay_ms.min(self.max_delay_ms as f64) as u64
83    }
84}
85
86/// A message scheduled for retry.
87#[derive(Debug, Clone)]
88pub struct RetryEntry {
89    /// The message to retry.
90    pub message: Message,
91
92    /// Number of times this message has been retried.
93    pub retry_count: u32,
94
95    /// When this message was first added to the queue.
96    pub added_at: DateTime<Utc>,
97
98    /// When this message should be retried next.
99    pub retry_at: DateTime<Utc>,
100}
101
102impl RetryEntry {
103    /// Checks if this entry is ready for retry.
104    pub fn is_ready(&self) -> bool {
105        Utc::now() >= self.retry_at
106    }
107
108    /// Checks if this entry has expired (exceeded max lifetime).
109    pub fn is_expired(&self, max_lifetime_ms: u64) -> bool {
110        let elapsed = Utc::now().signed_duration_since(self.added_at);
111        elapsed.num_milliseconds() >= max_lifetime_ms as i64
112    }
113}
114
115// Implement Ord for priority queue (earlier retry_at = higher priority)
116impl Ord for RetryEntry {
117    fn cmp(&self, other: &Self) -> Ordering {
118        // Reverse comparison for min-heap (earliest retry_at first)
119        other.retry_at.cmp(&self.retry_at).then_with(|| {
120            // Tie-breaker: higher priority messages first (non-reversed,
121            // so High > Low makes High pop first from the max-heap)
122            self.message.priority.cmp(&other.message.priority)
123        })
124    }
125}
126
127impl PartialOrd for RetryEntry {
128    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
129        Some(self.cmp(other))
130    }
131}
132
133impl Eq for RetryEntry {}
134
135impl PartialEq for RetryEntry {
136    fn eq(&self, other: &Self) -> bool {
137        self.retry_at == other.retry_at && self.message.priority == other.message.priority
138    }
139}
140
141/// Retry queue for managing message retries with exponential backoff.
142pub struct RetryQueue {
143    config: RetryConfig,
144    queue: BinaryHeap<RetryEntry>,
145    index: HashMap<String, ()>, // For fast lookup
146}
147
148impl RetryQueue {
149    /// Creates a new retry queue with default configuration.
150    pub fn new() -> Self {
151        Self::with_config(RetryConfig::default())
152    }
153
154    /// Creates a new retry queue with custom configuration.
155    pub fn with_config(config: RetryConfig) -> Self {
156        Self {
157            config,
158            queue: BinaryHeap::new(),
159            index: HashMap::new(),
160        }
161    }
162
163    /// Adds a message to the retry queue.
164    ///
165    /// The retry queue is a pure scheduling mechanism — it does not enforce a
166    /// retry limit.  ACK-level retry limits are enforced by the ACK manager in
167    /// the protocol layer.
168    ///
169    /// Duplicate messages (same ID already in queue) are silently ignored.
170    ///
171    /// # Arguments
172    ///
173    /// * `message` - The message to queue for retry
174    /// * `retry_count` - Current retry count (used for backoff calculation)
175    ///
176    /// # Returns
177    ///
178    /// The absolute time the retry is scheduled for, or `None` if the
179    /// message was already queued (nothing was scheduled).
180    pub fn enqueue(&mut self, message: Message, retry_count: u32) -> Option<DateTime<Utc>> {
181        let delay_ms = self.config.delay_for_retry(retry_count);
182        self.enqueue_with_delay(message, retry_count, delay_ms)
183    }
184
185    /// Adds a message to the retry queue with an explicit delay, bypassing
186    /// the backoff formula.
187    ///
188    /// Used for reachability probes toward peers an authoritative relay
189    /// verdict reported unreachable, where the interval is driven by
190    /// per-peer escalation rather than this message's retry count.
191    ///
192    /// Duplicate messages (same ID already in queue) are silently ignored;
193    /// `None` means nothing was scheduled.
194    pub fn enqueue_with_delay(
195        &mut self,
196        message: Message,
197        retry_count: u32,
198        delay_ms: u64,
199    ) -> Option<DateTime<Utc>> {
200        // Prevent duplicate entries for the same message
201        if self.index.contains_key(&message.id.as_str()) {
202            return None;
203        }
204
205        let retry_at =
206            Utc::now() + chrono::Duration::milliseconds(delay_ms.min(i64::MAX as u64) as i64);
207
208        let entry = RetryEntry {
209            message: message.clone(),
210            retry_count,
211            added_at: Utc::now(),
212            retry_at,
213        };
214
215        self.index.insert(message.id.as_str(), ());
216        self.queue.push(entry);
217        Some(retry_at)
218    }
219
220    /// Dequeues the next message that is ready for retry.
221    ///
222    /// # Returns
223    ///
224    /// Returns `Some(RetryEntry)` if a message is ready, `None` otherwise.
225    pub fn dequeue_ready(&mut self) -> Option<RetryEntry> {
226        // Peek at the top entry
227        let is_ready = self.queue.peek().is_some_and(|entry| entry.is_ready());
228        if is_ready {
229            if let Some(entry) = self.queue.pop() {
230                self.index.remove(&entry.message.id.as_str());
231                return Some(entry);
232            }
233        }
234        None
235    }
236
237    /// Gets all messages ready for retry without removing them.
238    pub fn peek_ready(&self) -> Vec<&RetryEntry> {
239        self.queue.iter().filter(|entry| entry.is_ready()).collect()
240    }
241
242    /// Removes a message from the queue (e.g., after successful delivery).
243    pub fn remove(&mut self, message_id: &str) -> bool {
244        if self.index.remove(message_id).is_some() {
245            // Remove from heap (expensive, but necessary)
246            self.queue
247                .retain(|entry| entry.message.id.as_str() != message_id);
248            true
249        } else {
250            false
251        }
252    }
253
254    /// Cleans up expired messages that exceeded their max lifetime.
255    ///
256    /// # Returns
257    ///
258    /// Returns the number of expired messages removed.
259    pub fn cleanup_expired(&mut self) -> usize {
260        let max_lifetime = self.config.outbox_max_lifetime_ms;
261
262        let before_count = self.queue.len();
263
264        // Filter out expired entries
265        let new_queue: BinaryHeap<RetryEntry> = self
266            .queue
267            .iter()
268            .filter(|entry| !entry.is_expired(max_lifetime))
269            .cloned()
270            .collect();
271
272        // Update index
273        self.index.clear();
274        for entry in &new_queue {
275            self.index.insert(entry.message.id.as_str(), ());
276        }
277
278        self.queue = new_queue;
279
280        before_count - self.queue.len()
281    }
282
283    /// Gets the number of messages in the queue.
284    pub fn len(&self) -> usize {
285        self.queue.len()
286    }
287
288    /// Checks if the queue is empty.
289    pub fn is_empty(&self) -> bool {
290        self.queue.is_empty()
291    }
292
293    /// Checks if a message is in the queue.
294    pub fn contains(&self, message_id: &str) -> bool {
295        self.index.contains_key(message_id)
296    }
297
298    /// Drains all entries from the queue, ignoring `retry_at` timing.
299    ///
300    /// Returns entries in priority order (earliest `retry_at` first, then
301    /// highest message priority). Used for immediate flush when transport
302    /// becomes available.
303    pub fn drain_all(&mut self) -> Vec<RetryEntry> {
304        self.index.clear();
305        let mut entries = Vec::with_capacity(self.queue.len());
306        while let Some(entry) = self.queue.pop() {
307            entries.push(entry);
308        }
309        entries
310    }
311
312    /// Gets the time until the next retry is ready.
313    ///
314    /// # Returns
315    ///
316    /// Returns `Some(Duration)` if there are pending retries, `None` if empty.
317    pub fn time_until_next_retry(&self) -> Option<chrono::Duration> {
318        self.queue.peek().map(|entry| {
319            let now = Utc::now();
320            if entry.retry_at > now {
321                entry.retry_at - now
322            } else {
323                chrono::Duration::zero()
324            }
325        })
326    }
327
328    /// Gets statistics about the retry queue.
329    pub fn stats(&self) -> RetryQueueStats {
330        let ready_count = self.peek_ready().len();
331        let priority_counts = self.count_by_priority();
332
333        RetryQueueStats {
334            total_count: self.len(),
335            ready_count,
336            critical_priority_count: priority_counts[&MessagePriority::Critical],
337            high_priority_count: priority_counts[&MessagePriority::High],
338            medium_priority_count: priority_counts[&MessagePriority::Medium],
339            low_priority_count: priority_counts[&MessagePriority::Low],
340        }
341    }
342
343    fn count_by_priority(&self) -> HashMap<MessagePriority, usize> {
344        let mut counts = HashMap::new();
345        counts.insert(MessagePriority::Low, 0);
346        counts.insert(MessagePriority::Medium, 0);
347        counts.insert(MessagePriority::High, 0);
348        counts.insert(MessagePriority::Critical, 0);
349
350        for entry in &self.queue {
351            *counts.entry(entry.message.priority).or_insert(0) += 1;
352        }
353
354        counts
355    }
356}
357
358impl Default for RetryQueue {
359    fn default() -> Self {
360        Self::new()
361    }
362}
363
364/// Statistics about the retry queue.
365#[derive(Debug, Clone)]
366pub struct RetryQueueStats {
367    /// Total number of messages in queue.
368    pub total_count: usize,
369    /// Number of messages ready for retry.
370    pub ready_count: usize,
371    /// Number of critical priority messages.
372    pub critical_priority_count: usize,
373    /// Number of high priority messages.
374    pub high_priority_count: usize,
375    /// Number of medium priority messages.
376    pub medium_priority_count: usize,
377    /// Number of low priority messages.
378    pub low_priority_count: usize,
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384    use offline_protocol_core::{AppId, UserId};
385    use std::thread;
386    use std::time::Duration;
387
388    fn create_test_message(priority: MessagePriority) -> Message {
389        Message::builder(
390            UserId::new("alice").unwrap(),
391            UserId::new("bob").unwrap(),
392            AppId::new("test").unwrap(),
393        )
394        .content("Test message")
395        .priority(priority)
396        .build()
397    }
398
399    #[test]
400    fn test_edge_driven_unreachable_dm_defaults_off() {
401        // The opt-in flood-control behaviors (edge-driven parking, restart
402        // age-gate, core resend rate-cap) are all gated on this flag. It MUST
403        // default false so every existing native/third-party app keeps the
404        // documented perpetual-probe behavior unchanged. If a future change
405        // flips the default, this fails and flags the silent regression.
406        assert!(!RetryConfig::default().edge_driven_unreachable_dm);
407    }
408
409    #[test]
410    fn test_enqueue_and_dequeue() {
411        let config = RetryConfig {
412            initial_delay_ms: 50, // Fast for testing
413            ..Default::default()
414        };
415        let mut queue = RetryQueue::with_config(config);
416
417        let msg = create_test_message(MessagePriority::Medium);
418        queue.enqueue(msg.clone(), 0);
419
420        assert_eq!(queue.len(), 1);
421        assert!(queue.contains(&msg.id.as_str()));
422
423        // Not ready immediately
424        assert!(queue.dequeue_ready().is_none());
425
426        // Wait for retry time
427        thread::sleep(Duration::from_millis(60));
428
429        // Should be ready now
430        let dequeued = queue.dequeue_ready().unwrap();
431        assert_eq!(dequeued.message.id, msg.id);
432        assert_eq!(queue.len(), 0);
433    }
434
435    #[test]
436    fn test_exponential_backoff() {
437        let config = RetryConfig {
438            initial_delay_ms: 100,
439            backoff_multiplier: 2.0,
440            max_delay_ms: 1000,
441            ..Default::default()
442        };
443
444        assert_eq!(config.delay_for_retry(0), 100);
445        assert_eq!(config.delay_for_retry(1), 200);
446        assert_eq!(config.delay_for_retry(2), 400);
447        assert_eq!(config.delay_for_retry(3), 800);
448        // 100 * 2^4 = 1600ms, capped at 1000ms
449        assert_eq!(config.delay_for_retry(4), 1000);
450        assert_eq!(config.delay_for_retry(100), 1000);
451    }
452
453    #[test]
454    fn test_backoff_extreme_multiplier_clamps_not_overflows() {
455        // Regression: the old formula multiplied in u64 before clamping, so a
456        // large multiplier overflowed (debug panic / release garbage delay).
457        for multiplier in [7.0, 1000.0, f32::MAX] {
458            let config = RetryConfig {
459                initial_delay_ms: 1000,
460                backoff_multiplier: multiplier,
461                max_delay_ms: 30_000,
462                ..Default::default()
463            };
464
465            for retry_count in [1, 20, 1000, u32::MAX] {
466                let delay = config.delay_for_retry(retry_count);
467                assert!(
468                    delay <= 30_000,
469                    "multiplier {multiplier} retry {retry_count} gave delay {delay}"
470                );
471            }
472        }
473    }
474
475    #[test]
476    fn test_backoff_non_finite_multiplier_collapses_to_max_delay() {
477        // Locks down the totality claim on delay_for_retry: NaN and infinity
478        // collapse to exactly max_delay_ms, not merely something <= it.
479        for multiplier in [f32::NAN, f32::INFINITY] {
480            let config = RetryConfig {
481                initial_delay_ms: 1000,
482                backoff_multiplier: multiplier,
483                max_delay_ms: 30_000,
484                ..Default::default()
485            };
486
487            for retry_count in [1, 20, u32::MAX] {
488                assert_eq!(
489                    config.delay_for_retry(retry_count),
490                    30_000,
491                    "multiplier {multiplier} retry {retry_count}"
492                );
493            }
494        }
495    }
496
497    #[test]
498    fn test_backoff_retry_zero_clamped_to_max_delay() {
499        // retry 0 no longer bypasses the max_delay_ms clamp: a misconfigured
500        // initial_delay_ms > max_delay_ms yields max_delay_ms, not the raw
501        // initial delay.
502        let config = RetryConfig {
503            initial_delay_ms: 60_000,
504            backoff_multiplier: 2.0,
505            max_delay_ms: 30_000,
506            ..Default::default()
507        };
508
509        assert_eq!(config.delay_for_retry(0), 30_000);
510    }
511
512    #[test]
513    fn test_enqueue_backoff_clamped_to_max_delay() {
514        // Exercise the real enqueue path with a multiplier that overflowed
515        // the old inline formula.
516        let config = RetryConfig {
517            initial_delay_ms: 1000,
518            backoff_multiplier: 10.0,
519            max_delay_ms: 30_000,
520            ..Default::default()
521        };
522        let mut queue = RetryQueue::with_config(config);
523
524        let msg = create_test_message(MessagePriority::Medium);
525        queue.enqueue(msg, 20);
526
527        let until_retry = queue.time_until_next_retry().unwrap();
528        assert!(until_retry <= chrono::Duration::milliseconds(30_000));
529        assert!(until_retry > chrono::Duration::zero());
530    }
531
532    #[test]
533    fn test_enqueue_accepts_high_retry_count() {
534        let config = RetryConfig {
535            max_retries: 2,
536            ..Default::default()
537        };
538        let mut queue = RetryQueue::with_config(config);
539
540        let msg = create_test_message(MessagePriority::Medium);
541
542        // enqueue no longer rejects based on max_retries — it's a pure
543        // scheduling mechanism. ACK timeouts govern permanent failure.
544        queue.enqueue(msg.clone(), 0);
545        queue.dequeue_ready(); // Clear queue
546
547        queue.enqueue(msg.clone(), 1);
548        queue.dequeue_ready();
549
550        // Previously this would fail; now it should succeed
551        queue.enqueue(msg.clone(), 2);
552        queue.dequeue_ready();
553
554        queue.enqueue(msg.clone(), 100);
555    }
556
557    #[test]
558    fn test_drain_all() {
559        let config = RetryConfig {
560            initial_delay_ms: 60_000, // Long delay so nothing is "ready"
561            ..Default::default()
562        };
563        let mut queue = RetryQueue::with_config(config);
564
565        let msg1 = create_test_message(MessagePriority::High);
566        let msg2 = create_test_message(MessagePriority::Low);
567        let msg3 = create_test_message(MessagePriority::Medium);
568
569        queue.enqueue(msg1.clone(), 0);
570        queue.enqueue(msg2.clone(), 0);
571        queue.enqueue(msg3.clone(), 0);
572
573        // Nothing should be ready (long delay)
574        assert!(queue.dequeue_ready().is_none());
575        assert_eq!(queue.len(), 3);
576
577        // drain_all returns everything regardless of timing, in heap order
578        // (earliest retry_at first, priority as tiebreaker)
579        let entries = queue.drain_all();
580        assert_eq!(entries.len(), 3);
581        assert!(queue.is_empty());
582        assert!(!queue.contains(&msg1.id.as_str()));
583    }
584
585    #[test]
586    fn test_priority_ordering() {
587        let config = RetryConfig {
588            initial_delay_ms: 50,
589            ..Default::default()
590        };
591        let mut queue = RetryQueue::with_config(config);
592
593        // Add messages with different priorities (same retry time)
594        let low = create_test_message(MessagePriority::Low);
595        let high = create_test_message(MessagePriority::High);
596        let medium = create_test_message(MessagePriority::Medium);
597
598        queue.enqueue(low.clone(), 0);
599        queue.enqueue(high.clone(), 0);
600        queue.enqueue(medium.clone(), 0);
601
602        thread::sleep(Duration::from_millis(60));
603
604        // All should be ready
605        let ready = queue.peek_ready();
606        assert_eq!(ready.len(), 3);
607
608        // Check that high priority is among the ready messages
609        let has_high = ready
610            .iter()
611            .any(|e| e.message.priority == MessagePriority::High);
612        assert!(has_high);
613    }
614
615    #[test]
616    fn test_priority_tiebreaker_ordering() {
617        // Verify that when retry_at is identical, higher priority pops first.
618        // We construct entries directly to ensure identical timestamps.
619        let now = Utc::now();
620        let make_entry = |priority: MessagePriority| {
621            let mut msg = create_test_message(priority);
622            // Force same priority into message
623            msg.priority = priority;
624            RetryEntry {
625                message: msg,
626                retry_count: 0,
627                added_at: now,
628                retry_at: now,
629            }
630        };
631
632        let mut heap = BinaryHeap::new();
633        heap.push(make_entry(MessagePriority::Low));
634        heap.push(make_entry(MessagePriority::High));
635        heap.push(make_entry(MessagePriority::Medium));
636
637        assert_eq!(heap.pop().unwrap().message.priority, MessagePriority::High);
638        assert_eq!(
639            heap.pop().unwrap().message.priority,
640            MessagePriority::Medium
641        );
642        assert_eq!(heap.pop().unwrap().message.priority, MessagePriority::Low);
643    }
644
645    #[test]
646    fn test_enqueue_with_delay_uses_explicit_delay() {
647        // The backoff formula would schedule retry 0 at initial_delay_ms
648        // (1s default); the explicit delay must win.
649        let mut queue = RetryQueue::new();
650        let msg = create_test_message(MessagePriority::Medium);
651
652        let before = Utc::now();
653        let retry_at = queue.enqueue_with_delay(msg, 0, 60_000).unwrap();
654
655        let delay = retry_at - before;
656        assert!(delay >= chrono::Duration::milliseconds(59_000));
657        assert!(delay <= chrono::Duration::milliseconds(61_000));
658    }
659
660    #[test]
661    fn test_enqueue_with_delay_ignores_duplicates() {
662        let mut queue = RetryQueue::new();
663        let msg = create_test_message(MessagePriority::Medium);
664
665        assert!(queue.enqueue_with_delay(msg.clone(), 0, 1000).is_some());
666        assert!(queue.enqueue_with_delay(msg.clone(), 0, 2000).is_none());
667        assert!(queue.enqueue(msg, 0).is_none());
668        assert_eq!(queue.len(), 1);
669    }
670
671    #[test]
672    fn test_remove_message() {
673        let mut queue = RetryQueue::new();
674        let msg = create_test_message(MessagePriority::Medium);
675
676        queue.enqueue(msg.clone(), 0);
677        assert!(queue.contains(&msg.id.as_str()));
678
679        assert!(queue.remove(&msg.id.as_str()));
680        assert!(!queue.contains(&msg.id.as_str()));
681        assert_eq!(queue.len(), 0);
682    }
683
684    #[test]
685    fn test_cleanup_expired() {
686        let config = RetryConfig {
687            outbox_max_lifetime_ms: 100, // 100ms for testing
688            ..Default::default()
689        };
690        let mut queue = RetryQueue::with_config(config);
691
692        let msg = create_test_message(MessagePriority::Medium);
693        queue.enqueue(msg, 0);
694
695        // Wait for expiration
696        thread::sleep(Duration::from_millis(150));
697
698        let removed = queue.cleanup_expired();
699        assert_eq!(removed, 1);
700        assert_eq!(queue.len(), 0);
701    }
702
703    #[test]
704    fn test_queue_stats() {
705        let config = RetryConfig {
706            initial_delay_ms: 50,
707            ..Default::default()
708        };
709        let mut queue = RetryQueue::with_config(config);
710
711        queue.enqueue(create_test_message(MessagePriority::High), 0);
712        queue.enqueue(create_test_message(MessagePriority::High), 0);
713        queue.enqueue(create_test_message(MessagePriority::Medium), 0);
714        queue.enqueue(create_test_message(MessagePriority::Low), 0);
715
716        let stats = queue.stats();
717        assert_eq!(stats.total_count, 4);
718        assert_eq!(stats.high_priority_count, 2);
719        assert_eq!(stats.medium_priority_count, 1);
720        assert_eq!(stats.low_priority_count, 1);
721    }
722}