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}