use super::*;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct AutoscaleConfig {
pub enabled: bool,
pub min_workers: usize,
pub max_workers: usize,
pub scale_up_threshold: usize,
pub scale_down_threshold: usize,
pub cooldown_period: Duration,
pub scale_step: usize,
pub evaluation_window: Duration,
}
impl Default for AutoscaleConfig {
fn default() -> Self {
Self {
enabled: true,
min_workers: 1,
max_workers: 10,
scale_up_threshold: 5,
scale_down_threshold: 2,
cooldown_period: Duration::from_secs(60),
scale_step: 1,
evaluation_window: Duration::from_secs(30),
}
}
}
impl AutoscaleConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn with_min_workers(mut self, min_workers: usize) -> Self {
self.min_workers = min_workers.max(1); self.max_workers = self.max_workers.max(self.min_workers);
self
}
pub fn with_max_workers(mut self, max_workers: usize) -> Self {
self.max_workers = max_workers.max(self.min_workers);
self
}
pub fn with_scale_up_threshold(mut self, threshold: usize) -> Self {
self.scale_up_threshold = threshold.max(1);
self
}
pub fn with_scale_down_threshold(mut self, threshold: usize) -> Self {
self.scale_down_threshold = threshold;
self
}
pub fn with_cooldown_period(mut self, period: Duration) -> Self {
self.cooldown_period = period;
self
}
pub fn with_scale_step(mut self, step: usize) -> Self {
self.scale_step = step.max(1);
self
}
pub fn with_evaluation_window(mut self, window: Duration) -> Self {
self.evaluation_window = window;
self
}
pub fn conservative() -> Self {
Self {
enabled: true,
min_workers: 2,
max_workers: 5,
scale_up_threshold: 10,
scale_down_threshold: 1,
cooldown_period: Duration::from_secs(300), scale_step: 1,
evaluation_window: Duration::from_secs(60),
}
}
pub fn aggressive() -> Self {
Self {
enabled: true,
min_workers: 1,
max_workers: 20,
scale_up_threshold: 3,
scale_down_threshold: 1,
cooldown_period: Duration::from_secs(30),
scale_step: 2,
evaluation_window: Duration::from_secs(15),
}
}
pub fn disabled() -> Self {
Self {
enabled: false,
..Self::default()
}
}
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct AutoscaleMetrics {
pub active_workers: usize,
pub avg_queue_depth: f64,
pub current_queue_depth: u64,
pub jobs_per_second: f64,
pub worker_utilization: f64,
pub time_since_last_scale: Duration,
pub last_scale_time: Option<chrono::DateTime<Utc>>,
}
#[derive(Debug, Clone, Copy)]
enum ScalingDecision {
ScaleUp,
ScaleDown,
}
pub(super) type QueueDepthHistory = Arc<std::sync::RwLock<Vec<(chrono::DateTime<Utc>, u64)>>>;
pub(super) fn apply_queue_depth(
current_depth: u64,
now: DateTime<Utc>,
config: &AutoscaleConfig,
metrics: &Arc<std::sync::RwLock<AutoscaleMetrics>>,
history: &QueueDepthHistory,
) -> Option<usize> {
let avg_depth = match history.write() {
Ok(mut hist) => {
hist.push((now, current_depth));
let cutoff = now
- chrono::Duration::from_std(config.evaluation_window)
.unwrap_or(chrono::Duration::seconds(30));
hist.retain(|(timestamp, _)| *timestamp > cutoff);
if hist.is_empty() {
current_depth as f64
} else {
hist.iter().map(|(_, depth)| *depth as f64).sum::<f64>() / hist.len() as f64
}
}
Err(_) => current_depth as f64,
};
let mut m = metrics.write().ok()?;
m.current_queue_depth = current_depth;
m.avg_queue_depth = avg_depth;
let time_since_last = m
.last_scale_time
.map(|t| now - t)
.and_then(|d| d.to_std().ok())
.unwrap_or(config.cooldown_period);
m.time_since_last_scale = time_since_last;
if time_since_last < config.cooldown_period {
return None;
}
let depth_per_worker = if m.active_workers > 0 {
avg_depth / m.active_workers as f64
} else {
avg_depth
};
let decision = if depth_per_worker > config.scale_up_threshold as f64
&& m.active_workers < config.max_workers
{
ScalingDecision::ScaleUp
} else if depth_per_worker < config.scale_down_threshold as f64
&& m.active_workers > config.min_workers
{
ScalingDecision::ScaleDown
} else {
return None;
};
let new_count = match decision {
ScalingDecision::ScaleUp => (m.active_workers + config.scale_step).min(config.max_workers),
ScalingDecision::ScaleDown => {
(m.active_workers.saturating_sub(config.scale_step)).max(config.min_workers)
}
};
info!(
"Autoscaling: {:?} from {} to {} workers (avg queue depth: {:.1})",
decision, m.active_workers, new_count, m.avg_queue_depth
);
m.active_workers = new_count;
m.last_scale_time = Some(now);
Some(new_count)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_autoscale_config_defaults() {
let config = AutoscaleConfig::default();
assert!(config.enabled);
assert_eq!(config.min_workers, 1);
assert_eq!(config.max_workers, 10);
assert_eq!(config.scale_up_threshold, 5);
assert_eq!(config.scale_down_threshold, 2);
assert_eq!(config.cooldown_period, Duration::from_secs(60));
assert_eq!(config.scale_step, 1);
assert_eq!(config.evaluation_window, Duration::from_secs(30));
}
#[test]
fn test_autoscale_config_builder() {
let config = AutoscaleConfig::new()
.with_min_workers(2)
.with_max_workers(20)
.with_scale_up_threshold(8)
.with_scale_down_threshold(1)
.with_cooldown_period(Duration::from_secs(120))
.with_scale_step(2)
.with_evaluation_window(Duration::from_secs(45));
assert_eq!(config.min_workers, 2);
assert_eq!(config.max_workers, 20);
assert_eq!(config.scale_up_threshold, 8);
assert_eq!(config.scale_down_threshold, 1);
assert_eq!(config.cooldown_period, Duration::from_secs(120));
assert_eq!(config.scale_step, 2);
assert_eq!(config.evaluation_window, Duration::from_secs(45));
}
#[test]
fn test_autoscale_config_presets() {
let conservative = AutoscaleConfig::conservative();
assert_eq!(conservative.min_workers, 2);
assert_eq!(conservative.max_workers, 5);
assert_eq!(conservative.scale_up_threshold, 10);
assert_eq!(conservative.cooldown_period, Duration::from_secs(300));
let aggressive = AutoscaleConfig::aggressive();
assert_eq!(aggressive.min_workers, 1);
assert_eq!(aggressive.max_workers, 20);
assert_eq!(aggressive.scale_up_threshold, 3);
assert_eq!(aggressive.cooldown_period, Duration::from_secs(30));
let disabled = AutoscaleConfig::disabled();
assert!(!disabled.enabled);
}
#[test]
fn test_autoscale_config_validation() {
let config = AutoscaleConfig::new().with_min_workers(0);
assert_eq!(config.min_workers, 1);
let config = AutoscaleConfig::new()
.with_min_workers(5)
.with_max_workers(3);
assert_eq!(config.max_workers, 5);
let config = AutoscaleConfig::new().with_scale_up_threshold(0);
assert_eq!(config.scale_up_threshold, 1);
let config = AutoscaleConfig::new().with_scale_step(0);
assert_eq!(config.scale_step, 1);
}
#[test]
fn test_autoscale_metrics_default() {
let metrics = AutoscaleMetrics::default();
assert_eq!(metrics.active_workers, 0);
assert_eq!(metrics.avg_queue_depth, 0.0);
assert_eq!(metrics.current_queue_depth, 0);
assert_eq!(metrics.jobs_per_second, 0.0);
assert_eq!(metrics.worker_utilization, 0.0);
assert_eq!(metrics.time_since_last_scale, Duration::from_secs(0));
assert!(metrics.last_scale_time.is_none());
}
fn autoscale_state(
active_workers: usize,
) -> (Arc<std::sync::RwLock<AutoscaleMetrics>>, QueueDepthHistory) {
(
Arc::new(std::sync::RwLock::new(AutoscaleMetrics {
active_workers,
..Default::default()
})),
Arc::new(std::sync::RwLock::new(Vec::new())),
)
}
fn fast_autoscale() -> AutoscaleConfig {
AutoscaleConfig::new()
.with_min_workers(1)
.with_max_workers(3)
.with_scale_up_threshold(4)
.with_scale_down_threshold(1)
.with_cooldown_period(Duration::ZERO)
.with_evaluation_window(Duration::from_secs(30))
}
#[test]
fn autoscaling_scales_up_within_max_workers() {
let config = fast_autoscale().with_scale_step(5);
let (metrics, history) = autoscale_state(1);
let decision = apply_queue_depth(10, Utc::now(), &config, &metrics, &history);
assert_eq!(decision, Some(3));
let m = metrics.read().unwrap().clone();
assert_eq!(m.active_workers, 3);
assert_eq!(m.current_queue_depth, 10);
assert_eq!(m.avg_queue_depth, 10.0);
assert!(m.last_scale_time.is_some());
let decision = apply_queue_depth(1000, Utc::now(), &config, &metrics, &history);
assert_eq!(decision, None);
assert_eq!(metrics.read().unwrap().active_workers, 3);
}
#[test]
fn autoscaling_scales_down_to_min_workers() {
let config = fast_autoscale().with_scale_step(2);
let (metrics, history) = autoscale_state(3);
let decision = apply_queue_depth(0, Utc::now(), &config, &metrics, &history);
assert_eq!(decision, Some(1));
let decision = apply_queue_depth(0, Utc::now(), &config, &metrics, &history);
assert_eq!(decision, None, "never below min_workers");
assert_eq!(metrics.read().unwrap().active_workers, 1);
}
#[test]
fn autoscaling_holds_between_thresholds_and_during_cooldown() {
let (metrics, history) = autoscale_state(2);
let decision = apply_queue_depth(6, Utc::now(), &fast_autoscale(), &metrics, &history);
assert_eq!(decision, None);
let config = fast_autoscale().with_cooldown_period(Duration::from_secs(3600));
let (metrics, history) = autoscale_state(1);
let now = Utc::now();
assert_eq!(
apply_queue_depth(100, now, &config, &metrics, &history),
Some(2),
"the first decision is not in a cooldown"
);
assert_eq!(
apply_queue_depth(
100,
now + chrono::Duration::seconds(1),
&config,
&metrics,
&history
),
None,
"a second decision within the cooldown period is suppressed"
);
let m = metrics.read().unwrap().clone();
assert_eq!(m.active_workers, 2);
assert_eq!(m.time_since_last_scale, Duration::from_secs(1));
}
#[test]
fn autoscaling_averages_depth_over_the_evaluation_window() {
let config = fast_autoscale()
.with_scale_up_threshold(100)
.with_scale_down_threshold(0)
.with_evaluation_window(Duration::from_secs(10));
let (metrics, history) = autoscale_state(1);
let start = Utc::now();
for (offset, depth) in [(0, 30), (4, 10), (8, 20)] {
apply_queue_depth(
depth,
start + chrono::Duration::seconds(offset),
&config,
&metrics,
&history,
);
}
assert_eq!(metrics.read().unwrap().avg_queue_depth, 20.0);
apply_queue_depth(
40,
start + chrono::Duration::seconds(12),
&config,
&metrics,
&history,
);
assert_eq!(history.read().unwrap().len(), 3);
let m = metrics.read().unwrap().clone();
assert_eq!(m.avg_queue_depth, (10.0 + 20.0 + 40.0) / 3.0);
assert_eq!(m.current_queue_depth, 40);
}
#[test]
fn with_min_workers_keeps_max_at_least_min() {
let config = AutoscaleConfig::new().with_min_workers(15);
assert_eq!(config.min_workers, 15);
assert!(config.max_workers >= config.min_workers);
}
}