Skip to main content

ipfrs_network/
bandwidth_allocator.rs

1//! Peer bandwidth allocator using weighted max-min fairness.
2//!
3//! Allocates outbound bandwidth fairly across peers with per-peer minimum
4//! guarantees and hard caps. Supports three allocation strategies:
5//! `EqualShare`, `WeightedFair`, and `MaxMinFair`.
6
7use std::collections::HashMap;
8
9// ── Strategy ─────────────────────────────────────────────────────────────────
10
11/// Strategy used by [`PeerBandwidthAllocator`] when distributing bandwidth.
12#[derive(Clone, Copy, Debug, PartialEq)]
13pub enum AllocationStrategy {
14    /// Each peer receives `total_bps / peer_count`, clamped to `[min_bps, max_bps]`.
15    EqualShare,
16    /// Each peer receives `(weight / Σweights) * total_bps`, clamped to `[min_bps, max_bps]`.
17    WeightedFair,
18    /// Iterative max-min: guarantee `min_bps` first, then distribute the remainder equally
19    /// until every peer hits its `max_bps` cap.
20    MaxMinFair,
21}
22
23// ── PeerAllocation ────────────────────────────────────────────────────────────
24
25/// Per-peer allocation record.
26#[derive(Clone, Debug)]
27pub struct PeerAllocation {
28    /// Unique peer identifier.
29    pub peer_id: String,
30    /// Relative weight used by `WeightedFair` (default `1.0`).
31    pub weight: f64,
32    /// Guaranteed minimum bandwidth in bps.
33    pub min_bps: u64,
34    /// Hard cap in bps (`u64::MAX` = unlimited).
35    pub max_bps: u64,
36    /// Result of the last [`PeerBandwidthAllocator::run_allocation`] call.
37    pub allocated_bps: u64,
38}
39
40impl PeerAllocation {
41    fn new(peer_id: String, weight: f64, min_bps: u64, max_bps: u64) -> Self {
42        Self {
43            peer_id,
44            weight,
45            min_bps,
46            max_bps,
47            allocated_bps: 0,
48        }
49    }
50}
51
52// ── AllocationStats ───────────────────────────────────────────────────────────
53
54/// Aggregate statistics produced by [`PeerBandwidthAllocator::stats`].
55#[derive(Clone, Debug)]
56pub struct AllocationStats {
57    /// Configured total bandwidth budget in bps.
58    pub total_bps: u64,
59    /// Sum of all per-peer `allocated_bps` after the last allocation run.
60    pub allocated_bps: u64,
61    /// `total_bps - allocated_bps`.
62    pub unallocated_bps: u64,
63    /// Number of peers currently tracked.
64    pub peer_count: usize,
65}
66
67impl AllocationStats {
68    /// Returns the fraction of the total bandwidth that has been allocated
69    /// (`allocated_bps / total_bps`), or `0.0` when `total_bps == 0`.
70    pub fn utilization(&self) -> f64 {
71        if self.total_bps == 0 {
72            return 0.0;
73        }
74        self.allocated_bps as f64 / self.total_bps as f64
75    }
76}
77
78// ── PeerBandwidthAllocator ────────────────────────────────────────────────────
79
80/// Allocates outbound bandwidth fairly across peers.
81pub struct PeerBandwidthAllocator {
82    /// All tracked peers, keyed by peer_id.
83    pub peers: HashMap<String, PeerAllocation>,
84    /// Total available bandwidth budget in bps.
85    pub total_bps: u64,
86    /// Active allocation strategy.
87    pub strategy: AllocationStrategy,
88}
89
90impl PeerBandwidthAllocator {
91    /// Creates a new allocator with the given budget and strategy.
92    pub fn new(total_bps: u64, strategy: AllocationStrategy) -> Self {
93        Self {
94            peers: HashMap::new(),
95            total_bps,
96            strategy,
97        }
98    }
99
100    /// Registers a new peer.  If the peer already exists it is updated in place.
101    pub fn add_peer(&mut self, peer_id: String, weight: f64, min_bps: u64, max_bps: u64) {
102        self.peers
103            .entry(peer_id.clone())
104            .and_modify(|p| {
105                p.weight = weight;
106                p.min_bps = min_bps;
107                p.max_bps = max_bps;
108            })
109            .or_insert_with(|| PeerAllocation::new(peer_id, weight, min_bps, max_bps));
110    }
111
112    /// Removes a peer and returns `true` if it existed.
113    pub fn remove_peer(&mut self, peer_id: &str) -> bool {
114        self.peers.remove(peer_id).is_some()
115    }
116
117    /// Runs the configured allocation strategy and stores results in each peer's
118    /// `allocated_bps` field.
119    pub fn run_allocation(&mut self) {
120        let count = self.peers.len();
121        if count == 0 {
122            return;
123        }
124
125        match self.strategy {
126            AllocationStrategy::EqualShare => self.run_equal_share(count),
127            AllocationStrategy::WeightedFair => self.run_weighted_fair(),
128            AllocationStrategy::MaxMinFair => self.run_max_min_fair(count),
129        }
130    }
131
132    // ── EqualShare ──────────────────────────────────────────────────────────
133
134    fn run_equal_share(&mut self, count: usize) {
135        let share = self.total_bps / count as u64;
136        for peer in self.peers.values_mut() {
137            peer.allocated_bps = share.clamp(peer.min_bps, peer.max_bps);
138        }
139    }
140
141    // ── WeightedFair ────────────────────────────────────────────────────────
142
143    fn run_weighted_fair(&mut self) {
144        let sum_weights: f64 = self.peers.values().map(|p| p.weight.max(0.0)).sum();
145
146        if sum_weights <= 0.0 {
147            // All weights are zero — fall back to zero allocation (honour min_bps).
148            for peer in self.peers.values_mut() {
149                peer.allocated_bps = peer.min_bps.min(peer.max_bps);
150            }
151            return;
152        }
153
154        let total = self.total_bps as f64;
155        for peer in self.peers.values_mut() {
156            let w = peer.weight.max(0.0);
157            let proportional = ((w / sum_weights) * total) as u64;
158            peer.allocated_bps = proportional.clamp(peer.min_bps, peer.max_bps);
159        }
160    }
161
162    // ── MaxMinFair ──────────────────────────────────────────────────────────
163
164    fn run_max_min_fair(&mut self, count: usize) {
165        // --- Step 1: satisfy minimum guarantees ---
166        // Sum of all min_bps (capped by max_bps so min can never exceed max).
167        let total_min: u64 = self.peers.values().map(|p| p.min_bps.min(p.max_bps)).sum();
168
169        // If the total budget cannot even cover the minimums we distribute what
170        // we have proportionally to the minimums (best-effort).
171        if total_min >= self.total_bps {
172            let total_bps = self.total_bps;
173            let total_min_f = total_min as f64;
174            for peer in self.peers.values_mut() {
175                let effective_min = peer.min_bps.min(peer.max_bps) as f64;
176                let share = if total_min_f > 0.0 {
177                    ((effective_min / total_min_f) * total_bps as f64) as u64
178                } else {
179                    0
180                };
181                peer.allocated_bps = share.min(peer.max_bps);
182            }
183            return;
184        }
185
186        // Assign minimums.
187        for peer in self.peers.values_mut() {
188            peer.allocated_bps = peer.min_bps.min(peer.max_bps);
189        }
190
191        // --- Step 2: iterative max-min distribution of remainder ---
192        // Remaining budget after satisfying minimums.
193        let mut remaining = self.total_bps.saturating_sub(total_min);
194        // Peers that have not yet hit their cap.
195        let mut uncapped_count = count;
196
197        loop {
198            if remaining == 0 || uncapped_count == 0 {
199                break;
200            }
201
202            let fair_share = remaining / uncapped_count as u64;
203            if fair_share == 0 {
204                break;
205            }
206
207            let mut newly_capped = 0u64; // bandwidth freed back by peers hitting their cap
208            let mut still_uncapped = 0usize;
209
210            for peer in self.peers.values_mut() {
211                if peer.allocated_bps >= peer.max_bps {
212                    // Already capped in a previous iteration.
213                    continue;
214                }
215                let candidate = peer.allocated_bps + fair_share;
216                if candidate >= peer.max_bps {
217                    newly_capped += candidate - peer.max_bps;
218                    peer.allocated_bps = peer.max_bps;
219                } else {
220                    peer.allocated_bps = candidate;
221                    still_uncapped += 1;
222                }
223            }
224
225            remaining = newly_capped;
226            uncapped_count = still_uncapped;
227        }
228
229        // If there is leftover and some peers are still under their cap,
230        // give whatever is left to the first uncapped peer (avoids wasting budget
231        // due to integer truncation).
232        if remaining > 0 {
233            if let Some(peer) = self
234                .peers
235                .values_mut()
236                .find(|p| p.allocated_bps < p.max_bps)
237            {
238                let extra = remaining.min(peer.max_bps - peer.allocated_bps);
239                peer.allocated_bps += extra;
240            }
241        }
242    }
243
244    // ── Query helpers ────────────────────────────────────────────────────────
245
246    /// Returns the last allocated bandwidth for `peer_id`, or `None` if unknown.
247    pub fn allocation_for(&self, peer_id: &str) -> Option<u64> {
248        self.peers.get(peer_id).map(|p| p.allocated_bps)
249    }
250
251    /// Returns aggregate statistics for the current state.
252    pub fn stats(&self) -> AllocationStats {
253        let allocated_bps: u64 = self.peers.values().map(|p| p.allocated_bps).sum();
254        let unallocated_bps = self.total_bps.saturating_sub(allocated_bps);
255        AllocationStats {
256            total_bps: self.total_bps,
257            allocated_bps,
258            unallocated_bps,
259            peer_count: self.peers.len(),
260        }
261    }
262}
263
264// ── Tests ─────────────────────────────────────────────────────────────────────
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    // helper: build an allocator with N equal peers (weight=1, min=0, max=unlimited)
271    fn alloc_equal_peers(n: usize, total_bps: u64) -> PeerBandwidthAllocator {
272        let mut alloc = PeerBandwidthAllocator::new(total_bps, AllocationStrategy::EqualShare);
273        for i in 0..n {
274            alloc.add_peer(format!("peer{i}"), 1.0, 0, u64::MAX);
275        }
276        alloc
277    }
278
279    // ── EqualShare ────────────────────────────────────────────────────────────
280
281    /// T01 – equal share distributes total_bps / peer_count to every peer.
282    #[test]
283    fn t01_equal_share_distributes_evenly() {
284        let mut alloc = alloc_equal_peers(4, 1000);
285        alloc.run_allocation();
286        for peer in alloc.peers.values() {
287            assert_eq!(
288                peer.allocated_bps, 250,
289                "peer {} got unexpected share",
290                peer.peer_id
291            );
292        }
293    }
294
295    /// T02 – EqualShare respects max_bps cap.
296    #[test]
297    fn t02_equal_share_respects_max_cap() {
298        let mut alloc = PeerBandwidthAllocator::new(1000, AllocationStrategy::EqualShare);
299        alloc.add_peer("p0".into(), 1.0, 0, 100); // cap at 100; share would be 500
300        alloc.add_peer("p1".into(), 1.0, 0, u64::MAX);
301        alloc.run_allocation();
302        assert_eq!(alloc.allocation_for("p0"), Some(100));
303        assert_eq!(alloc.allocation_for("p1"), Some(500));
304    }
305
306    /// T03 – EqualShare respects min_bps guarantee.
307    #[test]
308    fn t03_equal_share_respects_min_guarantee() {
309        let mut alloc = PeerBandwidthAllocator::new(1000, AllocationStrategy::EqualShare);
310        alloc.add_peer("p0".into(), 1.0, 600, u64::MAX); // share=500 < min=600
311        alloc.add_peer("p1".into(), 1.0, 0, u64::MAX);
312        alloc.run_allocation();
313        assert_eq!(alloc.allocation_for("p0"), Some(600));
314        assert_eq!(alloc.allocation_for("p1"), Some(500));
315    }
316
317    // ── WeightedFair ─────────────────────────────────────────────────────────
318
319    /// T04 – WeightedFair is proportional to weights.
320    #[test]
321    fn t04_weighted_fair_proportional() {
322        let mut alloc = PeerBandwidthAllocator::new(1200, AllocationStrategy::WeightedFair);
323        alloc.add_peer("p0".into(), 1.0, 0, u64::MAX);
324        alloc.add_peer("p1".into(), 2.0, 0, u64::MAX);
325        alloc.add_peer("p2".into(), 3.0, 0, u64::MAX);
326        alloc.run_allocation();
327        // weights 1:2:3 over 1200 bps → 200, 400, 600
328        assert_eq!(alloc.allocation_for("p0"), Some(200));
329        assert_eq!(alloc.allocation_for("p1"), Some(400));
330        assert_eq!(alloc.allocation_for("p2"), Some(600));
331    }
332
333    /// T05 – WeightedFair respects max_bps cap.
334    #[test]
335    fn t05_weighted_fair_respects_max_cap() {
336        let mut alloc = PeerBandwidthAllocator::new(1000, AllocationStrategy::WeightedFair);
337        alloc.add_peer("heavy".into(), 9.0, 0, 200); // would get 900 but capped at 200
338        alloc.add_peer("light".into(), 1.0, 0, u64::MAX);
339        alloc.run_allocation();
340        assert_eq!(alloc.allocation_for("heavy"), Some(200));
341        assert_eq!(alloc.allocation_for("light"), Some(100));
342    }
343
344    /// T06 – A peer with weight=0 receives min_bps (no proportional share).
345    #[test]
346    fn t06_weight_zero_peer_gets_min() {
347        let mut alloc = PeerBandwidthAllocator::new(1000, AllocationStrategy::WeightedFair);
348        alloc.add_peer("zero".into(), 0.0, 50, u64::MAX);
349        alloc.add_peer("normal".into(), 1.0, 0, u64::MAX);
350        alloc.run_allocation();
351        // weight=0 peer: proportional share = 0, clamped up to min_bps=50
352        assert_eq!(alloc.allocation_for("zero"), Some(50));
353        // weight=1 peer: gets full 1000
354        assert_eq!(alloc.allocation_for("normal"), Some(1000));
355    }
356
357    // ── MaxMinFair ────────────────────────────────────────────────────────────
358
359    /// T07 – MaxMinFair satisfies all min_bps guarantees.
360    #[test]
361    fn t07_max_min_fair_satisfies_minimums() {
362        let mut alloc = PeerBandwidthAllocator::new(1000, AllocationStrategy::MaxMinFair);
363        alloc.add_peer("p0".into(), 1.0, 100, u64::MAX);
364        alloc.add_peer("p1".into(), 1.0, 200, u64::MAX);
365        alloc.add_peer("p2".into(), 1.0, 50, u64::MAX);
366        alloc.run_allocation();
367        // After mins (100+200+50=350) remaining=650, equal share of 650/3≈216 each
368        assert!(
369            alloc
370                .allocation_for("p0")
371                .expect("test: peer should be registered and have an allocation")
372                >= 100
373        );
374        assert!(
375            alloc
376                .allocation_for("p1")
377                .expect("test: peer should be registered and have an allocation")
378                >= 200
379        );
380        assert!(
381            alloc
382                .allocation_for("p2")
383                .expect("test: peer should be registered and have an allocation")
384                >= 50
385        );
386    }
387
388    /// T08 – MaxMinFair does not exceed max_bps.
389    #[test]
390    fn t08_max_min_fair_respects_max_cap() {
391        let mut alloc = PeerBandwidthAllocator::new(1000, AllocationStrategy::MaxMinFair);
392        alloc.add_peer("capped".into(), 1.0, 0, 100);
393        alloc.add_peer("free".into(), 1.0, 0, u64::MAX);
394        alloc.run_allocation();
395        assert!(
396            alloc
397                .allocation_for("capped")
398                .expect("test: peer should be registered and have an allocation")
399                <= 100
400        );
401    }
402
403    /// T09 – MaxMinFair distributes remainder after minimums equally.
404    #[test]
405    fn t09_max_min_fair_equal_remainder() {
406        // 3 peers, min 0 each, no caps → each should get total/3
407        let mut alloc = PeerBandwidthAllocator::new(900, AllocationStrategy::MaxMinFair);
408        alloc.add_peer("a".into(), 1.0, 0, u64::MAX);
409        alloc.add_peer("b".into(), 1.0, 0, u64::MAX);
410        alloc.add_peer("c".into(), 1.0, 0, u64::MAX);
411        alloc.run_allocation();
412        let a = alloc
413            .allocation_for("a")
414            .expect("test: peer should be registered and have an allocation");
415        let b = alloc
416            .allocation_for("b")
417            .expect("test: peer should be registered and have an allocation");
418        let c = alloc
419            .allocation_for("c")
420            .expect("test: peer should be registered and have an allocation");
421        assert_eq!(a, 300);
422        assert_eq!(b, 300);
423        assert_eq!(c, 300);
424    }
425
426    /// T10 – MaxMinFair: when budget is less than sum of minimums, distributes proportionally.
427    #[test]
428    fn t10_max_min_fair_budget_below_minimums() {
429        let mut alloc = PeerBandwidthAllocator::new(100, AllocationStrategy::MaxMinFair);
430        alloc.add_peer("x".into(), 1.0, 200, u64::MAX); // min > total
431        alloc.add_peer("y".into(), 1.0, 200, u64::MAX);
432        alloc.run_allocation();
433        let x = alloc
434            .allocation_for("x")
435            .expect("test: allocation_for x should return Some after run_allocation");
436        let y = alloc
437            .allocation_for("y")
438            .expect("test: allocation_for y should return Some after run_allocation");
439        // Each should get approximately 50 (total/2); neither exceeds total.
440        assert!(x + y <= 100, "allocated more than total: x={x} y={y}");
441    }
442
443    // ── remove_peer ───────────────────────────────────────────────────────────
444
445    /// T11 – remove_peer returns true for existing peer and false for unknown.
446    #[test]
447    fn t11_remove_peer_returns_correct_bool() {
448        let mut alloc = alloc_equal_peers(2, 1000);
449        assert!(alloc.remove_peer("peer0"));
450        assert!(!alloc.remove_peer("peer0")); // already removed
451        assert!(!alloc.remove_peer("nonexistent"));
452    }
453
454    /// T12 – After removing a peer, allocation re-runs without it.
455    #[test]
456    fn t12_remove_then_rerun() {
457        let mut alloc = alloc_equal_peers(3, 900);
458        alloc.run_allocation();
459        assert_eq!(alloc.allocation_for("peer0"), Some(300));
460        alloc.remove_peer("peer2");
461        alloc.run_allocation();
462        // Now 2 peers share 900
463        assert_eq!(alloc.allocation_for("peer0"), Some(450));
464        assert_eq!(alloc.allocation_for("peer1"), Some(450));
465        assert_eq!(alloc.allocation_for("peer2"), None);
466    }
467
468    // ── allocation_for ────────────────────────────────────────────────────────
469
470    /// T13 – allocation_for returns Some for known peer and None for unknown.
471    #[test]
472    fn t13_allocation_for_some_and_none() {
473        let mut alloc = alloc_equal_peers(1, 1000);
474        alloc.run_allocation();
475        assert!(alloc.allocation_for("peer0").is_some());
476        assert!(alloc.allocation_for("ghost").is_none());
477    }
478
479    // ── stats ─────────────────────────────────────────────────────────────────
480
481    /// T14 – stats.utilization is correct after allocation.
482    #[test]
483    fn t14_stats_utilization() {
484        let mut alloc = alloc_equal_peers(2, 1000);
485        alloc.run_allocation();
486        let stats = alloc.stats();
487        // 2 peers each get 500 → 1000 allocated → utilization = 1.0
488        assert!((stats.utilization() - 1.0).abs() < f64::EPSILON);
489    }
490
491    /// T15 – stats fields are consistent.
492    #[test]
493    fn t15_stats_fields_consistent() {
494        let mut alloc = alloc_equal_peers(3, 900);
495        alloc.run_allocation();
496        let stats = alloc.stats();
497        assert_eq!(stats.total_bps, 900);
498        assert_eq!(stats.allocated_bps, 900);
499        assert_eq!(stats.unallocated_bps, 0);
500        assert_eq!(stats.peer_count, 3);
501    }
502
503    // ── Edge cases ────────────────────────────────────────────────────────────
504
505    /// T16 – Empty peer list: run_allocation is a no-op; stats returns zeros.
506    #[test]
507    fn t16_empty_peers_no_panic() {
508        let mut alloc = PeerBandwidthAllocator::new(1000, AllocationStrategy::MaxMinFair);
509        alloc.run_allocation(); // must not panic
510        let stats = alloc.stats();
511        assert_eq!(stats.peer_count, 0);
512        assert_eq!(stats.allocated_bps, 0);
513        assert_eq!(stats.utilization(), 0.0);
514    }
515
516    /// T17 – Single peer gets the full budget (up to its max_bps).
517    #[test]
518    fn t17_single_peer_gets_full_budget() {
519        let mut alloc = PeerBandwidthAllocator::new(5000, AllocationStrategy::WeightedFair);
520        alloc.add_peer("only".into(), 1.0, 0, u64::MAX);
521        alloc.run_allocation();
522        assert_eq!(alloc.allocation_for("only"), Some(5000));
523    }
524
525    /// T18 – Adding a peer and re-running changes allocations.
526    #[test]
527    fn t18_add_peer_then_rerun() {
528        let mut alloc = PeerBandwidthAllocator::new(600, AllocationStrategy::EqualShare);
529        alloc.add_peer("a".into(), 1.0, 0, u64::MAX);
530        alloc.run_allocation();
531        assert_eq!(alloc.allocation_for("a"), Some(600));
532
533        alloc.add_peer("b".into(), 1.0, 0, u64::MAX);
534        alloc.run_allocation();
535        assert_eq!(alloc.allocation_for("a"), Some(300));
536        assert_eq!(alloc.allocation_for("b"), Some(300));
537    }
538
539    /// T19 – stats.utilization is 0.0 when total_bps is 0.
540    #[test]
541    fn t19_utilization_zero_total() {
542        let alloc = PeerBandwidthAllocator::new(0, AllocationStrategy::EqualShare);
543        assert_eq!(alloc.stats().utilization(), 0.0);
544    }
545
546    /// T20 – MaxMinFair: capped peer's surplus is redistributed to free peers.
547    #[test]
548    fn t20_max_min_fair_surplus_redistributed() {
549        // total=1000, peer0 capped at 100, peer1 free
550        // After equal step: 500 each → peer0 capped at 100 → 400 surplus → peer1 gets 900
551        let mut alloc = PeerBandwidthAllocator::new(1000, AllocationStrategy::MaxMinFair);
552        alloc.add_peer("capped".into(), 1.0, 0, 100);
553        alloc.add_peer("free".into(), 1.0, 0, u64::MAX);
554        alloc.run_allocation();
555        assert_eq!(alloc.allocation_for("capped"), Some(100));
556        assert_eq!(alloc.allocation_for("free"), Some(900));
557    }
558}