Skip to main content

ipfrs_semantic/
auto_scaling.rs

1//! Auto-scaling advisor for production deployments
2//!
3//! This module provides intelligent recommendations for scaling semantic search
4//! systems based on observed metrics and workload patterns.
5//!
6//! # Features
7//!
8//! - **Load Analysis**: Analyze query load and resource utilization
9//! - **Scaling Recommendations**: Suggest horizontal/vertical scaling
10//! - **Cost Estimation**: Estimate infrastructure costs
11//! - **Performance Prediction**: Predict performance under different configurations
12//!
13//! # Example
14//!
15//! ```rust
16//! use ipfrs_semantic::auto_scaling::{AutoScalingAdvisor, WorkloadMetrics};
17//! use std::time::Duration;
18//!
19//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
20//! let mut advisor = AutoScalingAdvisor::new();
21//!
22//! // Record workload metrics
23//! let metrics = WorkloadMetrics {
24//!     queries_per_second: 1500.0,
25//!     avg_latency: Duration::from_millis(10),
26//!     p99_latency: Duration::from_millis(50),
27//!     memory_usage_mb: 4096.0,
28//!     cpu_utilization: 0.85,
29//!     cache_hit_rate: 0.60,
30//!     index_size: 10_000_000,
31//! };
32//!
33//! // Get scaling recommendations
34//! let recommendations = advisor.analyze(&metrics)?;
35//! for rec in &recommendations.actions {
36//!     println!("📊 {}: {}", rec.action_type, rec.description);
37//!     println!("   Impact: {}", rec.expected_impact);
38//! }
39//! # Ok(())
40//! # }
41//! ```
42
43use ipfrs_core::Result;
44use serde::{Deserialize, Serialize};
45use std::time::Duration;
46
47/// Workload metrics for a semantic search system
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct WorkloadMetrics {
50    /// Queries per second
51    pub queries_per_second: f64,
52    /// Average query latency
53    pub avg_latency: Duration,
54    /// P99 latency
55    pub p99_latency: Duration,
56    /// Memory usage in MB
57    pub memory_usage_mb: f64,
58    /// CPU utilization (0.0 to 1.0)
59    pub cpu_utilization: f64,
60    /// Cache hit rate (0.0 to 1.0)
61    pub cache_hit_rate: f64,
62    /// Total index size (number of vectors)
63    pub index_size: usize,
64}
65
66/// Scaling action type
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum ActionType {
69    /// Increase cache size
70    IncreaseCache,
71    /// Add more replicas
72    ScaleHorizontally,
73    /// Increase CPU/memory
74    ScaleVertically,
75    /// Optimize index parameters
76    OptimizeParameters,
77    /// Enable compression/quantization
78    EnableCompression,
79    /// Add warmup cache
80    AddWarmupCache,
81    /// No action needed
82    NoAction,
83}
84
85impl std::fmt::Display for ActionType {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        match self {
88            ActionType::IncreaseCache => write!(f, "Increase Cache"),
89            ActionType::ScaleHorizontally => write!(f, "Scale Horizontally"),
90            ActionType::ScaleVertically => write!(f, "Scale Vertically"),
91            ActionType::OptimizeParameters => write!(f, "Optimize Parameters"),
92            ActionType::EnableCompression => write!(f, "Enable Compression"),
93            ActionType::AddWarmupCache => write!(f, "Add Warmup Cache"),
94            ActionType::NoAction => write!(f, "No Action"),
95        }
96    }
97}
98
99/// A specific scaling recommendation
100#[derive(Debug, Clone)]
101pub struct ScalingAction {
102    /// Type of action
103    pub action_type: ActionType,
104    /// Priority (0.0 to 1.0, where 1.0 is highest)
105    pub priority: f64,
106    /// Description of the action
107    pub description: String,
108    /// Expected impact
109    pub expected_impact: String,
110    /// Estimated cost (relative units)
111    pub cost_estimate: f64,
112}
113
114/// Scaling recommendations report
115#[derive(Debug, Clone)]
116pub struct ScalingRecommendations {
117    /// Current system health score (0.0 to 1.0)
118    pub health_score: f64,
119    /// Predicted capacity before overload
120    pub capacity_headroom: f64,
121    /// Recommended actions
122    pub actions: Vec<ScalingAction>,
123    /// Cost-benefit analysis
124    pub cost_benefit_ratio: f64,
125}
126
127/// Configuration for auto-scaling advisor
128#[derive(Debug, Clone)]
129pub struct AdvisorConfig {
130    /// Target P99 latency threshold (ms)
131    pub target_p99_latency_ms: u64,
132    /// Target CPU utilization (0.0 to 1.0)
133    pub target_cpu_utilization: f64,
134    /// Minimum cache hit rate
135    pub min_cache_hit_rate: f64,
136    /// Target queries per second capacity
137    pub target_qps_capacity: f64,
138}
139
140impl Default for AdvisorConfig {
141    fn default() -> Self {
142        Self {
143            target_p99_latency_ms: 100,   // 100ms P99
144            target_cpu_utilization: 0.70, // 70% CPU target
145            min_cache_hit_rate: 0.75,     // 75% cache hit rate
146            target_qps_capacity: 1000.0,  // 1000 QPS
147        }
148    }
149}
150
151/// Auto-scaling advisor
152pub struct AutoScalingAdvisor {
153    /// Configuration
154    config: AdvisorConfig,
155    /// Historical metrics
156    history: Vec<WorkloadMetrics>,
157}
158
159impl AutoScalingAdvisor {
160    /// Create a new advisor with default config
161    pub fn new() -> Self {
162        Self {
163            config: AdvisorConfig::default(),
164            history: Vec::new(),
165        }
166    }
167
168    /// Create an advisor with custom config
169    pub fn with_config(config: AdvisorConfig) -> Self {
170        Self {
171            config,
172            history: Vec::new(),
173        }
174    }
175
176    /// Record workload metrics
177    pub fn record(&mut self, metrics: WorkloadMetrics) {
178        self.history.push(metrics);
179
180        // Keep only last 1000 samples
181        if self.history.len() > 1000 {
182            self.history.remove(0);
183        }
184    }
185
186    /// Analyze current workload and generate recommendations
187    pub fn analyze(&self, current: &WorkloadMetrics) -> Result<ScalingRecommendations> {
188        let mut actions = Vec::new();
189
190        // Check P99 latency
191        let p99_ms = current.p99_latency.as_millis() as u64;
192        if p99_ms > self.config.target_p99_latency_ms {
193            let latency_ratio = p99_ms as f64 / self.config.target_p99_latency_ms as f64;
194
195            if latency_ratio > 2.0 {
196                // Severe latency issues - need horizontal scaling
197                actions.push(ScalingAction {
198                    action_type: ActionType::ScaleHorizontally,
199                    priority: 0.9,
200                    description: format!(
201                        "Add replicas to handle load. Current P99: {}ms, Target: {}ms",
202                        p99_ms, self.config.target_p99_latency_ms
203                    ),
204                    expected_impact: format!(
205                        "Reduce P99 latency by ~{}%",
206                        ((latency_ratio - 1.0) * 50.0).min(70.0) as i32
207                    ),
208                    cost_estimate: latency_ratio * 10.0,
209                });
210            } else {
211                // Moderate latency - optimize parameters
212                actions.push(ScalingAction {
213                    action_type: ActionType::OptimizeParameters,
214                    priority: 0.6,
215                    description: format!(
216                        "Optimize HNSW parameters (reduce ef_search). Current P99: {}ms",
217                        p99_ms
218                    ),
219                    expected_impact: "Reduce P99 latency by 20-30% with minimal accuracy loss"
220                        .to_string(),
221                    cost_estimate: 0.5,
222                });
223            }
224        }
225
226        // Check CPU utilization
227        if current.cpu_utilization > 0.85 {
228            actions.push(ScalingAction {
229                action_type: ActionType::ScaleVertically,
230                priority: 0.8,
231                description: format!(
232                    "Increase CPU resources. Current: {:.1}%, Saturated at >85%",
233                    current.cpu_utilization * 100.0
234                ),
235                expected_impact: "Increase query throughput by 30-50%".to_string(),
236                cost_estimate: current.cpu_utilization * 8.0,
237            });
238        }
239
240        // Check cache hit rate
241        if current.cache_hit_rate < self.config.min_cache_hit_rate {
242            actions.push(ScalingAction {
243                action_type: ActionType::IncreaseCache,
244                priority: 0.7,
245                description: format!(
246                    "Increase cache size. Current hit rate: {:.1}%, Target: {:.1}%",
247                    current.cache_hit_rate * 100.0,
248                    self.config.min_cache_hit_rate * 100.0
249                ),
250                expected_impact: format!(
251                    "Improve hit rate by {:.0}%, reduce latency by 15-25%",
252                    (self.config.min_cache_hit_rate - current.cache_hit_rate) * 100.0
253                ),
254                cost_estimate: 3.0,
255            });
256        }
257
258        // Check memory pressure for large indices
259        if current.index_size > 5_000_000 && current.memory_usage_mb > 8192.0 {
260            actions.push(ScalingAction {
261                action_type: ActionType::EnableCompression,
262                priority: 0.65,
263                description: format!(
264                    "Enable quantization for {} vectors using {}MB memory",
265                    current.index_size, current.memory_usage_mb
266                ),
267                expected_impact: "Reduce memory by 4-8x with <5% accuracy loss".to_string(),
268                cost_estimate: 1.0,
269            });
270        }
271
272        // Sort actions by priority
273        actions.sort_by(|a, b| {
274            b.priority
275                .partial_cmp(&a.priority)
276                .unwrap_or(std::cmp::Ordering::Equal)
277        });
278
279        // Calculate health score
280        let health_score = self.calculate_health_score(current);
281
282        // Calculate capacity headroom
283        let capacity_headroom = self.calculate_capacity_headroom(current);
284
285        // Calculate cost-benefit ratio
286        let cost_benefit_ratio = if actions.is_empty() {
287            0.0
288        } else {
289            let total_benefit: f64 = actions.iter().map(|a| a.priority).sum();
290            let total_cost: f64 = actions.iter().map(|a| a.cost_estimate).sum();
291            if total_cost > 0.0 {
292                total_benefit / total_cost
293            } else {
294                0.0
295            }
296        };
297
298        Ok(ScalingRecommendations {
299            health_score,
300            capacity_headroom,
301            actions,
302            cost_benefit_ratio,
303        })
304    }
305
306    /// Calculate system health score (0.0 to 1.0)
307    fn calculate_health_score(&self, metrics: &WorkloadMetrics) -> f64 {
308        let mut score = 1.0;
309
310        // Penalty for high latency
311        let p99_ms = metrics.p99_latency.as_millis() as u64;
312        if p99_ms > self.config.target_p99_latency_ms {
313            let latency_penalty =
314                (p99_ms as f64 / self.config.target_p99_latency_ms as f64 - 1.0) * 0.3;
315            score -= latency_penalty.min(0.4);
316        }
317
318        // Penalty for high CPU
319        if metrics.cpu_utilization > self.config.target_cpu_utilization {
320            let cpu_penalty = (metrics.cpu_utilization - self.config.target_cpu_utilization) * 0.5;
321            score -= cpu_penalty.min(0.3);
322        }
323
324        // Penalty for low cache hit rate
325        if metrics.cache_hit_rate < self.config.min_cache_hit_rate {
326            let cache_penalty = (self.config.min_cache_hit_rate - metrics.cache_hit_rate) * 0.3;
327            score -= cache_penalty.min(0.2);
328        }
329
330        score.max(0.0)
331    }
332
333    /// Calculate capacity headroom (how much more load can be handled)
334    fn calculate_capacity_headroom(&self, metrics: &WorkloadMetrics) -> f64 {
335        // Estimate based on CPU utilization and current QPS
336        let _cpu_headroom = (1.0 - metrics.cpu_utilization).max(0.0);
337        let estimated_max_qps = metrics.queries_per_second / metrics.cpu_utilization;
338        let additional_capacity = estimated_max_qps - metrics.queries_per_second;
339
340        (additional_capacity / metrics.queries_per_second).clamp(0.0, 2.0)
341    }
342
343    /// Get historical trend analysis
344    pub fn trend_analysis(&self) -> TrendReport {
345        if self.history.len() < 2 {
346            return TrendReport::default();
347        }
348
349        let recent = &self.history[self.history.len().saturating_sub(10)..];
350
351        let avg_qps: f64 =
352            recent.iter().map(|m| m.queries_per_second).sum::<f64>() / recent.len() as f64;
353        let avg_cpu: f64 =
354            recent.iter().map(|m| m.cpu_utilization).sum::<f64>() / recent.len() as f64;
355        let avg_cache_hit: f64 =
356            recent.iter().map(|m| m.cache_hit_rate).sum::<f64>() / recent.len() as f64;
357
358        // Calculate trends
359        let qps_trend = if recent.len() > 1 {
360            (recent
361                .last()
362                .expect("recent.len() > 1 checked above")
363                .queries_per_second
364                - recent[0].queries_per_second)
365                / recent[0].queries_per_second
366        } else {
367            0.0
368        };
369
370        TrendReport {
371            avg_qps,
372            avg_cpu_utilization: avg_cpu,
373            avg_cache_hit_rate: avg_cache_hit,
374            qps_trend_percent: qps_trend * 100.0,
375            sample_count: recent.len(),
376        }
377    }
378}
379
380impl Default for AutoScalingAdvisor {
381    fn default() -> Self {
382        Self::new()
383    }
384}
385
386/// Trend analysis report
387#[derive(Debug, Clone, Default)]
388pub struct TrendReport {
389    /// Average QPS over recent period
390    pub avg_qps: f64,
391    /// Average CPU utilization
392    pub avg_cpu_utilization: f64,
393    /// Average cache hit rate
394    pub avg_cache_hit_rate: f64,
395    /// QPS trend (percent change)
396    pub qps_trend_percent: f64,
397    /// Number of samples analyzed
398    pub sample_count: usize,
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    #[test]
406    fn test_advisor_creation() {
407        let advisor = AutoScalingAdvisor::new();
408        assert_eq!(advisor.history.len(), 0);
409    }
410
411    #[test]
412    fn test_healthy_system() {
413        let advisor = AutoScalingAdvisor::new();
414
415        let metrics = WorkloadMetrics {
416            queries_per_second: 500.0,
417            avg_latency: Duration::from_millis(5),
418            p99_latency: Duration::from_millis(20),
419            memory_usage_mb: 2048.0,
420            cpu_utilization: 0.50,
421            cache_hit_rate: 0.85,
422            index_size: 1_000_000,
423        };
424
425        let recommendations = advisor
426            .analyze(&metrics)
427            .expect("test: analyze should succeed for healthy metrics");
428        assert!(recommendations.health_score > 0.8);
429        assert!(recommendations.actions.is_empty() || recommendations.actions[0].priority < 0.5);
430    }
431
432    #[test]
433    fn test_high_latency_detection() {
434        let advisor = AutoScalingAdvisor::new();
435
436        let metrics = WorkloadMetrics {
437            queries_per_second: 1500.0,
438            avg_latency: Duration::from_millis(50),
439            p99_latency: Duration::from_millis(250), // Very high!
440            memory_usage_mb: 4096.0,
441            cpu_utilization: 0.85,
442            cache_hit_rate: 0.60,
443            index_size: 10_000_000,
444        };
445
446        let recommendations = advisor
447            .analyze(&metrics)
448            .expect("test: analyze should succeed for high latency metrics");
449        assert!(recommendations.health_score < 0.7);
450        assert!(!recommendations.actions.is_empty());
451        assert!(recommendations
452            .actions
453            .iter()
454            .any(|a| a.action_type == ActionType::ScaleHorizontally));
455    }
456
457    #[test]
458    fn test_low_cache_hit_rate() {
459        let advisor = AutoScalingAdvisor::new();
460
461        let metrics = WorkloadMetrics {
462            queries_per_second: 1000.0,
463            avg_latency: Duration::from_millis(10),
464            p99_latency: Duration::from_millis(50),
465            memory_usage_mb: 2048.0,
466            cpu_utilization: 0.60,
467            cache_hit_rate: 0.40, // Very low!
468            index_size: 5_000_000,
469        };
470
471        let recommendations = advisor
472            .analyze(&metrics)
473            .expect("test: analyze should succeed for low cache hit rate metrics");
474        assert!(recommendations
475            .actions
476            .iter()
477            .any(|a| a.action_type == ActionType::IncreaseCache));
478    }
479
480    #[test]
481    fn test_high_cpu_utilization() {
482        let advisor = AutoScalingAdvisor::new();
483
484        let metrics = WorkloadMetrics {
485            queries_per_second: 2000.0,
486            avg_latency: Duration::from_millis(15),
487            p99_latency: Duration::from_millis(60),
488            memory_usage_mb: 4096.0,
489            cpu_utilization: 0.92, // Very high!
490            cache_hit_rate: 0.80,
491            index_size: 8_000_000,
492        };
493
494        let recommendations = advisor
495            .analyze(&metrics)
496            .expect("test: analyze should succeed for high CPU metrics");
497        assert!(recommendations
498            .actions
499            .iter()
500            .any(|a| a.action_type == ActionType::ScaleVertically));
501    }
502
503    #[test]
504    fn test_compression_recommendation() {
505        let advisor = AutoScalingAdvisor::new();
506
507        let metrics = WorkloadMetrics {
508            queries_per_second: 1000.0,
509            avg_latency: Duration::from_millis(10),
510            p99_latency: Duration::from_millis(50),
511            memory_usage_mb: 10000.0, // High memory usage
512            cpu_utilization: 0.60,
513            cache_hit_rate: 0.80,
514            index_size: 10_000_000, // Large index
515        };
516
517        let recommendations = advisor
518            .analyze(&metrics)
519            .expect("test: analyze should succeed for high memory metrics");
520        assert!(recommendations
521            .actions
522            .iter()
523            .any(|a| a.action_type == ActionType::EnableCompression));
524    }
525
526    #[test]
527    fn test_record_metrics() {
528        let mut advisor = AutoScalingAdvisor::new();
529
530        let metrics = WorkloadMetrics {
531            queries_per_second: 1000.0,
532            avg_latency: Duration::from_millis(10),
533            p99_latency: Duration::from_millis(50),
534            memory_usage_mb: 2048.0,
535            cpu_utilization: 0.60,
536            cache_hit_rate: 0.80,
537            index_size: 5_000_000,
538        };
539
540        advisor.record(metrics.clone());
541        advisor.record(metrics);
542
543        assert_eq!(advisor.history.len(), 2);
544    }
545
546    #[test]
547    fn test_capacity_headroom() {
548        let advisor = AutoScalingAdvisor::new();
549
550        let metrics = WorkloadMetrics {
551            queries_per_second: 1000.0,
552            avg_latency: Duration::from_millis(10),
553            p99_latency: Duration::from_millis(50),
554            memory_usage_mb: 2048.0,
555            cpu_utilization: 0.50, // 50% CPU means 100% headroom
556            cache_hit_rate: 0.80,
557            index_size: 5_000_000,
558        };
559
560        let recommendations = advisor
561            .analyze(&metrics)
562            .expect("test: analyze should succeed for capacity headroom check");
563        assert!(recommendations.capacity_headroom > 0.5);
564    }
565
566    #[test]
567    fn test_trend_analysis() {
568        let mut advisor = AutoScalingAdvisor::new();
569
570        for i in 0..10 {
571            let metrics = WorkloadMetrics {
572                queries_per_second: 1000.0 + (i as f64 * 100.0),
573                avg_latency: Duration::from_millis(10),
574                p99_latency: Duration::from_millis(50),
575                memory_usage_mb: 2048.0,
576                cpu_utilization: 0.60,
577                cache_hit_rate: 0.80,
578                index_size: 5_000_000,
579            };
580            advisor.record(metrics);
581        }
582
583        let trend = advisor.trend_analysis();
584        assert_eq!(trend.sample_count, 10);
585        assert!(trend.qps_trend_percent > 0.0); // Increasing trend
586    }
587
588    #[test]
589    fn test_custom_config() {
590        let config = AdvisorConfig {
591            target_p99_latency_ms: 50,
592            target_cpu_utilization: 0.80,
593            min_cache_hit_rate: 0.90,
594            target_qps_capacity: 5000.0,
595        };
596
597        let advisor = AutoScalingAdvisor::with_config(config);
598
599        let metrics = WorkloadMetrics {
600            queries_per_second: 1000.0,
601            avg_latency: Duration::from_millis(10),
602            p99_latency: Duration::from_millis(75), // Over custom target
603            memory_usage_mb: 2048.0,
604            cpu_utilization: 0.70,
605            cache_hit_rate: 0.85, // Below custom target
606            index_size: 5_000_000,
607        };
608
609        let recommendations = advisor
610            .analyze(&metrics)
611            .expect("test: analyze should succeed with custom config");
612        assert!(!recommendations.actions.is_empty());
613    }
614
615    #[test]
616    fn test_action_priority_ordering() {
617        let advisor = AutoScalingAdvisor::new();
618
619        let metrics = WorkloadMetrics {
620            queries_per_second: 2000.0,
621            avg_latency: Duration::from_millis(50),
622            p99_latency: Duration::from_millis(300), // Critical
623            memory_usage_mb: 10000.0,
624            cpu_utilization: 0.95, // Critical
625            cache_hit_rate: 0.40,  // Poor
626            index_size: 10_000_000,
627        };
628
629        let recommendations = advisor
630            .analyze(&metrics)
631            .expect("test: analyze should succeed for priority ordering check");
632
633        // Actions should be sorted by priority
634        for i in 1..recommendations.actions.len() {
635            assert!(recommendations.actions[i - 1].priority >= recommendations.actions[i].priority);
636        }
637    }
638}