Skip to main content

offline_protocol/protocol/
decryption_queue.rs

1//! Bounded pending decryption queue for encrypted messages received before
2//! the MLS session is ready, with per-peer and global limits, TTL expiration,
3//! and configurable overflow policies.
4
5use crate::config::{OverflowPolicy, PendingQueueConfig};
6use offline_protocol_core::Message;
7use offline_protocol_transport::TransportType;
8use std::collections::{HashMap, HashSet, VecDeque};
9use std::time::{Duration as StdDuration, Instant};
10use tracing::{debug, warn};
11
12const TTL_SPIKE_WARN_THRESHOLD: usize = 25;
13const PEER_PRESSURE_WARN_EVERY: u32 = 10;
14const DROP_WARN_EVERY: u64 = 100;
15const EVICTION_FAILURE_WARN_EVERY: u64 = 10;
16
17#[derive(Clone)]
18pub(crate) struct PendingDecryptMessage {
19    pub(crate) peer_id: String,
20    pub(crate) message_id: String,
21    pub(crate) received_at: Instant,
22    pub(crate) sequence: u64,
23    pub(crate) message: Message,
24    /// Transport the frame arrived on, when the caller knew it. Recorded so the
25    /// drain can send the deferred delivery ACK directly (see the
26    /// deferred-acknowledgement atom in
27    /// `docs/state-machines/delivery-and-acks.md`) instead of waiting for the
28    /// sender's next resend to
29    /// hit the duplicate re-ACK path. `None` when the message was enqueued from
30    /// a context with no transport (tests, or a re-queue by the handler during a
31    /// drain, which re-processes with no live arrival transport) — the drain
32    /// then falls back to the resend-driven ACK.
33    pub(crate) received_via: Option<TransportType>,
34}
35
36#[derive(Clone)]
37struct EntryRef {
38    peer_id: String,
39    message_id: String,
40    sequence: u64,
41}
42
43/// A message dropped from the pending queue (overflow or TTL expiry),
44/// together with the machine-readable drop reason. Returned to the protocol
45/// layer so it can surface user-visible consequences — in particular an
46/// encrypted media chunk that was already ACKed and dedup-marked on receipt
47/// and therefore can never be recovered.
48pub(crate) struct DroppedPendingMessage {
49    pub(crate) message: Message,
50    pub(crate) reason: &'static str,
51    /// Transport the frame arrived on, when known: the route its
52    /// `undecryptable` answer takes.
53    pub(crate) received_via: Option<TransportType>,
54}
55
56/// What [`PendingDecryptionQueue::enqueue_via`] did with the incoming frame,
57/// plus every frame it dropped along the way.
58///
59/// `admitted` is what the persistence hook keys on: only a frame the queue now
60/// holds has a record worth writing. It is `false` both when the frame was
61/// refused (it is then `refused`) and when the id was already queued, the
62/// resend case, where the original copy stays authoritative and nothing
63/// changed in memory or on disk.
64///
65/// `dropped` and `refused` are kept apart because they differ on disk. Every
66/// entry in `dropped` was admitted earlier, so it may have a persisted record
67/// to delete. The refused frame was never admitted: on the live path it has no
68/// record, and deleting one anyway would cost a storage round trip per frame
69/// during exactly the flood that fills the queue. On the restore path it came
70/// off disk and does have one, so that caller deletes it.
71pub(crate) struct EnqueueOutcome {
72    pub(crate) admitted: bool,
73    pub(crate) dropped: Vec<DroppedPendingMessage>,
74    pub(crate) refused: Option<DroppedPendingMessage>,
75}
76
77/// How `enqueue_via_inner` resolved the incoming frame.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79enum Admission {
80    /// The queue now holds the frame.
81    Admitted,
82    /// The id was already queued; the original copy stays.
83    AlreadyQueued,
84    /// The queue could not take the frame under its caps or overflow policy.
85    Refused,
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89enum QueueLimit {
90    PerPeer,
91    Global,
92    PerPeerBytes,
93    GlobalBytes,
94}
95
96impl QueueLimit {
97    fn as_str(self) -> &'static str {
98        match self {
99            Self::PerPeer => "per_peer",
100            Self::Global => "global",
101            Self::PerPeerBytes => "per_peer_bytes",
102            Self::GlobalBytes => "global_bytes",
103        }
104    }
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108enum DropReason {
109    OverflowDropOldest,
110    OverflowDropNewest,
111    TtlExpired,
112}
113
114impl DropReason {
115    fn as_str(self) -> &'static str {
116        match self {
117            Self::OverflowDropOldest => "overflow_drop_oldest",
118            Self::OverflowDropNewest => "overflow_drop_newest",
119            Self::TtlExpired => "ttl_expired",
120        }
121    }
122}
123
124/// Counters and gauges for pending encrypted message queue pressure.
125#[derive(Debug, Clone, Default)]
126pub struct PendingQueueMetrics {
127    /// Total encrypted messages received before session readiness.
128    pub pending_messages_received_total: u64,
129    /// Total queued messages evicted from pending storage.
130    pub pending_messages_evicted_total: u64,
131    /// Total messages dropped due to overflow policy decisions.
132    pub pending_messages_dropped_overflow_total: u64,
133    /// Total pending messages expired due to TTL.
134    pub pending_messages_expired_total: u64,
135    /// Number of failed eviction attempts while enforcing hard bounds.
136    pub pending_messages_eviction_failures_total: u64,
137    /// Number of detected pending queue invariant violations.
138    pub pending_queue_invariant_violations_total: u64,
139    /// Current number of messages in pending queues across all peers.
140    pub pending_messages_current: usize,
141    /// Current payload bytes (content plus binary content) queued across all peers.
142    pub pending_bytes_current: usize,
143    /// Current per-peer pending queue sizes.
144    pub pending_messages_per_peer: HashMap<String, usize>,
145}
146
147/// Encapsulates the bounded pending decryption queue: encrypted messages
148/// received before the MLS session is ready, with per-peer and global limits,
149/// TTL expiration, and configurable overflow policies.
150#[derive(Clone, Default)]
151pub(crate) struct PendingDecryptionQueue {
152    /// Per-peer FIFO queues of encrypted messages.
153    queues: HashMap<String, VecDeque<PendingDecryptMessage>>,
154    /// Global insertion order index for deterministic global oldest eviction.
155    global_order: VecDeque<EntryRef>,
156    /// Live sequence IDs currently present in pending queues.
157    live_sequences: HashSet<u64>,
158    /// Current number of pending encrypted messages across all peers.
159    total: usize,
160    /// Current payload bytes queued across all peers.
161    total_bytes: usize,
162    /// Current payload bytes queued per peer.
163    peer_bytes: HashMap<String, usize>,
164    /// Monotonic sequence assigned on enqueue for deterministic tie-breaking.
165    next_seq: u64,
166    /// Pending queue observability counters and gauges.
167    metrics: PendingQueueMetrics,
168    /// Overflow hit count per peer for warning signal emission.
169    peer_overflow_hits: HashMap<String, u32>,
170    /// Drop warning counters used for log-rate limiting by reason/limit.
171    drop_warning_counters: HashMap<String, u64>,
172}
173
174impl PendingDecryptionQueue {
175    // ---- Public accessors ----
176
177    /// Returns a reference to the current metrics snapshot.
178    pub(crate) fn metrics(&self) -> &PendingQueueMetrics {
179        &self.metrics
180    }
181
182    /// Returns true when no peers have pending messages.
183    #[cfg(test)]
184    pub(crate) fn is_empty(&self) -> bool {
185        self.queues.is_empty()
186    }
187
188    /// Returns true when the given peer has queued messages.
189    #[cfg(test)]
190    pub(crate) fn contains_peer(&self, peer_id: &str) -> bool {
191        self.queues.contains_key(peer_id)
192    }
193
194    /// Returns the number of pending messages for a specific peer.
195    #[cfg(test)]
196    pub(crate) fn peer_queue_len(&self, peer_id: &str) -> usize {
197        self.queues.get(peer_id).map(VecDeque::len).unwrap_or(0)
198    }
199
200    /// Returns the total number of pending messages across all peers.
201    #[cfg(test)]
202    pub(crate) fn total(&self) -> usize {
203        self.total
204    }
205
206    /// Returns the total payload bytes queued across all peers.
207    #[cfg(test)]
208    pub(crate) fn total_bytes(&self) -> usize {
209        self.total_bytes
210    }
211
212    /// Returns a reference to a specific entry in a peer's queue.
213    #[cfg(test)]
214    pub(crate) fn peek_entry(&self, peer_id: &str, index: usize) -> Option<&PendingDecryptMessage> {
215        self.queues.get(peer_id).and_then(|q| q.get(index))
216    }
217
218    /// Returns the maximum queue length across all peers.
219    #[cfg(test)]
220    pub(crate) fn max_peer_queue_len(&self) -> usize {
221        self.queues.values().map(VecDeque::len).max().unwrap_or(0)
222    }
223
224    /// Returns whether an overflow-pressure counter is tracked for a peer.
225    #[cfg(test)]
226    pub(crate) fn has_overflow_hits(&self, peer_id: &str) -> bool {
227        self.peer_overflow_hits.contains_key(peer_id)
228    }
229
230    /// Returns the number of peers with a tracked overflow-pressure counter.
231    #[cfg(test)]
232    pub(crate) fn overflow_hits_tracked(&self) -> usize {
233        self.peer_overflow_hits.len()
234    }
235
236    /// Returns the retained-memory footprint the queue charges for a message.
237    #[cfg(test)]
238    pub(crate) fn message_footprint(message: &Message) -> usize {
239        Self::message_bytes(message)
240    }
241
242    /// Overrides the `received_at` timestamp of the front entry for a peer (fault injection).
243    #[cfg(test)]
244    pub(crate) fn set_front_received_at(&mut self, peer_id: &str, at: Instant) {
245        if let Some(queue) = self.queues.get_mut(peer_id) {
246            if let Some(front) = queue.front_mut() {
247                front.received_at = at;
248            }
249        }
250    }
251
252    /// Clears the global order index to simulate index corruption (fault injection).
253    #[cfg(test)]
254    pub(crate) fn corrupt_clear_global_order(&mut self) {
255        self.global_order.clear();
256    }
257
258    // ---- Internal helpers ----
259
260    fn ttl(config: &PendingQueueConfig) -> StdDuration {
261        StdDuration::from_millis(config.pending_ttl_ms)
262    }
263
264    fn update_peer_gauge(&mut self, peer_id: &str) {
265        let peer_len = self.queues.get(peer_id).map(VecDeque::len).unwrap_or(0);
266        if peer_len == 0 {
267            self.metrics.pending_messages_per_peer.remove(peer_id);
268        } else {
269            self.metrics
270                .pending_messages_per_peer
271                .insert(peer_id.to_string(), peer_len);
272        }
273    }
274
275    fn update_current_gauge(&mut self) {
276        self.metrics.pending_messages_current = self.total;
277        self.metrics.pending_bytes_current = self.total_bytes;
278    }
279
280    /// Retained-memory footprint of a queued message, for budget accounting.
281    ///
282    /// The queue stores a full [`Message`] clone, so this must reflect every
283    /// owned field a peer can inflate — not just the text/binary payload.
284    /// Counting only `content`/`binary_content` (as this once did) let a
285    /// crafted message with a tiny payload but a megabyte-scale `sender` or
286    /// `metadata` map evade the per-peer/global byte budgets entirely and drive
287    /// the queue to OOM while `pending_bytes_current` reported near-zero. The id
288    /// fields (`sender`/`recipient`/`app_id`) are additionally length-capped at
289    /// the type boundary (`offline_protocol_core::MAX_ID_LEN`); `metadata` and
290    /// the media strings are not, so this accounting is what bounds them.
291    ///
292    /// Fixed per-entry overhead (queue node, index entry, the duplicated
293    /// `peer_id`/`message_id` strings) is intentionally *not* added here — the
294    /// per-peer and global *count* caps already bound that fixed cost; the byte
295    /// budget exists to bound variable field/payload bloat.
296    fn message_bytes(message: &Message) -> usize {
297        let metadata_bytes: usize = message
298            .metadata
299            .iter()
300            .map(|(k, v)| k.len() + v.len())
301            .sum();
302        let media_bytes = message
303            .media_metadata
304            .as_ref()
305            .map(|m| {
306                m.mime_type.len()
307                    + m.file_name.len()
308                    + m.thumbnail_base64.as_ref().map(String::len).unwrap_or(0)
309            })
310            .unwrap_or(0);
311        message.content.len()
312            + message
313                .binary_content
314                .as_ref()
315                .map(|binary| binary.len())
316                .unwrap_or(0)
317            + message.sender.as_str().len()
318            + message.recipient.as_str().len()
319            + message.app_id.as_str().len()
320            + metadata_bytes
321            + media_bytes
322    }
323
324    fn peer_bytes_for(&self, peer_id: &str) -> usize {
325        self.peer_bytes.get(peer_id).copied().unwrap_or(0)
326    }
327
328    fn next_sequence(&mut self) -> u64 {
329        let seq = self.next_seq;
330        self.next_seq = self.next_seq.wrapping_add(1);
331        seq
332    }
333
334    fn is_entry_expired(
335        config: &PendingQueueConfig,
336        entry: &PendingDecryptMessage,
337        now: Instant,
338    ) -> bool {
339        now.saturating_duration_since(entry.received_at) >= Self::ttl(config)
340    }
341
342    fn cleanup_global_order_front(&mut self) {
343        while let Some(front) = self.global_order.front() {
344            if self.live_sequences.contains(&front.sequence) {
345                break;
346            }
347            self.global_order.pop_front();
348        }
349    }
350
351    fn remove_entry_by_sequence(
352        &mut self,
353        peer_id: &str,
354        sequence: u64,
355    ) -> Option<PendingDecryptMessage> {
356        let (removed, queue_empty) = {
357            let queue = self.queues.get_mut(peer_id)?;
358            let position = queue.iter().position(|entry| entry.sequence == sequence)?;
359            let removed = queue.remove(position)?;
360            (removed, queue.is_empty())
361        };
362
363        self.live_sequences.remove(&sequence);
364        self.total = self.total.saturating_sub(1);
365        let removed_bytes = Self::message_bytes(&removed.message);
366        self.total_bytes = self.total_bytes.saturating_sub(removed_bytes);
367        if let Some(bytes) = self.peer_bytes.get_mut(peer_id) {
368            *bytes = bytes.saturating_sub(removed_bytes);
369        }
370        self.update_current_gauge();
371
372        if queue_empty {
373            self.queues.remove(peer_id);
374            self.peer_bytes.remove(peer_id);
375            // Prune the overflow-pressure counter alongside the queue. It is
376            // keyed by the (attacker-controllable) wire sender and was
377            // previously never removed, so a flood of distinct forged senders
378            // leaked one permanent entry each. A peer with no queued messages
379            // has no live pressure to track.
380            self.peer_overflow_hits.remove(peer_id);
381        }
382        self.update_peer_gauge(peer_id);
383        Some(removed)
384    }
385
386    fn record_drop(
387        &mut self,
388        reason: DropReason,
389        limit_triggered: Option<QueueLimit>,
390        peer_id: &str,
391        message_id: &str,
392        overflow_policy: OverflowPolicy,
393    ) {
394        if matches!(
395            reason,
396            DropReason::OverflowDropOldest | DropReason::TtlExpired
397        ) {
398            self.metrics.pending_messages_evicted_total = self
399                .metrics
400                .pending_messages_evicted_total
401                .saturating_add(1);
402        }
403
404        if matches!(
405            reason,
406            DropReason::OverflowDropOldest | DropReason::OverflowDropNewest
407        ) {
408            self.metrics.pending_messages_dropped_overflow_total = self
409                .metrics
410                .pending_messages_dropped_overflow_total
411                .saturating_add(1);
412        }
413
414        if reason == DropReason::TtlExpired {
415            self.metrics.pending_messages_expired_total = self
416                .metrics
417                .pending_messages_expired_total
418                .saturating_add(1);
419        }
420
421        let limit_label = limit_triggered.map(QueueLimit::as_str).unwrap_or("ttl");
422        let counter_key = format!("{}:{}", reason.as_str(), limit_label);
423        let drop_count = {
424            let counter = self.drop_warning_counters.entry(counter_key).or_insert(0);
425            *counter = counter.saturating_add(1);
426            *counter
427        };
428
429        debug!(
430            reason = reason.as_str(),
431            peer_id = %peer_id,
432            message_id = %message_id,
433            queue_size = self.total,
434            limit_triggered = limit_label,
435            overflow_policy = ?overflow_policy,
436            "Dropped pending encrypted message"
437        );
438        if drop_count == 1 || drop_count % DROP_WARN_EVERY == 0 {
439            warn!(
440                reason = reason.as_str(),
441                limit_triggered = limit_label,
442                drops = drop_count,
443                queue_size = self.total,
444                "Pending encrypted message drops continuing"
445            );
446        }
447    }
448
449    fn record_eviction_failure(
450        &mut self,
451        limit_triggered: QueueLimit,
452        peer_id: &str,
453        message_id: &str,
454        detail: &str,
455    ) {
456        self.metrics.pending_messages_eviction_failures_total = self
457            .metrics
458            .pending_messages_eviction_failures_total
459            .saturating_add(1);
460
461        let count = self.metrics.pending_messages_eviction_failures_total;
462        if count == 1 || count.is_multiple_of(EVICTION_FAILURE_WARN_EVERY) {
463            warn!(
464                limit_triggered = limit_triggered.as_str(),
465                peer_id = %peer_id,
466                message_id = %message_id,
467                failures = count,
468                detail = detail,
469                queue_size = self.total,
470                "Pending queue eviction failure detected"
471            );
472        } else {
473            debug!(
474                limit_triggered = limit_triggered.as_str(),
475                peer_id = %peer_id,
476                message_id = %message_id,
477                detail = detail,
478                "Pending queue eviction failure detected"
479            );
480        }
481    }
482
483    fn verify_invariants(&mut self, context: &str) {
484        let per_peer_sum: usize = self.queues.values().map(VecDeque::len).sum();
485        let live_count = self.live_sequences.len();
486        let current_gauge = self.metrics.pending_messages_current;
487        let total = self.total;
488        let peer_bytes_sum: usize = self.peer_bytes.values().sum();
489        let valid = per_peer_sum == total
490            && live_count == total
491            && current_gauge == total
492            && peer_bytes_sum == self.total_bytes;
493        if valid {
494            return;
495        }
496
497        self.metrics.pending_queue_invariant_violations_total = self
498            .metrics
499            .pending_queue_invariant_violations_total
500            .saturating_add(1);
501        warn!(
502            context = context,
503            per_peer_sum,
504            live_count,
505            current_gauge,
506            total,
507            violations = self.metrics.pending_queue_invariant_violations_total,
508            "Pending queue invariant violation detected"
509        );
510    }
511
512    fn record_peer_overflow_pressure(&mut self, peer_id: &str, per_peer_limit: usize) {
513        let hits = self
514            .peer_overflow_hits
515            .entry(peer_id.to_string())
516            .or_insert(0);
517        *hits = hits.saturating_add(1);
518        if hits.is_multiple_of(PEER_PRESSURE_WARN_EVERY) {
519            warn!(
520                peer_id = %peer_id,
521                overflow_hits = *hits,
522                per_peer_limit,
523                "Peer repeatedly hitting pending queue limits"
524            );
525        }
526    }
527
528    fn evict_global_oldest(
529        &mut self,
530        reason: DropReason,
531        limit_triggered: QueueLimit,
532        overflow_policy: OverflowPolicy,
533    ) -> Option<PendingDecryptMessage> {
534        self.cleanup_global_order_front();
535        let entry_ref = self.global_order.pop_front()?;
536        if !self.live_sequences.contains(&entry_ref.sequence) {
537            return None;
538        }
539        if let Some(evicted) = self.remove_entry_by_sequence(&entry_ref.peer_id, entry_ref.sequence)
540        {
541            self.record_drop(
542                reason,
543                Some(limit_triggered),
544                &evicted.peer_id,
545                &evicted.message_id,
546                overflow_policy,
547            );
548            return Some(evicted);
549        }
550        warn!(
551            peer_id = %entry_ref.peer_id,
552            message_id = %entry_ref.message_id,
553            sequence = entry_ref.sequence,
554            "Failed to evict pending message by global order reference"
555        );
556        None
557    }
558
559    // ---- Public queue operations ----
560
561    /// Prunes expired entries for a specific peer. Returns the dropped entries.
562    pub(crate) fn prune_expired_for_peer(
563        &mut self,
564        config: &PendingQueueConfig,
565        peer_id: &str,
566        now: Instant,
567    ) -> Vec<DroppedPendingMessage> {
568        let mut expired_entries = Vec::new();
569        let mut expired_sequences = Vec::new();
570        let mut expired_ids = Vec::new();
571        if let Some(queue) = self.queues.get(peer_id) {
572            for entry in queue {
573                if Self::is_entry_expired(config, entry, now) {
574                    expired_sequences.push(entry.sequence);
575                    expired_ids.push(entry.message_id.clone());
576                } else {
577                    break;
578                }
579            }
580        }
581
582        let overflow_policy = config.overflow_policy;
583        for (sequence, message_id) in expired_sequences.into_iter().zip(expired_ids) {
584            if let Some(expired) = self.remove_entry_by_sequence(peer_id, sequence) {
585                self.record_drop(
586                    DropReason::TtlExpired,
587                    None,
588                    peer_id,
589                    &message_id,
590                    overflow_policy,
591                );
592                expired_entries.push(DroppedPendingMessage {
593                    message: expired.message,
594                    reason: DropReason::TtlExpired.as_str(),
595                    received_via: expired.received_via,
596                });
597            }
598        }
599
600        if expired_entries.len() >= TTL_SPIKE_WARN_THRESHOLD {
601            warn!(
602                peer_id = %peer_id,
603                expired = expired_entries.len(),
604                ttl_ms = config.pending_ttl_ms,
605                "Pending encrypted message TTL eviction spike"
606            );
607        }
608
609        self.cleanup_global_order_front();
610        expired_entries
611    }
612
613    /// Prunes expired entries from the front of the global order. Returns the dropped entries.
614    pub(crate) fn prune_expired_global_front(
615        &mut self,
616        config: &PendingQueueConfig,
617        now: Instant,
618        max_evictions: usize,
619    ) -> Vec<DroppedPendingMessage> {
620        let mut evicted = Vec::new();
621        let overflow_policy = config.overflow_policy;
622        while evicted.len() < max_evictions {
623            self.cleanup_global_order_front();
624            let Some(front) = self.global_order.front().cloned() else {
625                break;
626            };
627            if !self.live_sequences.contains(&front.sequence) {
628                self.global_order.pop_front();
629                continue;
630            }
631
632            let Some(queue) = self.queues.get(&front.peer_id) else {
633                self.global_order.pop_front();
634                continue;
635            };
636            let Some(entry) = queue.iter().find(|entry| entry.sequence == front.sequence) else {
637                self.global_order.pop_front();
638                continue;
639            };
640
641            if !Self::is_entry_expired(config, entry, now) {
642                break;
643            }
644
645            if let Some(expired) = self.remove_entry_by_sequence(&front.peer_id, front.sequence) {
646                self.global_order.pop_front();
647                self.record_drop(
648                    DropReason::TtlExpired,
649                    None,
650                    &expired.peer_id,
651                    &expired.message_id,
652                    overflow_policy,
653                );
654                evicted.push(DroppedPendingMessage {
655                    message: expired.message,
656                    reason: DropReason::TtlExpired.as_str(),
657                    received_via: expired.received_via,
658                });
659            } else {
660                self.global_order.pop_front();
661            }
662        }
663
664        if evicted.len() >= TTL_SPIKE_WARN_THRESHOLD {
665            warn!(
666                expired = evicted.len(),
667                ttl_ms = config.pending_ttl_ms,
668                "Pending encrypted message TTL eviction spike"
669            );
670        }
671
672        evicted
673    }
674
675    /// Enqueues an encrypted message that arrived before the MLS session was
676    /// ready, without recording an arrival transport (the drain will fall back
677    /// to the resend-driven ACK). See [`Self::enqueue_via`]. Used only by tests;
678    /// production always routes through `enqueue_via`.
679    #[cfg(test)]
680    pub(crate) fn enqueue(
681        &mut self,
682        config: &PendingQueueConfig,
683        sender: &str,
684        message: &Message,
685    ) -> Vec<DroppedPendingMessage> {
686        let outcome = self.enqueue_via(config, sender, message, None);
687        let mut dropped = outcome.dropped;
688        dropped.extend(outcome.refused);
689        dropped
690    }
691
692    /// Enqueues an encrypted message that arrived before the MLS session was
693    /// ready, recording the transport it arrived on so the drain can ACK it
694    /// directly.
695    ///
696    /// Returns whether the frame was admitted, every queued entry dropped in
697    /// the process (TTL-expired, or evicted to make room), and the incoming
698    /// frame itself when it could not be admitted, so the protocol layer can
699    /// surface the loss and keep the persisted copy in step. See
700    /// [`EnqueueOutcome`] for why the refused frame is reported apart.
701    pub(crate) fn enqueue_via(
702        &mut self,
703        config: &PendingQueueConfig,
704        sender: &str,
705        message: &Message,
706        arrival_transport: Option<TransportType>,
707    ) -> EnqueueOutcome {
708        let mut dropped = Vec::new();
709        let admission =
710            self.enqueue_via_inner(config, sender, message, arrival_transport, &mut dropped);
711        EnqueueOutcome {
712            admitted: admission == Admission::Admitted,
713            dropped,
714            refused: (admission == Admission::Refused).then(|| DroppedPendingMessage {
715                message: message.clone(),
716                reason: DropReason::OverflowDropNewest.as_str(),
717                received_via: arrival_transport,
718            }),
719        }
720    }
721
722    /// The body of [`Self::enqueue_via`]. Only entries that were already queued
723    /// go onto `dropped`; the incoming frame's fate is the return value.
724    fn enqueue_via_inner(
725        &mut self,
726        config: &PendingQueueConfig,
727        sender: &str,
728        message: &Message,
729        arrival_transport: Option<TransportType>,
730        dropped: &mut Vec<DroppedPendingMessage>,
731    ) -> Admission {
732        self.metrics.pending_messages_received_total = self
733            .metrics
734            .pending_messages_received_total
735            .saturating_add(1);
736        let incoming_message_id = message.id.as_str();
737
738        let now = Instant::now();
739        dropped.extend(self.prune_expired_for_peer(config, sender, now));
740        dropped.extend(self.prune_expired_global_front(config, now, 64));
741
742        // Idempotent by message id: under the deferred-ACK model the sender
743        // resends an undecryptable message on its ACK-timeout, and each resend
744        // re-enters `enqueue`. Stacking duplicate copies would burn the per-peer
745        // budget (evicting *other* peers' recoverable messages) and surface the
746        // same message twice on drain. If this id is already queued for the
747        // peer, treat the resend as a no-op (the original copy is authoritative
748        // and its TTL is measured from first receipt).
749        if self.queues.get(sender).is_some_and(|queue| {
750            queue
751                .iter()
752                .any(|entry| entry.message_id == incoming_message_id)
753        }) {
754            return Admission::AlreadyQueued;
755        }
756
757        let per_peer_limit = config.max_pending_per_peer;
758        let global_limit = config.max_pending_global;
759        let per_peer_bytes_limit = config.max_pending_bytes_per_peer;
760        let global_bytes_limit = config.max_pending_bytes_global;
761        let overflow_policy = config.overflow_policy;
762        let incoming_bytes = Self::message_bytes(message);
763
764        // A message that can never fit within the byte budgets is dropped
765        // outright — evicting the entire queue would not make room for it.
766        // This also guarantees the byte-eviction loops below terminate.
767        if incoming_bytes > per_peer_bytes_limit || incoming_bytes > global_bytes_limit {
768            let limit = if incoming_bytes > per_peer_bytes_limit {
769                QueueLimit::PerPeerBytes
770            } else {
771                QueueLimit::GlobalBytes
772            };
773            self.record_drop(
774                DropReason::OverflowDropNewest,
775                Some(limit),
776                sender,
777                &incoming_message_id,
778                overflow_policy,
779            );
780            return Admission::Refused;
781        }
782
783        let peer_len = self.queues.get(sender).map(VecDeque::len).unwrap_or(0);
784        if peer_len >= per_peer_limit {
785            self.record_peer_overflow_pressure(sender, per_peer_limit);
786            match overflow_policy {
787                OverflowPolicy::DropNewest => {
788                    self.record_drop(
789                        DropReason::OverflowDropNewest,
790                        Some(QueueLimit::PerPeer),
791                        sender,
792                        &incoming_message_id,
793                        overflow_policy,
794                    );
795                    return Admission::Refused;
796                }
797                OverflowPolicy::DropOldest => {
798                    let evicted_sequence = self
799                        .queues
800                        .get(sender)
801                        .and_then(|queue| queue.front().map(|entry| entry.sequence));
802                    let mut evicted_any = false;
803                    if let Some(sequence) = evicted_sequence {
804                        if let Some(evicted) = self.remove_entry_by_sequence(sender, sequence) {
805                            self.record_drop(
806                                DropReason::OverflowDropOldest,
807                                Some(QueueLimit::PerPeer),
808                                &evicted.peer_id,
809                                &evicted.message_id,
810                                overflow_policy,
811                            );
812                            dropped.push(DroppedPendingMessage {
813                                message: evicted.message,
814                                reason: DropReason::OverflowDropOldest.as_str(),
815                                received_via: evicted.received_via,
816                            });
817                            evicted_any = true;
818                        }
819                    }
820                    if !evicted_any {
821                        self.record_eviction_failure(
822                            QueueLimit::PerPeer,
823                            sender,
824                            &incoming_message_id,
825                            "drop_oldest failed to evict per-peer oldest",
826                        );
827                        self.record_drop(
828                            DropReason::OverflowDropNewest,
829                            Some(QueueLimit::PerPeer),
830                            sender,
831                            &incoming_message_id,
832                            overflow_policy,
833                        );
834                        return Admission::Refused;
835                    }
836                }
837            }
838        }
839        let peer_len_after = self.queues.get(sender).map(VecDeque::len).unwrap_or(0);
840        if peer_len_after >= per_peer_limit {
841            self.record_eviction_failure(
842                QueueLimit::PerPeer,
843                sender,
844                &incoming_message_id,
845                "per-peer limit still saturated after eviction",
846            );
847            self.record_drop(
848                DropReason::OverflowDropNewest,
849                Some(QueueLimit::PerPeer),
850                sender,
851                &incoming_message_id,
852                overflow_policy,
853            );
854            return Admission::Refused;
855        }
856
857        if self.peer_bytes_for(sender) + incoming_bytes > per_peer_bytes_limit {
858            self.record_peer_overflow_pressure(sender, per_peer_limit);
859            if overflow_policy == OverflowPolicy::DropNewest {
860                self.record_drop(
861                    DropReason::OverflowDropNewest,
862                    Some(QueueLimit::PerPeerBytes),
863                    sender,
864                    &incoming_message_id,
865                    overflow_policy,
866                );
867                return Admission::Refused;
868            }
869            // DropOldest: evict from the peer's front until the incoming
870            // message fits. Terminates: each eviction shrinks the peer's byte
871            // total, and an empty queue leaves it at 0 (the incoming message
872            // fits by the oversized pre-check above).
873            while self.peer_bytes_for(sender) + incoming_bytes > per_peer_bytes_limit {
874                let evicted_sequence = self
875                    .queues
876                    .get(sender)
877                    .and_then(|queue| queue.front().map(|entry| entry.sequence));
878                let evicted = evicted_sequence
879                    .and_then(|sequence| self.remove_entry_by_sequence(sender, sequence));
880                match evicted {
881                    Some(evicted) => {
882                        self.record_drop(
883                            DropReason::OverflowDropOldest,
884                            Some(QueueLimit::PerPeerBytes),
885                            &evicted.peer_id,
886                            &evicted.message_id,
887                            overflow_policy,
888                        );
889                        dropped.push(DroppedPendingMessage {
890                            message: evicted.message,
891                            reason: DropReason::OverflowDropOldest.as_str(),
892                            received_via: evicted.received_via,
893                        });
894                    }
895                    None => {
896                        self.record_eviction_failure(
897                            QueueLimit::PerPeerBytes,
898                            sender,
899                            &incoming_message_id,
900                            "drop_oldest failed to evict per-peer oldest for byte budget",
901                        );
902                        self.record_drop(
903                            DropReason::OverflowDropNewest,
904                            Some(QueueLimit::PerPeerBytes),
905                            sender,
906                            &incoming_message_id,
907                            overflow_policy,
908                        );
909                        return Admission::Refused;
910                    }
911                }
912            }
913        }
914
915        if self.total >= global_limit {
916            warn!(
917                queue_size = self.total,
918                global_limit, "Pending encrypted queue at global pressure limit"
919            );
920            match overflow_policy {
921                OverflowPolicy::DropNewest => {
922                    self.record_drop(
923                        DropReason::OverflowDropNewest,
924                        Some(QueueLimit::Global),
925                        sender,
926                        &incoming_message_id,
927                        overflow_policy,
928                    );
929                    return Admission::Refused;
930                }
931                OverflowPolicy::DropOldest => {
932                    while self.total >= global_limit {
933                        match self.evict_global_oldest(
934                            DropReason::OverflowDropOldest,
935                            QueueLimit::Global,
936                            overflow_policy,
937                        ) {
938                            Some(evicted) => dropped.push(DroppedPendingMessage {
939                                message: evicted.message,
940                                reason: DropReason::OverflowDropOldest.as_str(),
941                                received_via: evicted.received_via,
942                            }),
943                            None => {
944                                self.record_eviction_failure(
945                                    QueueLimit::Global,
946                                    sender,
947                                    &incoming_message_id,
948                                    "drop_oldest failed to evict global oldest",
949                                );
950                                break;
951                            }
952                        }
953                    }
954                }
955            }
956        }
957        if self.total >= global_limit {
958            self.record_drop(
959                DropReason::OverflowDropNewest,
960                Some(QueueLimit::Global),
961                sender,
962                &incoming_message_id,
963                overflow_policy,
964            );
965            return Admission::Refused;
966        }
967
968        if self.total_bytes + incoming_bytes > global_bytes_limit {
969            warn!(
970                queue_bytes = self.total_bytes,
971                global_bytes_limit, "Pending encrypted queue at global byte budget"
972            );
973            if overflow_policy == OverflowPolicy::DropNewest {
974                self.record_drop(
975                    DropReason::OverflowDropNewest,
976                    Some(QueueLimit::GlobalBytes),
977                    sender,
978                    &incoming_message_id,
979                    overflow_policy,
980                );
981                return Admission::Refused;
982            }
983            while self.total_bytes + incoming_bytes > global_bytes_limit {
984                match self.evict_global_oldest(
985                    DropReason::OverflowDropOldest,
986                    QueueLimit::GlobalBytes,
987                    overflow_policy,
988                ) {
989                    Some(evicted) => dropped.push(DroppedPendingMessage {
990                        message: evicted.message,
991                        reason: DropReason::OverflowDropOldest.as_str(),
992                        received_via: evicted.received_via,
993                    }),
994                    None => {
995                        self.record_eviction_failure(
996                            QueueLimit::GlobalBytes,
997                            sender,
998                            &incoming_message_id,
999                            "drop_oldest failed to evict global oldest for byte budget",
1000                        );
1001                        break;
1002                    }
1003                }
1004            }
1005            if self.total_bytes + incoming_bytes > global_bytes_limit {
1006                self.record_drop(
1007                    DropReason::OverflowDropNewest,
1008                    Some(QueueLimit::GlobalBytes),
1009                    sender,
1010                    &incoming_message_id,
1011                    overflow_policy,
1012                );
1013                return Admission::Refused;
1014            }
1015        }
1016
1017        let sequence = self.next_sequence();
1018        let entry = PendingDecryptMessage {
1019            peer_id: sender.to_string(),
1020            message_id: incoming_message_id.clone(),
1021            received_at: now,
1022            sequence,
1023            message: message.clone(),
1024            received_via: arrival_transport,
1025        };
1026
1027        self.queues
1028            .entry(sender.to_string())
1029            .or_default()
1030            .push_back(entry.clone());
1031        self.global_order.push_back(EntryRef {
1032            peer_id: sender.to_string(),
1033            message_id: entry.message_id.clone(),
1034            sequence,
1035        });
1036        self.live_sequences.insert(sequence);
1037        self.total = self.total.saturating_add(1);
1038        self.total_bytes = self.total_bytes.saturating_add(incoming_bytes);
1039        let peer_bytes = self.peer_bytes.entry(sender.to_string()).or_insert(0);
1040        *peer_bytes = peer_bytes.saturating_add(incoming_bytes);
1041        self.update_peer_gauge(sender);
1042        self.update_current_gauge();
1043        self.cleanup_global_order_front();
1044        self.verify_invariants("enqueue");
1045
1046        let peer_len_post_insert = self.queues.get(sender).map(VecDeque::len).unwrap_or(0);
1047        let over_global = self.total > global_limit || self.total_bytes > global_bytes_limit;
1048        let over_peer = peer_len_post_insert > per_peer_limit
1049            || self.peer_bytes_for(sender) > per_peer_bytes_limit;
1050        if over_global || over_peer {
1051            let _ = self.remove_entry_by_sequence(sender, sequence);
1052            let limit = if over_global {
1053                QueueLimit::Global
1054            } else {
1055                QueueLimit::PerPeer
1056            };
1057            self.record_eviction_failure(
1058                limit,
1059                sender,
1060                &incoming_message_id,
1061                "post-insert hard-bound check failed; rolled back enqueue",
1062            );
1063            self.record_drop(
1064                DropReason::OverflowDropNewest,
1065                Some(limit),
1066                sender,
1067                &incoming_message_id,
1068                overflow_policy,
1069            );
1070            return Admission::Refused;
1071        }
1072
1073        Admission::Admitted
1074    }
1075
1076    /// Drains all pending messages for a peer, updating bookkeeping.
1077    /// Returns the drained messages for the caller to process.
1078    pub(crate) fn drain_for_peer(
1079        &mut self,
1080        config: &PendingQueueConfig,
1081        sender: &str,
1082    ) -> Vec<PendingDecryptMessage> {
1083        let now = Instant::now();
1084        let _ = self.prune_expired_for_peer(config, sender, now);
1085
1086        let messages = match self.queues.remove(sender) {
1087            Some(msgs) => msgs,
1088            None => return Vec::new(),
1089        };
1090
1091        if messages.is_empty() {
1092            return Vec::new();
1093        }
1094
1095        let drained: Vec<PendingDecryptMessage> = messages.into_iter().collect();
1096        for entry in &drained {
1097            self.live_sequences.remove(&entry.sequence);
1098            self.total = self.total.saturating_sub(1);
1099            self.total_bytes = self
1100                .total_bytes
1101                .saturating_sub(Self::message_bytes(&entry.message));
1102        }
1103        self.peer_bytes.remove(sender);
1104        // Prune the overflow-pressure counter with the drained queue (see the
1105        // matching cleanup in `remove_entry_by_sequence`) so it cannot outlive
1106        // the peer's queued messages and leak per-sender.
1107        self.peer_overflow_hits.remove(sender);
1108        self.update_peer_gauge(sender);
1109        self.update_current_gauge();
1110        self.cleanup_global_order_front();
1111        self.verify_invariants("drain_for_peer");
1112
1113        drained
1114    }
1115}