Skip to main content

lance_core/utils/
aimd.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! AIMD (Additive Increase / Multiplicative Decrease) rate controller.
5//!
6//! This module provides a reusable AIMD algorithm for dynamically adjusting
7//! request rates. On success windows, the rate increases additively. On
8//! windows with throttle signals, the rate decreases multiplicatively.
9//!
10//! The algorithm operates in discrete time windows. At the end of each window,
11//! the throttle ratio (throttled / total) is compared against a threshold:
12//! - Above threshold: `rate = max(rate * decrease_factor, min_rate)`
13//! - At or below threshold: `rate = min(rate + additive_increment, max_rate)`
14
15use std::sync::Mutex;
16use std::time::Duration;
17
18use crate::Result;
19
20/// Configuration for the AIMD rate controller.
21///
22/// Use builder methods to customize. Defaults are tuned for cloud object stores
23/// and will start at 20% of the max rate and require about 27 seconds of
24/// sustained success to reach the max rate.
25///
26/// - initial_rate: 2000 req/s
27/// - min_rate: 1 req/s
28/// - max_rate: 10000 req/s (0.0 disables the ceiling; must be finite otherwise)
29/// - decrease_factor: 0.5 (halve on throttle)
30/// - additive_increment: 300 req/s per success window
31/// - window_duration: 1 second
32/// - throttle_threshold: 0.0 (any throttle triggers decrease)
33#[derive(Debug, Clone)]
34pub struct AimdConfig {
35    pub initial_rate: f64,
36    pub min_rate: f64,
37    pub max_rate: f64,
38    pub decrease_factor: f64,
39    pub additive_increment: f64,
40    pub window_duration: Duration,
41    pub throttle_threshold: f64,
42}
43
44impl Default for AimdConfig {
45    fn default() -> Self {
46        Self {
47            initial_rate: 2000.0,
48            min_rate: 1.0,
49            max_rate: 10000.0,
50            decrease_factor: 0.5,
51            additive_increment: 300.0,
52            window_duration: Duration::from_secs(1),
53            throttle_threshold: 0.0,
54        }
55    }
56}
57
58impl AimdConfig {
59    pub fn with_initial_rate(self, initial_rate: f64) -> Self {
60        Self {
61            initial_rate,
62            ..self
63        }
64    }
65
66    pub fn with_min_rate(self, min_rate: f64) -> Self {
67        Self { min_rate, ..self }
68    }
69
70    pub fn with_max_rate(self, max_rate: f64) -> Self {
71        Self { max_rate, ..self }
72    }
73
74    pub fn with_decrease_factor(self, decrease_factor: f64) -> Self {
75        Self {
76            decrease_factor,
77            ..self
78        }
79    }
80
81    pub fn with_additive_increment(self, additive_increment: f64) -> Self {
82        Self {
83            additive_increment,
84            ..self
85        }
86    }
87
88    pub fn with_window_duration(self, window_duration: Duration) -> Self {
89        Self {
90            window_duration,
91            ..self
92        }
93    }
94
95    pub fn with_throttle_threshold(self, throttle_threshold: f64) -> Self {
96        Self {
97            throttle_threshold,
98            ..self
99        }
100    }
101
102    /// Validate that the configuration values are sensible.
103    pub fn validate(&self) -> Result<()> {
104        // Reject NaN and infinity first. The sign and ordering checks below
105        // compare with `<`/`>`, which are `false` for NaN and for a `+inf` on
106        // a field with no opposing bound, so a non-finite rate would otherwise
107        // slip through and silently disable throttling (a NaN rate makes the
108        // token bucket refill to full on every acquire) or, with a zero burst
109        // capacity, panic in `Duration::from_secs_f64`.
110        for (name, value) in [
111            ("initial_rate", self.initial_rate),
112            ("min_rate", self.min_rate),
113            ("max_rate", self.max_rate),
114            ("decrease_factor", self.decrease_factor),
115            ("additive_increment", self.additive_increment),
116            ("throttle_threshold", self.throttle_threshold),
117        ] {
118            if !value.is_finite() {
119                return Err(crate::Error::invalid_input(format!(
120                    "{name} must be finite, got {value}"
121                )));
122            }
123        }
124        if self.initial_rate <= 0.0 {
125            return Err(crate::Error::invalid_input(format!(
126                "initial_rate must be positive, got {}",
127                self.initial_rate
128            )));
129        }
130        if self.min_rate <= 0.0 {
131            return Err(crate::Error::invalid_input(format!(
132                "min_rate must be positive, got {}",
133                self.min_rate
134            )));
135        }
136        if self.max_rate < 0.0 {
137            return Err(crate::Error::invalid_input(format!(
138                "max_rate must be non-negative (0.0 = no ceiling), got {}",
139                self.max_rate
140            )));
141        }
142        if self.max_rate > 0.0 && self.min_rate > self.max_rate {
143            return Err(crate::Error::invalid_input(format!(
144                "min_rate ({}) must not exceed max_rate ({})",
145                self.min_rate, self.max_rate
146            )));
147        }
148        if self.decrease_factor <= 0.0 || self.decrease_factor >= 1.0 {
149            return Err(crate::Error::invalid_input(format!(
150                "decrease_factor must be in (0, 1), got {}",
151                self.decrease_factor
152            )));
153        }
154        if self.additive_increment <= 0.0 {
155            return Err(crate::Error::invalid_input(format!(
156                "additive_increment must be positive, got {}",
157                self.additive_increment
158            )));
159        }
160        if self.window_duration.is_zero() {
161            return Err(crate::Error::invalid_input(
162                "window_duration must be non-zero",
163            ));
164        }
165        if !(0.0..=1.0).contains(&self.throttle_threshold) {
166            return Err(crate::Error::invalid_input(format!(
167                "throttle_threshold must be in [0.0, 1.0], got {}",
168                self.throttle_threshold
169            )));
170        }
171        if self.max_rate > 0.0 && self.initial_rate > self.max_rate {
172            return Err(crate::Error::invalid_input(format!(
173                "initial_rate ({}) must not exceed max_rate ({})",
174                self.initial_rate, self.max_rate
175            )));
176        }
177        if self.initial_rate < self.min_rate {
178            return Err(crate::Error::invalid_input(format!(
179                "initial_rate ({}) must not be below min_rate ({})",
180                self.initial_rate, self.min_rate
181            )));
182        }
183        Ok(())
184    }
185}
186
187/// Outcome of a single request, used to feed the AIMD controller.
188///
189/// Non-throttle errors (e.g. 404, network timeout) should be mapped to
190/// `Success` since they don't indicate capacity problems.
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192pub enum RequestOutcome {
193    Success,
194    Throttled,
195}
196
197struct AimdState {
198    rate: f64,
199    window_start: std::time::Instant,
200    success_count: u64,
201    throttle_count: u64,
202}
203
204/// AIMD rate controller.
205///
206/// Thread-safe: uses an internal `Mutex` to protect state. The lock is held
207/// only briefly during `record_outcome` and `current_rate`.
208pub struct AimdController {
209    config: AimdConfig,
210    state: Mutex<AimdState>,
211}
212
213impl std::fmt::Debug for AimdController {
214    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
215        f.debug_struct("AimdController")
216            .field("config", &self.config)
217            .field("rate", &self.current_rate())
218            .finish()
219    }
220}
221
222impl AimdController {
223    /// Create a new AIMD controller with the given configuration.
224    pub fn new(config: AimdConfig) -> Result<Self> {
225        config.validate()?;
226        let rate = config.initial_rate;
227        Ok(Self {
228            config,
229            state: Mutex::new(AimdState {
230                rate,
231                window_start: std::time::Instant::now(),
232                success_count: 0,
233                throttle_count: 0,
234            }),
235        })
236    }
237
238    /// Record a request outcome and return the current rate.
239    ///
240    /// If the current time window has expired, the rate is adjusted before
241    /// recording the new outcome in a fresh window.
242    pub fn record_outcome(&self, outcome: RequestOutcome) -> f64 {
243        let mut state = self.state.lock().unwrap();
244        self.record_outcome_inner(&mut state, outcome, std::time::Instant::now())
245    }
246
247    fn record_outcome_inner(
248        &self,
249        state: &mut AimdState,
250        outcome: RequestOutcome,
251        now: std::time::Instant,
252    ) -> f64 {
253        // Check if the window has expired
254        let elapsed = now.duration_since(state.window_start);
255        if elapsed >= self.config.window_duration {
256            let total = state.success_count + state.throttle_count;
257            if total > 0 {
258                let throttle_ratio = state.throttle_count as f64 / total as f64;
259                if throttle_ratio > self.config.throttle_threshold {
260                    // Multiplicative decrease
261                    state.rate =
262                        (state.rate * self.config.decrease_factor).max(self.config.min_rate);
263                } else {
264                    // Additive increase
265                    state.rate += self.config.additive_increment;
266                    if self.config.max_rate > 0.0 {
267                        state.rate = state.rate.min(self.config.max_rate);
268                    }
269                }
270            }
271            // Reset window
272            state.window_start = now;
273            state.success_count = 0;
274            state.throttle_count = 0;
275        }
276
277        // Record this outcome
278        match outcome {
279            RequestOutcome::Success => state.success_count += 1,
280            RequestOutcome::Throttled => state.throttle_count += 1,
281        }
282
283        state.rate
284    }
285
286    /// Get the current rate without recording an outcome.
287    pub fn current_rate(&self) -> f64 {
288        self.state.lock().unwrap().rate
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use rstest::rstest;
296
297    #[rstest]
298    #[case::zero_initial_rate(
299        AimdConfig::default().with_initial_rate(0.0),
300        "initial_rate must be positive"
301    )]
302    #[case::negative_min_rate(
303        AimdConfig::default().with_min_rate(-1.0),
304        "min_rate must be positive"
305    )]
306    #[case::negative_max_rate(
307        AimdConfig::default().with_max_rate(-1.0),
308        "max_rate must be non-negative"
309    )]
310    #[case::min_exceeds_max(
311        AimdConfig::default().with_min_rate(100.0).with_max_rate(10.0),
312        "min_rate (100) must not exceed max_rate (10)"
313    )]
314    #[case::decrease_factor_zero(
315        AimdConfig::default().with_decrease_factor(0.0),
316        "decrease_factor must be in (0, 1)"
317    )]
318    #[case::decrease_factor_one(
319        AimdConfig::default().with_decrease_factor(1.0),
320        "decrease_factor must be in (0, 1)"
321    )]
322    #[case::decrease_factor_over_one(
323        AimdConfig::default().with_decrease_factor(1.5),
324        "decrease_factor must be in (0, 1)"
325    )]
326    #[case::zero_additive_increment(
327        AimdConfig::default().with_additive_increment(0.0),
328        "additive_increment must be positive"
329    )]
330    #[case::zero_window_duration(
331        AimdConfig::default().with_window_duration(Duration::ZERO),
332        "window_duration must be non-zero"
333    )]
334    #[case::threshold_over_one(
335        AimdConfig::default().with_throttle_threshold(1.1),
336        "throttle_threshold must be in [0.0, 1.0]"
337    )]
338    #[case::threshold_negative(
339        AimdConfig::default().with_throttle_threshold(-0.1),
340        "throttle_threshold must be in [0.0, 1.0]"
341    )]
342    #[case::initial_exceeds_max(
343        AimdConfig::default().with_initial_rate(11000.0),
344        "initial_rate (11000) must not exceed max_rate (10000)"
345    )]
346    #[case::initial_below_min(
347        AimdConfig::default().with_initial_rate(0.5).with_min_rate(1.0),
348        "initial_rate (0.5) must not be below min_rate (1)"
349    )]
350    #[case::nan_initial_rate(
351        AimdConfig::default().with_initial_rate(f64::NAN),
352        "initial_rate must be finite"
353    )]
354    #[case::inf_initial_rate(
355        AimdConfig::default().with_initial_rate(f64::INFINITY),
356        "initial_rate must be finite"
357    )]
358    #[case::nan_min_rate(
359        AimdConfig::default().with_min_rate(f64::NAN),
360        "min_rate must be finite"
361    )]
362    #[case::nan_max_rate(
363        AimdConfig::default().with_max_rate(f64::NAN),
364        "max_rate must be finite"
365    )]
366    #[case::inf_max_rate(
367        AimdConfig::default().with_max_rate(f64::INFINITY),
368        "max_rate must be finite"
369    )]
370    #[case::nan_decrease_factor(
371        AimdConfig::default().with_decrease_factor(f64::NAN),
372        "decrease_factor must be finite"
373    )]
374    #[case::nan_additive_increment(
375        AimdConfig::default().with_additive_increment(f64::NAN),
376        "additive_increment must be finite"
377    )]
378    #[case::nan_throttle_threshold(
379        AimdConfig::default().with_throttle_threshold(f64::NAN),
380        "throttle_threshold must be finite"
381    )]
382    fn test_config_validation_rejects_invalid(
383        #[case] config: AimdConfig,
384        #[case] expected_msg: &str,
385    ) {
386        let err = config.validate().unwrap_err();
387        assert!(
388            matches!(&err, crate::Error::InvalidInput { .. }),
389            "expected InvalidInput, got: {err:?}"
390        );
391        let msg = err.to_string();
392        assert!(
393            msg.contains(expected_msg),
394            "Expected error containing '{}', got: {}",
395            expected_msg,
396            msg
397        );
398    }
399
400    #[test]
401    fn test_default_config_ramps_to_ceiling_and_backs_off() {
402        let controller = AimdController::new(AimdConfig::default()).unwrap();
403        assert_eq!(controller.current_rate(), 2000.0);
404
405        let mut state = controller.state.lock().unwrap();
406        let start = state.window_start;
407        controller.record_outcome_inner(&mut state, RequestOutcome::Success, start);
408        for seconds in 1..=28 {
409            controller.record_outcome_inner(
410                &mut state,
411                RequestOutcome::Success,
412                start + Duration::from_secs(seconds),
413            );
414        }
415        assert_eq!(state.rate, 10000.0);
416
417        controller.record_outcome_inner(
418            &mut state,
419            RequestOutcome::Throttled,
420            start + Duration::from_secs(28),
421        );
422        controller.record_outcome_inner(
423            &mut state,
424            RequestOutcome::Success,
425            start + Duration::from_secs(29),
426        );
427        assert_eq!(state.rate, 5000.0);
428    }
429
430    #[test]
431    fn test_no_ceiling_config_is_valid() {
432        AimdConfig::default().with_max_rate(0.0).validate().unwrap();
433    }
434
435    #[test]
436    fn test_additive_increase_on_success_window() {
437        let config = AimdConfig::default()
438            .with_initial_rate(100.0)
439            .with_additive_increment(10.0)
440            .with_window_duration(Duration::from_millis(100));
441        let controller = AimdController::new(config).unwrap();
442
443        // Record some successes in the first window
444        let start = std::time::Instant::now();
445        {
446            let mut state = controller.state.lock().unwrap();
447            controller.record_outcome_inner(&mut state, RequestOutcome::Success, start);
448        }
449
450        // Advance past the window boundary and record another success
451        let after_window = start + Duration::from_millis(150);
452        {
453            let mut state = controller.state.lock().unwrap();
454            controller.record_outcome_inner(&mut state, RequestOutcome::Success, after_window);
455        }
456
457        // Rate should have increased by additive_increment
458        assert_eq!(controller.current_rate(), 110.0);
459    }
460
461    #[test]
462    fn test_multiplicative_decrease_on_throttle_window() {
463        let config = AimdConfig::default()
464            .with_initial_rate(100.0)
465            .with_decrease_factor(0.5)
466            .with_window_duration(Duration::from_millis(100));
467        let controller = AimdController::new(config).unwrap();
468
469        let start = std::time::Instant::now();
470        {
471            let mut state = controller.state.lock().unwrap();
472            controller.record_outcome_inner(&mut state, RequestOutcome::Throttled, start);
473        }
474
475        // Advance past window
476        let after_window = start + Duration::from_millis(150);
477        {
478            let mut state = controller.state.lock().unwrap();
479            controller.record_outcome_inner(&mut state, RequestOutcome::Success, after_window);
480        }
481
482        assert_eq!(controller.current_rate(), 50.0);
483    }
484
485    #[test]
486    fn test_floor_enforcement() {
487        let config = AimdConfig::default()
488            .with_initial_rate(2.0)
489            .with_min_rate(1.0)
490            .with_decrease_factor(0.5)
491            .with_window_duration(Duration::from_millis(100));
492        let controller = AimdController::new(config).unwrap();
493
494        let start = std::time::Instant::now();
495        {
496            let mut state = controller.state.lock().unwrap();
497            controller.record_outcome_inner(&mut state, RequestOutcome::Throttled, start);
498        }
499
500        // After decrease: 2.0 * 0.5 = 1.0 (at floor)
501        let t1 = start + Duration::from_millis(150);
502        {
503            let mut state = controller.state.lock().unwrap();
504            controller.record_outcome_inner(&mut state, RequestOutcome::Throttled, t1);
505        }
506        assert_eq!(controller.current_rate(), 1.0);
507
508        // Another decrease should stay at floor
509        let t2 = t1 + Duration::from_millis(150);
510        {
511            let mut state = controller.state.lock().unwrap();
512            controller.record_outcome_inner(&mut state, RequestOutcome::Success, t2);
513        }
514        assert_eq!(controller.current_rate(), 1.0);
515    }
516
517    #[test]
518    fn test_ceiling_enforcement() {
519        let config = AimdConfig::default()
520            .with_initial_rate(4990.0)
521            .with_max_rate(5000.0)
522            .with_additive_increment(20.0)
523            .with_window_duration(Duration::from_millis(100));
524        let controller = AimdController::new(config).unwrap();
525
526        let start = std::time::Instant::now();
527        {
528            let mut state = controller.state.lock().unwrap();
529            controller.record_outcome_inner(&mut state, RequestOutcome::Success, start);
530        }
531
532        let t1 = start + Duration::from_millis(150);
533        {
534            let mut state = controller.state.lock().unwrap();
535            controller.record_outcome_inner(&mut state, RequestOutcome::Success, t1);
536        }
537        // 4990 + 20 = 5010, clamped to 5000
538        assert_eq!(controller.current_rate(), 5000.0);
539    }
540
541    #[test]
542    fn test_no_ceiling_allows_unbounded_growth() {
543        let config = AimdConfig::default()
544            .with_initial_rate(100.0)
545            .with_max_rate(0.0)
546            .with_additive_increment(50.0)
547            .with_window_duration(Duration::from_millis(100));
548        let controller = AimdController::new(config).unwrap();
549
550        let start = std::time::Instant::now();
551        let mut t = start;
552
553        for _ in 0..5 {
554            {
555                let mut state = controller.state.lock().unwrap();
556                controller.record_outcome_inner(&mut state, RequestOutcome::Success, t);
557            }
558            t += Duration::from_millis(150);
559        }
560
561        // Trigger final window evaluation
562        {
563            let mut state = controller.state.lock().unwrap();
564            controller.record_outcome_inner(&mut state, RequestOutcome::Success, t);
565        }
566
567        // 100 + 50*5 = 350
568        assert_eq!(controller.current_rate(), 350.0);
569    }
570
571    #[test]
572    fn test_empty_window_no_adjustment() {
573        let config = AimdConfig::default()
574            .with_initial_rate(100.0)
575            .with_window_duration(Duration::from_millis(100));
576        let controller = AimdController::new(config).unwrap();
577
578        // Don't record anything in the first window, just advance time
579        let start = std::time::Instant::now();
580        let after = start + Duration::from_millis(150);
581        {
582            let mut state = controller.state.lock().unwrap();
583            // First outcome in a new window after empty window
584            controller.record_outcome_inner(&mut state, RequestOutcome::Success, after);
585        }
586        // No adjustment because the expired window had 0 total
587        assert_eq!(controller.current_rate(), 100.0);
588    }
589
590    #[test]
591    fn test_throttle_threshold_filtering() {
592        // With threshold 0.5, less than 50% throttles should still increase
593        let config = AimdConfig::default()
594            .with_initial_rate(100.0)
595            .with_throttle_threshold(0.5)
596            .with_additive_increment(10.0)
597            .with_window_duration(Duration::from_millis(100));
598        let controller = AimdController::new(config).unwrap();
599
600        let start = std::time::Instant::now();
601        {
602            let mut state = controller.state.lock().unwrap();
603            // 1 throttle out of 3 = 33% < 50% threshold
604            controller.record_outcome_inner(&mut state, RequestOutcome::Success, start);
605            controller.record_outcome_inner(&mut state, RequestOutcome::Success, start);
606            controller.record_outcome_inner(&mut state, RequestOutcome::Throttled, start);
607        }
608
609        // Advance past window
610        let t1 = start + Duration::from_millis(150);
611        {
612            let mut state = controller.state.lock().unwrap();
613            controller.record_outcome_inner(&mut state, RequestOutcome::Success, t1);
614        }
615
616        // Should have increased because 33% <= 50%
617        assert_eq!(controller.current_rate(), 110.0);
618    }
619
620    #[test]
621    fn test_throttle_threshold_triggers_decrease() {
622        // With threshold 0.5, >= 50% throttles should decrease
623        let config = AimdConfig::default()
624            .with_initial_rate(100.0)
625            .with_throttle_threshold(0.5)
626            .with_decrease_factor(0.5)
627            .with_window_duration(Duration::from_millis(100));
628        let controller = AimdController::new(config).unwrap();
629
630        let start = std::time::Instant::now();
631        {
632            let mut state = controller.state.lock().unwrap();
633            // 2 throttle out of 3 = 67% > 50% threshold
634            controller.record_outcome_inner(&mut state, RequestOutcome::Success, start);
635            controller.record_outcome_inner(&mut state, RequestOutcome::Throttled, start);
636            controller.record_outcome_inner(&mut state, RequestOutcome::Throttled, start);
637        }
638
639        let t1 = start + Duration::from_millis(150);
640        {
641            let mut state = controller.state.lock().unwrap();
642            controller.record_outcome_inner(&mut state, RequestOutcome::Success, t1);
643        }
644
645        assert_eq!(controller.current_rate(), 50.0);
646    }
647
648    #[test]
649    fn test_recovery_after_decrease() {
650        let config = AimdConfig::default()
651            .with_initial_rate(100.0)
652            .with_decrease_factor(0.5)
653            .with_additive_increment(10.0)
654            .with_window_duration(Duration::from_millis(100));
655        let controller = AimdController::new(config).unwrap();
656
657        let start = std::time::Instant::now();
658
659        // Window 1: throttle → decrease to 50
660        {
661            let mut state = controller.state.lock().unwrap();
662            controller.record_outcome_inner(&mut state, RequestOutcome::Throttled, start);
663        }
664        let t1 = start + Duration::from_millis(150);
665
666        // Window 2: success → increase to 60
667        {
668            let mut state = controller.state.lock().unwrap();
669            controller.record_outcome_inner(&mut state, RequestOutcome::Success, t1);
670        }
671        let t2 = t1 + Duration::from_millis(150);
672
673        // Window 3: success → increase to 70
674        {
675            let mut state = controller.state.lock().unwrap();
676            controller.record_outcome_inner(&mut state, RequestOutcome::Success, t2);
677        }
678        let t3 = t2 + Duration::from_millis(150);
679
680        // Trigger final evaluation
681        {
682            let mut state = controller.state.lock().unwrap();
683            controller.record_outcome_inner(&mut state, RequestOutcome::Success, t3);
684        }
685
686        assert_eq!(controller.current_rate(), 70.0);
687    }
688
689    #[test]
690    fn test_within_window_no_adjustment() {
691        let config = AimdConfig::default()
692            .with_initial_rate(100.0)
693            .with_window_duration(Duration::from_secs(10));
694        let controller = AimdController::new(config).unwrap();
695
696        // Record many outcomes but all within the same window
697        for _ in 0..100 {
698            controller.record_outcome(RequestOutcome::Throttled);
699        }
700
701        // Rate should still be initial since window hasn't expired
702        assert_eq!(controller.current_rate(), 100.0);
703    }
704}