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