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 at each concurrency level, index 0 = one connection.
30    samples: Vec<f64>,
31    /// Marginal gain required to justify one more connection, as a fraction of
32    /// the single-connection goodput.
33    min_gain_frac: f64,
34    /// Hard ceiling regardless of measurement (politeness, not physics).
35    max_conns: usize,
36    settled: Option<usize>,
37}
38
39impl Admission {
40    pub fn new(min_gain_frac: f64, max_conns: usize) -> Self {
41        Self {
42            samples: Vec::new(),
43            min_gain_frac,
44            max_conns: max_conns.max(1),
45            settled: None,
46        }
47    }
48
49    /// Record the aggregate goodput (bytes/s) observed with `self.level() + 1`
50    /// connections, and decide whether to admit another.
51    pub fn observe(&mut self, goodput: f64) -> Admit {
52        self.samples.push(goodput.max(0.0));
53        let n = self.samples.len();
54        if n >= self.max_conns {
55            self.settled = Some(n);
56            return Admit::Stop;
57        }
58        if n == 1 {
59            return Admit::Add;
60        }
61        let base = self.samples[0].max(1.0);
62        let gain = self.samples[n - 1] - self.samples[n - 2];
63        if gain < self.min_gain_frac * base {
64            // The previous level was as good; settle there, not here.
65            // Additional connections contribute negligible gain and add request overhead.
66            self.settled = Some(n - 1);
67            Admit::Stop
68        } else {
69            Admit::Add
70        }
71    }
72
73    /// Connections currently probed.
74    pub fn level(&self) -> usize {
75        self.samples.len()
76    }
77
78    /// The settled allocation, once probing has stopped.
79    pub fn settled(&self) -> Option<usize> {
80        self.settled
81    }
82
83    /// Best goodput seen, for reporting.
84    pub fn best_goodput(&self) -> f64 {
85        self.samples.iter().cloned().fold(0.0, f64::max)
86    }
87}
88
89/// Online estimate of the per-request setup cost `delta`.
90///
91/// A configured constant is not good enough: the same code saw `delta = 5 ms`
92/// against a loopback-equivalent origin and `420 ms` against a real proxied
93/// origin, and `delta` sets the repair deadband `theta`. Underestimating it by
94/// 2.8x caused measurable over-repair — each unnecessary repair costs a full
95/// `delta`, so the error compounds.
96#[derive(Clone, Copy, Debug)]
97pub struct DeltaEstimator {
98    ewma: f64,
99    alpha: f64,
100    n: u32,
101}
102
103impl DeltaEstimator {
104    pub fn new(prior_s: f64) -> Self {
105        Self {
106            ewma: prior_s.max(1e-4),
107            alpha: 0.3,
108            n: 0,
109        }
110    }
111
112    /// Record an observed request-to-first-byte latency.
113    pub fn observe(&mut self, ttfb_s: f64) {
114        let x = ttfb_s.clamp(1e-4, 30.0);
115        if self.n == 0 {
116            self.ewma = x;
117        } else {
118            self.ewma = (1.0 - self.alpha) * self.ewma + self.alpha * x;
119        }
120        self.n = self.n.saturating_add(1);
121    }
122
123    pub fn get(&self) -> f64 {
124        self.ewma
125    }
126
127    pub fn samples(&self) -> u32 {
128        self.n
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn saturated_path_settles_at_one() {
138        // A path already saturated by one connection: adding more yields nothing.
139        let mut a = Admission::new(0.15, 8);
140        assert_eq!(a.observe(1.0e6), Admit::Add);
141        assert_eq!(a.observe(1.02e6), Admit::Stop, "2% gain must be refused");
142        assert_eq!(
143            a.settled(),
144            Some(1),
145            "must settle at ONE, not at the probed 2"
146        );
147    }
148
149    #[test]
150    fn scalable_path_admits_until_knee() {
151        // rho = 4 * gamma: goodput rises linearly to 4 connections then flattens.
152        let mut a = Admission::new(0.15, 12);
153        let curve = [1.0e6, 2.0e6, 3.0e6, 4.0e6, 4.0e6, 4.0e6];
154        let mut last = Admit::Add;
155        for g in curve {
156            last = a.observe(g);
157            if last == Admit::Stop {
158                break;
159            }
160        }
161        assert_eq!(last, Admit::Stop);
162        assert_eq!(a.settled(), Some(4), "must find the knee at rho/gamma = 4");
163    }
164
165    #[test]
166    fn respects_politeness_ceiling() {
167        let mut a = Admission::new(0.01, 3);
168        for g in [1.0e6, 2.0e6, 3.0e6] {
169            a.observe(g);
170        }
171        assert_eq!(
172            a.settled(),
173            Some(3),
174            "ceiling binds even when gains continue"
175        );
176    }
177
178    #[test]
179    fn delta_estimator_tracks_a_step_change() {
180        let mut d = DeltaEstimator::new(0.15);
181        for _ in 0..12 {
182            d.observe(0.42);
183        }
184        assert!(
185            (d.get() - 0.42).abs() < 0.02,
186            "estimator must converge on the observed cost, got {}",
187            d.get()
188        );
189        assert_eq!(d.samples(), 12);
190    }
191
192    #[test]
193    fn delta_estimator_first_sample_replaces_prior() {
194        let mut d = DeltaEstimator::new(0.005);
195        d.observe(0.40);
196        assert!(
197            d.get() > 0.3,
198            "a wildly wrong prior must not survive one sample"
199        );
200    }
201}