diff --git a/crates/backend/src/health.rs b/crates/backend/src/health.rs index d09d1eb..97512d7 100644 --- a/crates/backend/src/health.rs +++ b/crates/backend/src/health.rs @@ -13,6 +13,10 @@ pub struct HealthConfig { pub unhealthy_error_rate: f64, /// Minimum requests before calculating error rate. pub min_requests: u64, + /// Smoothing factor for the EMA (0.0 < alpha <= 1.0). + /// Higher values weight recent observations more heavily. + /// Defaults to 0.1 for stable, noise-resistant latency tracking. + pub alpha: f64, } impl Default for HealthConfig { @@ -21,6 +25,7 @@ impl Default for HealthConfig { degraded_latency: Duration::from_millis(500), unhealthy_error_rate: 0.5, min_requests: 10, + alpha: 0.1, } } } @@ -57,8 +62,12 @@ impl HealthTracker for EmaHealthTracker { } else { self.error_count += 1; } - // EMA: new = (old + elapsed) / 2 - self.latency_ema = (self.latency_ema + duration) / 2; + // EMA: new = alpha * sample + (1 - alpha) * old + let alpha = self.config.alpha; + let old_nanos = self.latency_ema.as_nanos() as f64; + let new_nanos = duration.as_nanos() as f64; + let ema_nanos = alpha * new_nanos + (1.0 - alpha) * old_nanos; + self.latency_ema = Duration::from_nanos(ema_nanos as u64); } fn latency_ema(&self) -> Duration { @@ -101,34 +110,31 @@ mod tests { let mut tracker = EmaHealthTracker::new(HealthConfig::default()); tracker.record(Duration::from_millis(100), true); - assert_eq!(tracker.latency_ema(), Duration::from_millis(50)); + assert_eq!(tracker.latency_ema(), Duration::from_millis(10)); assert_eq!(tracker.error_rate(), 0.0); } #[test] fn test_latency_ema_calculation() { + // Default alpha = 0.1: new = 0.1 * sample + 0.9 * old let mut tracker = EmaHealthTracker::new(HealthConfig::default()); - // First request: 100ms -> EMA = (0 + 100) / 2 = 50ms + // First request: 100ms -> EMA = 0.1 * 100 + 0.9 * 0 = 10ms tracker.record(Duration::from_millis(100), true); - assert_eq!(tracker.latency_ema(), Duration::from_millis(50)); + assert_eq!(tracker.latency_ema(), Duration::from_millis(10)); - // Second request: 100ms -> EMA = (50 + 100) / 2 = 75ms + // Second request: 100ms -> EMA = 0.1 * 100 + 0.9 * 10 = 19ms tracker.record(Duration::from_millis(100), true); - assert_eq!(tracker.latency_ema(), Duration::from_millis(75)); + assert_eq!(tracker.latency_ema(), Duration::from_millis(19)); - // Third request: 100ms -> EMA = (75 + 100) / 2 = 87.5ms - // Duration division truncates, so we check the range + // Third request: 100ms -> EMA = 0.1 * 100 + 0.9 * 19 = 27.1ms tracker.record(Duration::from_millis(100), true); let ema = tracker.latency_ema(); assert!( - ema >= Duration::from_millis(87) && ema <= Duration::from_millis(88), - "Expected ~87-88ms, got {:?}", - ema + ema >= Duration::from_millis(27) && ema <= Duration::from_millis(28), + "Expected ~27-28ms, got {:?}", ema ); } - - #[test] fn test_error_rate_below_min_requests() { let config = HealthConfig { min_requests: 10, ..Default::default() }; let mut tracker = EmaHealthTracker::new(config); @@ -165,6 +171,7 @@ mod tests { degraded_latency: Duration::from_millis(500), unhealthy_error_rate: 0.5, min_requests: 1, + alpha: 0.1, }; let mut tracker = EmaHealthTracker::new(config); @@ -179,11 +186,12 @@ mod tests { degraded_latency: Duration::from_millis(100), unhealthy_error_rate: 0.5, min_requests: 1, + alpha: 0.1, }; let mut tracker = EmaHealthTracker::new(config); // Record high latency requests - for _ in 0..5 { + for _ in 0..30 { tracker.record(Duration::from_millis(200), true); } @@ -201,6 +209,7 @@ mod tests { degraded_latency: Duration::from_millis(500), unhealthy_error_rate: 0.3, min_requests: 5, + alpha: 0.1, }; let mut tracker = EmaHealthTracker::new(config);