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}