Skip to main content

clawft_kernel/
reliable_queue.rs

1//! Reliable message delivery with acknowledgment tracking.
2//!
3//! [`ReliableQueue`] extends the A2A messaging model with ack-based
4//! retry logic. Messages sent via `send_reliable()` are tracked until
5//! acknowledged or until max retries are exceeded, at which point they
6//! are routed to the [`DeadLetterQueue`].
7
8use std::sync::Arc;
9use std::time::{Duration, Instant};
10
11use dashmap::DashMap;
12use serde::{Deserialize, Serialize};
13
14use crate::dead_letter::{DeadLetterQueue, DeadLetterReason};
15use crate::ipc::KernelMessage;
16
17/// Configuration for reliable message delivery.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ReliableConfig {
20    /// Maximum number of delivery attempts before dead-lettering.
21    pub max_retries: u32,
22    /// Timeout for the first delivery attempt.
23    pub initial_timeout: Duration,
24    /// Maximum timeout after backoff (cap).
25    pub max_timeout: Duration,
26    /// Multiplier applied to timeout on each retry.
27    pub backoff_multiplier: f64,
28}
29
30impl Default for ReliableConfig {
31    fn default() -> Self {
32        Self {
33            max_retries: 3,
34            initial_timeout: Duration::from_secs(5),
35            max_timeout: Duration::from_secs(30),
36            backoff_multiplier: 2.0,
37        }
38    }
39}
40
41/// Tracks a pending delivery awaiting acknowledgment.
42#[derive(Debug, Clone)]
43pub struct PendingDelivery {
44    /// The message being delivered.
45    pub message: KernelMessage,
46    /// Number of delivery attempts so far.
47    pub attempts: u32,
48    /// When the first attempt was made.
49    pub first_sent: Instant,
50    /// When the most recent attempt was made.
51    pub last_attempt: Instant,
52    /// Deadline for the current attempt's ack.
53    pub ack_deadline: Instant,
54}
55
56/// Result of a reliable delivery attempt.
57#[non_exhaustive]
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub enum DeliveryResult {
60    /// Message was acknowledged by the receiver.
61    Acknowledged {
62        /// Message ID.
63        msg_id: String,
64        /// Time from first send to ack.
65        ack_time_ms: u64,
66    },
67    /// Maximum retries exceeded; message sent to dead letter queue.
68    MaxRetriesExceeded {
69        /// Message ID.
70        msg_id: String,
71        /// Total number of attempts made.
72        attempts: u32,
73    },
74    /// Message was dead-lettered for a non-retry reason.
75    DeadLettered {
76        /// Message ID.
77        msg_id: String,
78        /// Reason for dead-lettering.
79        reason: String,
80    },
81}
82
83/// Reliable message delivery with acknowledgment tracking.
84///
85/// Tracks pending deliveries by correlation ID. On ack timeout, retries
86/// with exponential backoff. After `max_retries`, routes to the
87/// dead letter queue.
88pub struct ReliableQueue {
89    pending: DashMap<String, PendingDelivery>,
90    config: ReliableConfig,
91    dead_letter: Arc<DeadLetterQueue>,
92    /// Optional learned dead-letter policy (Finding #5). When `None`
93    /// or untrained, `check_timeouts` and `expire_max_retries` use
94    /// the hardcoded config-driven formulas (bit-for-bit identical to
95    /// today). When trained, the model's `(delay_ms, should_discard)`
96    /// heads are consulted for backoff/discard decisions.
97    dead_letter_model: Option<Arc<std::sync::Mutex<crate::eml_kernel::DeadLetterModel>>>,
98}
99
100impl ReliableQueue {
101    /// Create a new reliable queue with the given config and dead letter queue.
102    pub fn new(config: ReliableConfig, dead_letter: Arc<DeadLetterQueue>) -> Self {
103        Self {
104            pending: DashMap::new(),
105            config,
106            dead_letter,
107            dead_letter_model: None,
108        }
109    }
110
111    /// Create with default configuration.
112    pub fn with_defaults(dead_letter: Arc<DeadLetterQueue>) -> Self {
113        Self::new(ReliableConfig::default(), dead_letter)
114    }
115
116    /// Install a learned
117    /// [`DeadLetterModel`](crate::eml_kernel::DeadLetterModel) on this
118    /// queue. With a model installed, [`Self::check_timeouts`] and
119    /// [`Self::expire_max_retries`] route their backoff / discard
120    /// decisions through the model. Untrained models fall back to
121    /// the hardcoded formulas, so `with_model` is drop-in safe.
122    ///
123    /// NOTE(eml-swap): wired — Finding #5 (DeadLetterModel).
124    pub fn with_model(
125        mut self,
126        model: Arc<std::sync::Mutex<crate::eml_kernel::DeadLetterModel>>,
127    ) -> Self {
128        self.dead_letter_model = Some(model);
129        self
130    }
131
132    /// Returns a handle to the optional dead-letter model.
133    pub fn dead_letter_model(
134        &self,
135    ) -> Option<&Arc<std::sync::Mutex<crate::eml_kernel::DeadLetterModel>>> {
136        self.dead_letter_model.as_ref()
137    }
138
139    /// Get the configuration.
140    pub fn config(&self) -> &ReliableConfig {
141        &self.config
142    }
143
144    /// Register a message for reliable delivery tracking.
145    ///
146    /// The message must have a `correlation_id` set. If not, one is
147    /// generated. Returns the correlation ID used for tracking.
148    pub fn register(&self, mut msg: KernelMessage) -> (String, KernelMessage) {
149        let correlation_id = msg
150            .correlation_id
151            .clone()
152            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
153        msg.correlation_id = Some(correlation_id.clone());
154
155        let now = Instant::now();
156        let pending = PendingDelivery {
157            message: msg.clone(),
158            attempts: 1,
159            first_sent: now,
160            last_attempt: now,
161            ack_deadline: now + self.config.initial_timeout,
162        };
163
164        self.pending.insert(correlation_id.clone(), pending);
165        (correlation_id, msg)
166    }
167
168    /// Acknowledge receipt of a message by correlation ID.
169    ///
170    /// Returns `Some(DeliveryResult::Acknowledged)` if the message was
171    /// pending, or `None` if not found (already acked or expired).
172    pub fn acknowledge(&self, correlation_id: &str) -> Option<DeliveryResult> {
173        let (_, pending) = self.pending.remove(correlation_id)?;
174        let ack_time = pending.first_sent.elapsed();
175        Some(DeliveryResult::Acknowledged {
176            msg_id: pending.message.id.clone(),
177            ack_time_ms: ack_time.as_millis() as u64,
178        })
179    }
180
181    /// Check for timed-out deliveries and return messages needing retry.
182    ///
183    /// Returns a list of `(correlation_id, message)` pairs that have
184    /// exceeded their ack deadline and should be retried.
185    ///
186    /// NOTE(eml-swap): wired — Finding #5 (DeadLetterModel). When a
187    /// learned model is installed and trained, the next-attempt
188    /// ack deadline comes from the model; untrained models fall back
189    /// to the existing `initial_timeout * backoff_multiplier^attempt`
190    /// formula bit-for-bit.
191    pub fn check_timeouts(&self) -> Vec<(String, KernelMessage)> {
192        let now = Instant::now();
193        let mut retries = Vec::new();
194        let queue_depth = self.pending.len();
195
196        for mut entry in self.pending.iter_mut() {
197            if now >= entry.ack_deadline {
198                let corr_id = entry.key().clone();
199                let pending = entry.value_mut();
200
201                pending.attempts += 1;
202                pending.last_attempt = now;
203
204                let message_age_ms = pending.first_sent.elapsed().as_millis() as u64;
205                let next_timeout = self.compute_next_timeout(
206                    pending.attempts.saturating_sub(1),
207                    message_age_ms,
208                    queue_depth,
209                );
210                pending.ack_deadline = now + next_timeout;
211
212                retries.push((corr_id, pending.message.clone()));
213            }
214        }
215
216        retries
217    }
218
219    /// Compute the next ack timeout for a retry attempt, consulting
220    /// the dead-letter model when one is installed.
221    ///
222    /// Fallback (model `None` or untrained): the existing
223    /// `initial_timeout * backoff_multiplier^attempt_idx` formula
224    /// clamped at `max_timeout`. This is byte-identical to the
225    /// pre-EML computation so untrained models produce the same
226    /// schedule.
227    fn compute_next_timeout(
228        &self,
229        attempt_idx: u32,
230        message_age_ms: u64,
231        queue_depth: usize,
232    ) -> Duration {
233        // Hardcoded fallback — never changes behaviour.
234        let backoff = self.config.initial_timeout.as_secs_f64()
235            * self.config.backoff_multiplier.powi(attempt_idx as i32);
236        let fallback = Duration::from_secs_f64(backoff.min(self.config.max_timeout.as_secs_f64()));
237
238        let Some(model_handle) = self.dead_letter_model.as_ref() else {
239            return fallback;
240        };
241        let Ok(model) = model_handle.lock() else {
242            return fallback;
243        };
244        if !model.is_trained() {
245            return fallback;
246        }
247        let (delay_ms, _should_discard) = model.predict(attempt_idx, message_age_ms, queue_depth);
248        Duration::from_millis(delay_ms.min(self.config.max_timeout.as_millis() as u64))
249    }
250
251    /// Move deliveries that have exceeded max retries to the dead letter queue.
252    ///
253    /// Returns the list of message IDs that were dead-lettered.
254    ///
255    /// NOTE(eml-swap): wired — Finding #5 (DeadLetterModel). When a
256    /// trained DeadLetterModel is installed, the discard decision
257    /// consults the model's `should_discard` head in addition to the
258    /// config's `max_retries` ceiling. The untrained fallback matches
259    /// today's "attempts > max_retries" rule exactly.
260    pub fn expire_max_retries(&self) -> Vec<DeliveryResult> {
261        let mut expired = Vec::new();
262        let mut to_remove = Vec::new();
263        let queue_depth = self.pending.len();
264
265        for entry in self.pending.iter() {
266            if self.should_discard(
267                entry.attempts,
268                entry.first_sent.elapsed().as_millis() as u64,
269                queue_depth,
270            ) {
271                to_remove.push(entry.key().clone());
272            }
273        }
274
275        for corr_id in to_remove {
276            if let Some((_, pending)) = self.pending.remove(&corr_id) {
277                let total_ms = pending.first_sent.elapsed().as_millis() as u64;
278                self.dead_letter.intake(
279                    pending.message.clone(),
280                    DeadLetterReason::Timeout {
281                        duration_ms: total_ms,
282                    },
283                );
284                expired.push(DeliveryResult::MaxRetriesExceeded {
285                    msg_id: pending.message.id,
286                    attempts: pending.attempts,
287                });
288            }
289        }
290
291        expired
292    }
293
294    /// Discard-decision helper. Consults the optional
295    /// [`DeadLetterModel`] when trained; otherwise uses the hardcoded
296    /// `attempts > max_retries` rule.
297    fn should_discard(&self, attempts: u32, message_age_ms: u64, queue_depth: usize) -> bool {
298        // Hardcoded fallback — unchanged.
299        let fallback = attempts > self.config.max_retries;
300
301        let Some(model_handle) = self.dead_letter_model.as_ref() else {
302            return fallback;
303        };
304        let Ok(model) = model_handle.lock() else {
305            return fallback;
306        };
307        if !model.is_trained() {
308            return fallback;
309        }
310        let (_delay_ms, model_discard) = model.predict(attempts, message_age_ms, queue_depth);
311        // The model is advisory: we still enforce the hard max_retries
312        // ceiling so a buggy model can never hold a message forever.
313        fallback || model_discard
314    }
315
316    /// Number of currently pending deliveries.
317    pub fn pending_count(&self) -> usize {
318        self.pending.len()
319    }
320
321    /// Check if a specific correlation ID is pending.
322    pub fn is_pending(&self, correlation_id: &str) -> bool {
323        self.pending.contains_key(correlation_id)
324    }
325
326    /// Get a snapshot of all pending deliveries.
327    pub fn pending_snapshot(&self) -> Vec<(String, PendingDelivery)> {
328        self.pending
329            .iter()
330            .map(|entry| (entry.key().clone(), entry.value().clone()))
331            .collect()
332    }
333
334    /// Cancel tracking for a specific message.
335    pub fn cancel(&self, correlation_id: &str) -> Option<KernelMessage> {
336        self.pending.remove(correlation_id).map(|(_, p)| p.message)
337    }
338
339    /// Calculate the timeout for a given attempt number.
340    ///
341    /// Uses the same EML-aware dispatch as [`Self::check_timeouts`]
342    /// so external callers observing the policy see the same delays
343    /// the queue would actually wait.
344    pub fn timeout_for_attempt(&self, attempt: u32) -> Duration {
345        self.compute_next_timeout(attempt, 0, self.pending.len())
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use crate::ipc::{MessagePayload, MessageTarget};
353
354    fn make_msg(from: u64, target: u64) -> KernelMessage {
355        KernelMessage::new(
356            from,
357            MessageTarget::Process(target),
358            MessagePayload::Text("reliable-test".into()),
359        )
360    }
361
362    fn make_queue() -> (ReliableQueue, Arc<DeadLetterQueue>) {
363        let dlq = Arc::new(DeadLetterQueue::new(100));
364        let config = ReliableConfig {
365            max_retries: 3,
366            initial_timeout: Duration::from_millis(100),
367            max_timeout: Duration::from_secs(2),
368            backoff_multiplier: 2.0,
369        };
370        (ReliableQueue::new(config, dlq.clone()), dlq)
371    }
372
373    #[test]
374    fn register_and_acknowledge() {
375        let (queue, _dlq) = make_queue();
376        let msg = make_msg(1, 2);
377        let (corr_id, _msg) = queue.register(msg);
378
379        assert!(queue.is_pending(&corr_id));
380        assert_eq!(queue.pending_count(), 1);
381
382        let result = queue.acknowledge(&corr_id).unwrap();
383        assert!(matches!(result, DeliveryResult::Acknowledged { .. }));
384        assert!(!queue.is_pending(&corr_id));
385        assert_eq!(queue.pending_count(), 0);
386    }
387
388    #[test]
389    fn acknowledge_unknown_returns_none() {
390        let (queue, _dlq) = make_queue();
391        assert!(queue.acknowledge("nonexistent").is_none());
392    }
393
394    #[test]
395    fn register_generates_correlation_id() {
396        let (queue, _dlq) = make_queue();
397        let msg = make_msg(1, 2);
398        assert!(msg.correlation_id.is_none());
399
400        let (corr_id, msg) = queue.register(msg);
401        assert!(!corr_id.is_empty());
402        assert_eq!(msg.correlation_id, Some(corr_id));
403    }
404
405    #[test]
406    fn register_preserves_existing_correlation_id() {
407        let (queue, _dlq) = make_queue();
408        let msg = KernelMessage::with_correlation(
409            1,
410            MessageTarget::Process(2),
411            MessagePayload::Text("test".into()),
412            "my-corr-id".into(),
413        );
414
415        let (corr_id, _) = queue.register(msg);
416        assert_eq!(corr_id, "my-corr-id");
417    }
418
419    #[test]
420    fn check_timeouts_empty() {
421        let (queue, _dlq) = make_queue();
422        let retries = queue.check_timeouts();
423        assert!(retries.is_empty());
424    }
425
426    #[test]
427    fn backoff_increases_per_attempt() {
428        let (queue, _dlq) = make_queue();
429        // initial = 100ms, multiplier = 2.0
430        let t0 = queue.timeout_for_attempt(0);
431        let t1 = queue.timeout_for_attempt(1);
432        let t2 = queue.timeout_for_attempt(2);
433
434        assert_eq!(t0.as_millis(), 100);
435        assert_eq!(t1.as_millis(), 200);
436        assert_eq!(t2.as_millis(), 400);
437    }
438
439    #[test]
440    fn backoff_caps_at_max_timeout() {
441        let (queue, _dlq) = make_queue();
442        // max_timeout = 2s, so attempt 10 should be capped
443        let t10 = queue.timeout_for_attempt(10);
444        assert!(t10 <= Duration::from_secs(2));
445    }
446
447    #[test]
448    fn expire_max_retries_sends_to_dlq() {
449        let (queue, dlq) = make_queue();
450        let msg = make_msg(1, 2);
451        let (corr_id, _msg) = queue.register(msg);
452
453        // Manually set attempts beyond max_retries
454        if let Some(mut entry) = queue.pending.get_mut(&corr_id) {
455            entry.attempts = 5; // > max_retries (3)
456        }
457
458        let expired = queue.expire_max_retries();
459        assert_eq!(expired.len(), 1);
460        assert!(matches!(
461            &expired[0],
462            DeliveryResult::MaxRetriesExceeded { attempts: 5, .. }
463        ));
464        assert_eq!(queue.pending_count(), 0);
465        assert_eq!(dlq.len(), 1);
466    }
467
468    #[test]
469    fn concurrent_sends_tracked_independently() {
470        let (queue, _dlq) = make_queue();
471        let (id1, _) = queue.register(make_msg(1, 2));
472        let (id2, _) = queue.register(make_msg(3, 4));
473        let (id3, _) = queue.register(make_msg(5, 6));
474
475        assert_eq!(queue.pending_count(), 3);
476
477        // Ack only the second one
478        queue.acknowledge(&id2);
479        assert_eq!(queue.pending_count(), 2);
480        assert!(queue.is_pending(&id1));
481        assert!(!queue.is_pending(&id2));
482        assert!(queue.is_pending(&id3));
483    }
484
485    #[test]
486    fn cancel_removes_tracking() {
487        let (queue, _dlq) = make_queue();
488        let (corr_id, _) = queue.register(make_msg(1, 2));
489        assert!(queue.is_pending(&corr_id));
490
491        let msg = queue.cancel(&corr_id).unwrap();
492        assert!(!queue.is_pending(&corr_id));
493        assert_eq!(msg.from, 1);
494    }
495
496    #[test]
497    fn cancel_nonexistent_returns_none() {
498        let (queue, _dlq) = make_queue();
499        assert!(queue.cancel("nope").is_none());
500    }
501
502    #[test]
503    fn pending_snapshot() {
504        let (queue, _dlq) = make_queue();
505        queue.register(make_msg(1, 2));
506        queue.register(make_msg(3, 4));
507
508        let snap = queue.pending_snapshot();
509        assert_eq!(snap.len(), 2);
510    }
511
512    #[test]
513    fn default_config() {
514        let config = ReliableConfig::default();
515        assert_eq!(config.max_retries, 3);
516        assert_eq!(config.initial_timeout, Duration::from_secs(5));
517        assert_eq!(config.max_timeout, Duration::from_secs(30));
518        assert!((config.backoff_multiplier - 2.0).abs() < f64::EPSILON);
519    }
520
521    #[test]
522    fn delivery_result_serde_roundtrip() {
523        let result = DeliveryResult::Acknowledged {
524            msg_id: "msg-1".into(),
525            ack_time_ms: 42,
526        };
527        let json = serde_json::to_string(&result).unwrap();
528        let restored: DeliveryResult = serde_json::from_str(&json).unwrap();
529        assert!(matches!(
530            restored,
531            DeliveryResult::Acknowledged {
532                ack_time_ms: 42,
533                ..
534            }
535        ));
536    }
537
538    // ── Finding #5: DeadLetterModel wiring ──────────────────────────
539
540    #[test]
541    fn untrained_model_preserves_backoff_schedule() {
542        // Finding #5: with an untrained DeadLetterModel installed,
543        // timeout_for_attempt must produce the same delays as the
544        // hardcoded fallback.
545        let dlq = Arc::new(DeadLetterQueue::new(100));
546        let cfg = ReliableConfig {
547            max_retries: 3,
548            initial_timeout: Duration::from_millis(100),
549            max_timeout: Duration::from_secs(2),
550            backoff_multiplier: 2.0,
551        };
552        let plain = ReliableQueue::new(cfg.clone(), dlq.clone());
553        let model = Arc::new(std::sync::Mutex::new(
554            crate::eml_kernel::DeadLetterModel::new(),
555        ));
556        let wired = ReliableQueue::new(cfg, dlq).with_model(model);
557
558        for attempt in 0..6 {
559            assert_eq!(
560                plain.timeout_for_attempt(attempt),
561                wired.timeout_for_attempt(attempt),
562                "untrained DeadLetterModel must reproduce backoff at attempt {attempt}"
563            );
564        }
565    }
566
567    #[test]
568    fn untrained_model_preserves_discard_rule() {
569        // Finding #5: untrained model must not change which deliveries
570        // get expired by `expire_max_retries`.
571        let dlq = Arc::new(DeadLetterQueue::new(100));
572        let cfg = ReliableConfig {
573            max_retries: 3,
574            initial_timeout: Duration::from_millis(100),
575            max_timeout: Duration::from_secs(2),
576            backoff_multiplier: 2.0,
577        };
578        let model = Arc::new(std::sync::Mutex::new(
579            crate::eml_kernel::DeadLetterModel::new(),
580        ));
581        let queue = ReliableQueue::new(cfg, dlq.clone()).with_model(model);
582
583        let (corr_id, _) = queue.register(make_msg(1, 2));
584        // attempts == 1 < max_retries (3); should NOT expire.
585        assert!(queue.expire_max_retries().is_empty());
586
587        if let Some(mut entry) = queue.pending.get_mut(&corr_id) {
588            entry.attempts = 5; // > max_retries
589        }
590        let expired = queue.expire_max_retries();
591        assert_eq!(expired.len(), 1);
592        assert_eq!(dlq.len(), 1);
593    }
594
595    #[test]
596    fn trained_model_can_shorten_backoff() {
597        // Finding #5: with a trained DeadLetterModel, timeout_for_attempt
598        // dispatches to the model and produces a different delay from
599        // the hardcoded formula.
600        let dlq = Arc::new(DeadLetterQueue::new(100));
601        let cfg = ReliableConfig {
602            max_retries: 3,
603            initial_timeout: Duration::from_millis(100),
604            max_timeout: Duration::from_secs(2),
605            backoff_multiplier: 2.0,
606        };
607        let plain = ReliableQueue::new(cfg.clone(), dlq.clone());
608
609        let untrained = crate::eml_kernel::DeadLetterModel::new();
610        let mut json = serde_json::to_value(&untrained).unwrap();
611        if let Some(inner) = json.get_mut("inner").and_then(|v| v.as_object_mut()) {
612            inner.insert("trained".into(), serde_json::Value::Bool(true));
613        }
614        let forced: crate::eml_kernel::DeadLetterModel = serde_json::from_value(json).unwrap();
615        assert!(forced.is_trained());
616
617        let model = Arc::new(std::sync::Mutex::new(forced));
618        let wired = ReliableQueue::new(cfg, dlq).with_model(model);
619
620        let attempt = 2;
621        let hardcoded = plain.timeout_for_attempt(attempt);
622        let model_driven = wired.timeout_for_attempt(attempt);
623        // The two must differ — proves the dispatch branch executed.
624        assert_ne!(hardcoded, model_driven);
625    }
626
627    #[test]
628    fn check_timeouts_with_untrained_model_matches_plain() {
629        // Bit-for-bit verification: check_timeouts() advances the
630        // ack_deadline by the same delta when the model is untrained.
631        let dlq = Arc::new(DeadLetterQueue::new(100));
632        let cfg = ReliableConfig {
633            max_retries: 3,
634            initial_timeout: Duration::from_millis(50),
635            max_timeout: Duration::from_secs(2),
636            backoff_multiplier: 2.0,
637        };
638        let model = Arc::new(std::sync::Mutex::new(
639            crate::eml_kernel::DeadLetterModel::new(),
640        ));
641        let queue = ReliableQueue::new(cfg, dlq).with_model(model);
642
643        let (corr_id, _) = queue.register(make_msg(1, 2));
644        // Force the deadline into the past so check_timeouts retries.
645        if let Some(mut entry) = queue.pending.get_mut(&corr_id) {
646            entry.ack_deadline = Instant::now() - Duration::from_millis(10);
647        }
648        let retries = queue.check_timeouts();
649        assert_eq!(retries.len(), 1);
650        // attempts now == 2; backoff = 50ms * 2^1 = 100ms.
651        let entry = queue.pending.get(&corr_id).unwrap();
652        assert_eq!(entry.attempts, 2);
653    }
654}