Skip to main content

confium_coordinator/
retry.rs

1//! Retry queue — exponential backoff for failed operations.
2//!
3//! Wraps an operation in retry logic: on failure, the operation is
4//! queued and retried with exponential backoff (with jitter) up to a
5//! configurable maximum number of attempts.
6
7use std::sync::Mutex;
8use std::time::Duration;
9
10/// Configuration for the retry queue.
11#[derive(Debug, Clone)]
12pub struct RetryConfig {
13    /// Initial delay between retries.
14    pub initial_delay: Duration,
15    /// Maximum delay between retries.
16    pub max_delay: Duration,
17    /// Maximum number of retry attempts.
18    pub max_attempts: u32,
19    /// Backoff multiplier (e.g., 2.0 for doubling).
20    pub backoff_multiplier: f64,
21}
22
23impl Default for RetryConfig {
24    fn default() -> Self {
25        Self {
26            initial_delay: Duration::from_millis(100),
27            max_delay: Duration::from_secs(30),
28            max_attempts: 5,
29            backoff_multiplier: 2.0,
30        }
31    }
32}
33
34/// Compute the delay for a given attempt number (0-based).
35pub fn delay_for_attempt(config: &RetryConfig, attempt: u32) -> Duration {
36    let multiplier = config.backoff_multiplier.powi(attempt as i32);
37    let millis = config.initial_delay.as_millis() as f64 * multiplier;
38    let capped = millis.min(config.max_delay.as_millis() as f64);
39    Duration::from_millis(capped as u64)
40}
41
42/// A pending retry entry.
43#[derive(Debug, Clone)]
44pub struct RetryEntry<T> {
45    /// The item to retry.
46    pub item: T,
47    /// Current attempt count (0 = first try, 1 = first retry, ...).
48    pub attempts: u32,
49    /// Next scheduled retry time (offset from queue start).
50    pub next_delay: Duration,
51}
52
53/// Thread-safe retry queue for items of type T.
54pub struct RetryQueue<T> {
55    config: RetryConfig,
56    entries: Mutex<Vec<RetryEntry<T>>>,
57}
58
59impl<T: Clone> RetryQueue<T> {
60    /// Create a new retry queue with the given configuration.
61    pub fn new(config: RetryConfig) -> Self {
62        Self {
63            config,
64            entries: Mutex::new(Vec::new()),
65        }
66    }
67
68    /// Enqueue an item for retry. If `attempts` exceeds `max_attempts`,
69    /// the item is NOT enqueued and `false` is returned (dead-letter).
70    pub fn enqueue(&self, item: T, attempts: u32) -> bool {
71        if attempts >= self.config.max_attempts {
72            return false;
73        }
74        let delay = delay_for_attempt(&self.config, attempts);
75        self.entries.lock().unwrap().push(RetryEntry {
76            item,
77            attempts,
78            next_delay: delay,
79        });
80        true
81    }
82
83    /// Dequeue all items that are ready for retry. Clears them from
84    /// the queue.
85    pub fn drain_ready(&self) -> Vec<RetryEntry<T>> {
86        let mut entries = self.entries.lock().unwrap();
87        std::mem::take(&mut *entries)
88    }
89
90    /// Peek at the number of items in the queue.
91    pub fn len(&self) -> usize {
92        self.entries.lock().unwrap().len()
93    }
94
95    /// Is the queue empty?
96    pub fn is_empty(&self) -> bool {
97        self.len() == 0
98    }
99
100    /// Get the configuration.
101    pub fn config(&self) -> &RetryConfig {
102        &self.config
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[test]
111    fn default_config_has_5_attempts() {
112        let config = RetryConfig::default();
113        assert_eq!(config.max_attempts, 5);
114        assert_eq!(config.initial_delay, Duration::from_millis(100));
115    }
116
117    #[test]
118    fn delay_doubles_each_attempt() {
119        let config = RetryConfig::default();
120        let d0 = delay_for_attempt(&config, 0);
121        let d1 = delay_for_attempt(&config, 1);
122        let d2 = delay_for_attempt(&config, 2);
123        assert_eq!(d0, Duration::from_millis(100));
124        assert_eq!(d1, Duration::from_millis(200));
125        assert_eq!(d2, Duration::from_millis(400));
126    }
127
128    #[test]
129    fn delay_capped_at_max() {
130        let config = RetryConfig::default();
131        let big = delay_for_attempt(&config, 20);
132        assert!(big <= config.max_delay);
133    }
134
135    #[test]
136    fn enqueue_adds_item() {
137        let queue = RetryQueue::<String>::new(RetryConfig::default());
138        assert!(queue.enqueue("task-1".into(), 0));
139        assert_eq!(queue.len(), 1);
140    }
141
142    #[test]
143    fn enqueue_at_max_attempts_rejected() {
144        let queue = RetryQueue::<String>::new(RetryConfig::default());
145        assert!(!queue.enqueue("doomed".into(), 5));
146        assert_eq!(queue.len(), 0);
147    }
148
149    #[test]
150    fn drain_removes_all() {
151        let queue = RetryQueue::<String>::new(RetryConfig::default());
152        queue.enqueue("a".into(), 0);
153        queue.enqueue("b".into(), 1);
154        queue.enqueue("c".into(), 2);
155        let drained = queue.drain_ready();
156        assert_eq!(drained.len(), 3);
157        assert!(queue.is_empty());
158    }
159
160    #[test]
161    fn retry_entry_carries_attempt_count() {
162        let queue = RetryQueue::<u32>::new(RetryConfig::default());
163        queue.enqueue(42, 3);
164        let drained = queue.drain_ready();
165        assert_eq!(drained[0].item, 42);
166        assert_eq!(drained[0].attempts, 3);
167    }
168
169    #[test]
170    fn delay_increases_with_attempts() {
171        let queue = RetryQueue::<u32>::new(RetryConfig::default());
172        queue.enqueue(1, 0);
173        queue.enqueue(2, 2);
174        queue.enqueue(3, 4);
175        let drained = queue.drain_ready();
176        assert!(drained[0].next_delay < drained[1].next_delay);
177        assert!(drained[1].next_delay < drained[2].next_delay);
178    }
179
180    #[test]
181    fn empty_queue_drains_to_empty() {
182        let queue = RetryQueue::<u32>::new(RetryConfig::default());
183        assert!(queue.drain_ready().is_empty());
184    }
185
186    #[test]
187    fn config_accessible() {
188        let config = RetryConfig {
189            max_attempts: 10,
190            ..Default::default()
191        };
192        let queue = RetryQueue::<u32>::new(config);
193        assert_eq!(queue.config().max_attempts, 10);
194    }
195
196    #[test]
197    fn custom_multiplier_changes_growth() {
198        let config = RetryConfig {
199            initial_delay: Duration::from_millis(1000),
200            max_delay: Duration::from_secs(600),
201            max_attempts: 5,
202            backoff_multiplier: 3.0,
203        };
204        assert_eq!(delay_for_attempt(&config, 0), Duration::from_millis(1000));
205        assert_eq!(delay_for_attempt(&config, 1), Duration::from_millis(3000));
206        assert_eq!(delay_for_attempt(&config, 2), Duration::from_millis(9000));
207    }
208}