Skip to main content

offline_protocol_reliability/
ack_manager.rs

1//! ACK (acknowledgment) management for tracking pending acknowledgments.
2
3use crate::constants::{DEFAULT_ACK_TIMEOUT_MS, DEFAULT_MAX_PENDING_ACKS};
4use chrono::{DateTime, Utc};
5use offline_protocol_core::{MessageId, MessagePriority};
6use std::collections::HashMap;
7use std::time::Duration;
8
9/// Information about an ACK that was evicted due to capacity pressure.
10#[derive(Debug, Clone)]
11#[non_exhaustive]
12pub struct AckEvictionInfo {
13    /// ID of the message whose ACK tracking was evicted.
14    pub message_id: MessageId,
15    /// Priority of the evicted message.
16    pub priority: MessagePriority,
17}
18
19/// Status of an ACK.
20///
21/// There is no `Received` state: an acknowledged message is removed from
22/// tracking entirely via [`AckManager::remove_ack`].
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum AckStatus {
25    /// Waiting for ACK.
26    Pending,
27    /// ACK timed out.
28    TimedOut,
29}
30
31/// Information about a pending ACK.
32#[derive(Debug, Clone)]
33pub struct PendingAck {
34    /// Message ID waiting for ACK.
35    pub message_id: MessageId,
36
37    /// When the message was sent.
38    pub sent_at: DateTime<Utc>,
39
40    /// Timeout for this ACK (in milliseconds).
41    pub timeout_ms: u64,
42
43    /// Current status.
44    pub status: AckStatus,
45
46    /// Number of times the message has been retried.
47    pub retry_count: u32,
48
49    /// Priority of the message (for eviction decisions).
50    pub priority: MessagePriority,
51}
52
53impl PendingAck {
54    /// Checks if this ACK has timed out.
55    pub fn is_timed_out(&self) -> bool {
56        if self.status != AckStatus::Pending {
57            return false;
58        }
59
60        let elapsed = Utc::now().signed_duration_since(self.sent_at);
61        elapsed.num_milliseconds() >= self.timeout_ms as i64
62    }
63}
64
65/// Configuration for ACK management.
66#[derive(Debug, Clone)]
67pub struct AckConfig {
68    /// Default ACK timeout in milliseconds.
69    pub default_timeout_ms: u64,
70
71    /// Maximum number of pending ACKs to track.
72    pub max_pending_acks: usize,
73}
74
75impl Default for AckConfig {
76    fn default() -> Self {
77        Self {
78            default_timeout_ms: DEFAULT_ACK_TIMEOUT_MS,
79            max_pending_acks: DEFAULT_MAX_PENDING_ACKS,
80        }
81    }
82}
83
84/// ACK manager for tracking pending acknowledgments.
85pub struct AckManager {
86    config: AckConfig,
87    pending_acks: HashMap<MessageId, PendingAck>,
88}
89
90impl AckManager {
91    /// Creates a new ACK manager with default configuration.
92    pub fn new() -> Self {
93        Self::with_config(AckConfig::default())
94    }
95
96    /// Creates a new ACK manager with custom configuration.
97    pub fn with_config(config: AckConfig) -> Self {
98        Self {
99            config,
100            pending_acks: HashMap::new(),
101        }
102    }
103
104    /// Registers a message as waiting for ACK.
105    ///
106    /// # Arguments
107    ///
108    /// * `message_id` - ID of the message waiting for ACK
109    /// * `timeout_ms` - Optional custom timeout (uses default if None)
110    ///
111    /// # Returns
112    ///
113    /// Returns `Ok(Some(info))` if a lower-priority ACK was evicted to make room,
114    /// `Ok(None)` if no eviction was needed, or `Err` if capacity is full and no
115    /// lower-priority ACK exists to evict.
116    pub fn register_pending_ack(
117        &mut self,
118        message_id: MessageId,
119        timeout_ms: Option<u64>,
120    ) -> crate::Result<Option<AckEvictionInfo>> {
121        self.register_pending_ack_with_priority(message_id, timeout_ms, MessagePriority::Medium)
122    }
123
124    /// Registers a message as waiting for ACK with specified priority.
125    ///
126    /// # Arguments
127    ///
128    /// * `message_id` - ID of the message waiting for ACK
129    /// * `timeout_ms` - Optional custom timeout (uses default if None)
130    /// * `priority` - Message priority for eviction decisions
131    ///
132    /// # Returns
133    ///
134    /// Returns `Ok(Some(info))` if a lower-priority ACK was evicted to make room,
135    /// `Ok(None)` if no eviction was needed, or `Err` if capacity is full and no
136    /// lower-priority ACK exists to evict.
137    pub fn register_pending_ack_with_priority(
138        &mut self,
139        message_id: MessageId,
140        timeout_ms: Option<u64>,
141        priority: MessagePriority,
142    ) -> crate::Result<Option<AckEvictionInfo>> {
143        // Check if we've hit the limit
144        let mut eviction = None;
145        if self.pending_acks.len() >= self.config.max_pending_acks {
146            // Clean up timed out ACKs first
147            self.cleanup_timed_out();
148
149            // If still at limit, try priority-based eviction
150            if self.pending_acks.len() >= self.config.max_pending_acks {
151                match self.evict_lowest_priority(priority) {
152                    Some(info) => eviction = Some(info),
153                    None => {
154                        return Err(crate::Error::Other(
155                            "Maximum pending ACKs limit reached (no lower priority ACKs to evict)"
156                                .to_string(),
157                        ));
158                    }
159                }
160            }
161        }
162
163        let pending = PendingAck {
164            message_id: message_id.clone(),
165            sent_at: Utc::now(),
166            timeout_ms: timeout_ms.unwrap_or(self.config.default_timeout_ms),
167            status: AckStatus::Pending,
168            retry_count: 0,
169            priority,
170        };
171
172        self.pending_acks.insert(message_id, pending);
173        Ok(eviction)
174    }
175
176    /// Attempts to evict the lowest priority ACK to make room for a higher priority one.
177    /// Returns `Some(info)` if an ACK was evicted, `None` if no lower priority ACK exists.
178    fn evict_lowest_priority(&mut self, new_priority: MessagePriority) -> Option<AckEvictionInfo> {
179        // Find the lowest priority pending ACK
180        let evict_candidate = self
181            .pending_acks
182            .iter()
183            .filter(|(_, ack)| ack.status == AckStatus::Pending)
184            .min_by(|(_, a), (_, b)| {
185                // Compare by priority first (lower priority = evict first)
186                let priority_cmp =
187                    Self::priority_rank(a.priority).cmp(&Self::priority_rank(b.priority));
188                if priority_cmp != std::cmp::Ordering::Equal {
189                    return priority_cmp;
190                }
191                // Within same priority, evict oldest first
192                a.sent_at.cmp(&b.sent_at)
193            })
194            .map(|(id, ack)| (id.clone(), ack.priority));
195
196        if let Some((evict_id, evict_priority)) = evict_candidate {
197            // Only evict if the candidate's priority is strictly lower
198            if Self::priority_rank(evict_priority) < Self::priority_rank(new_priority) {
199                self.pending_acks.remove(&evict_id);
200                return Some(AckEvictionInfo {
201                    message_id: evict_id,
202                    priority: evict_priority,
203                });
204            }
205        }
206
207        None
208    }
209
210    /// Returns a numeric rank for priority (higher = more important).
211    fn priority_rank(priority: MessagePriority) -> u8 {
212        match priority {
213            MessagePriority::Low => 0,
214            MessagePriority::Medium => 1,
215            MessagePriority::High => 2,
216            MessagePriority::Critical => 3,
217        }
218    }
219
220    /// Returns all ACKs that have timed out and marks them as `TimedOut`.
221    ///
222    /// Note: This does NOT remove entries from the map. Entries are kept so that:
223    /// - `increment_retry_count()` can reset them when the message is retried
224    /// - Late ACKs can still be matched via `remove_ack()`
225    ///
226    /// Entries are eventually removed by:
227    /// - `remove_ack()` when ACK is received or max retries exceeded
228    /// - `prune_old_timeouts()` for stale entries
229    pub fn drain_timed_out(&mut self) -> Vec<PendingAck> {
230        let mut timed_out = Vec::new();
231        for pending in self.pending_acks.values_mut() {
232            if pending.is_timed_out() {
233                pending.status = AckStatus::TimedOut;
234                timed_out.push(pending.clone());
235            }
236        }
237        timed_out
238    }
239
240    /// Removes an ACK from tracking (e.g., after successful delivery).
241    pub fn remove_ack(&mut self, message_id: &MessageId) -> Option<PendingAck> {
242        self.pending_acks.remove(message_id)
243    }
244
245    /// Gets all timed out ACKs.
246    ///
247    /// # Returns
248    ///
249    /// Returns a vector of message IDs that have timed out.
250    pub fn get_timed_out_acks(&self) -> Vec<MessageId> {
251        self.pending_acks
252            .values()
253            .filter(|ack| ack.is_timed_out())
254            .map(|ack| ack.message_id.clone())
255            .collect()
256    }
257
258    /// Updates the retry count for a message.
259    pub fn increment_retry_count(&mut self, message_id: &MessageId) {
260        if let Some(pending) = self.pending_acks.get_mut(message_id) {
261            pending.retry_count += 1;
262            pending.sent_at = Utc::now(); // Reset timer for retry
263            pending.status = AckStatus::Pending;
264        }
265    }
266
267    /// Gets information about a pending ACK.
268    pub fn get_pending_ack(&self, message_id: &MessageId) -> Option<&PendingAck> {
269        self.pending_acks.get(message_id)
270    }
271
272    /// Gets the number of pending ACKs.
273    pub fn pending_count(&self) -> usize {
274        self.pending_acks.len()
275    }
276
277    /// Checks if a message is waiting for ACK.
278    pub fn is_waiting_for_ack(&self, message_id: &MessageId) -> bool {
279        self.pending_acks.contains_key(message_id)
280    }
281
282    /// Cleans up timed out ACKs by marking them as timed out.
283    fn cleanup_timed_out(&mut self) {
284        self.drain_timed_out();
285    }
286
287    /// Removes all ACKs with TimedOut status that are older than a given duration.
288    ///
289    /// This helps prevent unbounded memory growth.
290    pub fn prune_old_timeouts(&mut self, older_than: Duration) {
291        let now = Utc::now();
292        let cutoff = now
293            - chrono::Duration::from_std(older_than).unwrap_or_else(|_| chrono::Duration::zero());
294
295        self.pending_acks
296            .retain(|_, ack| ack.status != AckStatus::TimedOut || ack.sent_at > cutoff);
297    }
298}
299
300impl Default for AckManager {
301    fn default() -> Self {
302        Self::new()
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use std::thread;
310
311    #[test]
312    fn test_register_and_receive_ack() {
313        let mut manager = AckManager::new();
314        let msg_id = MessageId::new();
315
316        // Register pending ACK
317        manager.register_pending_ack(msg_id.clone(), None).unwrap();
318        assert!(manager.is_waiting_for_ack(&msg_id));
319        assert_eq!(manager.pending_count(), 1);
320
321        // Receive ACK: the entry is removed from tracking entirely
322        let removed = manager.remove_ack(&msg_id).unwrap();
323        assert_eq!(removed.message_id, msg_id);
324        assert!(!manager.is_waiting_for_ack(&msg_id));
325        assert_eq!(manager.pending_count(), 0);
326    }
327
328    #[test]
329    fn test_ack_timeout() {
330        let config = AckConfig {
331            default_timeout_ms: 50, // 50ms for fast test
332            max_pending_acks: 100,
333        };
334        let mut manager = AckManager::with_config(config);
335        let msg_id = MessageId::new();
336
337        manager
338            .register_pending_ack(msg_id.clone(), Some(50))
339            .unwrap();
340
341        // Not timed out immediately
342        assert!(manager.drain_timed_out().is_empty());
343
344        // Wait for timeout
345        thread::sleep(Duration::from_millis(60));
346
347        // Should be timed out now
348        let timed_out = manager.drain_timed_out();
349        assert_eq!(timed_out.len(), 1);
350        assert_eq!(timed_out[0].message_id, msg_id);
351    }
352
353    #[test]
354    fn test_retry_count_increment() {
355        let mut manager = AckManager::new();
356        let msg_id = MessageId::new();
357
358        manager.register_pending_ack(msg_id.clone(), None).unwrap();
359
360        let pending = manager.get_pending_ack(&msg_id).unwrap();
361        assert_eq!(pending.retry_count, 0);
362
363        manager.increment_retry_count(&msg_id);
364
365        let pending = manager.get_pending_ack(&msg_id).unwrap();
366        assert_eq!(pending.retry_count, 1);
367    }
368
369    #[test]
370    fn test_remove_ack() {
371        let mut manager = AckManager::new();
372        let msg_id = MessageId::new();
373
374        manager.register_pending_ack(msg_id.clone(), None).unwrap();
375        assert!(manager.is_waiting_for_ack(&msg_id));
376
377        manager.remove_ack(&msg_id);
378        assert!(!manager.is_waiting_for_ack(&msg_id));
379    }
380
381    #[test]
382    fn test_max_pending_limit() {
383        let config = AckConfig {
384            default_timeout_ms: 5000,
385            max_pending_acks: 3,
386        };
387        let mut manager = AckManager::with_config(config);
388
389        // Should be able to register up to limit
390        manager
391            .register_pending_ack(MessageId::new(), None)
392            .unwrap();
393        manager
394            .register_pending_ack(MessageId::new(), None)
395            .unwrap();
396        manager
397            .register_pending_ack(MessageId::new(), None)
398            .unwrap();
399
400        // Fourth should fail (same priority can't evict)
401        let result = manager.register_pending_ack(MessageId::new(), None);
402        assert!(result.is_err());
403    }
404
405    #[test]
406    fn test_priority_based_eviction() {
407        let config = AckConfig {
408            default_timeout_ms: 5000,
409            max_pending_acks: 2,
410        };
411        let mut manager = AckManager::with_config(config);
412
413        let low_id = MessageId::new();
414        let medium_id = MessageId::new();
415
416        // Register low priority ACK
417        manager
418            .register_pending_ack_with_priority(low_id.clone(), None, MessagePriority::Low)
419            .unwrap();
420        // Register medium priority ACK
421        manager
422            .register_pending_ack_with_priority(medium_id.clone(), None, MessagePriority::Medium)
423            .unwrap();
424
425        assert_eq!(manager.pending_count(), 2);
426
427        // High priority should evict low priority
428        let high_id = MessageId::new();
429        manager
430            .register_pending_ack_with_priority(high_id.clone(), None, MessagePriority::High)
431            .unwrap();
432
433        assert_eq!(manager.pending_count(), 2);
434        assert!(!manager.is_waiting_for_ack(&low_id)); // Low was evicted
435        assert!(manager.is_waiting_for_ack(&medium_id)); // Medium still present
436        assert!(manager.is_waiting_for_ack(&high_id)); // High was added
437    }
438
439    #[test]
440    fn test_critical_priority_evicts_all() {
441        let config = AckConfig {
442            default_timeout_ms: 5000,
443            max_pending_acks: 1,
444        };
445        let mut manager = AckManager::with_config(config);
446
447        let medium_id = MessageId::new();
448        manager
449            .register_pending_ack_with_priority(medium_id.clone(), None, MessagePriority::Medium)
450            .unwrap();
451
452        // Critical should evict medium
453        let critical_id = MessageId::new();
454        manager
455            .register_pending_ack_with_priority(
456                critical_id.clone(),
457                None,
458                MessagePriority::Critical,
459            )
460            .unwrap();
461
462        assert_eq!(manager.pending_count(), 1);
463        assert!(!manager.is_waiting_for_ack(&medium_id));
464        assert!(manager.is_waiting_for_ack(&critical_id));
465    }
466
467    #[test]
468    fn test_prune_old_timeouts() {
469        let config = AckConfig {
470            default_timeout_ms: 10,
471            max_pending_acks: 100,
472        };
473        let mut manager = AckManager::with_config(config);
474
475        let msg_id = MessageId::new();
476        manager
477            .register_pending_ack(msg_id.clone(), Some(10))
478            .unwrap();
479
480        // Wait for timeout
481        thread::sleep(Duration::from_millis(20));
482        manager.cleanup_timed_out();
483
484        assert_eq!(manager.pending_count(), 1);
485
486        // Prune old timeouts
487        manager.prune_old_timeouts(Duration::from_millis(5));
488        assert_eq!(manager.pending_count(), 0);
489    }
490
491    #[test]
492    fn test_ack_not_found() {
493        let mut manager = AckManager::new();
494        let msg_id = MessageId::new();
495
496        // Removing an ACK for a non-existent message should return None
497        assert!(manager.remove_ack(&msg_id).is_none());
498    }
499
500    #[test]
501    fn test_eviction_returned_from_register() {
502        let config = AckConfig {
503            default_timeout_ms: 5000,
504            max_pending_acks: 2,
505        };
506        let mut manager = AckManager::with_config(config);
507
508        let low_id = MessageId::new();
509        // No eviction when registering into non-full capacity.
510        assert!(manager
511            .register_pending_ack_with_priority(low_id.clone(), None, MessagePriority::Low)
512            .unwrap()
513            .is_none());
514        assert!(manager
515            .register_pending_ack_with_priority(MessageId::new(), None, MessagePriority::Medium)
516            .unwrap()
517            .is_none());
518
519        // High priority triggers eviction of low — returned in-band.
520        let eviction = manager
521            .register_pending_ack_with_priority(MessageId::new(), None, MessagePriority::High)
522            .unwrap();
523        let eviction = eviction.expect("should have evicted the low-priority ACK");
524        assert_eq!(eviction.message_id, low_id);
525        assert_eq!(eviction.priority, MessagePriority::Low);
526    }
527}