use crate::error::{ForgeError, Result};
use crate::job::Job;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use tracing::{debug, info, warn};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AutoscalerConfig {
pub upscale_threshold: f64,
pub downscale_threshold: f64,
pub hysteresis_secs: u64,
pub eval_interval_secs: u64,
pub min_instances: u32,
pub max_instances: u32,
pub scale_up_step: u32,
pub scale_down_step: u32,
pub predictive_enabled: bool,
pub metrics_window_secs: u64,
}
impl Default for AutoscalerConfig {
fn default() -> Self {
Self {
upscale_threshold: 0.8,
downscale_threshold: 0.3,
hysteresis_secs: 300,
eval_interval_secs: 30,
min_instances: 1,
max_instances: 100,
scale_up_step: 1,
scale_down_step: 1,
predictive_enabled: false,
metrics_window_secs: 300,
}
}
}
impl AutoscalerConfig {
pub fn new() -> Self {
Self::default()
}
pub fn upscale_threshold(mut self, threshold: f64) -> Self {
self.upscale_threshold = threshold.clamp(0.0, 1.0);
self
}
pub fn downscale_threshold(mut self, threshold: f64) -> Self {
self.downscale_threshold = threshold.clamp(0.0, 1.0);
self
}
pub fn hysteresis_secs(mut self, secs: u64) -> Self {
self.hysteresis_secs = secs;
self
}
pub fn bounds(mut self, min: u32, max: u32) -> Self {
self.min_instances = min;
self.max_instances = max.max(min);
self
}
pub fn predictive(mut self, enabled: bool) -> Self {
self.predictive_enabled = enabled;
self
}
pub fn validate(&self) -> Result<()> {
if self.upscale_threshold <= self.downscale_threshold {
return Err(ForgeError::config(
"upscale_threshold must be greater than downscale_threshold",
));
}
if self.min_instances > self.max_instances {
return Err(ForgeError::config(
"min_instances cannot exceed max_instances",
));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScalingDecision {
NoChange,
ScaleUp(u32),
ScaleDown(u32),
ScaleTo(u32),
}
impl ScalingDecision {
pub fn is_scaling(&self) -> bool {
!matches!(self, Self::NoChange)
}
pub fn delta(&self) -> i32 {
match self {
Self::NoChange => 0,
Self::ScaleUp(n) => *n as i32,
Self::ScaleDown(n) => -(*n as i32),
Self::ScaleTo(_) => 0, }
}
}
#[derive(Debug, Clone)]
pub struct MetricsSnapshot {
pub cpu_utilization: f64,
pub memory_utilization: f64,
pub request_rate: f64,
pub latency_ms: f64,
pub current_instances: u32,
pub timestamp: Instant,
}
impl MetricsSnapshot {
pub fn new(cpu: f64, memory: f64, instances: u32) -> Self {
Self {
cpu_utilization: cpu.clamp(0.0, 1.0),
memory_utilization: memory.clamp(0.0, 1.0),
request_rate: 0.0,
latency_ms: 0.0,
current_instances: instances,
timestamp: Instant::now(),
}
}
pub fn utilization(&self) -> f64 {
self.cpu_utilization.max(self.memory_utilization)
}
}
#[async_trait]
pub trait ScalingPolicy: Send + Sync {
async fn evaluate(
&self,
metrics: &MetricsSnapshot,
config: &AutoscalerConfig,
) -> ScalingDecision;
fn name(&self) -> &str;
}
#[derive(Debug, Clone)]
pub struct ThresholdPolicy;
#[async_trait]
impl ScalingPolicy for ThresholdPolicy {
async fn evaluate(
&self,
metrics: &MetricsSnapshot,
config: &AutoscalerConfig,
) -> ScalingDecision {
let utilization = metrics.utilization();
if utilization >= config.upscale_threshold {
let new_count = (metrics.current_instances + config.scale_up_step)
.min(config.max_instances);
if new_count > metrics.current_instances {
return ScalingDecision::ScaleUp(new_count - metrics.current_instances);
}
} else if utilization <= config.downscale_threshold {
let new_count = metrics
.current_instances
.saturating_sub(config.scale_down_step)
.max(config.min_instances);
if new_count < metrics.current_instances {
return ScalingDecision::ScaleDown(metrics.current_instances - new_count);
}
}
ScalingDecision::NoChange
}
fn name(&self) -> &str {
"threshold"
}
}
#[derive(Debug, Clone)]
pub struct TargetUtilizationPolicy {
target: f64,
tolerance: f64,
}
impl TargetUtilizationPolicy {
pub fn new(target: f64) -> Self {
Self {
target: target.clamp(0.1, 0.9),
tolerance: 0.1,
}
}
pub fn with_tolerance(mut self, tolerance: f64) -> Self {
self.tolerance = tolerance.clamp(0.01, 0.5);
self
}
}
#[async_trait]
impl ScalingPolicy for TargetUtilizationPolicy {
async fn evaluate(
&self,
metrics: &MetricsSnapshot,
config: &AutoscalerConfig,
) -> ScalingDecision {
let utilization = metrics.utilization();
let current = metrics.current_instances as f64;
let desired = (current * utilization / self.target).ceil() as u32;
let desired = desired.clamp(config.min_instances, config.max_instances);
let diff = (utilization - self.target).abs();
if diff <= self.tolerance {
return ScalingDecision::NoChange;
}
if desired > metrics.current_instances {
ScalingDecision::ScaleUp(desired - metrics.current_instances)
} else if desired < metrics.current_instances {
ScalingDecision::ScaleDown(metrics.current_instances - desired)
} else {
ScalingDecision::NoChange
}
}
fn name(&self) -> &str {
"target-utilization"
}
}
#[derive(Debug)]
struct JobScalingState {
last_scale_time: Option<Instant>,
metrics_history: Vec<MetricsSnapshot>,
}
impl JobScalingState {
fn new() -> Self {
Self {
last_scale_time: None,
metrics_history: Vec::new(),
}
}
fn can_scale(&self, hysteresis: Duration) -> bool {
match self.last_scale_time {
Some(t) => t.elapsed() >= hysteresis,
None => true,
}
}
fn record_scale(&mut self) {
self.last_scale_time = Some(Instant::now());
}
fn add_metrics(&mut self, snapshot: MetricsSnapshot, max_history: usize) {
self.metrics_history.push(snapshot);
if self.metrics_history.len() > max_history {
self.metrics_history.remove(0);
}
}
}
pub struct Autoscaler {
config: AutoscalerConfig,
policy: Arc<dyn ScalingPolicy>,
job_states: RwLock<HashMap<String, JobScalingState>>,
}
impl Autoscaler {
pub fn new(config: AutoscalerConfig) -> Result<Self> {
config.validate()?;
Ok(Self {
config,
policy: Arc::new(ThresholdPolicy),
job_states: RwLock::new(HashMap::new()),
})
}
pub fn with_policy(config: AutoscalerConfig, policy: Arc<dyn ScalingPolicy>) -> Result<Self> {
config.validate()?;
Ok(Self {
config,
policy,
job_states: RwLock::new(HashMap::new()),
})
}
pub fn config(&self) -> &AutoscalerConfig {
&self.config
}
pub async fn evaluate(&self, job_id: &str, metrics: MetricsSnapshot) -> ScalingDecision {
let hysteresis = Duration::from_secs(self.config.hysteresis_secs);
{
let states = self.job_states.read().await;
if let Some(state) = states.get(job_id) {
if !state.can_scale(hysteresis) {
debug!(
job_id = %job_id,
"Scaling blocked by hysteresis"
);
return ScalingDecision::NoChange;
}
}
}
let decision = self.policy.evaluate(&metrics, &self.config).await;
{
let mut states = self.job_states.write().await;
let state = states
.entry(job_id.to_string())
.or_insert_with(JobScalingState::new);
state.add_metrics(metrics, 100);
if decision.is_scaling() {
info!(
job_id = %job_id,
decision = ?decision,
policy = %self.policy.name(),
"Scaling decision made"
);
state.record_scale();
}
}
decision
}
pub async fn force_scale(&self, job_id: &str, decision: ScalingDecision) {
let mut states = self.job_states.write().await;
let state = states
.entry(job_id.to_string())
.or_insert_with(JobScalingState::new);
warn!(
job_id = %job_id,
decision = ?decision,
"Forced scaling decision"
);
state.record_scale();
}
pub async fn get_metrics_history(&self, job_id: &str) -> Vec<MetricsSnapshot> {
let states = self.job_states.read().await;
states
.get(job_id)
.map(|s| s.metrics_history.clone())
.unwrap_or_default()
}
pub async fn clear_job(&self, job_id: &str) {
let mut states = self.job_states.write().await;
states.remove(job_id);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_threshold_policy_scale_up() {
let policy = ThresholdPolicy;
let config = AutoscalerConfig::default();
let metrics = MetricsSnapshot::new(0.85, 0.5, 5);
let decision = policy.evaluate(&metrics, &config).await;
assert_eq!(decision, ScalingDecision::ScaleUp(1));
}
#[tokio::test]
async fn test_threshold_policy_scale_down() {
let policy = ThresholdPolicy;
let config = AutoscalerConfig::default();
let metrics = MetricsSnapshot::new(0.2, 0.1, 5);
let decision = policy.evaluate(&metrics, &config).await;
assert_eq!(decision, ScalingDecision::ScaleDown(1));
}
#[tokio::test]
async fn test_threshold_policy_no_change() {
let policy = ThresholdPolicy;
let config = AutoscalerConfig::default();
let metrics = MetricsSnapshot::new(0.5, 0.5, 5);
let decision = policy.evaluate(&metrics, &config).await;
assert_eq!(decision, ScalingDecision::NoChange);
}
#[tokio::test]
async fn test_autoscaler_hysteresis() {
let config = AutoscalerConfig::default().hysteresis_secs(1);
let autoscaler = Autoscaler::new(config).unwrap();
let metrics = MetricsSnapshot::new(0.9, 0.5, 5);
let decision = autoscaler.evaluate("job-1", metrics.clone()).await;
assert!(decision.is_scaling());
let decision = autoscaler.evaluate("job-1", metrics).await;
assert_eq!(decision, ScalingDecision::NoChange);
}
#[tokio::test]
async fn test_target_utilization_policy() {
let policy = TargetUtilizationPolicy::new(0.7);
let config = AutoscalerConfig::default();
let metrics = MetricsSnapshot::new(0.9, 0.5, 5);
let decision = policy.evaluate(&metrics, &config).await;
assert!(matches!(decision, ScalingDecision::ScaleUp(_)));
let metrics = MetricsSnapshot::new(0.3, 0.2, 5);
let decision = policy.evaluate(&metrics, &config).await;
assert!(matches!(decision, ScalingDecision::ScaleDown(_)));
}
#[test]
fn test_config_validation() {
let config = AutoscalerConfig::default()
.upscale_threshold(0.3)
.downscale_threshold(0.8);
assert!(config.validate().is_err());
}
}