Skip to main content

rtc_interceptor/gcc/
overuse.rs

1//! Deciding whether the delay trend means the path is congested.
2
3use super::threshold::AdaptiveThreshold;
4use std::time::{Duration, Instant};
5
6/// What the delay signal currently says about the path.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
8pub enum Usage {
9    /// The queue is neither growing nor draining meaningfully.
10    #[default]
11    Normal,
12    /// The queue is growing: more is being sent than the path can carry.
13    Over,
14    /// The queue is draining: there is room the sender is not using.
15    Under,
16}
17
18/// How long the trend must stay outside the threshold before overuse is declared.
19///
20/// Without this any single noisy group would trigger a backoff, and the estimate would sawtooth on
21/// a path that is fine.
22pub const DEFAULT_OVERUSE_TIME: Duration = Duration::from_millis(10);
23
24/// Turns a filtered delay trend into a [`Usage`], with hysteresis.
25///
26/// # Why the debounce
27///
28/// The trend crossing the threshold once means very little — the filter is still noisy, and a
29/// single video frame arriving late will do it. Overuse is only declared when the trend stays
30/// outside **and** has not decreased since the last reading **and** at least two readings agree.
31/// Upstream does the same (`overuse_detector.go`); it is what stops the estimate sawtoothing.
32///
33/// Coming *back* has no such delay: `Normal` and `Under` are declared immediately, because being
34/// slow to notice that a path has recovered wastes capacity for as long as it takes to notice.
35#[derive(Debug, Clone, Copy)]
36pub struct OveruseDetector {
37    threshold: AdaptiveThreshold,
38    overuse_time: Duration,
39    /// When the trend first went outside the threshold in the current run.
40    outside_since: Option<Instant>,
41    /// Consecutive readings outside, so a lone sample cannot trigger.
42    consecutive: u32,
43    /// The previous estimate, to tell a growing queue from one that has stopped growing.
44    previous_estimate_ms: f64,
45    usage: Usage,
46}
47
48impl Default for OveruseDetector {
49    fn default() -> Self {
50        Self {
51            threshold: AdaptiveThreshold::new(),
52            overuse_time: DEFAULT_OVERUSE_TIME,
53            outside_since: None,
54            consecutive: 0,
55            previous_estimate_ms: 0.0,
56            usage: Usage::Normal,
57        }
58    }
59}
60
61impl OveruseDetector {
62    /// A detector with the draft's tuning.
63    pub fn new() -> Self {
64        Self::default()
65    }
66
67    /// What the detector currently believes.
68    pub fn usage(&self) -> Usage {
69        self.usage
70    }
71
72    /// The threshold the trend is being compared against, in milliseconds.
73    pub fn threshold_ms(&self) -> f64 {
74        self.threshold.value_ms()
75    }
76
77    /// Fold in one filtered trend reading and return the resulting usage.
78    ///
79    /// `estimate_ms` is the kalman-filtered delay gradient; `now` is when it was measured.
80    pub fn update(&mut self, now: Instant, estimate_ms: f64) -> Usage {
81        let threshold_ms = self.threshold.value_ms();
82
83        self.usage = if estimate_ms > threshold_ms {
84            // Growing. Declare overuse only once it has persisted, is still growing, and more than
85            // one reading agrees.
86            let since = *self.outside_since.get_or_insert(now);
87            self.consecutive += 1;
88
89            let long_enough = now.saturating_duration_since(since) >= self.overuse_time;
90            let still_growing = estimate_ms >= self.previous_estimate_ms;
91
92            if long_enough && still_growing && self.consecutive > 1 {
93                Usage::Over
94            } else {
95                // Not yet convinced: hold whatever was believed before rather than flapping.
96                self.usage
97            }
98        } else if estimate_ms < -threshold_ms {
99            self.outside_since = None;
100            self.consecutive = 0;
101            Usage::Under
102        } else {
103            self.outside_since = None;
104            self.consecutive = 0;
105            Usage::Normal
106        };
107
108        self.previous_estimate_ms = estimate_ms;
109        // The threshold follows the trend, so a persistently jittery path stops reading as
110        // persistently congested. Updated after the comparison, so a reading is judged against the
111        // threshold that was in force when it was taken.
112        self.threshold.update(now, estimate_ms);
113
114        self.usage
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    /// A trend inside the threshold is not congestion, however long it goes on.
123    #[test]
124    fn a_quiet_path_reads_normal() {
125        let epoch = Instant::now();
126        let mut detector = OveruseDetector::new();
127
128        for step in 0..100u64 {
129            let usage = detector.update(epoch + Duration::from_millis(step * 20), 1.0);
130            assert_eq!(Usage::Normal, usage, "at step {step}");
131        }
132    }
133
134    /// A single spike must not trigger a backoff — that is what the debounce is for.
135    #[test]
136    fn one_spike_is_not_overuse() {
137        let epoch = Instant::now();
138        let mut detector = OveruseDetector::new();
139
140        detector.update(epoch, 0.0);
141        let usage = detector.update(epoch + Duration::from_millis(20), 40.0);
142
143        assert_eq!(
144            Usage::Normal,
145            usage,
146            "a lone reading outside the threshold is noise, not congestion"
147        );
148    }
149
150    /// A sustained, growing trend is.
151    #[test]
152    fn a_sustained_growing_trend_is_overuse() {
153        let epoch = Instant::now();
154        let mut detector = OveruseDetector::new();
155
156        let mut usage = Usage::Normal;
157        for step in 0..10u64 {
158            // Growing each time, so `still_growing` holds.
159            usage = detector.update(
160                epoch + Duration::from_millis(step * 20),
161                20.0 + step as f64 * 2.0,
162            );
163        }
164
165        assert_eq!(
166            Usage::Over,
167            usage,
168            "a queue that keeps growing must eventually be declared"
169        );
170    }
171
172    /// Recovery is immediate: once the trend is back inside, the path is usable again and waiting
173    /// to say so wastes capacity.
174    #[test]
175    fn recovery_is_not_debounced() {
176        let epoch = Instant::now();
177        let mut detector = OveruseDetector::new();
178
179        let mut at = epoch;
180        for step in 0..10u64 {
181            at = epoch + Duration::from_millis(step * 20);
182            detector.update(at, 20.0 + step as f64 * 2.0);
183        }
184        assert_eq!(Usage::Over, detector.usage());
185
186        let usage = detector.update(at + Duration::from_millis(20), 0.0);
187        assert_eq!(
188            Usage::Normal,
189            usage,
190            "back inside the threshold must be believed at once"
191        );
192    }
193
194    /// A strongly negative trend is a draining queue: there is room the sender is not using.
195    #[test]
196    fn a_draining_queue_reads_under() {
197        let epoch = Instant::now();
198        let mut detector = OveruseDetector::new();
199
200        let usage = detector.update(epoch, -40.0);
201        assert_eq!(Usage::Under, usage);
202    }
203
204    /// A queue that grew and then *stopped* growing is not still overusing — the trend is high but
205    /// flat, which means the backlog is steady rather than increasing.
206    #[test]
207    fn a_high_but_flat_trend_does_not_re_declare_overuse() {
208        let epoch = Instant::now();
209        let mut detector = OveruseDetector::new();
210
211        // Climb into overuse.
212        let mut at = epoch;
213        for step in 0..10u64 {
214            at = epoch + Duration::from_millis(step * 20);
215            detector.update(at, 20.0 + step as f64 * 2.0);
216        }
217        assert_eq!(Usage::Over, detector.usage());
218
219        // Now flat, and back inside the threshold as it adapts upward.
220        for step in 0..200u64 {
221            at += Duration::from_millis(20);
222            detector.update(at, 1.0);
223            let _ = step;
224        }
225        assert_eq!(
226            Usage::Normal,
227            detector.usage(),
228            "a settled path must return to normal"
229        );
230    }
231}