quiche/recovery/gcongestion/bbr2/rtt_jump_detector/global_min.rs
1// Copyright (C) 2026, Cloudflare, Inc.
2// All rights reserved.
3//
4// Redistribution and use in source and binary forms, with or without
5// modification, are permitted provided that the following conditions are
6// met:
7//
8// * Redistributions of source code must retain the above copyright notice,
9// this list of conditions and the following disclaimer.
10//
11// * Redistributions in binary form must reproduce the above copyright
12// notice, this list of conditions and the following disclaimer in the
13// documentation and/or other materials provided with the distribution.
14//
15// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
16// IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
17// THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
18// PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
19// CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
20// EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
21// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
22// PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
23// LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
24// NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
25// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26
27use std::time::Duration;
28use std::time::Instant;
29
30use super::RttJumpUpdate;
31
32// An RTT jump is a sustained RTT increase above the connection-lifetime minimum
33// RTT. The GlobalMin detector treats samples above 3x that baseline as
34// elevated, counts a jump only after repeated elevated samples persist for a
35// baseline-scaled dwell period, and clears the episode when RTT falls back to
36// 3x baseline or below.
37//
38// GlobalMin episode lifecycle:
39//
40// elevated sustained elevated clear
41// Idle -------> Active -----------------> Persistent
42// ^ | |
43// | | clear | clear
44// +--------------+---------------------------+
45//
46// Sample trace:
47//
48// __A__ __P__
49// __A__| | __A__A__| |__P__
50// | | | |
51// I__| |___I___| |___I
52// I__|
53//
54// I = Idle
55// A = Active
56// P = Persistent
57
58/// Tracks the lifecycle of a global-min RTT jump episode.
59#[derive(Debug, Default, Clone, Copy)]
60enum GlobalMinEpisode {
61 /// No elevation currently observed.
62 #[default]
63 Idle,
64 /// A jump has been observed but not yet sustained long enough to be
65 /// considered persistent.
66 Active {
67 elevated_samples: usize,
68 episode_start_time: Instant,
69 },
70 /// The elevated RTT has been confirmed as a persistent network condition.
71 Persistent,
72}
73
74/// Multiplicative factor over the global-min RTT baseline above which a sample
75/// is classified as an RTT jump.
76const GLOBAL_MIN_JUMP_THRESHOLD: f32 = 3.0;
77
78/// Elevated RTT samples required before an active episode becomes persistent.
79const GLOBAL_MIN_CONFIRM_SAMPLES: usize = 3;
80
81/// Minimum episode duration, expressed as a multiple of the global-min RTT
82/// baseline, required before an active episode becomes persistent.
83const GLOBAL_MIN_CONFIRM_DURATION_MULTIPLIER: u32 = 3;
84
85#[derive(Debug, Default)]
86pub(super) struct GlobalMinDetector {
87 /// Connection-lifetime minimum RTT sample used by the detector.
88 baseline: Option<Duration>,
89 /// Lifecycle state used to distinguish transient RTT spikes from persistent
90 /// increases.
91 episode: GlobalMinEpisode,
92}
93
94impl GlobalMinDetector {
95 /// Global-min RTT jump detector step: connection-lifetime minimum baseline
96 /// with a strict multiplicative jump test and a sample/duration
97 /// confirmation gate.
98 pub(super) fn on_rtt_sample(
99 &mut self, rtt_sample: Duration, event_time: Instant,
100 full_bandwidth_reached: bool,
101 ) -> RttJumpUpdate {
102 if !full_bandwidth_reached {
103 self.update_baseline(rtt_sample);
104 return RttJumpUpdate::None;
105 }
106
107 let Some(baseline) = self.baseline else {
108 self.update_baseline(rtt_sample);
109 return RttJumpUpdate::None;
110 };
111
112 let is_jump = rtt_sample > baseline.mul_f32(GLOBAL_MIN_JUMP_THRESHOLD);
113 let is_clear = !is_jump;
114 let mut update = RttJumpUpdate::None;
115
116 match self.episode {
117 GlobalMinEpisode::Idle =>
118 if is_jump {
119 self.episode = GlobalMinEpisode::Active {
120 elevated_samples: 1,
121 episode_start_time: event_time,
122 };
123 },
124
125 GlobalMinEpisode::Active {
126 elevated_samples,
127 episode_start_time,
128 } =>
129 if is_clear {
130 self.episode = GlobalMinEpisode::Idle;
131 } else {
132 let elevated_samples = elevated_samples + 1;
133 let dwell = baseline * GLOBAL_MIN_CONFIRM_DURATION_MULTIPLIER;
134 let samples_ok =
135 elevated_samples >= GLOBAL_MIN_CONFIRM_SAMPLES;
136 let time_ok = event_time
137 .saturating_duration_since(episode_start_time) >=
138 dwell;
139
140 if samples_ok && time_ok {
141 update = RttJumpUpdate::PersistentConfirmed {
142 episode_start_time,
143 };
144 self.episode = GlobalMinEpisode::Persistent;
145 } else {
146 self.episode = GlobalMinEpisode::Active {
147 elevated_samples,
148 episode_start_time,
149 };
150 }
151 },
152
153 GlobalMinEpisode::Persistent =>
154 if is_clear {
155 self.episode = GlobalMinEpisode::Idle;
156 },
157 }
158
159 self.update_baseline(rtt_sample);
160 update
161 }
162
163 fn update_baseline(&mut self, rtt_sample: Duration) {
164 self.baseline =
165 Some(self.baseline.map_or(rtt_sample, |min| min.min(rtt_sample)));
166 }
167
168 #[cfg(test)]
169 pub(super) fn is_rtt_jump_active(&self) -> bool {
170 !matches!(self.episode, GlobalMinEpisode::Idle)
171 }
172
173 #[cfg(test)]
174 pub(super) fn is_rtt_jump_persistent(&self) -> bool {
175 matches!(self.episode, GlobalMinEpisode::Persistent)
176 }
177}
178
179#[cfg(test)]
180#[path = "global_min_tests.rs"]
181mod tests;