Skip to main content

cronoq_core/
autoscale.rs

1//! Queue-pressure autoscaling policy (SPEC ยง10).
2
3use std::collections::VecDeque;
4
5use crate::config::AutoscaleConfig;
6use crate::spec::Priority;
7use crate::stats::Ewma;
8use crate::time::Time;
9
10/// Demand below this fraction of one slot counts as none. The arrival rate is a moving average
11/// that decays toward zero but never reaches it, and rounding a vanishing rate up would keep an
12/// idle pool at one slot for as long as the average takes to underflow (about half an hour).
13const NEGLIGIBLE: f64 = 0.01;
14
15/// Slots for a fractional demand: rounded up, with negligible demand as zero.
16fn slots(x: f64) -> f64 {
17    if x < NEGLIGIBLE { 0.0 } else { x.ceil() }
18}
19
20/// Signals sampled at each evaluation.
21#[derive(Clone, Debug, Default)]
22pub struct Signals {
23    /// Tasks that became eligible since the previous evaluation.
24    pub arrivals: u64,
25    /// Seconds since the previous evaluation.
26    pub dt: f64,
27    /// Mean service time in seconds.
28    pub service_secs: f64,
29    /// Waiting tasks per class (ready, due and retrying).
30    pub backlog: [u64; Priority::COUNT],
31    /// Workers currently running a task.
32    pub busy: u32,
33    /// Workers currently available to the pool.
34    pub capacity: u32,
35    /// Peak concurrent reservations of not-yet-started fixed-start tasks within the horizon.
36    pub reserved: u32,
37    /// Lower bound on the result (`PoolConfig::min_workers`).
38    pub min: u32,
39    /// Upper bound on the result (`PoolConfig::max_workers`).
40    pub max: u32,
41}
42
43/// Per-pool autoscaler state: smoothed rates, the last target and the history used for
44/// scale-down stabilization.
45#[derive(Clone, Debug, Default)]
46pub struct AutoscaleState {
47    /// Smoothed arrival rate (tasks per second).
48    pub arrival_rate: Ewma,
49    /// Last requested target.
50    pub target: Option<u32>,
51    /// Desired capacity computed at the last evaluation (before step limits).
52    pub last_desired: u32,
53    history: VecDeque<(Time, u32)>,
54    last_down: Option<Time>,
55}
56
57impl AutoscaleState {
58    /// Unclamped-by-step desired capacity from the signals.
59    pub fn desired(&mut self, cfg: &AutoscaleConfig, s: &Signals) -> u32 {
60        if s.dt > 0.0 {
61            self.arrival_rate
62                .update(s.arrivals as f64 / s.dt, cfg.ewma_alpha);
63        }
64        let lambda = self.arrival_rate.get().unwrap_or(0.0);
65        let svc = s.service_secs.max(1e-9);
66        let base = slots(lambda * svc / cfg.target_utilization.clamp(0.01, 1.0));
67        let backlog: u64 = s.backlog.iter().sum();
68        let drain = (backlog as f64 * svc / cfg.drain_time.as_secs_f64().max(1e-3)).ceil();
69        let mut slo_need = 0.0f64;
70        let mut ahead = 0u64;
71        for c in 0..Priority::COUNT {
72            ahead += s.backlog[c];
73            if let Some(slo) = cfg.slo[c]
74                && s.backlog[c] > 0
75            {
76                slo_need = slo_need.max((ahead as f64 * svc / slo.as_secs_f64().max(1e-3)).ceil());
77            }
78        }
79        let need = (base + drain).max(slo_need).max(s.busy as f64) + s.reserved as f64;
80        let d = (need.min(u32::MAX as f64) as u32).clamp(s.min, s.max.max(s.min));
81        self.last_desired = d;
82        d
83    }
84
85    /// Evaluates the policy; returns a new target when it changes.
86    pub fn evaluate(&mut self, cfg: &AutoscaleConfig, s: &Signals, now: Time) -> Option<u32> {
87        let desired = self.desired(cfg, s);
88        self.history.push_back((now, desired));
89        while let Some(&(t, _)) = self.history.front() {
90            if now.saturating_sub(t) > cfg.stabilization_window {
91                self.history.pop_front();
92            } else {
93                break;
94            }
95        }
96        let current = self.target.unwrap_or(s.capacity);
97        let next = if desired > current {
98            let step = ((current as f64 * cfg.max_step_up_ratio).ceil() as u32)
99                .max(cfg.max_step_up_abs)
100                .max(1);
101            desired.min(current.saturating_add(step))
102        } else if desired < current {
103            let recommended = self
104                .history
105                .iter()
106                .map(|&(_, d)| d)
107                .max()
108                .unwrap_or(desired);
109            let cooled = self
110                .last_down
111                .is_none_or(|t| now.saturating_sub(t) >= cfg.cooldown);
112            if recommended < current && cooled {
113                self.last_down = Some(now);
114                recommended.max(current.saturating_sub(cfg.max_step_down.max(1)))
115            } else {
116                current
117            }
118        } else {
119            current
120        };
121        let next = next.clamp(s.min, s.max.max(s.min));
122        if Some(next) != self.target && (self.target.is_some() || next != s.capacity) {
123            self.target = Some(next);
124            return Some(next);
125        }
126        self.target = Some(next);
127        None
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use core::time::Duration;
135
136    fn sig(backlog: u64, cap: u32) -> Signals {
137        Signals {
138            arrivals: 0,
139            dt: 1.0,
140            service_secs: 1.0,
141            backlog: [0, 0, backlog, 0],
142            busy: cap,
143            capacity: cap,
144            reserved: 0,
145            min: 1,
146            max: 10,
147        }
148    }
149
150    #[test]
151    fn scales_up_on_backlog_and_down_after_stabilization() {
152        let cfg = AutoscaleConfig {
153            stabilization_window: Duration::from_secs(5),
154            cooldown: Duration::from_secs(2),
155            ..Default::default()
156        };
157        let mut st = AutoscaleState::default();
158        // 300 queued 1s tasks, drain in 30s => 10 workers wanted, step up limited to max(1*1, 4) = +4.
159        assert_eq!(
160            st.evaluate(&cfg, &sig(300, 1), Time::from_secs_f64(1.0)),
161            Some(5)
162        );
163        assert_eq!(
164            st.evaluate(&cfg, &sig(300, 5), Time::from_secs_f64(2.0)),
165            Some(10)
166        );
167        // Idle: stays up during stabilization window.
168        let idle = Signals {
169            busy: 0,
170            ..sig(0, 10)
171        };
172        assert_eq!(st.evaluate(&cfg, &idle, Time::from_secs_f64(3.0)), None);
173        assert_eq!(st.evaluate(&cfg, &idle, Time::from_secs_f64(6.0)), None);
174        // After the window the high samples expire; scale down one step at a time with cooldown.
175        assert_eq!(st.evaluate(&cfg, &idle, Time::from_secs_f64(8.5)), Some(9));
176        assert_eq!(st.evaluate(&cfg, &idle, Time::from_secs_f64(9.0)), None);
177        assert_eq!(st.evaluate(&cfg, &idle, Time::from_secs_f64(10.6)), Some(8));
178    }
179
180    #[test]
181    fn an_idle_pool_returns_to_zero() {
182        let cfg = AutoscaleConfig::default();
183        let mut st = AutoscaleState::default();
184        let busy = Signals {
185            arrivals: 8,
186            busy: 0,
187            min: 0,
188            ..sig(0, 0)
189        };
190        assert!(st.desired(&cfg, &busy) >= 1);
191        // The arrivals stop. The smoothed rate decays by 30% a second and never reaches zero;
192        // within a minute the demand is negligible and no slot is wanted.
193        let idle = Signals {
194            arrivals: 0,
195            ..busy
196        };
197        let mut d = u32::MAX;
198        for _ in 0..60 {
199            d = st.desired(&cfg, &idle);
200        }
201        assert_eq!(d, 0);
202        // A real trickle still counts: one 1 s task every 50 s.
203        let mut st = AutoscaleState::default();
204        let trickle = Signals {
205            arrivals: 1,
206            dt: 50.0,
207            ..idle
208        };
209        assert_eq!(st.desired(&cfg, &trickle), 1);
210    }
211
212    #[test]
213    fn slo_drives_capacity() {
214        let mut cfg = AutoscaleConfig::default();
215        cfg.slo[0] = Some(Duration::from_secs(1));
216        let mut st = AutoscaleState::default();
217        let s = Signals {
218            backlog: [6, 0, 0, 0],
219            busy: 0,
220            capacity: 1,
221            max: 100,
222            ..sig(0, 1)
223        };
224        // 6 critical tasks, 1s each, must wait <= 1s => 6 workers.
225        assert_eq!(st.desired(&cfg, &s), 6);
226    }
227
228    #[test]
229    fn reservations_add_capacity() {
230        let cfg = AutoscaleConfig::default();
231        let mut st = AutoscaleState::default();
232        let s = Signals {
233            reserved: 3,
234            busy: 0,
235            ..sig(0, 1)
236        };
237        assert_eq!(st.desired(&cfg, &s), 3);
238    }
239}