1use std::collections::HashMap;
22
23#[derive(Debug, Clone, PartialEq)]
29pub struct ScheduleSlot {
30 pub peer_id: String,
32 pub allocated_requests: u32,
34 pub weight: f64,
36 pub last_updated: u64,
38}
39
40#[derive(Debug, Clone, PartialEq)]
42pub enum BackpressureSignal {
43 None,
45 Mild {
47 factor: f64,
49 },
50 Severe {
52 factor: f64,
54 },
55 Overloaded,
57}
58
59impl BackpressureSignal {
60 #[inline]
67 pub fn factor(&self) -> f64 {
68 match self {
69 Self::None => 1.0,
70 Self::Mild { factor } => *factor,
71 Self::Severe { factor } => *factor,
72 Self::Overloaded => 0.0,
73 }
74 }
75
76 pub fn label(&self) -> &'static str {
78 match self {
79 Self::None => "none",
80 Self::Mild { .. } => "mild",
81 Self::Severe { .. } => "severe",
82 Self::Overloaded => "overloaded",
83 }
84 }
85}
86
87#[derive(Debug, Clone, PartialEq)]
90pub struct PeerMetrics {
91 pub peer_id: String,
93 pub success_count: u64,
95 pub failure_count: u64,
97 pub total_latency_ms: u64,
99 pub last_seen: u64,
101 pub consecutive_failures: u32,
103}
104
105impl PeerMetrics {
106 pub fn new(peer_id: String, now: u64) -> Self {
108 Self {
109 peer_id,
110 success_count: 0,
111 failure_count: 0,
112 total_latency_ms: 0,
113 last_seen: now,
114 consecutive_failures: 0,
115 }
116 }
117
118 pub fn success_rate(&self) -> f64 {
123 let total = self.success_count + self.failure_count;
124 if total == 0 {
125 1.0
126 } else {
127 self.success_count as f64 / total as f64
128 }
129 }
130
131 pub fn avg_latency_ms(&self) -> f64 {
136 if self.success_count == 0 {
137 f64::MAX
138 } else {
139 self.total_latency_ms as f64 / self.success_count as f64
140 }
141 }
142}
143
144#[derive(Debug, Clone)]
146pub struct SchedulerConfig {
147 pub max_peers: usize,
149 pub base_requests_per_peer: u32,
151 pub max_requests_per_peer: u32,
153 pub min_requests_per_peer: u32,
155 pub backpressure_threshold: f64,
158 pub failure_penalty: f64,
161 pub latency_weight: f64,
163 pub success_weight: f64,
165}
166
167impl Default for SchedulerConfig {
168 fn default() -> Self {
169 Self {
170 max_peers: 50,
171 base_requests_per_peer: 10,
172 max_requests_per_peer: 100,
173 min_requests_per_peer: 1,
174 backpressure_threshold: 0.8,
175 failure_penalty: 0.5,
176 latency_weight: 0.3,
177 success_weight: 0.7,
178 }
179 }
180}
181
182#[derive(Debug, Clone)]
184pub struct SchedulerStats {
185 pub registered_peers: usize,
187 pub active_slots: usize,
189 pub total_dispatched: u64,
191 pub total_succeeded: u64,
193 pub success_rate: f64,
195 pub backpressure: String,
197}
198
199#[derive(Debug)]
225pub struct AdaptivePeerScheduler {
226 pub config: SchedulerConfig,
228 pub peers: HashMap<String, PeerMetrics>,
230 pub schedule: HashMap<String, ScheduleSlot>,
232 pub global_backpressure: BackpressureSignal,
234 pub total_requests_dispatched: u64,
236 pub total_requests_succeeded: u64,
238}
239
240impl AdaptivePeerScheduler {
241 pub fn new(config: SchedulerConfig) -> Self {
245 Self {
246 config,
247 peers: HashMap::new(),
248 schedule: HashMap::new(),
249 global_backpressure: BackpressureSignal::None,
250 total_requests_dispatched: 0,
251 total_requests_succeeded: 0,
252 }
253 }
254
255 pub fn register_peer(&mut self, peer_id: String, now: u64) {
263 if self.peers.contains_key(&peer_id) {
264 return;
265 }
266
267 if self.peers.len() >= self.config.max_peers {
269 if let Some(oldest_id) = self.oldest_peer_id() {
270 self.peers.remove(&oldest_id);
271 self.schedule.remove(&oldest_id);
272 }
273 }
274
275 self.peers
276 .insert(peer_id.clone(), PeerMetrics::new(peer_id, now));
277 }
278
279 pub fn remove_peer(&mut self, peer_id: &str) -> bool {
283 let removed = self.peers.remove(peer_id).is_some();
284 self.schedule.remove(peer_id);
285 removed
286 }
287
288 pub fn record_success(&mut self, peer_id: &str, latency_ms: u64, now: u64) {
295 if let Some(metrics) = self.peers.get_mut(peer_id) {
296 metrics.success_count = metrics.success_count.saturating_add(1);
297 metrics.total_latency_ms = metrics.total_latency_ms.saturating_add(latency_ms);
298 metrics.last_seen = now;
299 metrics.consecutive_failures = 0;
300 self.total_requests_succeeded = self.total_requests_succeeded.saturating_add(1);
301 }
302 }
303
304 pub fn record_failure(&mut self, peer_id: &str, now: u64) {
308 if let Some(metrics) = self.peers.get_mut(peer_id) {
309 metrics.failure_count = metrics.failure_count.saturating_add(1);
310 metrics.consecutive_failures = metrics.consecutive_failures.saturating_add(1);
311 metrics.last_seen = now;
312 }
313 }
314
315 pub fn set_backpressure(&mut self, signal: BackpressureSignal) {
322 self.global_backpressure = signal;
323 }
324
325 pub fn compute_weight(&self, metrics: &PeerMetrics) -> f64 {
340 let sr = metrics.success_rate();
341 let avg_lat = metrics.avg_latency_ms();
342
343 let lat_component = if avg_lat == f64::MAX {
345 0.0
346 } else {
347 1.0 / (1.0 + avg_lat / 1000.0)
348 };
349
350 let mut weight =
351 self.config.success_weight * sr + self.config.latency_weight * lat_component;
352
353 if sr < self.config.backpressure_threshold {
355 weight *= self.config.failure_penalty;
356 }
357
358 weight.clamp(0.01, 1.0)
359 }
360
361 pub fn recompute_schedule(&mut self, now: u64) {
373 let bp_factor = self.global_backpressure.factor();
374 let base = self.config.base_requests_per_peer as f64;
375 let min_req = self.config.min_requests_per_peer;
376 let max_req = self.config.max_requests_per_peer;
377 let overloaded = matches!(self.global_backpressure, BackpressureSignal::Overloaded);
378
379 let peer_data: Vec<(String, f64)> = self
381 .peers
382 .values()
383 .map(|m| (m.peer_id.clone(), self.compute_weight(m)))
384 .collect();
385
386 for (peer_id, weight) in peer_data {
387 let allocated = if overloaded {
388 0u32
389 } else {
390 let raw = (weight * base * bp_factor).round() as u32;
391 raw.clamp(min_req, max_req)
392 };
393
394 let slot = self
395 .schedule
396 .entry(peer_id.clone())
397 .or_insert_with(|| ScheduleSlot {
398 peer_id: peer_id.clone(),
399 allocated_requests: 0,
400 weight: 0.0,
401 last_updated: now,
402 });
403 slot.allocated_requests = allocated;
404 slot.weight = weight;
405 slot.last_updated = now;
406 }
407
408 self.schedule
410 .retain(|pid, _| self.peers.contains_key(pid.as_str()));
411 }
412
413 pub fn next_peer(&self) -> Option<&str> {
422 self.schedule
423 .values()
424 .filter(|s| s.allocated_requests > 0)
425 .max_by_key(|s| s.allocated_requests)
426 .map(|s| s.peer_id.as_str())
427 }
428
429 pub fn mark_dispatched(&mut self) {
433 self.total_requests_dispatched = self.total_requests_dispatched.saturating_add(1);
434 }
435
436 pub fn peek_schedule(&self) -> Vec<(&str, u32, f64)> {
443 let mut entries: Vec<(&str, u32, f64)> = self
444 .schedule
445 .values()
446 .map(|s| (s.peer_id.as_str(), s.allocated_requests, s.weight))
447 .collect();
448 entries.sort_by_key(|b| std::cmp::Reverse(b.1));
449 entries
450 }
451
452 pub fn evict_stale_peers(&mut self, now: u64, max_idle_ms: u64) {
457 let stale: Vec<String> = self
458 .peers
459 .values()
460 .filter(|m| now.saturating_sub(m.last_seen) > max_idle_ms)
461 .map(|m| m.peer_id.clone())
462 .collect();
463
464 for pid in stale {
465 self.peers.remove(&pid);
466 self.schedule.remove(&pid);
467 }
468 }
469
470 pub fn scheduler_stats(&self) -> SchedulerStats {
474 let active_slots = self
475 .schedule
476 .values()
477 .filter(|s| s.allocated_requests > 0)
478 .count();
479
480 let success_rate = if self.total_requests_dispatched == 0 {
481 1.0
482 } else {
483 self.total_requests_succeeded as f64 / self.total_requests_dispatched as f64
484 };
485
486 SchedulerStats {
487 registered_peers: self.peers.len(),
488 active_slots,
489 total_dispatched: self.total_requests_dispatched,
490 total_succeeded: self.total_requests_succeeded,
491 success_rate,
492 backpressure: self.global_backpressure.label().to_string(),
493 }
494 }
495
496 fn oldest_peer_id(&self) -> Option<String> {
500 self.peers
501 .values()
502 .min_by_key(|m| m.last_seen)
503 .map(|m| m.peer_id.clone())
504 }
505}
506
507#[cfg(test)]
512mod tests {
513 use super::{
514 AdaptivePeerScheduler, BackpressureSignal, PeerMetrics, ScheduleSlot, SchedulerConfig,
515 };
516
517 fn default_scheduler() -> AdaptivePeerScheduler {
520 AdaptivePeerScheduler::new(SchedulerConfig::default())
521 }
522
523 fn scheduler_with_peer(peer_id: &str) -> AdaptivePeerScheduler {
524 let mut s = default_scheduler();
525 s.register_peer(peer_id.to_string(), 0);
526 s
527 }
528
529 #[test]
532 fn test_peer_metrics_success_rate_no_activity() {
533 let m = PeerMetrics::new("p1".to_string(), 0);
534 assert_eq!(m.success_rate(), 1.0, "brand-new peer should be 1.0");
535 }
536
537 #[test]
538 fn test_peer_metrics_success_rate_all_success() {
539 let mut m = PeerMetrics::new("p1".to_string(), 0);
540 m.success_count = 5;
541 assert_eq!(m.success_rate(), 1.0);
542 }
543
544 #[test]
545 fn test_peer_metrics_success_rate_mixed() {
546 let mut m = PeerMetrics::new("p1".to_string(), 0);
547 m.success_count = 3;
548 m.failure_count = 1;
549 assert!((m.success_rate() - 0.75).abs() < 1e-9);
550 }
551
552 #[test]
553 fn test_peer_metrics_success_rate_all_failure() {
554 let mut m = PeerMetrics::new("p1".to_string(), 0);
555 m.failure_count = 10;
556 assert_eq!(m.success_rate(), 0.0);
557 }
558
559 #[test]
560 fn test_peer_metrics_avg_latency_no_success() {
561 let m = PeerMetrics::new("p1".to_string(), 0);
562 assert_eq!(m.avg_latency_ms(), f64::MAX);
563 }
564
565 #[test]
566 fn test_peer_metrics_avg_latency_computed() {
567 let mut m = PeerMetrics::new("p1".to_string(), 0);
568 m.success_count = 4;
569 m.total_latency_ms = 200;
570 assert!((m.avg_latency_ms() - 50.0).abs() < 1e-9);
571 }
572
573 #[test]
576 fn test_config_defaults() {
577 let c = SchedulerConfig::default();
578 assert_eq!(c.max_peers, 50);
579 assert_eq!(c.base_requests_per_peer, 10);
580 assert_eq!(c.max_requests_per_peer, 100);
581 assert_eq!(c.min_requests_per_peer, 1);
582 assert!((c.backpressure_threshold - 0.8).abs() < 1e-9);
583 assert!((c.failure_penalty - 0.5).abs() < 1e-9);
584 assert!((c.latency_weight - 0.3).abs() < 1e-9);
585 assert!((c.success_weight - 0.7).abs() < 1e-9);
586 }
587
588 #[test]
591 fn test_backpressure_none_factor() {
592 assert_eq!(BackpressureSignal::None.factor(), 1.0);
593 }
594
595 #[test]
596 fn test_backpressure_mild_factor() {
597 let s = BackpressureSignal::Mild { factor: 0.6 };
598 assert!((s.factor() - 0.6).abs() < 1e-9);
599 }
600
601 #[test]
602 fn test_backpressure_severe_factor() {
603 let s = BackpressureSignal::Severe { factor: 0.2 };
604 assert!((s.factor() - 0.2).abs() < 1e-9);
605 }
606
607 #[test]
608 fn test_backpressure_overloaded_factor() {
609 assert_eq!(BackpressureSignal::Overloaded.factor(), 0.0);
610 }
611
612 #[test]
613 fn test_backpressure_labels() {
614 assert_eq!(BackpressureSignal::None.label(), "none");
615 assert_eq!(BackpressureSignal::Mild { factor: 0.5 }.label(), "mild");
616 assert_eq!(BackpressureSignal::Severe { factor: 0.1 }.label(), "severe");
617 assert_eq!(BackpressureSignal::Overloaded.label(), "overloaded");
618 }
619
620 #[test]
623 fn test_register_peer_basic() {
624 let mut s = default_scheduler();
625 s.register_peer("p1".to_string(), 100);
626 assert_eq!(s.peers.len(), 1);
627 assert!(s.peers.contains_key("p1"));
628 }
629
630 #[test]
631 fn test_register_peer_idempotent() {
632 let mut s = default_scheduler();
633 s.register_peer("p1".to_string(), 100);
634 s.register_peer("p1".to_string(), 200); assert_eq!(s.peers.len(), 1);
636 assert_eq!(s.peers["p1"].last_seen, 100);
638 }
639
640 #[test]
641 fn test_register_peer_evicts_oldest_when_full() {
642 let config = SchedulerConfig {
643 max_peers: 2,
644 ..SchedulerConfig::default()
645 };
646 let mut s = AdaptivePeerScheduler::new(config);
647 s.register_peer("p1".to_string(), 10);
648 s.register_peer("p2".to_string(), 20);
649 assert_eq!(s.peers.len(), 2);
650 s.register_peer("p3".to_string(), 30);
652 assert_eq!(s.peers.len(), 2);
653 assert!(!s.peers.contains_key("p1"), "oldest peer must be evicted");
654 assert!(s.peers.contains_key("p3"));
655 }
656
657 #[test]
660 fn test_remove_peer_known() {
661 let mut s = scheduler_with_peer("p1");
662 assert!(s.remove_peer("p1"));
663 assert!(s.peers.is_empty());
664 }
665
666 #[test]
667 fn test_remove_peer_unknown() {
668 let mut s = default_scheduler();
669 assert!(!s.remove_peer("ghost"));
670 }
671
672 #[test]
673 fn test_remove_peer_clears_schedule_slot() {
674 let mut s = scheduler_with_peer("p1");
675 s.recompute_schedule(0);
676 s.remove_peer("p1");
677 assert!(s.schedule.is_empty());
678 }
679
680 #[test]
683 fn test_record_success_updates_metrics() {
684 let mut s = scheduler_with_peer("p1");
685 s.record_success("p1", 80, 500);
686 let m = &s.peers["p1"];
687 assert_eq!(m.success_count, 1);
688 assert_eq!(m.total_latency_ms, 80);
689 assert_eq!(m.last_seen, 500);
690 assert_eq!(m.consecutive_failures, 0);
691 assert_eq!(s.total_requests_succeeded, 1);
692 }
693
694 #[test]
695 fn test_record_success_ignores_unknown_peer() {
696 let mut s = default_scheduler();
697 s.record_success("ghost", 10, 1000); assert_eq!(s.total_requests_succeeded, 0);
699 }
700
701 #[test]
702 fn test_record_failure_updates_metrics() {
703 let mut s = scheduler_with_peer("p1");
704 s.record_failure("p1", 200);
705 let m = &s.peers["p1"];
706 assert_eq!(m.failure_count, 1);
707 assert_eq!(m.consecutive_failures, 1);
708 assert_eq!(m.last_seen, 200);
709 }
710
711 #[test]
712 fn test_record_failure_resets_after_success() {
713 let mut s = scheduler_with_peer("p1");
714 s.record_failure("p1", 100);
715 s.record_failure("p1", 200);
716 s.record_success("p1", 50, 300);
717 assert_eq!(s.peers["p1"].consecutive_failures, 0);
718 }
719
720 #[test]
721 fn test_record_failure_ignores_unknown_peer() {
722 let mut s = default_scheduler();
723 s.record_failure("ghost", 1000); }
725
726 #[test]
729 fn test_compute_weight_new_peer() {
730 let s = default_scheduler();
731 let m = PeerMetrics::new("p1".to_string(), 0);
732 let w = s.compute_weight(&m);
735 assert!((w - 0.7).abs() < 1e-9, "got {w}");
736 }
737
738 #[test]
739 fn test_compute_weight_perfect_fast_peer() {
740 let s = default_scheduler();
741 let mut m = PeerMetrics::new("p1".to_string(), 0);
742 m.success_count = 100;
743 m.total_latency_ms = 0; let w = s.compute_weight(&m);
747 assert!((w - 1.0).abs() < 1e-9, "got {w}");
748 }
749
750 #[test]
751 fn test_compute_weight_clamped_below() {
752 let s = default_scheduler();
753 let mut m = PeerMetrics::new("p1".to_string(), 0);
754 m.failure_count = 1000; let w = s.compute_weight(&m);
757 assert!(w >= 0.01, "weight must be at least 0.01, got {w}");
758 }
759
760 #[test]
761 fn test_compute_weight_failure_penalty_applied() {
762 let s = default_scheduler();
763 let mut m = PeerMetrics::new("p1".to_string(), 0);
764 m.success_count = 1;
766 m.failure_count = 1;
767 m.total_latency_ms = 1000; let w = s.compute_weight(&m);
769 assert!((w - 0.25).abs() < 1e-6, "got {w}");
772 }
773
774 #[test]
777 fn test_recompute_schedule_creates_slots() {
778 let mut s = default_scheduler();
779 s.register_peer("p1".to_string(), 0);
780 s.register_peer("p2".to_string(), 0);
781 s.recompute_schedule(100);
782 assert_eq!(s.schedule.len(), 2);
783 }
784
785 #[test]
786 fn test_recompute_schedule_overloaded_zeroes_all() {
787 let mut s = scheduler_with_peer("p1");
788 s.record_success("p1", 10, 50);
789 s.set_backpressure(BackpressureSignal::Overloaded);
790 s.recompute_schedule(100);
791 let slot = &s.schedule["p1"];
792 assert_eq!(slot.allocated_requests, 0);
793 }
794
795 #[test]
796 fn test_recompute_schedule_mild_backpressure() {
797 let mut s = scheduler_with_peer("p1");
798 for _ in 0..10 {
800 s.record_success("p1", 0, 100);
801 }
802 s.set_backpressure(BackpressureSignal::Mild { factor: 0.5 });
803 s.recompute_schedule(200);
804 let slot = &s.schedule["p1"];
805 assert_eq!(
807 slot.allocated_requests, 5,
808 "got {}",
809 slot.allocated_requests
810 );
811 }
812
813 #[test]
814 fn test_recompute_schedule_no_backpressure_base_alloc() {
815 let mut s = scheduler_with_peer("p1");
816 for _ in 0..10 {
817 s.record_success("p1", 0, 100);
818 }
819 s.recompute_schedule(200);
820 let slot = &s.schedule["p1"];
821 assert_eq!(slot.allocated_requests, 10);
823 }
824
825 #[test]
826 fn test_recompute_schedule_respects_min_alloc() {
827 let _s = scheduler_with_peer("p1");
828 let cfg = SchedulerConfig {
832 base_requests_per_peer: 0,
833 ..SchedulerConfig::default()
834 }; let mut s2 = AdaptivePeerScheduler::new(cfg);
836 s2.register_peer("p1".to_string(), 0);
837 s2.recompute_schedule(0);
838 assert_eq!(s2.schedule["p1"].allocated_requests, 1);
839 }
840
841 #[test]
842 fn test_recompute_schedule_respects_max_alloc() {
843 let cfg = SchedulerConfig {
844 base_requests_per_peer: 1000,
845 max_requests_per_peer: 50,
846 ..SchedulerConfig::default()
847 };
848 let mut s = AdaptivePeerScheduler::new(cfg);
849 s.register_peer("p1".to_string(), 0);
850 for _ in 0..100 {
851 s.record_success("p1", 0, 100);
852 }
853 s.recompute_schedule(200);
854 assert!(s.schedule["p1"].allocated_requests <= 50);
855 }
856
857 #[test]
860 fn test_next_peer_empty_schedule() {
861 let s = default_scheduler();
862 assert!(s.next_peer().is_none());
863 }
864
865 #[test]
866 fn test_next_peer_all_zero() {
867 let mut s = scheduler_with_peer("p1");
868 s.set_backpressure(BackpressureSignal::Overloaded);
869 s.recompute_schedule(0);
870 assert!(s.next_peer().is_none());
871 }
872
873 #[test]
874 fn test_next_peer_returns_highest_allocation() {
875 let mut s = default_scheduler();
876 s.register_peer("p1".to_string(), 0);
877 s.register_peer("p2".to_string(), 0);
878 for _ in 0..20 {
880 s.record_success("p2", 0, 50);
881 }
882 s.record_failure("p1", 50);
883 s.record_failure("p1", 50);
884 s.recompute_schedule(100);
885 let best = s.next_peer().expect("should have a peer");
886 let p2_alloc = s.schedule["p2"].allocated_requests;
888 let p1_alloc = s.schedule["p1"].allocated_requests;
889 assert!(
890 p2_alloc >= p1_alloc,
891 "p2={p2_alloc} should >= p1={p1_alloc}"
892 );
893 assert_eq!(best, "p2");
895 }
896
897 #[test]
900 fn test_peek_schedule_sorted_desc() {
901 let mut s = default_scheduler();
902 for i in 1u32..=5 {
903 let id = format!("p{i}");
904 s.register_peer(id.clone(), 0);
905 for _ in 0..i {
906 s.record_success(&id, 0, 100);
907 }
908 }
909 s.recompute_schedule(200);
910 let view = s.peek_schedule();
911 for pair in view.windows(2) {
913 assert!(
914 pair[0].1 >= pair[1].1,
915 "not descending: {} < {}",
916 pair[0].1,
917 pair[1].1
918 );
919 }
920 }
921
922 #[test]
923 fn test_peek_schedule_empty() {
924 let s = default_scheduler();
925 assert!(s.peek_schedule().is_empty());
926 }
927
928 #[test]
931 fn test_evict_stale_peers_removes_old() {
932 let mut s = default_scheduler();
933 s.register_peer("old".to_string(), 0);
934 s.register_peer("fresh".to_string(), 9000);
935 s.evict_stale_peers(10_000, 5_000);
937 assert!(!s.peers.contains_key("old"), "old peer must be evicted");
938 assert!(s.peers.contains_key("fresh"), "fresh peer must remain");
939 }
940
941 #[test]
942 fn test_evict_stale_peers_keeps_recent() {
943 let mut s = default_scheduler();
944 s.register_peer("p1".to_string(), 9500);
945 s.evict_stale_peers(10_000, 5_000);
946 assert!(s.peers.contains_key("p1"));
947 }
948
949 #[test]
950 fn test_evict_stale_peers_clears_schedule_slot() {
951 let mut s = default_scheduler();
952 s.register_peer("old".to_string(), 0);
953 s.recompute_schedule(100);
954 s.evict_stale_peers(10_000, 5_000);
955 assert!(!s.schedule.contains_key("old"));
956 }
957
958 #[test]
959 fn test_evict_stale_peers_exact_boundary() {
960 let mut s = default_scheduler();
961 s.register_peer("boundary".to_string(), 5000);
963 s.evict_stale_peers(10_000, 5_000);
964 assert!(
965 s.peers.contains_key("boundary"),
966 "exact boundary must be kept"
967 );
968 }
969
970 #[test]
973 fn test_scheduler_stats_empty() {
974 let s = default_scheduler();
975 let st = s.scheduler_stats();
976 assert_eq!(st.registered_peers, 0);
977 assert_eq!(st.active_slots, 0);
978 assert_eq!(st.total_dispatched, 0);
979 assert_eq!(st.total_succeeded, 0);
980 assert!((st.success_rate - 1.0).abs() < 1e-9);
981 assert_eq!(st.backpressure, "none");
982 }
983
984 #[test]
985 fn test_scheduler_stats_after_activity() {
986 let mut s = scheduler_with_peer("p1");
987 s.record_success("p1", 30, 100);
988 s.mark_dispatched();
989 s.recompute_schedule(200);
990 let st = s.scheduler_stats();
991 assert_eq!(st.registered_peers, 1);
992 assert_eq!(st.total_succeeded, 1);
993 assert_eq!(st.total_dispatched, 1);
994 assert!((st.success_rate - 1.0).abs() < 1e-9);
995 assert!(st.active_slots >= 1);
996 }
997
998 #[test]
999 fn test_scheduler_stats_backpressure_label() {
1000 let mut s = default_scheduler();
1001 s.set_backpressure(BackpressureSignal::Severe { factor: 0.1 });
1002 assert_eq!(s.scheduler_stats().backpressure, "severe");
1003 }
1004
1005 #[test]
1008 fn test_mark_dispatched_increments_counter() {
1009 let mut s = default_scheduler();
1010 assert_eq!(s.total_requests_dispatched, 0);
1011 s.mark_dispatched();
1012 s.mark_dispatched();
1013 assert_eq!(s.total_requests_dispatched, 2);
1014 }
1015
1016 #[test]
1019 fn test_schedule_slot_last_updated_set() {
1020 let mut s = scheduler_with_peer("p1");
1021 s.recompute_schedule(9999);
1022 assert_eq!(s.schedule["p1"].last_updated, 9999);
1023 }
1024
1025 #[test]
1026 fn test_schedule_slot_weight_set() {
1027 let mut s = scheduler_with_peer("p1");
1028 s.recompute_schedule(0);
1029 let w = s.schedule["p1"].weight;
1030 assert!((0.01..=1.0).contains(&w), "weight {w} out of range");
1031 }
1032
1033 #[test]
1036 fn test_recompute_removes_orphaned_schedule_slots() {
1037 let mut s = default_scheduler();
1038 s.register_peer("p1".to_string(), 0);
1039 s.recompute_schedule(0);
1040 s.schedule.insert(
1042 "ghost".to_string(),
1043 ScheduleSlot {
1044 peer_id: "ghost".to_string(),
1045 allocated_requests: 5,
1046 weight: 0.5,
1047 last_updated: 0,
1048 },
1049 );
1050 s.recompute_schedule(100);
1051 assert!(
1052 !s.schedule.contains_key("ghost"),
1053 "orphaned slot must be removed"
1054 );
1055 }
1056
1057 #[test]
1060 fn test_success_count_saturates() {
1061 let mut s = scheduler_with_peer("p1");
1062 s.peers.get_mut("p1").expect("must exist").success_count = u64::MAX;
1063 s.record_success("p1", 0, 0); assert_eq!(s.peers["p1"].success_count, u64::MAX);
1065 }
1066
1067 #[test]
1068 fn test_failure_count_saturates() {
1069 let mut s = scheduler_with_peer("p1");
1070 s.peers.get_mut("p1").expect("must exist").failure_count = u64::MAX;
1071 s.record_failure("p1", 0); assert_eq!(s.peers["p1"].failure_count, u64::MAX);
1073 }
1074
1075 #[test]
1078 fn test_competitive_allocation_higher_success_wins() {
1079 let mut s = default_scheduler();
1080 s.register_peer("good".to_string(), 0);
1081 s.register_peer("bad".to_string(), 0);
1082
1083 for _ in 0..100 {
1084 s.record_success("good", 10, 100);
1085 }
1086 for _ in 0..100 {
1087 s.record_failure("bad", 100);
1088 }
1089 s.recompute_schedule(200);
1090
1091 let good = s.schedule["good"].allocated_requests;
1092 let bad = s.schedule["bad"].allocated_requests;
1093 assert!(good > bad, "good={good} should > bad={bad}");
1094 }
1095
1096 #[test]
1097 fn test_severe_backpressure_reduces_allocation() {
1098 let mut s = scheduler_with_peer("p1");
1099 for _ in 0..10 {
1100 s.record_success("p1", 0, 100);
1101 }
1102 s.recompute_schedule(200);
1103 let full_alloc = s.schedule["p1"].allocated_requests;
1104
1105 s.set_backpressure(BackpressureSignal::Severe { factor: 0.1 });
1106 s.recompute_schedule(300);
1107 let reduced_alloc = s.schedule["p1"].allocated_requests;
1108
1109 assert!(
1110 reduced_alloc <= full_alloc,
1111 "severe bp should reduce: {reduced_alloc} <= {full_alloc}"
1112 );
1113 }
1114}