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