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        // The bar is a FRACTION OF PROPORTIONAL SCALING, not a fixed fraction of the
93        // single-connection rate.
94        //
95        // Judging a step against `min_gain_frac * samples[0]` asks the wrong question.
96        // It asks "did throughput improve at all", and on a warming path the answer is
97        // always yes — TCP flows admitted a window ago are still opening their
98        // congestion windows, so aggregate throughput keeps rising whether or not the
99        // extra concurrency is doing anything. Measured consequence: the search reached
100        // the ceiling in 9 of 12 runs on paths where a single connection was 1.8-3.2x
101        // faster than the ceiling it chose.
102        //
103        // The right question is "did throughput improve as much as adding these
104        // connections should have". Doubling from k to 2k on a link with genuine
105        // headroom roughly doubles delivery; doubling on a saturated link leaves it
106        // flat. Comparing the observed ratio against the ratio of connection counts
107        // separates those two cases, and it does so without needing to know the link's
108        // capacity or RTT.
109        //
110        // `min_gain_frac` becomes the share of proportional scaling required: 0.15 means
111        // a step must deliver at least 15% of what perfect scaling would have. That is
112        // permissive enough to admit a genuinely parallel path (where the ratio
113        // approaches 1.0) and strict enough to refuse a saturated one (where it
114        // approaches 0).
115        if !self.step_pays(n - 1) {
116            // The previous level was as good: settle at the best one MEASURED, which
117            // is not necessarily the previous one — a noisy window can make an
118            // intermediate level look best, and the point of the search is to end up
119            // where the throughput actually was.
120            self.settled = Some(self.best_level());
121            Admit::Stop
122        } else {
123            Admit::Add
124        }
125    }
126
127    /// Did the step into sample `i` deliver enough to justify the connections it added?
128    ///
129    /// Measured as a fraction of PROPORTIONAL scaling. Doubling the connections on a
130    /// link with real headroom roughly doubles delivery; doubling on a saturated link
131    /// leaves delivery flat. The ratio of the two separates those cases without needing
132    /// to know the link's capacity or RTT, and it is scale-free, so it works the same at
133    /// 1 -> 2 as at 4 -> 8.
134    ///
135    /// `min_gain_frac` is therefore the share of perfect scaling required: 0.15 means a
136    /// step must realise at least 15% of the throughput it would have gained if the
137    /// added connections were free and the link were unlimited.
138    fn step_pays(&self, i: usize) -> bool {
139        if i == 0 || i >= self.samples.len() {
140            return false;
141        }
142        let prev_rate = self.samples[i - 1].max(1.0);
143        let prev_level = self.levels[i - 1].max(1) as f64;
144        let this_level = self.levels[i].max(1) as f64;
145        if this_level <= prev_level {
146            return false;
147        }
148        let ideal = prev_rate * (this_level / prev_level);
149        let headroom = (ideal - prev_rate).max(1e-9);
150        (self.samples[i] - prev_rate) / headroom >= self.min_gain_frac
151    }
152
153    /// The smallest level that is within `min_gain_frac` of the best goodput measured.
154    ///
155    /// Not simply "the highest sample". A level whose marginal gain was refused must
156    /// not then be settled on — that would reject a level and adopt it in the same
157    /// breath. A 2% improvement from doubling the connections is inside the noise this
158    /// threshold exists to reject, so the answer is the *cheapest* level that performs
159    /// indistinguishably from the best one.
160    ///
161    /// Equal throughput on fewer connections is strictly better: fewer handshakes, less
162    /// origin load, and less exposure to the repair machinery. That is what lets the
163    /// search return "one" on a path a single stream already saturates, which is the
164    /// case that motivated the whole in-band ramp.
165    fn best_level(&self) -> usize {
166        if self.samples.is_empty() {
167            return 1;
168        }
169        // Walk the levels in order and keep the last one whose own step paid its way,
170        // by the SAME per-connection rule the admission test applies. Comparing every
171        // sample against a band around the peak instead would re-admit a level the
172        // test had just refused: gains accumulate, so after several steps the top
173        // sample is the highest even when the final step was worthless.
174        // Uses the SAME rule as `observe_at`, deliberately. An earlier version scored
175        // levels against a band around the peak while `observe_at` tested a per-step
176        // gain, and the two disagreed: a level whose step had just been refused could
177        // still come back as "best", so the search rejected a level and adopted it in
178        // the same breath. One rule, applied in one place, cannot contradict itself.
179        let mut best = self.levels.first().copied().unwrap_or(1);
180        for i in 1..self.samples.len() {
181            if self.step_pays(i) {
182                best = self.levels[i];
183            } else {
184                // The first step that fails to pay ends the search. Levels beyond it
185                // were reached on the strength of earlier gains, not their own.
186                break;
187            }
188        }
189        best.clamp(1, self.max_conns)
190    }
191
192    /// Back-compatible entry point for callers that admit one connection at a time.
193    pub fn observe(&mut self, goodput: f64) -> Admit {
194        let level = self.samples.len() + 1;
195        self.observe_at(level, goodput)
196    }
197
198    /// Connections currently probed.
199    pub fn level(&self) -> usize {
200        self.samples.len()
201    }
202
203    /// The settled allocation, once probing has stopped.
204    pub fn settled(&self) -> Option<usize> {
205        self.settled
206    }
207
208    /// Best goodput seen, for reporting.
209    pub fn best_goodput(&self) -> f64 {
210        self.samples.iter().cloned().fold(0.0, f64::max)
211    }
212}
213
214/// Online estimate of the per-request setup cost `delta`.
215///
216/// A configured constant is not good enough: the same code saw `delta = 5 ms`
217/// against a loopback-equivalent origin and `420 ms` against a real proxied
218/// origin, and `delta` sets the repair deadband `theta`. Underestimating it by
219/// 2.8x caused measurable over-repair — each unnecessary repair costs a full
220/// `delta`, so the error compounds.
221#[derive(Clone, Copy, Debug)]
222pub struct DeltaEstimator {
223    ewma: f64,
224    alpha: f64,
225    n: u32,
226}
227
228impl DeltaEstimator {
229    pub fn new(prior_s: f64) -> Self {
230        Self {
231            ewma: prior_s.max(1e-4),
232            alpha: 0.3,
233            n: 0,
234        }
235    }
236
237    /// Record an observed request-to-first-byte latency.
238    pub fn observe(&mut self, ttfb_s: f64) {
239        let x = ttfb_s.clamp(1e-4, 30.0);
240        if self.n == 0 {
241            self.ewma = x;
242        } else {
243            self.ewma = (1.0 - self.alpha) * self.ewma + self.alpha * x;
244        }
245        self.n = self.n.saturating_add(1);
246    }
247
248    pub fn get(&self) -> f64 {
249        self.ewma
250    }
251
252    pub fn samples(&self) -> u32 {
253        self.n
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    /// A DOUBLING caller must get connection counts back, not sample indices.
262    ///
263    /// This is the defect that made `--adaptive` useless in the field. `observe` inferred
264    /// the level from `samples.len()`, which holds only when the caller admits one
265    /// connection per window. The in-band ramp doubles, because incrementing takes
266    /// `max - 1` windows and costs more clock than the concurrency saves — so sample 3
267    /// describes FOUR connections.
268    ///
269    /// Two things broke at once, and the field data showed both. Settled counts over
270    /// HISTORICAL MEASUREMENT (pre-fix implementation; retained to document why this test
271    /// exists, not as a current result). Five repetitions on an 11 MB object came back
272    /// `[2, 8, 8, 8, 8]` on a path a single stream already saturates: the `2` is sample 3
273    /// reported as level 2, and the four `8`s are the ceiling arm, whose
274    /// `samples.len() >= max_conns` test needs eight samples while doubling only ever
275    /// produces four. The mode ended up no better than hard-coding the ceiling
276    /// (0.99x, p = 0.63) while one connection was 1.97x faster than either.
277    #[test]
278    fn a_doubling_caller_settles_on_a_real_connection_count() {
279        // Saturated path: 1 -> 2 -> 4 all deliver the same, so the answer is 1.
280        let mut a = Admission::new(0.15, 8);
281        assert_eq!(a.observe_at(1, 1.00e6), Admit::Add);
282        assert_eq!(
283            a.observe_at(2, 1.01e6),
284            Admit::Stop,
285            "1% per added conn is noise"
286        );
287        assert_eq!(
288            a.settled(),
289            Some(1),
290            "must settle at ONE connection, not at a sample index"
291        );
292
293        // A path with real headroom: doubling keeps paying, so it must reach the
294        // ceiling and report the CEILING, not the number of samples it took.
295        let mut b = Admission::new(0.15, 8);
296        assert_eq!(b.observe_at(1, 1.0e6), Admit::Add);
297        assert_eq!(b.observe_at(2, 2.0e6), Admit::Add);
298        assert_eq!(b.observe_at(4, 4.0e6), Admit::Add);
299        assert_eq!(b.observe_at(8, 8.0e6), Admit::Stop, "ceiling is a stop");
300        assert_eq!(
301            b.settled(),
302            Some(8),
303            "a path that scales to the ceiling must settle AT the ceiling; \
304             four samples reached level 8 and the old sample-count test never fired"
305        );
306    }
307
308    /// Gain must be judged per connection added, not per window.
309    ///
310    /// Doubling from 4 to 8 adds four connections; incrementing from 1 to 2 adds one.
311    /// Holding both to the same absolute bar lets a large step pass on noise, which is
312    /// how a saturated path ran away to the ceiling.
313    #[test]
314    fn gain_is_normalised_by_connections_added() {
315        let mut a = Admission::new(0.15, 16);
316        assert_eq!(a.observe_at(1, 1.00e6), Admit::Add);
317        assert_eq!(a.observe_at(2, 1.20e6), Admit::Add, "20% for one conn pays");
318        // +0.40e6 across four added connections is 0.10e6 each — below the 0.15 bar,
319        // even though the raw step is larger than the one that just passed.
320        assert_eq!(
321            a.observe_at(8, 1.60e6),
322            Admit::Stop,
323            "a 6x jump in connections must not pass on the strength of the raw delta"
324        );
325        assert_eq!(a.settled(), Some(2), "settle at the level that last paid");
326    }
327
328    #[test]
329    fn saturated_path_settles_at_one() {
330        // A path already saturated by one connection: adding more yields nothing.
331        let mut a = Admission::new(0.15, 8);
332        assert_eq!(a.observe(1.0e6), Admit::Add);
333        assert_eq!(a.observe(1.02e6), Admit::Stop, "2% gain must be refused");
334        assert_eq!(
335            a.settled(),
336            Some(1),
337            "must settle at ONE, not at the probed 2"
338        );
339    }
340
341    #[test]
342    fn scalable_path_admits_until_knee() {
343        // rho = 4 * gamma: goodput rises linearly to 4 connections then flattens.
344        let mut a = Admission::new(0.15, 12);
345        let curve = [1.0e6, 2.0e6, 3.0e6, 4.0e6, 4.0e6, 4.0e6];
346        let mut last = Admit::Add;
347        for g in curve {
348            last = a.observe(g);
349            if last == Admit::Stop {
350                break;
351            }
352        }
353        assert_eq!(last, Admit::Stop);
354        assert_eq!(a.settled(), Some(4), "must find the knee at rho/gamma = 4");
355    }
356
357    #[test]
358    fn respects_politeness_ceiling() {
359        let mut a = Admission::new(0.01, 3);
360        for g in [1.0e6, 2.0e6, 3.0e6] {
361            a.observe(g);
362        }
363        assert_eq!(
364            a.settled(),
365            Some(3),
366            "ceiling binds even when gains continue"
367        );
368    }
369
370    #[test]
371    fn delta_estimator_tracks_a_step_change() {
372        let mut d = DeltaEstimator::new(0.15);
373        for _ in 0..12 {
374            d.observe(0.42);
375        }
376        assert!(
377            (d.get() - 0.42).abs() < 0.02,
378            "estimator must converge on the observed cost, got {}",
379            d.get()
380        );
381        assert_eq!(d.samples(), 12);
382    }
383
384    #[test]
385    fn delta_estimator_first_sample_replaces_prior() {
386        let mut d = DeltaEstimator::new(0.005);
387        d.observe(0.40);
388        assert!(
389            d.get() > 0.3,
390            "a wildly wrong prior must not survive one sample"
391        );
392    }
393}