Skip to main content

hya_core/
admission.rs

1//! Online concurrency admission: dynamically probe and scale connection counts
2//! based on measured marginal goodput.
3//!
4//! When per-source rate limits and per-connection capacities are unknown, opening
5//! extra connections on an already saturated path adds request overhead without
6//! improving throughput.
7//!
8//! This module implements incremental greedy admission: it probes connections
9//! one at a time, checks whether throughput increases by more than a minimum gain
10//! threshold, and settles at the optimal connection count upon diminishing returns.
11
12/// Decision returned by the controller after each probe interval.
13#[derive(Clone, Copy, PartialEq, Eq, Debug)]
14pub enum Admit {
15    /// Open one more connection to this source and keep probing.
16    Add,
17    /// Saturated: the last admission did not pay for itself. Settle here.
18    Stop,
19}
20
21/// Per-source incremental admission controller.
22///
23/// Feed it the aggregate goodput observed at each concurrency level. It compares
24/// the marginal gain against `min_gain_frac` of the goodput achieved by the first
25/// connection — a scale-free threshold, so it behaves identically on a 1 MB/s
26/// and a 1 GB/s path.
27#[derive(Clone, Debug)]
28pub struct Admission {
29    /// Goodput observed, one entry per measurement window.
30    samples: Vec<f64>,
31    /// The connection count each sample was measured at, parallel to `samples`.
32    ///
33    /// Kept explicitly because the sample index is NOT the level: the in-band ramp
34    /// doubles, so sample 3 describes four connections. Inferring one from the other
35    /// is the defect this field exists to prevent.
36    levels: Vec<usize>,
37    /// Marginal gain required to justify one more connection, as a fraction of
38    /// the single-connection goodput.
39    min_gain_frac: f64,
40    /// Hard ceiling regardless of measurement (politeness, not physics).
41    max_conns: usize,
42    settled: Option<usize>,
43}
44
45impl Admission {
46    pub fn new(min_gain_frac: f64, max_conns: usize) -> Self {
47        Self {
48            samples: Vec::new(),
49            levels: Vec::new(),
50            min_gain_frac,
51            max_conns: max_conns.max(1),
52            settled: None,
53        }
54    }
55
56    /// Record the aggregate goodput (bytes/s) observed with `self.level() + 1`
57    /// connections, and decide whether to admit another.
58    /// Record the goodput measured while `level` connections were active.
59    ///
60    /// # Why the level is a parameter and not the sample count
61    ///
62    /// This originally inferred the level from `samples.len()`, which is correct only
63    /// if callers admit one connection per observation. The in-band ramp doubles
64    /// (1, 2, 4, 8) because incrementing takes `max - 1` windows and costs more clock
65    /// than the concurrency saves. Under doubling the third sample describes FOUR
66    /// connections, so returning `samples.len() - 1` returned a sample index dressed
67    /// as a connection count.
68    ///
69    /// HISTORICAL MEASUREMENT (pre-fix implementation; retained to document why this
70    /// design exists, not as a current result). 11 MB object, five repetitions: settled counts came back
71    /// `[2, 8, 8, 8, 8]` on a path a single stream already saturates. The `2` is the
72    /// index bug reporting sample 3 as "2"; the four `8`s are the ceiling arm firing
73    /// because the sample-count test `n >= max_conns` needs eight samples and doubling
74    /// only ever takes four. The search could not settle anywhere sensible, and the
75    /// mode was no faster than hard-coding the ceiling (0.99x, p = 0.63) while a
76    /// single connection was 1.97x faster than both.
77    pub fn observe_at(&mut self, level: usize, goodput: f64) -> Admit {
78        let level = level.max(1);
79        self.samples.push(goodput.max(0.0));
80        self.levels.push(level);
81        let n = self.samples.len();
82
83        // Ceiling test on the LEVEL, not on how many samples it took to get here.
84        if level >= self.max_conns {
85            self.settled = Some(self.best_level());
86            return Admit::Stop;
87        }
88        if n == 1 {
89            return Admit::Add;
90        }
91
92        if !self.step_pays(n - 1) {
93            // Settle at the best level MEASURED, not the previous one: a noisy
94            // window can make an intermediate level the true peak.
95            self.settled = Some(self.best_level());
96            Admit::Stop
97        } else {
98            Admit::Add
99        }
100    }
101
102    /// Did the step into sample `i` deliver enough to justify the connections it added?
103    ///
104    /// Measured as a fraction of PROPORTIONAL scaling, not of the single-connection
105    /// rate: on a warming path throughput rises whether or not the extra concurrency
106    /// does anything (judging against `samples[0]` sent the search to the ceiling in
107    /// 9 of 12 runs where one connection was 1.8-3.2x faster). Doubling on a link
108    /// with headroom roughly doubles delivery; doubling on a saturated one leaves it
109    /// flat. The ratio separates the two without knowing capacity or RTT and is
110    /// scale-free, so 1 -> 2 is judged like 4 -> 8. `min_gain_frac` is the share of
111    /// perfect scaling required: 0.15 admits a genuinely parallel path (ratio near
112    /// 1.0) and refuses a saturated one (near 0).
113    fn step_pays(&self, i: usize) -> bool {
114        if i == 0 || i >= self.samples.len() {
115            return false;
116        }
117        let prev_rate = self.samples[i - 1].max(1.0);
118        let prev_level = self.levels[i - 1].max(1) as f64;
119        let this_level = self.levels[i].max(1) as f64;
120        if this_level <= prev_level {
121            return false;
122        }
123        let ideal = prev_rate * (this_level / prev_level);
124        let headroom = (ideal - prev_rate).max(1e-9);
125        (self.samples[i] - prev_rate) / headroom >= self.min_gain_frac
126    }
127
128    /// The smallest level that is within `min_gain_frac` of the best goodput measured.
129    ///
130    /// Not simply "the highest sample". A level whose marginal gain was refused must
131    /// not then be settled on — that would reject a level and adopt it in the same
132    /// breath. A 2% improvement from doubling the connections is inside the noise this
133    /// threshold exists to reject, so the answer is the *cheapest* level that performs
134    /// indistinguishably from the best one.
135    ///
136    /// Equal throughput on fewer connections is strictly better: fewer handshakes, less
137    /// origin load, and less exposure to the repair machinery. That is what lets the
138    /// search return "one" on a path a single stream already saturates, which is the
139    /// case that motivated the whole in-band ramp.
140    ///
141    /// The last level whose own step paid, by the SAME `step_pays` rule the
142    /// admission test applies, stopping at the first that did not: levels beyond
143    /// it were reached on earlier gains, not their own. Scoring against a band
144    /// around the peak instead let a level be refused and adopted in the same
145    /// breath, because accumulated gains make the top sample the highest even
146    /// when the final step was worthless.
147    fn best_level(&self) -> usize {
148        if self.samples.is_empty() {
149            return 1;
150        }
151        let mut best = self.levels.first().copied().unwrap_or(1);
152        for i in 1..self.samples.len() {
153            if self.step_pays(i) {
154                best = self.levels[i];
155            } else {
156                break;
157            }
158        }
159        best.clamp(1, self.max_conns)
160    }
161
162    /// Lower the ceiling the search may reach.
163    ///
164    /// Raising it is deliberately not possible: the caller lowers this when the
165    /// ORIGIN has refused a request, and a ceiling learned from a refusal must not
166    /// be undone by the search that provoked it. Recovery from a momentary limit is
167    /// the transfer loop's business, not the controller's.
168    pub fn clamp_max(&mut self, max: usize) {
169        let max = max.max(1);
170        if max < self.max_conns {
171            self.max_conns = max;
172            if let Some(n) = self.settled {
173                self.settled = Some(n.min(max));
174            }
175        }
176    }
177
178    /// Back-compatible entry point for callers that admit one connection at a time.
179    pub fn observe(&mut self, goodput: f64) -> Admit {
180        let level = self.samples.len() + 1;
181        self.observe_at(level, goodput)
182    }
183
184    /// Connections currently probed.
185    pub fn level(&self) -> usize {
186        self.samples.len()
187    }
188
189    /// The settled allocation, once probing has stopped.
190    pub fn settled(&self) -> Option<usize> {
191        self.settled
192    }
193
194    /// Best goodput seen, for reporting.
195    pub fn best_goodput(&self) -> f64 {
196        self.samples.iter().cloned().fold(0.0, f64::max)
197    }
198
199    /// Every measurement taken, as `(connections, bytes per second)` in the
200    /// order it was taken. For reporting what the search saw, not for deciding.
201    pub fn samples(&self) -> impl Iterator<Item = (usize, f64)> + '_ {
202        self.levels
203            .iter()
204            .copied()
205            .zip(self.samples.iter().copied())
206    }
207}
208
209/// Online estimate of the per-request setup cost `delta`.
210///
211/// A configured constant is not good enough: the same code saw `delta = 5 ms`
212/// against a loopback-equivalent origin and `420 ms` against a real proxied
213/// origin, and `delta` sets the repair deadband `theta`. Underestimating it by
214/// 2.8x caused measurable over-repair — each unnecessary repair costs a full
215/// `delta`, so the error compounds.
216#[derive(Clone, Copy, Debug)]
217pub struct DeltaEstimator {
218    ewma: f64,
219    alpha: f64,
220    n: u32,
221}
222
223impl DeltaEstimator {
224    pub fn new(prior_s: f64) -> Self {
225        Self {
226            ewma: prior_s.max(1e-4),
227            alpha: 0.3,
228            n: 0,
229        }
230    }
231
232    /// Record an observed request-to-first-byte latency.
233    pub fn observe(&mut self, ttfb_s: f64) {
234        let x = ttfb_s.clamp(1e-4, 30.0);
235        if self.n == 0 {
236            self.ewma = x;
237        } else {
238            self.ewma = (1.0 - self.alpha) * self.ewma + self.alpha * x;
239        }
240        self.n = self.n.saturating_add(1);
241    }
242
243    pub fn get(&self) -> f64 {
244        self.ewma
245    }
246
247    pub fn samples(&self) -> u32 {
248        self.n
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    /// A DOUBLING caller must get connection counts back, not sample indices.
257    ///
258    /// This is the defect that made `--adaptive` useless in the field. `observe` inferred
259    /// the level from `samples.len()`, which holds only when the caller admits one
260    /// connection per window. The in-band ramp doubles, because incrementing takes
261    /// `max - 1` windows and costs more clock than the concurrency saves — so sample 3
262    /// describes FOUR connections.
263    ///
264    /// Two things broke at once, and the field data showed both. Settled counts over
265    /// HISTORICAL MEASUREMENT (pre-fix implementation; retained to document why this test
266    /// exists, not as a current result). Five repetitions on an 11 MB object came back
267    /// `[2, 8, 8, 8, 8]` on a path a single stream already saturates: the `2` is sample 3
268    /// reported as level 2, and the four `8`s are the ceiling arm, whose
269    /// `samples.len() >= max_conns` test needs eight samples while doubling only ever
270    /// produces four. The mode ended up no better than hard-coding the ceiling
271    /// (0.99x, p = 0.63) while one connection was 1.97x faster than either.
272    #[test]
273    fn a_doubling_caller_settles_on_a_real_connection_count() {
274        // Saturated path: 1 -> 2 -> 4 all deliver the same, so the answer is 1.
275        let mut a = Admission::new(0.15, 8);
276        assert_eq!(a.observe_at(1, 1.00e6), Admit::Add);
277        assert_eq!(
278            a.observe_at(2, 1.01e6),
279            Admit::Stop,
280            "1% per added conn is noise"
281        );
282        assert_eq!(
283            a.settled(),
284            Some(1),
285            "must settle at ONE connection, not at a sample index"
286        );
287
288        // A path with real headroom: doubling keeps paying, so it must reach the
289        // ceiling and report the CEILING, not the number of samples it took.
290        let mut b = Admission::new(0.15, 8);
291        assert_eq!(b.observe_at(1, 1.0e6), Admit::Add);
292        assert_eq!(b.observe_at(2, 2.0e6), Admit::Add);
293        assert_eq!(b.observe_at(4, 4.0e6), Admit::Add);
294        assert_eq!(b.observe_at(8, 8.0e6), Admit::Stop, "ceiling is a stop");
295        assert_eq!(
296            b.settled(),
297            Some(8),
298            "a path that scales to the ceiling must settle AT the ceiling; \
299             four samples reached level 8 and the old sample-count test never fired"
300        );
301    }
302
303    /// Gain must be judged per connection added, not per window.
304    ///
305    /// Doubling from 4 to 8 adds four connections; incrementing from 1 to 2 adds one.
306    /// Holding both to the same absolute bar lets a large step pass on noise, which is
307    /// how a saturated path ran away to the ceiling.
308    #[test]
309    fn gain_is_normalised_by_connections_added() {
310        let mut a = Admission::new(0.15, 16);
311        assert_eq!(a.observe_at(1, 1.00e6), Admit::Add);
312        assert_eq!(a.observe_at(2, 1.20e6), Admit::Add, "20% for one conn pays");
313        // +0.40e6 across four added connections is 0.10e6 each — below the 0.15 bar,
314        // even though the raw step is larger than the one that just passed.
315        assert_eq!(
316            a.observe_at(8, 1.60e6),
317            Admit::Stop,
318            "a 6x jump in connections must not pass on the strength of the raw delta"
319        );
320        assert_eq!(a.settled(), Some(2), "settle at the level that last paid");
321    }
322
323    #[test]
324    fn saturated_path_settles_at_one() {
325        // A path already saturated by one connection: adding more yields nothing.
326        let mut a = Admission::new(0.15, 8);
327        assert_eq!(a.observe(1.0e6), Admit::Add);
328        assert_eq!(a.observe(1.02e6), Admit::Stop, "2% gain must be refused");
329        assert_eq!(
330            a.settled(),
331            Some(1),
332            "must settle at ONE, not at the probed 2"
333        );
334    }
335
336    #[test]
337    fn scalable_path_admits_until_knee() {
338        // rho = 4 * gamma: goodput rises linearly to 4 connections then flattens.
339        let mut a = Admission::new(0.15, 12);
340        let curve = [1.0e6, 2.0e6, 3.0e6, 4.0e6, 4.0e6, 4.0e6];
341        let mut last = Admit::Add;
342        for g in curve {
343            last = a.observe(g);
344            if last == Admit::Stop {
345                break;
346            }
347        }
348        assert_eq!(last, Admit::Stop);
349        assert_eq!(a.settled(), Some(4), "must find the knee at rho/gamma = 4");
350    }
351
352    #[test]
353    fn respects_politeness_ceiling() {
354        let mut a = Admission::new(0.01, 3);
355        for g in [1.0e6, 2.0e6, 3.0e6] {
356            a.observe(g);
357        }
358        assert_eq!(
359            a.settled(),
360            Some(3),
361            "ceiling binds even when gains continue"
362        );
363    }
364
365    #[test]
366    fn delta_estimator_tracks_a_step_change() {
367        let mut d = DeltaEstimator::new(0.15);
368        for _ in 0..12 {
369            d.observe(0.42);
370        }
371        assert!(
372            (d.get() - 0.42).abs() < 0.02,
373            "estimator must converge on the observed cost, got {}",
374            d.get()
375        );
376        assert_eq!(d.samples(), 12);
377    }
378
379    #[test]
380    fn delta_estimator_first_sample_replaces_prior() {
381        let mut d = DeltaEstimator::new(0.005);
382        d.observe(0.40);
383        assert!(
384            d.get() > 0.3,
385            "a wildly wrong prior must not survive one sample"
386        );
387    }
388}