1use std::collections::HashMap;
19
20#[derive(Debug, Clone)]
22pub struct BatchMessage {
23 pub msg_id: u64,
25 pub peer_id: String,
27 pub payload: Vec<u8>,
29 pub enqueued_at: u64,
31}
32
33#[derive(Debug, Clone)]
35pub struct BatchConfig {
36 pub max_batch_bytes: u64,
38 pub max_batch_count: usize,
40 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#[derive(Debug, Clone, PartialEq)]
57pub enum FlushReason {
58 SizeThreshold,
60 CountThreshold,
62 AgeThreshold,
64 ManualFlush,
66}
67
68#[derive(Debug, Clone)]
70pub struct BatchFlush {
71 pub peer_id: String,
73 pub messages: Vec<BatchMessage>,
75 pub reason: FlushReason,
77 pub total_bytes: u64,
79}
80
81#[derive(Debug, Clone, Default)]
83pub struct BatcherStats {
84 pub total_pushed: u64,
86 pub total_flushed: u64,
88 pub total_batches: u64,
90}
91
92impl BatcherStats {
93 pub fn average_batch_size(&self) -> f64 {
97 self.total_flushed as f64 / self.total_batches.max(1) as f64
98 }
99}
100
101pub struct PeerMessageBatcher {
103 pub pending: HashMap<String, Vec<BatchMessage>>,
105 pub config: BatchConfig,
107 pub stats: BatcherStats,
109 pub next_id: u64,
111 pub tick: u64,
113}
114
115impl PeerMessageBatcher {
116 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 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 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 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 pub fn tick_advance(&mut self) -> Vec<BatchFlush> {
170 self.tick += 1;
171
172 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 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 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 pub fn stats(&self) -> &BatcherStats {
222 &self.stats
223 }
224
225 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 #[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 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 #[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 assert!(batcher.push("peer-B".to_string(), vec![0u8; 90]).is_none());
312
313 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 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 #[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 #[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 for _ in 0..4 {
400 let flushes = batcher.tick_advance();
401 assert!(flushes.is_empty(), "should not flush before age threshold");
402 }
403
404 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 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 #[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 #[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 #[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 batcher.push("peer-L".to_string(), vec![0u8; 1]);
529 batcher.push("peer-L".to_string(), vec![0u8; 1]);
530
531 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 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 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 batcher.push("peer-N2".to_string(), vec![0u8; 1]);
564 batcher.flush_peer("peer-N2");
565
566 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 let r1 = batcher.push("peer-P1".to_string(), vec![0u8; 1]);
596 assert!(r1.is_none());
597
598 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 let q = batcher
605 .pending
606 .get("peer-P1")
607 .expect("peer-P1 queue must exist");
608 assert_eq!(q.len(), 1);
609 }
610}