1use ipfrs_core::Result;
44use serde::{Deserialize, Serialize};
45use std::time::Duration;
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct WorkloadMetrics {
50 pub queries_per_second: f64,
52 pub avg_latency: Duration,
54 pub p99_latency: Duration,
56 pub memory_usage_mb: f64,
58 pub cpu_utilization: f64,
60 pub cache_hit_rate: f64,
62 pub index_size: usize,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum ActionType {
69 IncreaseCache,
71 ScaleHorizontally,
73 ScaleVertically,
75 OptimizeParameters,
77 EnableCompression,
79 AddWarmupCache,
81 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#[derive(Debug, Clone)]
101pub struct ScalingAction {
102 pub action_type: ActionType,
104 pub priority: f64,
106 pub description: String,
108 pub expected_impact: String,
110 pub cost_estimate: f64,
112}
113
114#[derive(Debug, Clone)]
116pub struct ScalingRecommendations {
117 pub health_score: f64,
119 pub capacity_headroom: f64,
121 pub actions: Vec<ScalingAction>,
123 pub cost_benefit_ratio: f64,
125}
126
127#[derive(Debug, Clone)]
129pub struct AdvisorConfig {
130 pub target_p99_latency_ms: u64,
132 pub target_cpu_utilization: f64,
134 pub min_cache_hit_rate: f64,
136 pub target_qps_capacity: f64,
138}
139
140impl Default for AdvisorConfig {
141 fn default() -> Self {
142 Self {
143 target_p99_latency_ms: 100, target_cpu_utilization: 0.70, min_cache_hit_rate: 0.75, target_qps_capacity: 1000.0, }
148 }
149}
150
151pub struct AutoScalingAdvisor {
153 config: AdvisorConfig,
155 history: Vec<WorkloadMetrics>,
157}
158
159impl AutoScalingAdvisor {
160 pub fn new() -> Self {
162 Self {
163 config: AdvisorConfig::default(),
164 history: Vec::new(),
165 }
166 }
167
168 pub fn with_config(config: AdvisorConfig) -> Self {
170 Self {
171 config,
172 history: Vec::new(),
173 }
174 }
175
176 pub fn record(&mut self, metrics: WorkloadMetrics) {
178 self.history.push(metrics);
179
180 if self.history.len() > 1000 {
182 self.history.remove(0);
183 }
184 }
185
186 pub fn analyze(&self, current: &WorkloadMetrics) -> Result<ScalingRecommendations> {
188 let mut actions = Vec::new();
189
190 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 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 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 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 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 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 actions.sort_by(|a, b| {
274 b.priority
275 .partial_cmp(&a.priority)
276 .unwrap_or(std::cmp::Ordering::Equal)
277 });
278
279 let health_score = self.calculate_health_score(current);
281
282 let capacity_headroom = self.calculate_capacity_headroom(current);
284
285 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 fn calculate_health_score(&self, metrics: &WorkloadMetrics) -> f64 {
308 let mut score = 1.0;
309
310 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 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 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 fn calculate_capacity_headroom(&self, metrics: &WorkloadMetrics) -> f64 {
335 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 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 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#[derive(Debug, Clone, Default)]
388pub struct TrendReport {
389 pub avg_qps: f64,
391 pub avg_cpu_utilization: f64,
393 pub avg_cache_hit_rate: f64,
395 pub qps_trend_percent: f64,
397 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), 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, 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, 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, cpu_utilization: 0.60,
513 cache_hit_rate: 0.80,
514 index_size: 10_000_000, };
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, 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); }
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), memory_usage_mb: 2048.0,
604 cpu_utilization: 0.70,
605 cache_hit_rate: 0.85, 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), memory_usage_mb: 10000.0,
624 cpu_utilization: 0.95, cache_hit_rate: 0.40, 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 for i in 1..recommendations.actions.len() {
635 assert!(recommendations.actions[i - 1].priority >= recommendations.actions[i].priority);
636 }
637 }
638}