offline_protocol_reliability/
retry_queue.rs1use 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#[derive(Debug, Clone)]
14pub struct RetryConfig {
15 pub max_retries: u32,
17
18 pub initial_delay_ms: u64,
20
21 pub max_delay_ms: u64,
23
24 pub backoff_multiplier: f32,
26
27 pub outbox_max_lifetime_ms: u64,
29
30 pub pending_message_max_lifetime_ms: u64,
32
33 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 edge_driven_unreachable_dm: false,
65 }
66 }
67}
68
69impl RetryConfig {
70 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#[derive(Debug, Clone)]
88pub struct RetryEntry {
89 pub message: Message,
91
92 pub retry_count: u32,
94
95 pub added_at: DateTime<Utc>,
97
98 pub retry_at: DateTime<Utc>,
100}
101
102impl RetryEntry {
103 pub fn is_ready(&self) -> bool {
105 Utc::now() >= self.retry_at
106 }
107
108 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
115impl Ord for RetryEntry {
117 fn cmp(&self, other: &Self) -> Ordering {
118 other.retry_at.cmp(&self.retry_at).then_with(|| {
120 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
141pub struct RetryQueue {
143 config: RetryConfig,
144 queue: BinaryHeap<RetryEntry>,
145 index: HashMap<String, ()>, }
147
148impl RetryQueue {
149 pub fn new() -> Self {
151 Self::with_config(RetryConfig::default())
152 }
153
154 pub fn with_config(config: RetryConfig) -> Self {
156 Self {
157 config,
158 queue: BinaryHeap::new(),
159 index: HashMap::new(),
160 }
161 }
162
163 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 pub fn enqueue_with_delay(
195 &mut self,
196 message: Message,
197 retry_count: u32,
198 delay_ms: u64,
199 ) -> Option<DateTime<Utc>> {
200 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 pub fn dequeue_ready(&mut self) -> Option<RetryEntry> {
226 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 pub fn peek_ready(&self) -> Vec<&RetryEntry> {
239 self.queue.iter().filter(|entry| entry.is_ready()).collect()
240 }
241
242 pub fn remove(&mut self, message_id: &str) -> bool {
244 if self.index.remove(message_id).is_some() {
245 self.queue
247 .retain(|entry| entry.message.id.as_str() != message_id);
248 true
249 } else {
250 false
251 }
252 }
253
254 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 let new_queue: BinaryHeap<RetryEntry> = self
266 .queue
267 .iter()
268 .filter(|entry| !entry.is_expired(max_lifetime))
269 .cloned()
270 .collect();
271
272 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 pub fn len(&self) -> usize {
285 self.queue.len()
286 }
287
288 pub fn is_empty(&self) -> bool {
290 self.queue.is_empty()
291 }
292
293 pub fn contains(&self, message_id: &str) -> bool {
295 self.index.contains_key(message_id)
296 }
297
298 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 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 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#[derive(Debug, Clone)]
366pub struct RetryQueueStats {
367 pub total_count: usize,
369 pub ready_count: usize,
371 pub critical_priority_count: usize,
373 pub high_priority_count: usize,
375 pub medium_priority_count: usize,
377 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 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, ..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 assert!(queue.dequeue_ready().is_none());
425
426 thread::sleep(Duration::from_millis(60));
428
429 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 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 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 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 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 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 queue.enqueue(msg.clone(), 0);
545 queue.dequeue_ready(); queue.enqueue(msg.clone(), 1);
548 queue.dequeue_ready();
549
550 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, ..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 assert!(queue.dequeue_ready().is_none());
575 assert_eq!(queue.len(), 3);
576
577 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 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 let ready = queue.peek_ready();
606 assert_eq!(ready.len(), 3);
607
608 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 let now = Utc::now();
620 let make_entry = |priority: MessagePriority| {
621 let mut msg = create_test_message(priority);
622 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 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, ..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 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}