Skip to main content

zentinel_proxy/upstream/
peak_ewma.rs

1//! Peak EWMA load balancer
2//!
3//! Implements Twitter Finagle's Peak EWMA (Exponentially Weighted Moving Average)
4//! algorithm. This algorithm tracks the latency of each backend using an
5//! exponentially weighted moving average, and selects the backend with the
6//! lowest predicted completion time.
7//!
8//! The "peak" aspect means we use the maximum of:
9//! - Current EWMA latency
10//! - Most recent observed latency (to quickly react to latency spikes)
11//!
12//! Reference: <https://twitter.github.io/finagle/guide/Clients.html#power-of-two-choices-p2c-least-loaded>
13
14use async_trait::async_trait;
15use std::collections::HashMap;
16use std::sync::atomic::{AtomicU64, Ordering};
17use std::sync::Arc;
18use std::time::{Duration, Instant};
19use tokio::sync::RwLock;
20use tracing::{debug, trace, warn};
21
22use zentinel_common::errors::{ZentinelError, ZentinelResult};
23
24use super::{LoadBalancer, RequestContext, TargetSelection, UpstreamTarget};
25
26/// Configuration for Peak EWMA load balancer
27#[derive(Debug, Clone)]
28pub struct PeakEwmaConfig {
29    /// Decay time for EWMA calculation (default: 10 seconds)
30    /// Lower values make the algorithm more responsive to recent latency changes
31    pub decay_time: Duration,
32    /// Initial latency estimate for new backends (default: 1ms)
33    pub initial_latency: Duration,
34    /// Penalty multiplier for backends with active connections (default: 1.5)
35    /// Higher values favor backends with fewer connections
36    pub load_penalty: f64,
37}
38
39impl Default for PeakEwmaConfig {
40    fn default() -> Self {
41        Self {
42            decay_time: Duration::from_secs(10),
43            initial_latency: Duration::from_millis(1),
44            load_penalty: 1.5,
45        }
46    }
47}
48
49/// Per-target statistics for EWMA tracking
50struct TargetStats {
51    /// EWMA latency in nanoseconds
52    ewma_ns: AtomicU64,
53    /// Last observed latency in nanoseconds
54    last_latency_ns: AtomicU64,
55    /// Timestamp of last update (as nanos since some epoch)
56    last_update_ns: AtomicU64,
57    /// Number of active connections
58    active_connections: AtomicU64,
59    /// Epoch for relative timestamps
60    epoch: Instant,
61}
62
63impl TargetStats {
64    fn new(initial_latency: Duration) -> Self {
65        let initial_ns = initial_latency.as_nanos() as u64;
66        Self {
67            ewma_ns: AtomicU64::new(initial_ns),
68            last_latency_ns: AtomicU64::new(initial_ns),
69            last_update_ns: AtomicU64::new(0),
70            active_connections: AtomicU64::new(0),
71            epoch: Instant::now(),
72        }
73    }
74
75    /// Update EWMA with a new latency observation
76    fn update(&self, latency: Duration, decay_time: Duration) {
77        let latency_ns = latency.as_nanos() as u64;
78        let now_ns = self.epoch.elapsed().as_nanos() as u64;
79        let last_update = self.last_update_ns.load(Ordering::Relaxed);
80
81        // Calculate decay factor: e^(-elapsed / decay_time)
82        let elapsed_ns = now_ns.saturating_sub(last_update);
83        let decay = (-((elapsed_ns as f64) / (decay_time.as_nanos() as f64))).exp();
84
85        // EWMA update: new_ewma = old_ewma * decay + new_value * (1 - decay)
86        let old_ewma = self.ewma_ns.load(Ordering::Relaxed);
87        let new_ewma = ((old_ewma as f64) * decay + (latency_ns as f64) * (1.0 - decay)) as u64;
88
89        self.ewma_ns.store(new_ewma, Ordering::Relaxed);
90        self.last_latency_ns.store(latency_ns, Ordering::Relaxed);
91        self.last_update_ns.store(now_ns, Ordering::Relaxed);
92    }
93
94    /// Get the peak latency (max of EWMA and last observed)
95    fn peak_latency_ns(&self) -> u64 {
96        let ewma = self.ewma_ns.load(Ordering::Relaxed);
97        let last = self.last_latency_ns.load(Ordering::Relaxed);
98        ewma.max(last)
99    }
100
101    /// Calculate the load score (latency * (1 + active_connections * penalty))
102    fn load_score(&self, load_penalty: f64) -> f64 {
103        let latency = self.peak_latency_ns() as f64;
104        let active = self.active_connections.load(Ordering::Relaxed) as f64;
105        latency * (1.0 + active * load_penalty)
106    }
107
108    fn increment_connections(&self) {
109        self.active_connections.fetch_add(1, Ordering::Relaxed);
110    }
111
112    fn decrement_connections(&self) {
113        let prev = self.active_connections.fetch_sub(1, Ordering::Relaxed);
114        if prev == 0 {
115            self.active_connections.fetch_add(1, Ordering::Relaxed);
116            warn!("Attempted to decrement active connections below zero");
117        }
118    }
119}
120
121/// Peak EWMA load balancer
122pub struct PeakEwmaBalancer {
123    /// Original target list
124    targets: Vec<UpstreamTarget>,
125    /// Per-target statistics
126    stats: HashMap<String, Arc<TargetStats>>,
127    /// Health status per target
128    health_status: Arc<RwLock<HashMap<String, bool>>>,
129    /// Configuration
130    config: PeakEwmaConfig,
131}
132
133impl PeakEwmaBalancer {
134    /// Create a new Peak EWMA balancer
135    pub fn new(targets: Vec<UpstreamTarget>, config: PeakEwmaConfig) -> Self {
136        let mut health_status = HashMap::new();
137        let mut stats = HashMap::new();
138
139        for target in &targets {
140            let addr = target.full_address();
141            health_status.insert(addr.clone(), true);
142            stats.insert(addr, Arc::new(TargetStats::new(config.initial_latency)));
143        }
144
145        Self {
146            targets,
147            stats,
148            health_status: Arc::new(RwLock::new(health_status)),
149            config,
150        }
151    }
152}
153
154#[async_trait]
155impl LoadBalancer for PeakEwmaBalancer {
156    async fn select(&self, _context: Option<&RequestContext>) -> ZentinelResult<TargetSelection> {
157        trace!(
158            total_targets = self.targets.len(),
159            algorithm = "peak_ewma",
160            "Selecting upstream target"
161        );
162
163        let health = self.health_status.read().await;
164        let healthy_targets: Vec<_> = self
165            .targets
166            .iter()
167            .filter(|t| *health.get(&t.full_address()).unwrap_or(&true))
168            .collect();
169        drop(health);
170
171        if healthy_targets.is_empty() {
172            warn!(
173                total_targets = self.targets.len(),
174                algorithm = "peak_ewma",
175                "No healthy upstream targets available"
176            );
177            return Err(ZentinelError::NoHealthyUpstream);
178        }
179
180        // Find target with lowest load score
181        let mut best_target = None;
182        let mut best_score = f64::MAX;
183
184        for target in &healthy_targets {
185            let addr = target.full_address();
186            if let Some(stats) = self.stats.get(&addr) {
187                let score = stats.load_score(self.config.load_penalty);
188                trace!(
189                    target = %addr,
190                    score = score,
191                    ewma_ns = stats.ewma_ns.load(Ordering::Relaxed),
192                    active_connections = stats.active_connections.load(Ordering::Relaxed),
193                    "Evaluating target load score"
194                );
195                if score < best_score {
196                    best_score = score;
197                    best_target = Some(target);
198                }
199            }
200        }
201
202        let target = best_target.ok_or(ZentinelError::NoHealthyUpstream)?;
203
204        // Increment active connections for selected target
205        if let Some(stats) = self.stats.get(&target.full_address()) {
206            stats.increment_connections();
207        }
208
209        trace!(
210            selected_target = %target.full_address(),
211            load_score = best_score,
212            healthy_count = healthy_targets.len(),
213            algorithm = "peak_ewma",
214            "Selected target via Peak EWMA"
215        );
216
217        Ok(TargetSelection {
218            address: target.full_address(),
219            weight: target.weight,
220            metadata: HashMap::new(),
221        })
222    }
223
224    async fn release(&self, selection: &TargetSelection) {
225        if let Some(stats) = self.stats.get(&selection.address) {
226            stats.decrement_connections();
227            trace!(
228                target = %selection.address,
229                active_connections = stats.active_connections.load(Ordering::Relaxed),
230                algorithm = "peak_ewma",
231                "Released connection"
232            );
233        }
234    }
235
236    async fn report_result(
237        &self,
238        selection: &TargetSelection,
239        success: bool,
240        latency: Option<Duration>,
241    ) {
242        // Release the connection
243        self.release(selection).await;
244
245        // Update EWMA if we have latency data
246        if let Some(latency) = latency {
247            if let Some(stats) = self.stats.get(&selection.address) {
248                stats.update(latency, self.config.decay_time);
249                trace!(
250                    target = %selection.address,
251                    latency_ms = latency.as_millis(),
252                    new_ewma_ns = stats.ewma_ns.load(Ordering::Relaxed),
253                    algorithm = "peak_ewma",
254                    "Updated EWMA latency"
255                );
256            }
257        }
258
259        // Update health if request failed
260        if !success {
261            self.report_health(&selection.address, false).await;
262        }
263    }
264
265    async fn report_result_with_latency(
266        &self,
267        address: &str,
268        success: bool,
269        latency: Option<Duration>,
270    ) {
271        // Update EWMA if we have latency data
272        if let Some(latency) = latency {
273            if let Some(stats) = self.stats.get(address) {
274                stats.update(latency, self.config.decay_time);
275                debug!(
276                    target = %address,
277                    latency_ms = latency.as_millis(),
278                    new_ewma_ns = stats.ewma_ns.load(Ordering::Relaxed),
279                    algorithm = "peak_ewma",
280                    "Updated EWMA latency via report_result_with_latency"
281                );
282            }
283        }
284
285        // Update health
286        self.report_health(address, success).await;
287    }
288
289    async fn report_health(&self, address: &str, healthy: bool) {
290        trace!(
291            target = %address,
292            healthy = healthy,
293            algorithm = "peak_ewma",
294            "Updating target health status"
295        );
296        self.health_status
297            .write()
298            .await
299            .insert(address.to_string(), healthy);
300    }
301
302    async fn healthy_targets(&self) -> Vec<String> {
303        self.health_status
304            .read()
305            .await
306            .iter()
307            .filter_map(|(addr, &healthy)| if healthy { Some(addr.clone()) } else { None })
308            .collect()
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    fn make_targets(count: usize) -> Vec<UpstreamTarget> {
317        (0..count)
318            .map(|i| UpstreamTarget::new(format!("backend-{}", i), 8080, 100))
319            .collect()
320    }
321
322    #[tokio::test]
323    async fn test_selects_lowest_latency() {
324        let targets = make_targets(3);
325        let balancer = PeakEwmaBalancer::new(targets, PeakEwmaConfig::default());
326
327        // Simulate different latencies for each backend
328        let addr0 = "backend-0:8080".to_string();
329        let addr1 = "backend-1:8080".to_string();
330        let addr2 = "backend-2:8080".to_string();
331
332        // Update latencies: backend-1 has lowest
333        balancer
334            .stats
335            .get(&addr0)
336            .unwrap()
337            .update(Duration::from_millis(100), Duration::from_secs(10));
338        balancer
339            .stats
340            .get(&addr1)
341            .unwrap()
342            .update(Duration::from_millis(10), Duration::from_secs(10));
343        balancer
344            .stats
345            .get(&addr2)
346            .unwrap()
347            .update(Duration::from_millis(50), Duration::from_secs(10));
348
349        // Should select backend-1 (lowest latency)
350        let selection = balancer.select(None).await.unwrap();
351        assert_eq!(selection.address, addr1);
352    }
353
354    #[tokio::test]
355    async fn test_considers_active_connections() {
356        let targets = make_targets(2);
357        let balancer = PeakEwmaBalancer::new(targets, PeakEwmaConfig::default());
358
359        let addr0 = "backend-0:8080".to_string();
360        let addr1 = "backend-1:8080".to_string();
361
362        // Same latency, but backend-0 has active connections
363        balancer
364            .stats
365            .get(&addr0)
366            .unwrap()
367            .update(Duration::from_millis(10), Duration::from_secs(10));
368        balancer
369            .stats
370            .get(&addr1)
371            .unwrap()
372            .update(Duration::from_millis(10), Duration::from_secs(10));
373
374        // Add active connections to backend-0
375        for _ in 0..5 {
376            balancer.stats.get(&addr0).unwrap().increment_connections();
377        }
378
379        // Should select backend-1 (no active connections)
380        let selection = balancer.select(None).await.unwrap();
381        assert_eq!(selection.address, addr1);
382    }
383
384    #[tokio::test]
385    async fn test_ewma_decay() {
386        let targets = make_targets(1);
387        let config = PeakEwmaConfig {
388            decay_time: Duration::from_millis(100),
389            initial_latency: Duration::from_millis(50), // Start with 50ms
390            load_penalty: 1.5,
391        };
392        let balancer = PeakEwmaBalancer::new(targets, config);
393
394        let addr = "backend-0:8080".to_string();
395        let stats = balancer.stats.get(&addr).unwrap();
396
397        // Wait a bit so the first update has some elapsed time
398        tokio::time::sleep(Duration::from_millis(50)).await;
399
400        // Update with high latency
401        stats.update(Duration::from_millis(100), Duration::from_millis(100));
402        let after_high = stats.ewma_ns.load(Ordering::Relaxed);
403
404        // Wait for decay and update with low latency
405        tokio::time::sleep(Duration::from_millis(200)).await;
406        stats.update(Duration::from_millis(10), Duration::from_millis(100));
407        let after_low = stats.ewma_ns.load(Ordering::Relaxed);
408
409        // After the low latency update (with significant decay time),
410        // the EWMA should move toward the low value
411        // decay = e^(-200/100) = e^(-2) ≈ 0.135
412        // new_ewma ≈ old * 0.135 + 10ms * 0.865 ≈ mostly 10ms
413        let low_latency_ns = Duration::from_millis(10).as_nanos() as u64;
414        let high_latency_ns = Duration::from_millis(100).as_nanos() as u64;
415
416        // The after_low value should be between low and high, closer to low
417        assert!(
418            after_low < high_latency_ns,
419            "EWMA after low update ({}) should be less than high latency ({})",
420            after_low,
421            high_latency_ns
422        );
423        assert!(
424            after_low > low_latency_ns,
425            "EWMA after low update ({}) should be greater than low latency ({}) due to some carry-over",
426            after_low,
427            low_latency_ns
428        );
429    }
430
431    #[tokio::test]
432    async fn test_connection_tracking() {
433        let targets = make_targets(1);
434        let balancer = PeakEwmaBalancer::new(targets, PeakEwmaConfig::default());
435
436        // Select increments connections
437        let selection = balancer.select(None).await.unwrap();
438        let stats = balancer.stats.get(&selection.address).unwrap();
439        assert_eq!(stats.active_connections.load(Ordering::Relaxed), 1);
440
441        // Release decrements connections
442        balancer.release(&selection).await;
443        assert_eq!(stats.active_connections.load(Ordering::Relaxed), 0);
444    }
445}