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    /// Carry an accumulated retry count onto a pending ACK.
268    ///
269    /// `register_pending_ack*` always starts a fresh ACK at `retry_count: 0`.
270    /// When a message is *re-sent* from the retry queue and its prior ACK had
271    /// already been cleared (the resend registers a brand-new ACK), that new ACK
272    /// would otherwise reset the backoff ladder to 0 — pinning `delay_for_retry`
273    /// at its 1s floor for a recipient that never ACKs (an offline peer), which
274    /// re-sends ~once per second forever. Seeding the new ACK with the count the
275    /// retry-queue entry already carries lets the exponential backoff advance
276    /// across resends. No-op if the message has no pending ACK.
277    pub fn set_retry_count(&mut self, message_id: &MessageId, retry_count: u32) {
278        if let Some(pending) = self.pending_acks.get_mut(message_id) {
279            pending.retry_count = retry_count;
280        }
281    }
282
283    /// Gets information about a pending ACK.
284    pub fn get_pending_ack(&self, message_id: &MessageId) -> Option<&PendingAck> {
285        self.pending_acks.get(message_id)
286    }
287
288    /// Gets the number of pending ACKs.
289    pub fn pending_count(&self) -> usize {
290        self.pending_acks.len()
291    }
292
293    /// Checks if a message is waiting for ACK.
294    pub fn is_waiting_for_ack(&self, message_id: &MessageId) -> bool {
295        self.pending_acks.contains_key(message_id)
296    }
297
298    /// Cleans up timed out ACKs by marking them as timed out.
299    fn cleanup_timed_out(&mut self) {
300        self.drain_timed_out();
301    }
302
303    /// Removes all ACKs with TimedOut status that are older than a given duration.
304    ///
305    /// This helps prevent unbounded memory growth.
306    pub fn prune_old_timeouts(&mut self, older_than: Duration) {
307        let now = Utc::now();
308        let cutoff = now
309            - chrono::Duration::from_std(older_than).unwrap_or_else(|_| chrono::Duration::zero());
310
311        self.pending_acks
312            .retain(|_, ack| ack.status != AckStatus::TimedOut || ack.sent_at > cutoff);
313    }
314}
315
316impl Default for AckManager {
317    fn default() -> Self {
318        Self::new()
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use std::thread;
326
327    #[test]
328    fn test_register_and_receive_ack() {
329        let mut manager = AckManager::new();
330        let msg_id = MessageId::new();
331
332        // Register pending ACK
333        manager.register_pending_ack(msg_id.clone(), None).unwrap();
334        assert!(manager.is_waiting_for_ack(&msg_id));
335        assert_eq!(manager.pending_count(), 1);
336
337        // Receive ACK: the entry is removed from tracking entirely
338        let removed = manager.remove_ack(&msg_id).unwrap();
339        assert_eq!(removed.message_id, msg_id);
340        assert!(!manager.is_waiting_for_ack(&msg_id));
341        assert_eq!(manager.pending_count(), 0);
342    }
343
344    #[test]
345    fn test_ack_timeout() {
346        let config = AckConfig {
347            default_timeout_ms: 50, // 50ms for fast test
348            max_pending_acks: 100,
349        };
350        let mut manager = AckManager::with_config(config);
351        let msg_id = MessageId::new();
352
353        manager
354            .register_pending_ack(msg_id.clone(), Some(50))
355            .unwrap();
356
357        // Not timed out immediately
358        assert!(manager.drain_timed_out().is_empty());
359
360        // Wait for timeout
361        thread::sleep(Duration::from_millis(60));
362
363        // Should be timed out now
364        let timed_out = manager.drain_timed_out();
365        assert_eq!(timed_out.len(), 1);
366        assert_eq!(timed_out[0].message_id, msg_id);
367    }
368
369    #[test]
370    fn test_retry_count_increment() {
371        let mut manager = AckManager::new();
372        let msg_id = MessageId::new();
373
374        manager.register_pending_ack(msg_id.clone(), None).unwrap();
375
376        let pending = manager.get_pending_ack(&msg_id).unwrap();
377        assert_eq!(pending.retry_count, 0);
378
379        manager.increment_retry_count(&msg_id);
380
381        let pending = manager.get_pending_ack(&msg_id).unwrap();
382        assert_eq!(pending.retry_count, 1);
383    }
384
385    #[test]
386    fn test_set_retry_count_carries_backoff_forward() {
387        // Pins the flap fix: when a relay-accepted resend registers a FRESH ACK
388        // (the prior one was cleared by an ACK timeout that re-queued the
389        // message), the backoff ladder must be seeded from the accumulated
390        // count instead of resetting to 0. Resetting to 0 pins delay_for_retry
391        // to its 1s floor forever for an offline peer, which floods the relay
392        // and flaps the connection. If a future change reverts set_retry_count
393        // to a no-op or drops the call site, this test fails loudly.
394        let mut manager = AckManager::new();
395        let msg_id = MessageId::new();
396
397        // Fresh ACK always starts at 0 (register hardcodes it).
398        manager.register_pending_ack(msg_id.clone(), None).unwrap();
399        assert_eq!(manager.get_pending_ack(&msg_id).unwrap().retry_count, 0);
400
401        // Carry an accumulated count forward onto the freshly-registered ACK.
402        manager.set_retry_count(&msg_id, 5);
403        assert_eq!(manager.get_pending_ack(&msg_id).unwrap().retry_count, 5);
404
405        // Idempotent/absolute set (not increment).
406        manager.set_retry_count(&msg_id, 7);
407        assert_eq!(manager.get_pending_ack(&msg_id).unwrap().retry_count, 7);
408
409        // No-op for an unknown message (no pending ACK).
410        manager.set_retry_count(&MessageId::new(), 9);
411    }
412
413    #[test]
414    fn test_remove_ack() {
415        let mut manager = AckManager::new();
416        let msg_id = MessageId::new();
417
418        manager.register_pending_ack(msg_id.clone(), None).unwrap();
419        assert!(manager.is_waiting_for_ack(&msg_id));
420
421        manager.remove_ack(&msg_id);
422        assert!(!manager.is_waiting_for_ack(&msg_id));
423    }
424
425    #[test]
426    fn test_max_pending_limit() {
427        let config = AckConfig {
428            default_timeout_ms: 5000,
429            max_pending_acks: 3,
430        };
431        let mut manager = AckManager::with_config(config);
432
433        // Should be able to register up to limit
434        manager
435            .register_pending_ack(MessageId::new(), None)
436            .unwrap();
437        manager
438            .register_pending_ack(MessageId::new(), None)
439            .unwrap();
440        manager
441            .register_pending_ack(MessageId::new(), None)
442            .unwrap();
443
444        // Fourth should fail (same priority can't evict)
445        let result = manager.register_pending_ack(MessageId::new(), None);
446        assert!(result.is_err());
447    }
448
449    #[test]
450    fn test_priority_based_eviction() {
451        let config = AckConfig {
452            default_timeout_ms: 5000,
453            max_pending_acks: 2,
454        };
455        let mut manager = AckManager::with_config(config);
456
457        let low_id = MessageId::new();
458        let medium_id = MessageId::new();
459
460        // Register low priority ACK
461        manager
462            .register_pending_ack_with_priority(low_id.clone(), None, MessagePriority::Low)
463            .unwrap();
464        // Register medium priority ACK
465        manager
466            .register_pending_ack_with_priority(medium_id.clone(), None, MessagePriority::Medium)
467            .unwrap();
468
469        assert_eq!(manager.pending_count(), 2);
470
471        // High priority should evict low priority
472        let high_id = MessageId::new();
473        manager
474            .register_pending_ack_with_priority(high_id.clone(), None, MessagePriority::High)
475            .unwrap();
476
477        assert_eq!(manager.pending_count(), 2);
478        assert!(!manager.is_waiting_for_ack(&low_id)); // Low was evicted
479        assert!(manager.is_waiting_for_ack(&medium_id)); // Medium still present
480        assert!(manager.is_waiting_for_ack(&high_id)); // High was added
481    }
482
483    #[test]
484    fn test_critical_priority_evicts_all() {
485        let config = AckConfig {
486            default_timeout_ms: 5000,
487            max_pending_acks: 1,
488        };
489        let mut manager = AckManager::with_config(config);
490
491        let medium_id = MessageId::new();
492        manager
493            .register_pending_ack_with_priority(medium_id.clone(), None, MessagePriority::Medium)
494            .unwrap();
495
496        // Critical should evict medium
497        let critical_id = MessageId::new();
498        manager
499            .register_pending_ack_with_priority(
500                critical_id.clone(),
501                None,
502                MessagePriority::Critical,
503            )
504            .unwrap();
505
506        assert_eq!(manager.pending_count(), 1);
507        assert!(!manager.is_waiting_for_ack(&medium_id));
508        assert!(manager.is_waiting_for_ack(&critical_id));
509    }
510
511    #[test]
512    fn test_prune_old_timeouts() {
513        let config = AckConfig {
514            default_timeout_ms: 10,
515            max_pending_acks: 100,
516        };
517        let mut manager = AckManager::with_config(config);
518
519        let msg_id = MessageId::new();
520        manager
521            .register_pending_ack(msg_id.clone(), Some(10))
522            .unwrap();
523
524        // Wait for timeout
525        thread::sleep(Duration::from_millis(20));
526        manager.cleanup_timed_out();
527
528        assert_eq!(manager.pending_count(), 1);
529
530        // Prune old timeouts
531        manager.prune_old_timeouts(Duration::from_millis(5));
532        assert_eq!(manager.pending_count(), 0);
533    }
534
535    #[test]
536    fn test_ack_not_found() {
537        let mut manager = AckManager::new();
538        let msg_id = MessageId::new();
539
540        // Removing an ACK for a non-existent message should return None
541        assert!(manager.remove_ack(&msg_id).is_none());
542    }
543
544    #[test]
545    fn test_eviction_returned_from_register() {
546        let config = AckConfig {
547            default_timeout_ms: 5000,
548            max_pending_acks: 2,
549        };
550        let mut manager = AckManager::with_config(config);
551
552        let low_id = MessageId::new();
553        // No eviction when registering into non-full capacity.
554        assert!(manager
555            .register_pending_ack_with_priority(low_id.clone(), None, MessagePriority::Low)
556            .unwrap()
557            .is_none());
558        assert!(manager
559            .register_pending_ack_with_priority(MessageId::new(), None, MessagePriority::Medium)
560            .unwrap()
561            .is_none());
562
563        // High priority triggers eviction of low — returned in-band.
564        let eviction = manager
565            .register_pending_ack_with_priority(MessageId::new(), None, MessagePriority::High)
566            .unwrap();
567        let eviction = eviction.expect("should have evicted the low-priority ACK");
568        assert_eq!(eviction.message_id, low_id);
569        assert_eq!(eviction.priority, MessagePriority::Low);
570    }
571}