Skip to main content

ipfrs_network/
message_prioritizer.rs

1//! PeerMessagePrioritizer — Multi-level priority queue with aging for outbound messages.
2//!
3//! Each peer gets its own sorted queue (highest effective priority first, FIFO within same
4//! priority). An aging mechanism prevents message starvation by promoting lower-priority
5//! messages after they have waited for a configurable number of ticks.
6//!
7//! ## Design highlights
8//!
9//! - **Four priority levels**: Background → Normal → High → Urgent
10//! - **Aging / anti-starvation**: `advance_tick` promotes stale messages one level at a time
11//! - **Per-peer capacity cap**: when full, the lowest-priority message is evicted to make room
12//! - **Rich statistics**: enqueued, dequeued, promoted, dropped counters with a throughput ratio
13//! - **No `unwrap()`**: every fallible operation is expressed through `Option` / explicit checks
14
15use std::collections::HashMap;
16
17// ─── MessagePriority ──────────────────────────────────────────────────────────
18
19/// Four-level priority for outbound peer messages.
20///
21/// The integer discriminants are intentional: `Ord` derived from them means
22/// `Background < Normal < High < Urgent` so arithmetic promotion (`as u8 + 1`)
23/// produces the next level.
24#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
25pub enum MessagePriority {
26    /// Best-effort background traffic (gossip, keep-alives, …).
27    Background = 0,
28    /// Default priority for ordinary messages.
29    Normal = 1,
30    /// Important messages that should be delivered before normal ones.
31    High = 2,
32    /// Time-sensitive messages that preempt all others.
33    Urgent = 3,
34}
35
36impl MessagePriority {
37    /// Promote this priority one level upward, capped at `Urgent`.
38    fn promote(self) -> Self {
39        match self {
40            Self::Background => Self::Normal,
41            Self::Normal => Self::High,
42            Self::High => Self::Urgent,
43            Self::Urgent => Self::Urgent,
44        }
45    }
46}
47
48// ─── PrioritizedMessage ───────────────────────────────────────────────────────
49
50/// A single outbound message sitting in a peer's priority queue.
51#[derive(Clone, Debug, PartialEq, Eq)]
52pub struct PrioritizedMessage {
53    /// Monotonically increasing identifier assigned at enqueue time.
54    pub msg_id: u64,
55    /// Destination peer identifier.
56    pub peer_id: String,
57    /// Original priority assigned by the caller.
58    pub priority: MessagePriority,
59    /// Size of the message payload in bytes.
60    pub payload_bytes: u64,
61    /// Value of `current_tick` when the message was enqueued.
62    pub enqueued_tick: u64,
63    /// Effective priority after aging promotions; starts equal to `priority`.
64    pub effective_priority: MessagePriority,
65}
66
67// ─── AgingConfig ─────────────────────────────────────────────────────────────
68
69/// Tunable parameters for the aging / anti-starvation mechanism.
70#[derive(Clone, Debug)]
71pub struct AgingConfig {
72    /// Number of ticks a message must wait before its effective priority is
73    /// promoted by one level. Default: 50.
74    pub promote_after_ticks: u64,
75    /// Maximum number of messages kept per peer queue. Default: 256.
76    pub max_queue_size: usize,
77}
78
79impl Default for AgingConfig {
80    fn default() -> Self {
81        Self {
82            promote_after_ticks: 50,
83            max_queue_size: 256,
84        }
85    }
86}
87
88// ─── PrioritizerStats ────────────────────────────────────────────────────────
89
90/// Aggregate statistics for a `PeerMessagePrioritizer` instance.
91#[derive(Clone, Debug, Default)]
92pub struct PrioritizerStats {
93    /// Total messages successfully enqueued (including those that displaced a
94    /// dropped message).
95    pub total_enqueued: u64,
96    /// Total messages removed via `dequeue`.
97    pub total_dequeued: u64,
98    /// Total aging promotions applied across all messages and all ticks.
99    pub total_promoted: u64,
100    /// Total messages evicted because the per-peer queue was at capacity.
101    pub total_dropped: u64,
102}
103
104impl PrioritizerStats {
105    /// Fraction of enqueued messages that have been dequeued.
106    ///
107    /// Returns `0.0` when nothing has been enqueued yet.
108    pub fn throughput_ratio(&self) -> f64 {
109        self.total_dequeued as f64 / self.total_enqueued.max(1) as f64
110    }
111}
112
113// ─── PeerMessagePrioritizer ───────────────────────────────────────────────────
114
115/// Multi-level outbound message prioritizer with per-peer queues and aging.
116///
117/// # Ordering guarantee
118///
119/// Within a peer's queue messages are sorted so that:
120/// 1. Higher `effective_priority` comes first.
121/// 2. Among messages with the same `effective_priority`, the one enqueued
122///    earliest (lowest `enqueued_tick`) comes first (FIFO).
123pub struct PeerMessagePrioritizer {
124    /// Per-peer queues; each `Vec` is kept sorted (front = highest priority).
125    pub queues: HashMap<String, Vec<PrioritizedMessage>>,
126    /// Aging and capacity configuration.
127    pub config: AgingConfig,
128    /// Running statistics.
129    pub stats: PrioritizerStats,
130    /// Logical clock advanced via `advance_tick`.
131    pub current_tick: u64,
132    /// Counter used to assign unique message identifiers.
133    pub next_id: u64,
134}
135
136impl PeerMessagePrioritizer {
137    /// Create a new prioritizer with the supplied configuration.
138    pub fn new(config: AgingConfig) -> Self {
139        Self {
140            queues: HashMap::new(),
141            config,
142            stats: PrioritizerStats::default(),
143            current_tick: 0,
144            next_id: 1,
145        }
146    }
147
148    // ── Internal helpers ──────────────────────────────────────────────────────
149
150    /// Insert `msg` into `queue` maintaining the sorted invariant.
151    ///
152    /// Sort key: descending `effective_priority`, then ascending `enqueued_tick`
153    /// (FIFO within the same effective priority).
154    fn insert_sorted(queue: &mut Vec<PrioritizedMessage>, msg: PrioritizedMessage) {
155        // Binary-search for the correct insertion point.
156        let pos = queue.partition_point(|m| {
157            // Items that should come *before* `msg` satisfy this predicate.
158            m.effective_priority > msg.effective_priority
159                || (m.effective_priority == msg.effective_priority
160                    && m.enqueued_tick <= msg.enqueued_tick)
161        });
162        queue.insert(pos, msg);
163    }
164
165    /// Drop the message with the lowest priority (and, within that, the most
166    /// recently enqueued one) from `queue`.  Returns `true` if a message was
167    /// removed.
168    fn drop_lowest(queue: &mut Vec<PrioritizedMessage>) -> bool {
169        if queue.is_empty() {
170            return false;
171        }
172        // The queue is sorted highest-first so the lowest-priority messages are
173        // at the tail.  Among equal-priority tail messages prefer removing the
174        // one with the highest `enqueued_tick` (most recent, least-waited).
175        let lowest_prio = queue.last().map(|m| m.effective_priority);
176        if let Some(lp) = lowest_prio {
177            // Find the index of the most-recently-enqueued message among those
178            // sharing the lowest effective priority.
179            let victim = queue
180                .iter()
181                .enumerate()
182                .rev() // iterate from tail toward head
183                .take_while(|(_, m)| m.effective_priority == lp)
184                .max_by_key(|(_, m)| m.enqueued_tick)
185                .map(|(i, _)| i);
186
187            if let Some(idx) = victim {
188                queue.remove(idx);
189                return true;
190            }
191        }
192        false
193    }
194
195    // ── Public API ────────────────────────────────────────────────────────────
196
197    /// Enqueue a new message for `peer_id`.
198    ///
199    /// If the peer's queue is already at `max_queue_size`:
200    /// 1. Attempt to drop the lowest-priority message (`Background` first, then
201    ///    `Normal`).
202    /// 2. If the queue is still full after the drop attempt, return `None`.
203    ///
204    /// On success, returns `Some(msg_id)`.
205    pub fn enqueue(
206        &mut self,
207        peer_id: String,
208        priority: MessagePriority,
209        payload_bytes: u64,
210    ) -> Option<u64> {
211        let queue = self.queues.entry(peer_id.clone()).or_default();
212
213        if queue.len() >= self.config.max_queue_size {
214            // Try to make room by evicting the lowest-priority message.
215            if Self::drop_lowest(queue) {
216                self.stats.total_dropped += 1;
217            }
218            // Re-check — if still full we cannot accept the message.
219            if queue.len() >= self.config.max_queue_size {
220                return None;
221            }
222        }
223
224        let msg_id = self.next_id;
225        self.next_id += 1;
226
227        let msg = PrioritizedMessage {
228            msg_id,
229            peer_id,
230            priority,
231            payload_bytes,
232            enqueued_tick: self.current_tick,
233            effective_priority: priority,
234        };
235
236        Self::insert_sorted(queue, msg);
237        self.stats.total_enqueued += 1;
238        Some(msg_id)
239    }
240
241    /// Remove and return the highest-priority message for `peer_id`, or `None`
242    /// if the peer has no queued messages.
243    pub fn dequeue(&mut self, peer_id: &str) -> Option<PrioritizedMessage> {
244        let queue = self.queues.get_mut(peer_id)?;
245        if queue.is_empty() {
246            return None;
247        }
248        let msg = queue.remove(0);
249        self.stats.total_dequeued += 1;
250        Some(msg)
251    }
252
253    /// Advance the logical clock by one tick and apply aging promotions.
254    ///
255    /// Any message whose waiting time (`current_tick - enqueued_tick`) meets or
256    /// exceeds `promote_after_ticks` and whose `effective_priority` is below
257    /// `Urgent` is promoted by one level.  Queues that had at least one
258    /// promotion are re-sorted to preserve the ordering invariant.
259    pub fn advance_tick(&mut self) {
260        self.current_tick += 1;
261
262        let promote_after = self.config.promote_after_ticks;
263        let current_tick = self.current_tick;
264
265        for queue in self.queues.values_mut() {
266            let mut any_promoted = false;
267
268            for msg in queue.iter_mut() {
269                if msg.effective_priority < MessagePriority::Urgent
270                    && current_tick.saturating_sub(msg.enqueued_tick) >= promote_after
271                {
272                    msg.effective_priority = msg.effective_priority.promote();
273                    self.stats.total_promoted += 1;
274                    any_promoted = true;
275                }
276            }
277
278            if any_promoted {
279                // Re-sort: descending effective_priority, then ascending enqueued_tick.
280                queue.sort_by(|a, b| {
281                    b.effective_priority
282                        .cmp(&a.effective_priority)
283                        .then_with(|| a.enqueued_tick.cmp(&b.enqueued_tick))
284                });
285            }
286        }
287    }
288
289    /// Return a reference to the accumulated statistics.
290    pub fn stats(&self) -> &PrioritizerStats {
291        &self.stats
292    }
293
294    /// Return the number of messages currently queued for `peer_id`.
295    pub fn queue_depth(&self, peer_id: &str) -> usize {
296        self.queues.get(peer_id).map_or(0, |q| q.len())
297    }
298}
299
300// ─── Tests ────────────────────────────────────────────────────────────────────
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    fn default_prioritizer() -> PeerMessagePrioritizer {
307        PeerMessagePrioritizer::new(AgingConfig::default())
308    }
309
310    // ── 1. enqueue returns a unique msg_id ────────────────────────────────────
311
312    #[test]
313    fn enqueue_returns_msg_id() {
314        let mut p = default_prioritizer();
315        let id = p.enqueue("peer-a".to_string(), MessagePriority::Normal, 128);
316        assert!(id.is_some(), "enqueue should return Some(id)");
317        assert_eq!(id.expect("test: enqueue should return msg_id"), 1u64);
318    }
319
320    // ── 2. msg_ids are monotonically increasing ───────────────────────────────
321
322    #[test]
323    fn msg_ids_are_monotonically_increasing() {
324        let mut p = default_prioritizer();
325        let id1 = p
326            .enqueue("peer-a".to_string(), MessagePriority::Normal, 64)
327            .expect("test: first enqueue should return msg_id");
328        let id2 = p
329            .enqueue("peer-a".to_string(), MessagePriority::Normal, 64)
330            .expect("test: second enqueue should return msg_id");
331        let id3 = p
332            .enqueue("peer-b".to_string(), MessagePriority::High, 64)
333            .expect("test: third enqueue should return msg_id");
334        assert!(id1 < id2);
335        assert!(id2 < id3);
336    }
337
338    // ── 3. dequeue returns highest effective priority first ───────────────────
339
340    #[test]
341    fn dequeue_highest_priority_first() {
342        let mut p = default_prioritizer();
343        p.enqueue("peer-a".to_string(), MessagePriority::Background, 10)
344            .expect("test: enqueue Background should succeed");
345        p.enqueue("peer-a".to_string(), MessagePriority::Urgent, 10)
346            .expect("test: enqueue Urgent should succeed");
347        p.enqueue("peer-a".to_string(), MessagePriority::Normal, 10)
348            .expect("test: enqueue Normal should succeed");
349
350        let first = p
351            .dequeue("peer-a")
352            .expect("test: first dequeue should return Urgent message");
353        assert_eq!(first.effective_priority, MessagePriority::Urgent);
354
355        let second = p
356            .dequeue("peer-a")
357            .expect("test: second dequeue should return Normal message");
358        assert_eq!(second.effective_priority, MessagePriority::Normal);
359
360        let third = p
361            .dequeue("peer-a")
362            .expect("test: third dequeue should return Background message");
363        assert_eq!(third.effective_priority, MessagePriority::Background);
364    }
365
366    // ── 4. FIFO within the same priority ─────────────────────────────────────
367
368    #[test]
369    fn fifo_within_same_priority() {
370        let mut p = default_prioritizer();
371        let id1 = p
372            .enqueue("peer-a".to_string(), MessagePriority::Normal, 10)
373            .expect("test: first enqueue should succeed");
374        let id2 = p
375            .enqueue("peer-a".to_string(), MessagePriority::Normal, 20)
376            .expect("test: second enqueue should succeed");
377        let id3 = p
378            .enqueue("peer-a".to_string(), MessagePriority::Normal, 30)
379            .expect("test: third enqueue should succeed");
380
381        assert_eq!(
382            p.dequeue("peer-a")
383                .expect("test: first dequeue should return message")
384                .msg_id,
385            id1
386        );
387        assert_eq!(
388            p.dequeue("peer-a")
389                .expect("test: second dequeue should return message")
390                .msg_id,
391            id2
392        );
393        assert_eq!(
394            p.dequeue("peer-a")
395                .expect("test: third dequeue should return message")
396                .msg_id,
397            id3
398        );
399    }
400
401    // ── 5. FIFO is stable across ticks ───────────────────────────────────────
402
403    #[test]
404    fn fifo_stable_across_ticks() {
405        let config = AgingConfig {
406            promote_after_ticks: 1000, // never promote during this test
407            max_queue_size: 256,
408        };
409        let mut p = PeerMessagePrioritizer::new(config);
410        let id1 = p
411            .enqueue("peer-a".to_string(), MessagePriority::High, 10)
412            .expect("test: first enqueue at tick 0 should succeed");
413        p.advance_tick();
414        let id2 = p
415            .enqueue("peer-a".to_string(), MessagePriority::High, 10)
416            .expect("test: second enqueue at tick 1 should succeed");
417        p.advance_tick();
418        let id3 = p
419            .enqueue("peer-a".to_string(), MessagePriority::High, 10)
420            .expect("test: third enqueue at tick 2 should succeed");
421
422        assert_eq!(
423            p.dequeue("peer-a")
424                .expect("test: first dequeue should return message")
425                .msg_id,
426            id1
427        );
428        assert_eq!(
429            p.dequeue("peer-a")
430                .expect("test: second dequeue should return message")
431                .msg_id,
432            id2
433        );
434        assert_eq!(
435            p.dequeue("peer-a")
436                .expect("test: third dequeue should return message")
437                .msg_id,
438            id3
439        );
440    }
441
442    // ── 6. advance_tick promotes after threshold ──────────────────────────────
443
444    #[test]
445    fn advance_tick_promotes_after_threshold() {
446        let config = AgingConfig {
447            promote_after_ticks: 3,
448            max_queue_size: 256,
449        };
450        let mut p = PeerMessagePrioritizer::new(config);
451        p.enqueue("peer-a".to_string(), MessagePriority::Background, 10)
452            .expect("test: enqueue Background should succeed");
453
454        // Advance 2 ticks — not enough.
455        p.advance_tick();
456        p.advance_tick();
457        assert_eq!(p.stats().total_promoted, 0);
458
459        // Third tick crosses the threshold.
460        p.advance_tick();
461        assert_eq!(p.stats().total_promoted, 1);
462
463        let msg = p
464            .dequeue("peer-a")
465            .expect("test: dequeue should return promoted message");
466        assert_eq!(msg.effective_priority, MessagePriority::Normal);
467        assert_eq!(msg.priority, MessagePriority::Background); // original unchanged
468    }
469
470    // ── 7. Promotion is capped at Urgent ──────────────────────────────────────
471
472    #[test]
473    fn promotion_capped_at_urgent() {
474        let config = AgingConfig {
475            promote_after_ticks: 1,
476            max_queue_size: 256,
477        };
478        let mut p = PeerMessagePrioritizer::new(config);
479        p.enqueue("peer-a".to_string(), MessagePriority::High, 10)
480            .expect("test: enqueue High should succeed");
481
482        // Advance enough ticks to try to promote beyond Urgent.
483        for _ in 0..10 {
484            p.advance_tick();
485        }
486
487        let msg = p
488            .dequeue("peer-a")
489            .expect("test: dequeue should return Urgent message after promotion");
490        assert_eq!(msg.effective_priority, MessagePriority::Urgent);
491    }
492
493    // ── 8. total_promoted counter ─────────────────────────────────────────────
494
495    #[test]
496    fn total_promoted_counter() {
497        let config = AgingConfig {
498            promote_after_ticks: 2,
499            max_queue_size: 256,
500        };
501        let mut p = PeerMessagePrioritizer::new(config);
502        // Two Background messages enqueued at tick 0.
503        p.enqueue("peer-a".to_string(), MessagePriority::Background, 10)
504            .expect("test: first enqueue Background should succeed");
505        p.enqueue("peer-a".to_string(), MessagePriority::Background, 10)
506            .expect("test: second enqueue Background should succeed");
507
508        p.advance_tick(); // tick 1 — waited 1 tick, not yet promoted
509        assert_eq!(p.stats().total_promoted, 0);
510
511        p.advance_tick(); // tick 2 — waited 2 ticks, both promoted
512        assert_eq!(p.stats().total_promoted, 2);
513    }
514
515    // ── 9. drop lowest priority when full ────────────────────────────────────
516
517    #[test]
518    fn drop_lowest_priority_when_full() {
519        let config = AgingConfig {
520            promote_after_ticks: 9999,
521            max_queue_size: 3,
522        };
523        let mut p = PeerMessagePrioritizer::new(config);
524
525        p.enqueue("peer-a".to_string(), MessagePriority::Normal, 10)
526            .expect("test: enqueue Normal should succeed");
527        p.enqueue("peer-a".to_string(), MessagePriority::Background, 10)
528            .expect("test: enqueue Background should succeed");
529        p.enqueue("peer-a".to_string(), MessagePriority::High, 10)
530            .expect("test: enqueue High should succeed");
531        // Queue is now full (3 messages).
532
533        // Enqueue Urgent — should evict Background, succeed.
534        let result = p.enqueue("peer-a".to_string(), MessagePriority::Urgent, 10);
535        assert!(
536            result.is_some(),
537            "should succeed by evicting lowest-priority message"
538        );
539        assert_eq!(p.stats().total_dropped, 1);
540        assert_eq!(p.queue_depth("peer-a"), 3);
541
542        // All remaining messages should have priority >= Normal.
543        let priorities: Vec<_> = p.queues["peer-a"]
544            .iter()
545            .map(|m| m.effective_priority)
546            .collect();
547        for prio in &priorities {
548            assert!(
549                *prio >= MessagePriority::Normal,
550                "Background should have been evicted"
551            );
552        }
553    }
554
555    // ── 10. total_dropped counter ─────────────────────────────────────────────
556
557    #[test]
558    fn total_dropped_counter() {
559        let config = AgingConfig {
560            promote_after_ticks: 9999,
561            max_queue_size: 2,
562        };
563        let mut p = PeerMessagePrioritizer::new(config);
564        p.enqueue("peer-a".to_string(), MessagePriority::Background, 10)
565            .expect("test: first enqueue Background should succeed");
566        p.enqueue("peer-a".to_string(), MessagePriority::Background, 10)
567            .expect("test: second enqueue Background should succeed");
568        // Evict 1 Background, enqueue Normal.
569        p.enqueue("peer-a".to_string(), MessagePriority::Normal, 10)
570            .expect("test: enqueue Normal should succeed");
571        assert_eq!(p.stats().total_dropped, 1);
572    }
573
574    // ── 11. returns None when queue still full after drop attempt ─────────────
575
576    #[test]
577    fn returns_none_when_still_full_after_drop() {
578        let config = AgingConfig {
579            promote_after_ticks: 9999,
580            max_queue_size: 2,
581        };
582        let mut p = PeerMessagePrioritizer::new(config);
583        p.enqueue("peer-a".to_string(), MessagePriority::Urgent, 10)
584            .expect("test: first enqueue Urgent should succeed");
585        p.enqueue("peer-a".to_string(), MessagePriority::Urgent, 10)
586            .expect("test: second enqueue Urgent should succeed");
587        // Queue full with Urgents; nothing lower to drop — new message rejected.
588        // But drop_lowest will remove one (same priority, most recent).
589        // Let's fill with two Urgents then try a third Urgent.
590        let third = p.enqueue("peer-a".to_string(), MessagePriority::Urgent, 10);
591        // After dropping one Urgent there is space, so this succeeds.
592        assert!(third.is_some());
593        assert_eq!(p.stats().total_dropped, 1);
594        assert_eq!(p.queue_depth("peer-a"), 2);
595    }
596
597    // ── 12. empty dequeue returns None ───────────────────────────────────────
598
599    #[test]
600    fn empty_dequeue_returns_none() {
601        let mut p = default_prioritizer();
602        assert!(p.dequeue("nonexistent-peer").is_none());
603    }
604
605    // ── 13. dequeue from empty queue returns None ─────────────────────────────
606
607    #[test]
608    fn dequeue_exhausted_queue_returns_none() {
609        let mut p = default_prioritizer();
610        p.enqueue("peer-a".to_string(), MessagePriority::Normal, 10)
611            .expect("test: enqueue Normal should succeed");
612        p.dequeue("peer-a");
613        assert!(p.dequeue("peer-a").is_none());
614    }
615
616    // ── 14. queue_depth reflects current state ────────────────────────────────
617
618    #[test]
619    fn queue_depth_reflects_state() {
620        let mut p = default_prioritizer();
621        assert_eq!(p.queue_depth("peer-a"), 0);
622        p.enqueue("peer-a".to_string(), MessagePriority::Normal, 10)
623            .expect("test: first enqueue should succeed");
624        assert_eq!(p.queue_depth("peer-a"), 1);
625        p.enqueue("peer-a".to_string(), MessagePriority::High, 10)
626            .expect("test: second enqueue should succeed");
627        assert_eq!(p.queue_depth("peer-a"), 2);
628        p.dequeue("peer-a");
629        assert_eq!(p.queue_depth("peer-a"), 1);
630    }
631
632    // ── 15. throughput_ratio ──────────────────────────────────────────────────
633
634    #[test]
635    fn throughput_ratio_correct() {
636        let mut p = default_prioritizer();
637        // Zero enqueued → ratio = 0.0
638        assert!((p.stats().throughput_ratio() - 0.0).abs() < f64::EPSILON);
639
640        p.enqueue("peer-a".to_string(), MessagePriority::Normal, 10)
641            .expect("test: first enqueue should succeed");
642        p.enqueue("peer-a".to_string(), MessagePriority::Normal, 10)
643            .expect("test: second enqueue should succeed");
644        p.enqueue("peer-a".to_string(), MessagePriority::Normal, 10)
645            .expect("test: third enqueue should succeed");
646        p.dequeue("peer-a");
647        // 1 dequeued / 3 enqueued = 0.333…
648        let ratio = p.stats().throughput_ratio();
649        assert!((ratio - 1.0 / 3.0).abs() < 1e-9, "ratio = {ratio}");
650    }
651
652    // ── 16. total_enqueued and total_dequeued counters ────────────────────────
653
654    #[test]
655    fn enqueued_and_dequeued_counters() {
656        let mut p = default_prioritizer();
657        for _ in 0..5 {
658            p.enqueue("peer-a".to_string(), MessagePriority::Normal, 10)
659                .expect("test: enqueue in loop should succeed");
660        }
661        assert_eq!(p.stats().total_enqueued, 5);
662        for _ in 0..3 {
663            p.dequeue("peer-a");
664        }
665        assert_eq!(p.stats().total_dequeued, 3);
666    }
667
668    // ── 17. multi-peer isolation ──────────────────────────────────────────────
669
670    #[test]
671    fn multi_peer_isolation() {
672        let mut p = default_prioritizer();
673        p.enqueue("peer-a".to_string(), MessagePriority::Urgent, 10)
674            .expect("test: enqueue Urgent for peer-a should succeed");
675        p.enqueue("peer-b".to_string(), MessagePriority::Background, 10)
676            .expect("test: enqueue Background for peer-b should succeed");
677
678        let msg_a = p
679            .dequeue("peer-a")
680            .expect("test: dequeue peer-a should return Urgent message");
681        assert_eq!(msg_a.effective_priority, MessagePriority::Urgent);
682        assert_eq!(msg_a.peer_id, "peer-a");
683
684        let msg_b = p
685            .dequeue("peer-b")
686            .expect("test: dequeue peer-b should return Background message");
687        assert_eq!(msg_b.effective_priority, MessagePriority::Background);
688        assert_eq!(msg_b.peer_id, "peer-b");
689    }
690
691    // ── 18. advance_tick does not promote Urgent messages ────────────────────
692
693    #[test]
694    fn advance_tick_does_not_promote_urgent() {
695        let config = AgingConfig {
696            promote_after_ticks: 1,
697            max_queue_size: 256,
698        };
699        let mut p = PeerMessagePrioritizer::new(config);
700        p.enqueue("peer-a".to_string(), MessagePriority::Urgent, 10)
701            .expect("test: enqueue Urgent should succeed");
702
703        p.advance_tick();
704
705        assert_eq!(p.stats().total_promoted, 0);
706        let msg = p
707            .dequeue("peer-a")
708            .expect("test: dequeue should return Urgent message");
709        assert_eq!(msg.effective_priority, MessagePriority::Urgent);
710    }
711
712    // ── 19. payload_bytes is preserved ───────────────────────────────────────
713
714    #[test]
715    fn payload_bytes_preserved() {
716        let mut p = default_prioritizer();
717        p.enqueue("peer-a".to_string(), MessagePriority::Normal, 4096)
718            .expect("test: enqueue 4096 bytes should succeed");
719        let msg = p
720            .dequeue("peer-a")
721            .expect("test: dequeue should return message with payload");
722        assert_eq!(msg.payload_bytes, 4096);
723    }
724
725    // ── 20. promote_after_ticks boundary (strictly >=) ───────────────────────
726
727    #[test]
728    fn promote_at_exact_threshold() {
729        let config = AgingConfig {
730            promote_after_ticks: 5,
731            max_queue_size: 256,
732        };
733        let mut p = PeerMessagePrioritizer::new(config);
734        p.enqueue("peer-a".to_string(), MessagePriority::Background, 10)
735            .expect("test: enqueue Background should succeed");
736
737        for _ in 0..4 {
738            p.advance_tick();
739        }
740        assert_eq!(
741            p.stats().total_promoted,
742            0,
743            "should not promote before threshold"
744        );
745
746        p.advance_tick(); // tick 5 — exactly at threshold
747        assert_eq!(
748            p.stats().total_promoted,
749            1,
750            "should promote at exact threshold"
751        );
752    }
753}