Skip to main content

ipfrs_network/
message_batcher.rs

1//! Peer message batching for outbound message coalescing
2//!
3//! This module provides `PeerMessageBatcher`, which accumulates outbound messages
4//! destined for the same peer and flushes them as a single batch when a size or
5//! time threshold is reached.
6//!
7//! ## Design
8//!
9//! Messages are accumulated per-peer in an in-memory queue. Flushing is triggered by:
10//! - **Size threshold**: accumulated payload bytes >= `max_batch_bytes`
11//! - **Count threshold**: number of queued messages >= `max_batch_count`
12//! - **Age threshold**: oldest message age (in ticks) >= `max_age_ticks`
13//! - **Manual flush**: caller explicitly flushes a peer or all peers
14//!
15//! The batcher uses a logical tick counter (`u64`) rather than wall-clock time,
16//! allowing deterministic testing without OS time dependencies.
17
18use std::collections::HashMap;
19
20/// A single outbound message queued for batched delivery.
21#[derive(Debug, Clone)]
22pub struct BatchMessage {
23    /// Monotonic message ID assigned at push time.
24    pub msg_id: u64,
25    /// Destination peer identifier.
26    pub peer_id: String,
27    /// Raw payload bytes.
28    pub payload: Vec<u8>,
29    /// Logical tick counter value at the time the message was enqueued.
30    pub enqueued_at: u64,
31}
32
33/// Configuration for `PeerMessageBatcher`.
34#[derive(Debug, Clone)]
35pub struct BatchConfig {
36    /// Flush when accumulated payload bytes across the peer's queue reach this value.
37    pub max_batch_bytes: u64,
38    /// Flush when the peer's queue reaches this many messages.
39    pub max_batch_count: usize,
40    /// Flush when the oldest message in the peer's queue has been waiting for at
41    /// least this many ticks (i.e. `tick - enqueued_at >= max_age_ticks`).
42    pub max_age_ticks: u64,
43}
44
45impl Default for BatchConfig {
46    fn default() -> Self {
47        Self {
48            max_batch_bytes: 65_536,
49            max_batch_count: 64,
50            max_age_ticks: 100,
51        }
52    }
53}
54
55/// Reason why a batch was flushed.
56#[derive(Debug, Clone, PartialEq)]
57pub enum FlushReason {
58    /// Accumulated payload bytes exceeded `max_batch_bytes`.
59    SizeThreshold,
60    /// Number of queued messages reached `max_batch_count`.
61    CountThreshold,
62    /// Oldest message waited at least `max_age_ticks` ticks.
63    AgeThreshold,
64    /// Caller explicitly flushed the peer's queue.
65    ManualFlush,
66}
67
68/// A completed batch ready for delivery.
69#[derive(Debug, Clone)]
70pub struct BatchFlush {
71    /// Destination peer.
72    pub peer_id: String,
73    /// Messages included in this batch (in insertion order).
74    pub messages: Vec<BatchMessage>,
75    /// What triggered the flush.
76    pub reason: FlushReason,
77    /// Sum of payload lengths across all messages in this batch.
78    pub total_bytes: u64,
79}
80
81/// Aggregate statistics for a `PeerMessageBatcher`.
82#[derive(Debug, Clone, Default)]
83pub struct BatcherStats {
84    /// Total number of messages pushed via `push`.
85    pub total_pushed: u64,
86    /// Total number of messages delivered across all flushed batches.
87    pub total_flushed: u64,
88    /// Total number of batches that have been flushed.
89    pub total_batches: u64,
90}
91
92impl BatcherStats {
93    /// Average number of messages per flushed batch.
94    ///
95    /// Returns `0.0` when no batches have been flushed yet.
96    pub fn average_batch_size(&self) -> f64 {
97        self.total_flushed as f64 / self.total_batches.max(1) as f64
98    }
99}
100
101/// Batches outbound messages per-peer and flushes them when thresholds are met.
102pub struct PeerMessageBatcher {
103    /// Per-peer pending message queues.
104    pub pending: HashMap<String, Vec<BatchMessage>>,
105    /// Batching configuration.
106    pub config: BatchConfig,
107    /// Running statistics.
108    pub stats: BatcherStats,
109    /// Next message ID to assign.
110    pub next_id: u64,
111    /// Current logical tick counter.
112    pub tick: u64,
113}
114
115impl PeerMessageBatcher {
116    /// Create a new batcher with the supplied configuration.
117    pub fn new(config: BatchConfig) -> Self {
118        Self {
119            pending: HashMap::new(),
120            config,
121            stats: BatcherStats::default(),
122            next_id: 0,
123            tick: 0,
124        }
125    }
126
127    /// Push a message for `peer_id`.
128    ///
129    /// Assigns a monotonic `msg_id` and records `enqueued_at = tick`, then appends
130    /// to the peer's queue.  If either the size or count threshold is exceeded
131    /// *after* appending, the peer's queue is immediately flushed and the resulting
132    /// `BatchFlush` is returned.  Otherwise `None` is returned and the message
133    /// stays in the queue until the next flush trigger.
134    pub fn push(&mut self, peer_id: String, payload: Vec<u8>) -> Option<BatchFlush> {
135        let msg_id = self.next_id;
136        self.next_id += 1;
137
138        let msg = BatchMessage {
139            msg_id,
140            peer_id: peer_id.clone(),
141            payload,
142            enqueued_at: self.tick,
143        };
144
145        let queue = self.pending.entry(peer_id.clone()).or_default();
146        queue.push(msg);
147        self.stats.total_pushed += 1;
148
149        // Check size threshold.
150        let total_bytes: u64 = queue.iter().map(|m| m.payload.len() as u64).sum();
151        if total_bytes >= self.config.max_batch_bytes {
152            return Some(self.do_flush(&peer_id, FlushReason::SizeThreshold));
153        }
154
155        // Check count threshold.
156        if queue.len() >= self.config.max_batch_count {
157            return Some(self.do_flush(&peer_id, FlushReason::CountThreshold));
158        }
159
160        None
161    }
162
163    /// Advance the logical tick counter by one and check age thresholds.
164    ///
165    /// For each peer whose oldest pending message has been waiting for at least
166    /// `max_age_ticks` ticks, the queue is flushed with `AgeThreshold`.
167    ///
168    /// Returns all flushes that were triggered.
169    pub fn tick_advance(&mut self) -> Vec<BatchFlush> {
170        self.tick += 1;
171
172        // Collect peers that need age-triggered flushing.
173        // We must collect first to avoid holding a borrow while we mutate `pending`.
174        let peers_to_flush: Vec<String> = self
175            .pending
176            .iter()
177            .filter_map(|(peer_id, queue)| {
178                if let Some(oldest) = queue.first() {
179                    if self.tick.saturating_sub(oldest.enqueued_at) >= self.config.max_age_ticks {
180                        return Some(peer_id.clone());
181                    }
182                }
183                None
184            })
185            .collect();
186
187        peers_to_flush
188            .into_iter()
189            .map(|peer_id| self.do_flush(&peer_id, FlushReason::AgeThreshold))
190            .collect()
191    }
192
193    /// Force-flush a specific peer's pending messages with `ManualFlush`.
194    ///
195    /// Returns `None` if the peer has no pending messages.
196    pub fn flush_peer(&mut self, peer_id: &str) -> Option<BatchFlush> {
197        match self.pending.get(peer_id) {
198            Some(queue) if !queue.is_empty() => {
199                Some(self.do_flush(peer_id, FlushReason::ManualFlush))
200            }
201            _ => None,
202        }
203    }
204
205    /// Force-flush every peer that has pending messages with `ManualFlush`.
206    pub fn flush_all(&mut self) -> Vec<BatchFlush> {
207        let peers: Vec<String> = self
208            .pending
209            .iter()
210            .filter(|(_, q)| !q.is_empty())
211            .map(|(p, _)| p.clone())
212            .collect();
213
214        peers
215            .into_iter()
216            .map(|peer_id| self.do_flush(&peer_id, FlushReason::ManualFlush))
217            .collect()
218    }
219
220    /// Return a reference to the current statistics.
221    pub fn stats(&self) -> &BatcherStats {
222        &self.stats
223    }
224
225    /// Drain the queue for `peer_id`, compute statistics, and return a `BatchFlush`.
226    ///
227    /// # Panics (never)
228    ///
229    /// This function is careful to handle missing/empty queue entries gracefully.
230    fn do_flush(&mut self, peer_id: &str, reason: FlushReason) -> BatchFlush {
231        let messages = self.pending.remove(peer_id).unwrap_or_default();
232
233        let total_bytes: u64 = messages.iter().map(|m| m.payload.len() as u64).sum();
234        let count = messages.len() as u64;
235
236        self.stats.total_flushed += count;
237        self.stats.total_batches += 1;
238
239        BatchFlush {
240            peer_id: peer_id.to_string(),
241            messages,
242            reason,
243            total_bytes,
244        }
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    fn default_batcher() -> PeerMessageBatcher {
253        PeerMessageBatcher::new(BatchConfig::default())
254    }
255
256    // -------------------------------------------------------------------------
257    // push – under threshold → None
258    // -------------------------------------------------------------------------
259
260    #[test]
261    fn test_push_under_all_thresholds_returns_none() {
262        let mut batcher = default_batcher();
263        let result = batcher.push("peer-A".to_string(), vec![0u8; 100]);
264        assert!(result.is_none(), "single small message should not flush");
265    }
266
267    #[test]
268    fn test_push_increments_total_pushed() {
269        let mut batcher = default_batcher();
270        batcher.push("peer-A".to_string(), vec![1u8; 10]);
271        batcher.push("peer-A".to_string(), vec![2u8; 10]);
272        assert_eq!(batcher.stats().total_pushed, 2);
273    }
274
275    #[test]
276    fn test_push_assigns_monotonic_ids() {
277        let mut batcher = default_batcher();
278        batcher.push("peer-A".to_string(), vec![0u8; 1]);
279        batcher.push("peer-A".to_string(), vec![0u8; 1]);
280        let queue = batcher.pending.get("peer-A").expect("queue must exist");
281        assert_eq!(queue[0].msg_id, 0);
282        assert_eq!(queue[1].msg_id, 1);
283    }
284
285    #[test]
286    fn test_push_records_enqueued_at_tick() {
287        let mut batcher = default_batcher();
288        // Advance tick to 5 before pushing.
289        for _ in 0..5 {
290            batcher.tick_advance();
291        }
292        batcher.push("peer-A".to_string(), vec![0u8; 1]);
293        let queue = batcher.pending.get("peer-A").expect("queue must exist");
294        assert_eq!(queue[0].enqueued_at, 5);
295    }
296
297    // -------------------------------------------------------------------------
298    // push – size threshold → Some(SizeThreshold)
299    // -------------------------------------------------------------------------
300
301    #[test]
302    fn test_push_over_size_threshold_returns_size_flush() {
303        let config = BatchConfig {
304            max_batch_bytes: 100,
305            max_batch_count: 1000,
306            max_age_ticks: 10_000,
307        };
308        let mut batcher = PeerMessageBatcher::new(config);
309
310        // Push 90 bytes – still under threshold.
311        assert!(batcher.push("peer-B".to_string(), vec![0u8; 90]).is_none());
312
313        // Push 10 more bytes to reach exactly 100 – should trigger SizeThreshold.
314        let flush = batcher
315            .push("peer-B".to_string(), vec![0u8; 10])
316            .expect("should flush on size threshold");
317
318        assert_eq!(flush.peer_id, "peer-B");
319        assert_eq!(flush.reason, FlushReason::SizeThreshold);
320        assert_eq!(flush.total_bytes, 100);
321        assert_eq!(flush.messages.len(), 2);
322    }
323
324    #[test]
325    fn test_push_size_flush_clears_peer_queue() {
326        let config = BatchConfig {
327            max_batch_bytes: 50,
328            max_batch_count: 1000,
329            max_age_ticks: 10_000,
330        };
331        let mut batcher = PeerMessageBatcher::new(config);
332        batcher.push("peer-C".to_string(), vec![0u8; 50]);
333        // Queue should be gone after flush.
334        assert!(
335            !batcher.pending.contains_key("peer-C")
336                || batcher
337                    .pending
338                    .get("peer-C")
339                    .map(|q| q.is_empty())
340                    .unwrap_or(false)
341        );
342    }
343
344    // -------------------------------------------------------------------------
345    // push – count threshold → Some(CountThreshold)
346    // -------------------------------------------------------------------------
347
348    #[test]
349    fn test_push_over_count_threshold_returns_count_flush() {
350        let config = BatchConfig {
351            max_batch_bytes: u64::MAX,
352            max_batch_count: 3,
353            max_age_ticks: 10_000,
354        };
355        let mut batcher = PeerMessageBatcher::new(config);
356
357        assert!(batcher.push("peer-D".to_string(), vec![0u8; 1]).is_none());
358        assert!(batcher.push("peer-D".to_string(), vec![0u8; 1]).is_none());
359
360        let flush = batcher
361            .push("peer-D".to_string(), vec![0u8; 1])
362            .expect("should flush on count threshold");
363
364        assert_eq!(flush.reason, FlushReason::CountThreshold);
365        assert_eq!(flush.messages.len(), 3);
366    }
367
368    #[test]
369    fn test_push_count_flush_updates_stats() {
370        let config = BatchConfig {
371            max_batch_bytes: u64::MAX,
372            max_batch_count: 2,
373            max_age_ticks: 10_000,
374        };
375        let mut batcher = PeerMessageBatcher::new(config);
376        batcher.push("peer-E".to_string(), vec![0u8; 1]);
377        batcher.push("peer-E".to_string(), vec![0u8; 1]);
378
379        let stats = batcher.stats();
380        assert_eq!(stats.total_flushed, 2);
381        assert_eq!(stats.total_batches, 1);
382    }
383
384    // -------------------------------------------------------------------------
385    // tick_advance – age threshold → AgeThreshold
386    // -------------------------------------------------------------------------
387
388    #[test]
389    fn test_tick_advance_fires_age_threshold() {
390        let config = BatchConfig {
391            max_batch_bytes: u64::MAX,
392            max_batch_count: 1000,
393            max_age_ticks: 5,
394        };
395        let mut batcher = PeerMessageBatcher::new(config);
396        batcher.push("peer-F".to_string(), vec![0u8; 1]);
397
398        // Advance 4 ticks – should not yet flush.
399        for _ in 0..4 {
400            let flushes = batcher.tick_advance();
401            assert!(flushes.is_empty(), "should not flush before age threshold");
402        }
403
404        // 5th advance reaches age threshold.
405        let flushes = batcher.tick_advance();
406        assert_eq!(flushes.len(), 1);
407        assert_eq!(flushes[0].reason, FlushReason::AgeThreshold);
408        assert_eq!(flushes[0].peer_id, "peer-F");
409    }
410
411    #[test]
412    fn test_tick_advance_multiple_peers() {
413        let config = BatchConfig {
414            max_batch_bytes: u64::MAX,
415            max_batch_count: 1000,
416            max_age_ticks: 3,
417        };
418        let mut batcher = PeerMessageBatcher::new(config);
419        batcher.push("peer-G1".to_string(), vec![0u8; 1]);
420        batcher.push("peer-G2".to_string(), vec![0u8; 1]);
421
422        // Advance 3 ticks.
423        for _ in 0..2 {
424            batcher.tick_advance();
425        }
426        let flushes = batcher.tick_advance();
427        assert_eq!(flushes.len(), 2, "both peers should age-flush together");
428        let reasons: Vec<_> = flushes.iter().map(|f| &f.reason).collect();
429        assert!(reasons.iter().all(|r| **r == FlushReason::AgeThreshold));
430    }
431
432    #[test]
433    fn test_tick_advance_no_flush_when_empty() {
434        let mut batcher = default_batcher();
435        for _ in 0..200 {
436            let flushes = batcher.tick_advance();
437            assert!(flushes.is_empty());
438        }
439    }
440
441    // -------------------------------------------------------------------------
442    // flush_peer – ManualFlush
443    // -------------------------------------------------------------------------
444
445    #[test]
446    fn test_flush_peer_returns_manual_flush() {
447        let mut batcher = default_batcher();
448        batcher.push("peer-H".to_string(), vec![1u8; 20]);
449        batcher.push("peer-H".to_string(), vec![2u8; 30]);
450
451        let flush = batcher
452            .flush_peer("peer-H")
453            .expect("should return flush for non-empty peer");
454
455        assert_eq!(flush.reason, FlushReason::ManualFlush);
456        assert_eq!(flush.messages.len(), 2);
457        assert_eq!(flush.total_bytes, 50);
458    }
459
460    #[test]
461    fn test_flush_peer_returns_none_when_empty() {
462        let mut batcher = default_batcher();
463        let result = batcher.flush_peer("unknown-peer");
464        assert!(result.is_none());
465    }
466
467    #[test]
468    fn test_flush_peer_clears_queue() {
469        let mut batcher = default_batcher();
470        batcher.push("peer-I".to_string(), vec![0u8; 5]);
471        batcher.flush_peer("peer-I");
472
473        let result = batcher.flush_peer("peer-I");
474        assert!(result.is_none(), "queue should be empty after flush");
475    }
476
477    // -------------------------------------------------------------------------
478    // flush_all
479    // -------------------------------------------------------------------------
480
481    #[test]
482    fn test_flush_all_flushes_multiple_peers() {
483        let mut batcher = default_batcher();
484        batcher.push("peer-J1".to_string(), vec![0u8; 10]);
485        batcher.push("peer-J2".to_string(), vec![0u8; 20]);
486        batcher.push("peer-J3".to_string(), vec![0u8; 30]);
487
488        let flushes = batcher.flush_all();
489        assert_eq!(flushes.len(), 3);
490        assert!(flushes.iter().all(|f| f.reason == FlushReason::ManualFlush));
491    }
492
493    #[test]
494    fn test_flush_all_returns_empty_vec_when_nothing_pending() {
495        let mut batcher = default_batcher();
496        let flushes = batcher.flush_all();
497        assert!(flushes.is_empty());
498    }
499
500    #[test]
501    fn test_flush_all_clears_all_queues() {
502        let mut batcher = default_batcher();
503        batcher.push("peer-K1".to_string(), vec![0u8; 1]);
504        batcher.push("peer-K2".to_string(), vec![0u8; 1]);
505        batcher.flush_all();
506
507        let flushes_after = batcher.flush_all();
508        assert!(
509            flushes_after.is_empty(),
510            "all queues should be empty after flush_all"
511        );
512    }
513
514    // -------------------------------------------------------------------------
515    // stats
516    // -------------------------------------------------------------------------
517
518    #[test]
519    fn test_stats_after_multiple_flushes() {
520        let config = BatchConfig {
521            max_batch_bytes: u64::MAX,
522            max_batch_count: 2,
523            max_age_ticks: 10_000,
524        };
525        let mut batcher = PeerMessageBatcher::new(config);
526
527        // First batch (peer-L, 2 messages → CountThreshold flush)
528        batcher.push("peer-L".to_string(), vec![0u8; 1]);
529        batcher.push("peer-L".to_string(), vec![0u8; 1]);
530
531        // Second batch (peer-M, 2 messages → CountThreshold flush)
532        batcher.push("peer-M".to_string(), vec![0u8; 1]);
533        batcher.push("peer-M".to_string(), vec![0u8; 1]);
534
535        let stats = batcher.stats();
536        assert_eq!(stats.total_pushed, 4);
537        assert_eq!(stats.total_flushed, 4);
538        assert_eq!(stats.total_batches, 2);
539    }
540
541    #[test]
542    fn test_average_batch_size_zero_when_no_batches() {
543        let batcher = default_batcher();
544        // total_batches = 0 → max(1) makes denominator 1 → returns 0.0
545        assert_eq!(batcher.stats().average_batch_size(), 0.0);
546    }
547
548    #[test]
549    fn test_average_batch_size_correct() {
550        let config = BatchConfig {
551            max_batch_bytes: u64::MAX,
552            max_batch_count: 3,
553            max_age_ticks: 10_000,
554        };
555        let mut batcher = PeerMessageBatcher::new(config);
556
557        // Batch 1: 3 messages
558        batcher.push("peer-N".to_string(), vec![0u8; 1]);
559        batcher.push("peer-N".to_string(), vec![0u8; 1]);
560        batcher.push("peer-N".to_string(), vec![0u8; 1]);
561
562        // Batch 2: manually flush peer-N2 with 1 message
563        batcher.push("peer-N2".to_string(), vec![0u8; 1]);
564        batcher.flush_peer("peer-N2");
565
566        // total_flushed = 4, total_batches = 2 → average = 2.0
567        let avg = batcher.stats().average_batch_size();
568        assert!(
569            (avg - 2.0).abs() < f64::EPSILON,
570            "expected average 2.0, got {avg}"
571        );
572    }
573
574    #[test]
575    fn test_do_flush_total_bytes_computed_correctly() {
576        let mut batcher = default_batcher();
577        batcher.push("peer-O".to_string(), vec![0u8; 100]);
578        batcher.push("peer-O".to_string(), vec![0u8; 200]);
579        batcher.push("peer-O".to_string(), vec![0u8; 50]);
580
581        let flush = batcher.flush_peer("peer-O").expect("flush must succeed");
582        assert_eq!(flush.total_bytes, 350);
583    }
584
585    #[test]
586    fn test_separate_peer_queues_are_independent() {
587        let config = BatchConfig {
588            max_batch_bytes: u64::MAX,
589            max_batch_count: 2,
590            max_age_ticks: 10_000,
591        };
592        let mut batcher = PeerMessageBatcher::new(config);
593
594        // peer-P1 gets 1 message (under count threshold).
595        let r1 = batcher.push("peer-P1".to_string(), vec![0u8; 1]);
596        assert!(r1.is_none());
597
598        // peer-P2 reaches the count threshold independently.
599        batcher.push("peer-P2".to_string(), vec![0u8; 1]);
600        let r2 = batcher.push("peer-P2".to_string(), vec![0u8; 1]);
601        assert!(r2.is_some(), "peer-P2 should flush independently");
602
603        // peer-P1 still has its message.
604        let q = batcher
605            .pending
606            .get("peer-P1")
607            .expect("peer-P1 queue must exist");
608        assert_eq!(q.len(), 1);
609    }
610}