Skip to main content

tokio_prompt_orchestrator/
admission_control.rs

1//! # AIMD Adaptive Admission Control
2//!
3//! Additive-Increase / Multiplicative-Decrease concurrency limiter with
4//! gradient-based overload detection and a rolling acceptance-rate window.
5//!
6//! ## Design
7//!
8//! [`AdmissionController`] tracks an adaptive concurrency `limit` that starts
9//! at `min_limit` and grows linearly on success (`+additive_increase`) and
10//! shrinks geometrically on overload (`* multiplicative_decrease`).
11//!
12//! Callers call [`AdmissionController::try_acquire`] which returns an
13//! [`AcquireGuard`] RAII handle.  Dropping the guard decrements `in_flight`.
14//! The caller then signals success via [`AcquireGuard::success`] (or lets it
15//! drop silently for a neutral outcome).
16//!
17//! Latency gradient: every time a request completes the controller computes
18//! the ratio of the current latency against an exponential moving average.
19//! When the ratio exceeds `gradient_threshold` (default 2.0) it calls
20//! `on_overload`.
21//!
22//! ## Example
23//!
24//! ```rust
25//! use tokio_prompt_orchestrator::admission_control::{
26//!     AdmissionController, AdmissionConfig,
27//! };
28//! use std::time::Duration;
29//!
30//! let ctrl = AdmissionController::new(AdmissionConfig::default());
31//! if let Some(guard) = ctrl.try_acquire() {
32//!     guard.success(); // signal a successful, fast response
33//! }
34//! ```
35
36use std::sync::atomic::{AtomicUsize, Ordering};
37use std::sync::{Arc, Mutex};
38use std::time::{Duration, Instant};
39
40// ── Config ────────────────────────────────────────────────────────────────────
41
42/// Configuration for [`AdmissionController`].
43#[derive(Debug, Clone)]
44pub struct AdmissionConfig {
45    /// Starting (and minimum) concurrency limit.
46    pub min_limit: usize,
47    /// Hard cap on the concurrency limit.
48    pub max_limit: usize,
49    /// Amount added to `limit` on each successful call (additive increase).
50    pub additive_increase: usize,
51    /// Factor applied to `limit` on overload (multiplicative decrease, 0–1).
52    pub multiplicative_decrease: f64,
53    /// Ratio of current latency to EWMA latency above which overload is
54    /// declared (gradient trigger).
55    pub gradient_threshold: f64,
56    /// EWMA smoothing factor for the latency average (0–1; smaller = slower).
57    pub latency_ewma_alpha: f64,
58}
59
60impl Default for AdmissionConfig {
61    fn default() -> Self {
62        Self {
63            min_limit: 4,
64            max_limit: 512,
65            additive_increase: 1,
66            multiplicative_decrease: 0.9,
67            gradient_threshold: 2.0,
68            latency_ewma_alpha: 0.1,
69        }
70    }
71}
72
73// ── Rolling acceptance window ─────────────────────────────────────────────────
74
75/// 100-sample rolling acceptance window for computing `acceptance_rate`.
76struct AcceptanceWindow {
77    buf: [bool; 100],
78    head: usize,
79    filled: bool,
80}
81
82impl AcceptanceWindow {
83    fn new() -> Self {
84        Self {
85            buf: [false; 100],
86            head: 0,
87            filled: false,
88        }
89    }
90
91    fn record(&mut self, accepted: bool) {
92        self.buf[self.head] = accepted;
93        self.head = (self.head + 1) % 100;
94        if self.head == 0 {
95            self.filled = true;
96        }
97    }
98
99    fn rate(&self) -> f64 {
100        let n = if self.filled { 100 } else { self.head };
101        if n == 0 {
102            return 1.0;
103        }
104        let accepted = self.buf[..n].iter().filter(|&&b| b).count();
105        accepted as f64 / n as f64
106    }
107}
108
109// ── Inner mutable state ───────────────────────────────────────────────────────
110
111struct Inner {
112    /// Current adaptive limit (cached copy for atomic-free fast path).
113    limit_snapshot: usize,
114    /// EWMA of request latency in nanoseconds.
115    latency_ewma_ns: f64,
116    /// Rolling acceptance window.
117    acceptance: AcceptanceWindow,
118}
119
120// ── AdmissionController ───────────────────────────────────────────────────────
121
122/// AIMD adaptive concurrency controller.
123pub struct AdmissionController {
124    config: AdmissionConfig,
125    /// Authoritative concurrency limit (updated by on_success / on_overload).
126    limit: AtomicUsize,
127    /// Number of requests currently in flight.
128    in_flight: Arc<AtomicUsize>,
129    inner: Mutex<Inner>,
130}
131
132impl AdmissionController {
133    /// Create a new controller with the given configuration.
134    pub fn new(config: AdmissionConfig) -> Arc<Self> {
135        let start = config.min_limit;
136        Arc::new(Self {
137            config: config.clone(),
138            limit: AtomicUsize::new(start),
139            in_flight: Arc::new(AtomicUsize::new(0)),
140            inner: Mutex::new(Inner {
141                limit_snapshot: start,
142                latency_ewma_ns: 0.0,
143                acceptance: AcceptanceWindow::new(),
144            }),
145        })
146    }
147
148    /// Try to acquire a slot.
149    ///
150    /// Returns `None` if `in_flight >= limit`, otherwise returns an
151    /// [`AcquireGuard`] that decrements `in_flight` on drop.
152    pub fn try_acquire(self: &Arc<Self>) -> Option<AcquireGuard> {
153        let limit = self.limit.load(Ordering::Relaxed);
154        // Optimistic increment then check.
155        let prev = self.in_flight.fetch_add(1, Ordering::AcqRel);
156        if prev < limit {
157            let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner());
158            g.acceptance.record(true);
159            drop(g);
160            Some(AcquireGuard {
161                ctrl: Arc::clone(self),
162                start: Instant::now(),
163                signalled: false,
164            })
165        } else {
166            // Revert the increment — no slot available.
167            self.in_flight.fetch_sub(1, Ordering::AcqRel);
168            let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner());
169            g.acceptance.record(false);
170            None
171        }
172    }
173
174    /// Additive increase: grow the limit by `additive_increase`, up to
175    /// `max_limit`.
176    pub fn on_success(&self) {
177        let current = self.limit.load(Ordering::Relaxed);
178        let next = (current + self.config.additive_increase)
179            .min(self.config.max_limit);
180        self.limit.store(next, Ordering::Relaxed);
181        let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner());
182        g.limit_snapshot = next;
183    }
184
185    /// Multiplicative decrease: shrink the limit by `multiplicative_decrease`,
186    /// floored at `min_limit`.
187    pub fn on_overload(&self) {
188        let current = self.limit.load(Ordering::Relaxed);
189        let next = ((current as f64 * self.config.multiplicative_decrease)
190            .floor() as usize)
191            .max(self.config.min_limit);
192        self.limit.store(next, Ordering::Relaxed);
193        let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner());
194        g.limit_snapshot = next;
195    }
196
197    /// Record a completed request with its observed latency and apply AIMD +
198    /// gradient logic.
199    ///
200    /// Called internally by [`AcquireGuard::success`].
201    fn record_latency(&self, latency: Duration) {
202        let ns = latency.as_nanos() as f64;
203        let mut g = self.inner.lock().unwrap_or_else(|e| e.into_inner());
204        if g.latency_ewma_ns == 0.0 {
205            g.latency_ewma_ns = ns;
206        } else {
207            let alpha = self.config.latency_ewma_alpha;
208            g.latency_ewma_ns = alpha * ns + (1.0 - alpha) * g.latency_ewma_ns;
209        }
210        let ewma = g.latency_ewma_ns;
211        drop(g);
212
213        if ewma > 0.0 && ns / ewma > self.config.gradient_threshold {
214            self.on_overload();
215        } else {
216            self.on_success();
217        }
218    }
219
220    /// Return a snapshot of current admission statistics.
221    pub fn stats(&self) -> AdmissionStats {
222        let limit = self.limit.load(Ordering::Relaxed);
223        let in_flight = self.in_flight.load(Ordering::Relaxed);
224        let g = self.inner.lock().unwrap_or_else(|e| e.into_inner());
225        AdmissionStats {
226            current_limit: limit,
227            in_flight,
228            acceptance_rate: g.acceptance.rate(),
229        }
230    }
231
232    /// Current adaptive concurrency limit.
233    pub fn current_limit(&self) -> usize {
234        self.limit.load(Ordering::Relaxed)
235    }
236
237    /// Number of requests currently in flight.
238    pub fn in_flight(&self) -> usize {
239        self.in_flight.load(Ordering::Relaxed)
240    }
241}
242
243// ── AcquireGuard ──────────────────────────────────────────────────────────────
244
245/// RAII guard returned by [`AdmissionController::try_acquire`].
246///
247/// Dropping this guard decrements `in_flight`.  Call [`AcquireGuard::success`]
248/// before dropping to signal a successful (fast) completion and trigger the
249/// AIMD increase or gradient calculation.
250pub struct AcquireGuard {
251    ctrl: Arc<AdmissionController>,
252    start: Instant,
253    signalled: bool,
254}
255
256impl AcquireGuard {
257    /// Signal that this request completed successfully.  Triggers latency
258    /// gradient evaluation and potentially AIMD increase.
259    pub fn success(mut self) {
260        self.signalled = true;
261        let latency = self.start.elapsed();
262        self.ctrl.record_latency(latency);
263        // Drop will still fire and decrement in_flight.
264    }
265}
266
267impl Drop for AcquireGuard {
268    fn drop(&mut self) {
269        self.ctrl.in_flight.fetch_sub(1, Ordering::AcqRel);
270    }
271}
272
273// ── AdmissionStats ────────────────────────────────────────────────────────────
274
275/// Snapshot of admission controller metrics.
276#[derive(Debug, Clone)]
277pub struct AdmissionStats {
278    /// Current adaptive concurrency limit.
279    pub current_limit: usize,
280    /// Number of requests currently in flight.
281    pub in_flight: usize,
282    /// Rolling acceptance rate over the last 100 try_acquire calls (0–1).
283    pub acceptance_rate: f64,
284}
285
286// ── Unit tests ────────────────────────────────────────────────────────────────
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use std::sync::atomic::AtomicBool;
292    use std::thread;
293
294    #[test]
295    fn try_acquire_within_limit() {
296        let ctrl = AdmissionController::new(AdmissionConfig {
297            min_limit: 2,
298            max_limit: 10,
299            ..Default::default()
300        });
301        let g1 = ctrl.try_acquire().expect("slot 1");
302        let g2 = ctrl.try_acquire().expect("slot 2");
303        assert!(ctrl.try_acquire().is_none(), "limit reached");
304        drop(g1);
305        assert!(ctrl.try_acquire().is_some(), "slot freed");
306        drop(g2);
307    }
308
309    #[test]
310    fn on_success_increases_limit() {
311        let ctrl = AdmissionController::new(AdmissionConfig {
312            min_limit: 4,
313            max_limit: 10,
314            additive_increase: 2,
315            ..Default::default()
316        });
317        let start = ctrl.current_limit();
318        ctrl.on_success();
319        assert_eq!(ctrl.current_limit(), start + 2);
320    }
321
322    #[test]
323    fn on_overload_decreases_limit() {
324        let ctrl = AdmissionController::new(AdmissionConfig {
325            min_limit: 4,
326            max_limit: 100,
327            multiplicative_decrease: 0.5,
328            ..Default::default()
329        });
330        // Start at min_limit=4, manually push the limit up.
331        ctrl.limit.store(20, Ordering::Relaxed);
332        ctrl.on_overload();
333        assert_eq!(ctrl.current_limit(), 10);
334    }
335
336    #[test]
337    fn limit_capped_at_max() {
338        let ctrl = AdmissionController::new(AdmissionConfig {
339            min_limit: 4,
340            max_limit: 5,
341            additive_increase: 10,
342            ..Default::default()
343        });
344        ctrl.on_success();
345        assert_eq!(ctrl.current_limit(), 5);
346    }
347
348    #[test]
349    fn limit_floored_at_min() {
350        let ctrl = AdmissionController::new(AdmissionConfig {
351            min_limit: 4,
352            max_limit: 100,
353            multiplicative_decrease: 0.01,
354            ..Default::default()
355        });
356        ctrl.on_overload();
357        assert_eq!(ctrl.current_limit(), 4);
358    }
359
360    #[test]
361    fn in_flight_decrements_on_drop() {
362        let ctrl = AdmissionController::new(AdmissionConfig {
363            min_limit: 4,
364            max_limit: 100,
365            ..Default::default()
366        });
367        {
368            let _g = ctrl.try_acquire().unwrap();
369            assert_eq!(ctrl.in_flight(), 1);
370        }
371        assert_eq!(ctrl.in_flight(), 0);
372    }
373
374    #[test]
375    fn acceptance_rate_tracks_rejections() {
376        let ctrl = AdmissionController::new(AdmissionConfig {
377            min_limit: 1,
378            max_limit: 1,
379            ..Default::default()
380        });
381        // First acquire succeeds, subsequent ones are rejected until we drop.
382        let g = ctrl.try_acquire().unwrap();
383        for _ in 0..9 {
384            let _ = ctrl.try_acquire(); // rejected
385        }
386        drop(g);
387        let stats = ctrl.stats();
388        // 1 accepted out of 10 calls = 0.1
389        assert!(stats.acceptance_rate < 0.5);
390    }
391
392    #[test]
393    fn concurrent_acquire_release() {
394        let ctrl = AdmissionController::new(AdmissionConfig {
395            min_limit: 8,
396            max_limit: 8,
397            ..Default::default()
398        });
399        let success = Arc::new(AtomicBool::new(true));
400        let mut handles = Vec::new();
401        for _ in 0..8 {
402            let ctrl2 = Arc::clone(&ctrl);
403            let ok = Arc::clone(&success);
404            handles.push(thread::spawn(move || {
405                if let Some(g) = ctrl2.try_acquire() {
406                    thread::sleep(Duration::from_millis(5));
407                    g.success();
408                } else {
409                    ok.store(false, Ordering::Relaxed);
410                }
411            }));
412        }
413        for h in handles {
414            h.join().expect("thread panicked");
415        }
416        assert!(success.load(Ordering::Relaxed));
417        assert_eq!(ctrl.in_flight(), 0);
418    }
419
420    #[test]
421    fn aimd_convergence_under_normal_load() {
422        // Under fast responses the limit should grow from min to max.
423        let ctrl = AdmissionController::new(AdmissionConfig {
424            min_limit: 4,
425            max_limit: 20,
426            additive_increase: 1,
427            multiplicative_decrease: 0.9,
428            gradient_threshold: 10.0, // high threshold: never overload
429            latency_ewma_alpha: 0.5,
430            ..Default::default()
431        });
432        for _ in 0..20 {
433            if let Some(g) = ctrl.try_acquire() {
434                g.success();
435            } else {
436                ctrl.on_success();
437            }
438        }
439        // Limit should have grown well above min.
440        assert!(ctrl.current_limit() > 4);
441    }
442}