Skip to main content

a3s_box_runtime/pool/
scaler.rs

1//! Pool autoscaler — adjusts `min_idle` based on acquire pressure.
2//!
3//! Monitors hit/miss rates over a sliding window and scales the pool's
4//! `min_idle` target up or down to match demand.
5
6use std::collections::VecDeque;
7use std::time::Instant;
8
9use a3s_box_core::config::ScalingPolicy;
10
11/// Result of a scaling evaluation.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum ScaleDecision {
14    /// Increase min_idle by the given amount.
15    ScaleUp(usize),
16    /// Decrease min_idle by the given amount.
17    ScaleDown(usize),
18    /// No change needed.
19    Hold,
20}
21
22/// Sliding window of acquire events for miss rate calculation.
23struct PressureWindow {
24    /// Ring buffer of (timestamp, was_hit).
25    events: VecDeque<(Instant, bool)>,
26    /// Window duration.
27    window: std::time::Duration,
28}
29
30impl PressureWindow {
31    fn new(window_secs: u64) -> Self {
32        Self {
33            events: VecDeque::new(),
34            window: std::time::Duration::from_secs(window_secs),
35        }
36    }
37
38    /// Record an acquire event (hit = from pool, miss = on-demand boot).
39    fn record(&mut self, hit: bool) {
40        self.events.push_back((Instant::now(), hit));
41        self.prune();
42    }
43
44    /// Record an event with a specific timestamp (for testing).
45    #[cfg(test)]
46    fn record_at(&mut self, at: Instant, hit: bool) {
47        self.events.push_back((at, hit));
48        self.prune();
49    }
50
51    /// Remove events outside the window.
52    fn prune(&mut self) {
53        let cutoff = Instant::now()
54            .checked_sub(self.window)
55            .unwrap_or_else(Instant::now);
56        while let Some(&(ts, _)) = self.events.front() {
57            if ts < cutoff {
58                self.events.pop_front();
59            } else {
60                break;
61            }
62        }
63    }
64
65    /// Calculate the miss rate (0.0 to 1.0). Returns None if no events.
66    fn miss_rate(&mut self) -> Option<f64> {
67        self.prune();
68        if self.events.is_empty() {
69            return None;
70        }
71        let total = self.events.len() as f64;
72        let misses = self.events.iter().filter(|(_, hit)| !hit).count() as f64;
73        Some(misses / total)
74    }
75
76    /// Number of events in the window.
77    fn len(&self) -> usize {
78        self.events.len()
79    }
80}
81
82/// Pool autoscaler that adjusts min_idle based on pressure signals.
83pub struct PoolScaler {
84    /// Scaling policy configuration.
85    policy: ScalingPolicy,
86    /// Sliding window of acquire events.
87    window: PressureWindow,
88    /// Last time a scaling decision was made.
89    last_scale_at: Option<Instant>,
90    /// Current dynamic min_idle value.
91    current_min_idle: usize,
92    /// Effective upper bound for min_idle.
93    max_min_idle: usize,
94    /// External pressure signal from Gateway (0.0 = idle, 1.0 = saturated).
95    gateway_pressure: f64,
96    /// Timestamp of last gateway pressure update.
97    gateway_pressure_at: Option<Instant>,
98}
99
100impl PoolScaler {
101    /// Create a new scaler with the given policy and initial min_idle.
102    pub fn new(policy: ScalingPolicy, initial_min_idle: usize, max_size: usize) -> Self {
103        let max_min_idle = if policy.max_min_idle > 0 {
104            policy.max_min_idle.min(max_size)
105        } else {
106            max_size
107        };
108
109        Self {
110            window: PressureWindow::new(policy.window_secs),
111            policy,
112            last_scale_at: None,
113            current_min_idle: initial_min_idle,
114            max_min_idle,
115            gateway_pressure: 0.0,
116            gateway_pressure_at: None,
117        }
118    }
119
120    /// Record an acquire event. `hit` = true if served from pool.
121    pub fn record_acquire(&mut self, hit: bool) {
122        self.window.record(hit);
123    }
124
125    /// Get the current dynamic min_idle value.
126    pub fn current_min_idle(&self) -> usize {
127        self.current_min_idle
128    }
129
130    /// Update the external Gateway pressure signal.
131    ///
132    /// Pressure is a value from 0.0 (idle) to 1.0 (saturated), derived from
133    /// Gateway metrics like request rate, queue depth, or latency percentiles.
134    ///
135    /// When Gateway pressure is high, the scaler biases toward scaling up
136    /// even if the local miss rate is moderate.
137    pub fn update_gateway_pressure(&mut self, pressure: f64) {
138        self.gateway_pressure = pressure.clamp(0.0, 1.0);
139        self.gateway_pressure_at = Some(Instant::now());
140    }
141
142    /// Get the current Gateway pressure value.
143    pub fn gateway_pressure(&self) -> f64 {
144        self.gateway_pressure
145    }
146
147    /// Check if the Gateway pressure signal is fresh (within 2x window).
148    fn is_gateway_pressure_fresh(&self) -> bool {
149        match self.gateway_pressure_at {
150            Some(at) => at.elapsed().as_secs() < self.policy.window_secs * 2,
151            None => false,
152        }
153    }
154
155    /// Compute the effective miss rate, blending local miss rate with Gateway pressure.
156    ///
157    /// If Gateway pressure is available and fresh, the effective rate is:
158    ///   `effective = local_miss_rate * 0.6 + gateway_pressure * 0.4`
159    ///
160    /// This allows the scaler to pre-warm VMs when Gateway sees rising traffic
161    /// even before local misses occur.
162    fn effective_miss_rate(&mut self) -> Option<f64> {
163        let local = self.window.miss_rate()?;
164
165        if self.is_gateway_pressure_fresh() && self.gateway_pressure > 0.0 {
166            Some(local * 0.6 + self.gateway_pressure * 0.4)
167        } else {
168            Some(local)
169        }
170    }
171
172    /// Evaluate pressure and return a scaling decision.
173    ///
174    /// Respects cooldown period between decisions.
175    pub fn evaluate(&mut self) -> ScaleDecision {
176        // Check cooldown
177        if let Some(last) = self.last_scale_at {
178            if last.elapsed().as_secs() < self.policy.cooldown_secs {
179                return ScaleDecision::Hold;
180            }
181        }
182
183        // Need at least a few events to make a decision
184        if self.window.len() < 3 {
185            return ScaleDecision::Hold;
186        }
187
188        let miss_rate = match self.effective_miss_rate() {
189            Some(rate) => rate,
190            None => return ScaleDecision::Hold,
191        };
192
193        if miss_rate > self.policy.scale_up_threshold {
194            // High miss rate → scale up
195            let new_min = (self.current_min_idle + 1).min(self.max_min_idle);
196            if new_min > self.current_min_idle {
197                self.current_min_idle = new_min;
198                self.last_scale_at = Some(Instant::now());
199                ScaleDecision::ScaleUp(1)
200            } else {
201                ScaleDecision::Hold // Already at max
202            }
203        } else if miss_rate < self.policy.scale_down_threshold {
204            // Low miss rate → scale down
205            let new_min = self.current_min_idle.saturating_sub(1).max(1);
206            if new_min < self.current_min_idle {
207                self.current_min_idle = new_min;
208                self.last_scale_at = Some(Instant::now());
209                ScaleDecision::ScaleDown(1)
210            } else {
211                ScaleDecision::Hold // Already at floor
212            }
213        } else {
214            ScaleDecision::Hold
215        }
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    fn default_policy() -> ScalingPolicy {
224        ScalingPolicy {
225            enabled: true,
226            scale_up_threshold: 0.3,
227            scale_down_threshold: 0.05,
228            max_min_idle: 0, // use max_size
229            cooldown_secs: 60,
230            window_secs: 120,
231        }
232    }
233
234    // --- PressureWindow tests ---
235
236    #[test]
237    fn test_window_empty_miss_rate_is_none() {
238        let mut w = PressureWindow::new(120);
239        assert_eq!(w.miss_rate(), None);
240        assert_eq!(w.len(), 0);
241    }
242
243    #[test]
244    fn test_window_all_hits() {
245        let mut w = PressureWindow::new(120);
246        for _ in 0..10 {
247            w.record(true);
248        }
249        assert_eq!(w.miss_rate(), Some(0.0));
250    }
251
252    #[test]
253    fn test_window_all_misses() {
254        let mut w = PressureWindow::new(120);
255        for _ in 0..10 {
256            w.record(false);
257        }
258        assert_eq!(w.miss_rate(), Some(1.0));
259    }
260
261    #[test]
262    fn test_window_mixed_events() {
263        let mut w = PressureWindow::new(120);
264        // 3 misses, 7 hits → 30% miss rate
265        for _ in 0..3 {
266            w.record(false);
267        }
268        for _ in 0..7 {
269            w.record(true);
270        }
271        let rate = w.miss_rate().unwrap();
272        assert!((rate - 0.3).abs() < 0.001);
273    }
274
275    #[test]
276    fn test_window_prunes_old_events() {
277        // Window of 1 second
278        let mut w = PressureWindow::new(1);
279        let old = Instant::now() - std::time::Duration::from_secs(5);
280        w.record_at(old, false);
281        w.record_at(old, false);
282        // These old events should be pruned
283        w.record(true);
284        assert_eq!(w.len(), 1);
285        assert_eq!(w.miss_rate(), Some(0.0));
286    }
287
288    #[test]
289    fn test_window_record_at() {
290        let mut w = PressureWindow::new(300);
291        let now = Instant::now();
292        w.record_at(now, true);
293        w.record_at(now, false);
294        assert_eq!(w.len(), 2);
295        assert_eq!(w.miss_rate(), Some(0.5));
296    }
297
298    // --- ScaleDecision tests ---
299
300    #[test]
301    fn test_scale_decision_equality() {
302        assert_eq!(ScaleDecision::Hold, ScaleDecision::Hold);
303        assert_eq!(ScaleDecision::ScaleUp(1), ScaleDecision::ScaleUp(1));
304        assert_eq!(ScaleDecision::ScaleDown(1), ScaleDecision::ScaleDown(1));
305        assert_ne!(ScaleDecision::ScaleUp(1), ScaleDecision::ScaleDown(1));
306    }
307
308    #[test]
309    fn test_scale_decision_debug() {
310        let d = ScaleDecision::ScaleUp(2);
311        assert!(format!("{:?}", d).contains("ScaleUp"));
312    }
313
314    // --- PoolScaler tests ---
315
316    #[test]
317    fn test_scaler_initial_min_idle() {
318        let scaler = PoolScaler::new(default_policy(), 2, 10);
319        assert_eq!(scaler.current_min_idle(), 2);
320    }
321
322    #[test]
323    fn test_scaler_max_min_idle_defaults_to_max_size() {
324        let scaler = PoolScaler::new(default_policy(), 2, 10);
325        assert_eq!(scaler.max_min_idle, 10);
326    }
327
328    #[test]
329    fn test_scaler_max_min_idle_capped_by_max_size() {
330        let mut policy = default_policy();
331        policy.max_min_idle = 20; // higher than max_size
332        let scaler = PoolScaler::new(policy, 2, 10);
333        assert_eq!(scaler.max_min_idle, 10); // capped to max_size
334    }
335
336    #[test]
337    fn test_scaler_max_min_idle_explicit() {
338        let mut policy = default_policy();
339        policy.max_min_idle = 5;
340        let scaler = PoolScaler::new(policy, 2, 10);
341        assert_eq!(scaler.max_min_idle, 5);
342    }
343
344    #[test]
345    fn test_scaler_hold_with_few_events() {
346        let mut scaler = PoolScaler::new(default_policy(), 2, 10);
347        scaler.record_acquire(false);
348        scaler.record_acquire(false);
349        // Only 2 events, need at least 3
350        assert_eq!(scaler.evaluate(), ScaleDecision::Hold);
351    }
352
353    #[test]
354    fn test_scaler_scale_up_on_high_miss_rate() {
355        let mut policy = default_policy();
356        policy.cooldown_secs = 0; // disable cooldown for test
357        let mut scaler = PoolScaler::new(policy, 2, 10);
358
359        // 4 misses, 1 hit → 80% miss rate > 30% threshold
360        for _ in 0..4 {
361            scaler.record_acquire(false);
362        }
363        scaler.record_acquire(true);
364
365        assert_eq!(scaler.evaluate(), ScaleDecision::ScaleUp(1));
366        assert_eq!(scaler.current_min_idle(), 3);
367    }
368
369    #[test]
370    fn test_scaler_scale_down_on_low_miss_rate() {
371        let mut policy = default_policy();
372        policy.cooldown_secs = 0;
373        let mut scaler = PoolScaler::new(policy, 3, 10);
374
375        // 20 hits, 0 misses → 0% miss rate < 5% threshold
376        for _ in 0..20 {
377            scaler.record_acquire(true);
378        }
379
380        assert_eq!(scaler.evaluate(), ScaleDecision::ScaleDown(1));
381        assert_eq!(scaler.current_min_idle(), 2);
382    }
383
384    #[test]
385    fn test_scaler_hold_in_normal_range() {
386        let mut policy = default_policy();
387        policy.cooldown_secs = 0;
388        let mut scaler = PoolScaler::new(policy, 3, 10);
389
390        // 10 hits, 1 miss → 9% miss rate (between 5% and 30%)
391        for _ in 0..10 {
392            scaler.record_acquire(true);
393        }
394        scaler.record_acquire(false);
395
396        assert_eq!(scaler.evaluate(), ScaleDecision::Hold);
397        assert_eq!(scaler.current_min_idle(), 3);
398    }
399
400    #[test]
401    fn test_scaler_wont_exceed_max_min_idle() {
402        let mut policy = default_policy();
403        policy.cooldown_secs = 0;
404        policy.max_min_idle = 3;
405        let mut scaler = PoolScaler::new(policy, 3, 10);
406
407        // All misses but already at max_min_idle
408        for _ in 0..5 {
409            scaler.record_acquire(false);
410        }
411
412        assert_eq!(scaler.evaluate(), ScaleDecision::Hold);
413        assert_eq!(scaler.current_min_idle(), 3);
414    }
415
416    #[test]
417    fn test_scaler_wont_go_below_one() {
418        let mut policy = default_policy();
419        policy.cooldown_secs = 0;
420        let mut scaler = PoolScaler::new(policy, 1, 10);
421
422        // All hits, but already at floor of 1
423        for _ in 0..10 {
424            scaler.record_acquire(true);
425        }
426
427        assert_eq!(scaler.evaluate(), ScaleDecision::Hold);
428        assert_eq!(scaler.current_min_idle(), 1);
429    }
430
431    #[test]
432    fn test_scaler_cooldown_prevents_rapid_scaling() {
433        let policy = default_policy(); // cooldown = 60s
434        let mut scaler = PoolScaler::new(policy, 2, 10);
435
436        // First evaluation: scale up
437        for _ in 0..5 {
438            scaler.record_acquire(false);
439        }
440        assert_eq!(scaler.evaluate(), ScaleDecision::ScaleUp(1));
441
442        // Second evaluation immediately: should hold due to cooldown
443        for _ in 0..5 {
444            scaler.record_acquire(false);
445        }
446        assert_eq!(scaler.evaluate(), ScaleDecision::Hold);
447    }
448
449    #[test]
450    fn test_scaler_successive_scale_ups() {
451        let mut policy = default_policy();
452        policy.cooldown_secs = 0;
453        let mut scaler = PoolScaler::new(policy, 1, 5);
454
455        // Scale up multiple times
456        for _ in 0..3 {
457            for _ in 0..5 {
458                scaler.record_acquire(false);
459            }
460            let decision = scaler.evaluate();
461            assert_eq!(decision, ScaleDecision::ScaleUp(1));
462        }
463        assert_eq!(scaler.current_min_idle(), 4);
464    }
465
466    #[test]
467    fn test_scaler_no_events_holds() {
468        let mut policy = default_policy();
469        policy.cooldown_secs = 0;
470        let mut scaler = PoolScaler::new(policy, 2, 10);
471        assert_eq!(scaler.evaluate(), ScaleDecision::Hold);
472    }
473
474    // --- ScalingPolicy config tests ---
475
476    #[test]
477    fn test_scaling_policy_default() {
478        let policy = ScalingPolicy::default();
479        assert!(!policy.enabled);
480        assert!((policy.scale_up_threshold - 0.3).abs() < 0.001);
481        assert!((policy.scale_down_threshold - 0.05).abs() < 0.001);
482        assert_eq!(policy.max_min_idle, 0);
483        assert_eq!(policy.cooldown_secs, 60);
484        assert_eq!(policy.window_secs, 120);
485    }
486
487    #[test]
488    fn test_scaling_policy_serde_roundtrip() {
489        let policy = ScalingPolicy {
490            enabled: true,
491            scale_up_threshold: 0.4,
492            scale_down_threshold: 0.1,
493            max_min_idle: 8,
494            cooldown_secs: 30,
495            window_secs: 60,
496        };
497        let json = serde_json::to_string(&policy).unwrap();
498        let parsed: ScalingPolicy = serde_json::from_str(&json).unwrap();
499        assert!(parsed.enabled);
500        assert!((parsed.scale_up_threshold - 0.4).abs() < 0.001);
501        assert!((parsed.scale_down_threshold - 0.1).abs() < 0.001);
502        assert_eq!(parsed.max_min_idle, 8);
503        assert_eq!(parsed.cooldown_secs, 30);
504        assert_eq!(parsed.window_secs, 60);
505    }
506
507    #[test]
508    fn test_scaling_policy_deserialize_with_defaults() {
509        let json = r#"{"enabled": true}"#;
510        let policy: ScalingPolicy = serde_json::from_str(json).unwrap();
511        assert!(policy.enabled);
512        assert!((policy.scale_up_threshold - 0.3).abs() < 0.001);
513        assert_eq!(policy.cooldown_secs, 60);
514        assert_eq!(policy.window_secs, 120);
515    }
516
517    // --- Gateway Pressure tests ---
518
519    #[test]
520    fn test_gateway_pressure_default_zero() {
521        let scaler = PoolScaler::new(default_policy(), 2, 10);
522        assert_eq!(scaler.gateway_pressure(), 0.0);
523    }
524
525    #[test]
526    fn test_update_gateway_pressure() {
527        let mut scaler = PoolScaler::new(default_policy(), 2, 10);
528        scaler.update_gateway_pressure(0.75);
529        assert!((scaler.gateway_pressure() - 0.75).abs() < 0.001);
530    }
531
532    #[test]
533    fn test_gateway_pressure_clamped() {
534        let mut scaler = PoolScaler::new(default_policy(), 2, 10);
535        scaler.update_gateway_pressure(1.5);
536        assert!((scaler.gateway_pressure() - 1.0).abs() < 0.001);
537
538        scaler.update_gateway_pressure(-0.5);
539        assert!((scaler.gateway_pressure() - 0.0).abs() < 0.001);
540    }
541
542    #[test]
543    fn test_gateway_pressure_boosts_scale_up() {
544        let mut policy = default_policy();
545        policy.cooldown_secs = 0;
546        policy.scale_up_threshold = 0.3;
547        let mut scaler = PoolScaler::new(policy, 2, 10);
548
549        // Local: 1 miss, 9 hits → 10% miss rate (below 30% threshold)
550        scaler.record_acquire(false);
551        for _ in 0..9 {
552            scaler.record_acquire(true);
553        }
554
555        // Without gateway pressure: should hold
556        assert_eq!(scaler.evaluate(), ScaleDecision::Hold);
557
558        // With high gateway pressure: effective = 0.1 * 0.6 + 0.8 * 0.4 = 0.38 > 0.3
559        scaler.update_gateway_pressure(0.8);
560        assert_eq!(scaler.evaluate(), ScaleDecision::ScaleUp(1));
561    }
562
563    #[test]
564    fn test_gateway_pressure_zero_no_effect() {
565        let mut policy = default_policy();
566        policy.cooldown_secs = 0;
567        let mut scaler = PoolScaler::new(policy, 3, 10);
568
569        // All hits, gateway pressure = 0
570        for _ in 0..10 {
571            scaler.record_acquire(true);
572        }
573        scaler.update_gateway_pressure(0.0);
574
575        // effective = 0.0 * 0.6 + 0.0 * 0.4 = 0.0 → scale down
576        assert_eq!(scaler.evaluate(), ScaleDecision::ScaleDown(1));
577    }
578
579    #[test]
580    fn test_gateway_pressure_stale_ignored() {
581        let mut policy = default_policy();
582        policy.cooldown_secs = 0;
583        policy.window_secs = 1; // 1 second window → stale after 2 seconds
584        let mut scaler = PoolScaler::new(policy, 2, 10);
585
586        // Set pressure but backdate it
587        scaler.gateway_pressure = 0.9;
588        scaler.gateway_pressure_at = Some(Instant::now() - std::time::Duration::from_secs(10));
589
590        // Local: 1 miss, 9 hits → 10% miss rate
591        scaler.record_acquire(false);
592        for _ in 0..9 {
593            scaler.record_acquire(true);
594        }
595
596        // Stale gateway pressure should be ignored → use local only → hold
597        assert_eq!(scaler.evaluate(), ScaleDecision::Hold);
598    }
599
600    #[test]
601    fn test_effective_miss_rate_blending() {
602        let mut policy = default_policy();
603        policy.cooldown_secs = 0;
604        let mut scaler = PoolScaler::new(policy, 2, 10);
605
606        // 5 misses, 5 hits → 50% local miss rate
607        for _ in 0..5 {
608            scaler.record_acquire(false);
609        }
610        for _ in 0..5 {
611            scaler.record_acquire(true);
612        }
613
614        // No gateway pressure → effective = local = 0.5
615        let rate = scaler.effective_miss_rate().unwrap();
616        assert!((rate - 0.5).abs() < 0.001);
617
618        // With gateway pressure 0.2 → effective = 0.5 * 0.6 + 0.2 * 0.4 = 0.38
619        scaler.update_gateway_pressure(0.2);
620        let rate = scaler.effective_miss_rate().unwrap();
621        assert!((rate - 0.38).abs() < 0.001);
622    }
623
624    #[test]
625    fn test_gateway_pressure_prevents_scale_down() {
626        let mut policy = default_policy();
627        policy.cooldown_secs = 0;
628        policy.scale_down_threshold = 0.05;
629        let mut scaler = PoolScaler::new(policy, 3, 10);
630
631        // All hits locally → 0% miss rate → would normally scale down
632        for _ in 0..10 {
633            scaler.record_acquire(true);
634        }
635
636        // But gateway pressure is moderate → effective = 0.0 * 0.6 + 0.3 * 0.4 = 0.12 > 0.05
637        scaler.update_gateway_pressure(0.3);
638        assert_eq!(scaler.evaluate(), ScaleDecision::Hold);
639    }
640}