1use crate::error::{MlError, Result};
7use std::collections::HashMap;
9use std::path::PathBuf;
10use std::sync::{Arc, RwLock};
11use tracing::{debug, info};
12
13#[derive(Debug, Clone)]
15pub struct ModelVersion {
16 pub version: String,
18 pub path: PathBuf,
20 pub deployed_at: std::time::SystemTime,
22 pub metadata: HashMap<String, String>,
24 pub metrics: VersionMetrics,
26}
27
28#[derive(Debug, Clone, Default)]
30pub struct VersionMetrics {
31 pub requests: u64,
33 pub avg_latency_ms: f32,
35 pub success_rate: f32,
37 pub avg_cpu_usage: f32,
39 pub avg_memory_mb: f32,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum DeploymentStrategy {
46 Replace,
48 BlueGreen,
50 Canary {
52 initial_percent: u8,
54 step_percent: u8,
56 },
57 ABTest {
59 split_percent: u8,
61 },
62 Shadow,
66}
67
68#[derive(Debug, Clone)]
70pub struct ServerConfig {
71 pub max_concurrent: usize,
73 pub timeout_ms: u64,
75 pub enable_queue: bool,
77 pub queue_size: usize,
79 pub health_check: bool,
81 pub health_check_interval_s: u64,
83}
84
85impl Default for ServerConfig {
86 fn default() -> Self {
87 Self {
88 max_concurrent: 100,
89 timeout_ms: 30000,
90 enable_queue: true,
91 queue_size: 1000,
92 health_check: true,
93 health_check_interval_s: 30,
94 }
95 }
96}
97
98pub struct ModelServer {
100 config: ServerConfig,
101 versions: Arc<RwLock<HashMap<String, ModelVersion>>>,
102 active_version: Arc<RwLock<String>>,
103 routing: Arc<RwLock<RoutingStrategy>>,
104}
105
106#[derive(Debug, Clone)]
108enum RoutingStrategy {
109 Single {
111 version: String,
113 },
114 Weighted {
116 weights: HashMap<String, u8>,
118 },
119 Canary {
121 stable: String,
123 canary: String,
125 canary_percent: u8,
127 },
128 Shadow {
133 stable: String,
135 shadow: String,
137 },
138}
139
140impl ModelServer {
141 #[must_use]
143 pub fn new(config: ServerConfig) -> Self {
144 info!("Initializing model server");
145 Self {
146 config,
147 versions: Arc::new(RwLock::new(HashMap::new())),
148 active_version: Arc::new(RwLock::new(String::new())),
149 routing: Arc::new(RwLock::new(RoutingStrategy::Single {
150 version: String::new(),
151 })),
152 }
153 }
154
155 pub fn register_version(
160 &mut self,
161 version_id: &str,
162 model_path: PathBuf,
163 metadata: HashMap<String, String>,
164 ) -> Result<()> {
165 info!("Registering model version: {}", version_id);
166
167 if !model_path.exists() {
168 return Err(MlError::InvalidConfig(format!(
169 "Model file not found: {}",
170 model_path.display()
171 )));
172 }
173
174 let version = ModelVersion {
175 version: version_id.to_string(),
176 path: model_path,
177 deployed_at: std::time::SystemTime::now(),
178 metadata,
179 metrics: VersionMetrics::default(),
180 };
181
182 if let Ok(mut versions) = self.versions.write() {
183 versions.insert(version_id.to_string(), version);
184 }
185
186 Ok(())
187 }
188
189 pub fn deploy(&mut self, version_id: &str, strategy: DeploymentStrategy) -> Result<()> {
194 info!(
195 "Deploying version {} with strategy {:?}",
196 version_id, strategy
197 );
198
199 let version_exists = self
201 .versions
202 .read()
203 .map(|v| v.contains_key(version_id))
204 .unwrap_or(false);
205
206 if !version_exists {
207 return Err(MlError::InvalidConfig(format!(
208 "Version not found: {}",
209 version_id
210 )));
211 }
212
213 match strategy {
214 DeploymentStrategy::Replace => self.deploy_replace(version_id),
215 DeploymentStrategy::BlueGreen => self.deploy_blue_green(version_id),
216 DeploymentStrategy::Canary {
217 initial_percent,
218 step_percent,
219 } => self.deploy_canary(version_id, initial_percent, step_percent),
220 DeploymentStrategy::ABTest { split_percent } => {
221 self.deploy_ab_test(version_id, split_percent)
222 }
223 DeploymentStrategy::Shadow => self.deploy_shadow(version_id),
224 }
225 }
226
227 pub fn rollback(&mut self, version_id: &str) -> Result<()> {
232 info!("Rolling back to version: {}", version_id);
233 self.deploy_replace(version_id)
234 }
235
236 #[must_use]
238 pub fn version_metrics(&self) -> HashMap<String, VersionMetrics> {
239 self.versions
240 .read()
241 .map(|versions| {
242 versions
243 .iter()
244 .map(|(k, v)| (k.clone(), v.metrics.clone()))
245 .collect()
246 })
247 .unwrap_or_default()
248 }
249
250 #[must_use]
252 pub fn active_version(&self) -> String {
253 self.active_version
254 .read()
255 .map(|v| v.clone())
256 .unwrap_or_default()
257 }
258
259 #[must_use]
261 pub fn health_check(&self) -> HealthStatus {
262 if !self.config.health_check {
263 return HealthStatus::Unknown;
264 }
265
266 let has_models = self.versions.read().map(|v| !v.is_empty()).unwrap_or(false);
268
269 if !has_models {
270 return HealthStatus::Unhealthy;
271 }
272
273 let active_version = self.active_version();
275 if active_version.is_empty() {
276 return HealthStatus::Degraded;
277 }
278
279 let version_exists = self
281 .versions
282 .read()
283 .map(|v| v.contains_key(&active_version))
284 .unwrap_or(false);
285
286 if !version_exists {
287 return HealthStatus::Unhealthy;
288 }
289
290 if let Ok(memory_info) = Self::get_memory_usage() {
294 if let Some(status) = Self::memory_pressure_status(memory_info.usage_percent) {
295 return status;
296 }
297 }
298
299 HealthStatus::Healthy
300 }
301
302 fn memory_pressure_status(usage_percent: f32) -> Option<HealthStatus> {
308 if usage_percent > 95.0 {
309 Some(HealthStatus::Unhealthy)
310 } else if usage_percent > 90.0 {
311 Some(HealthStatus::Degraded)
312 } else {
313 None
314 }
315 }
316
317 fn get_memory_usage() -> Result<MemoryInfo> {
319 #[cfg(target_os = "linux")]
320 {
321 Self::get_memory_usage_linux()
322 }
323
324 #[cfg(target_os = "macos")]
325 {
326 Self::get_memory_usage_macos()
327 }
328
329 #[cfg(target_os = "windows")]
330 {
331 Self::get_memory_usage_windows()
332 }
333
334 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
335 {
336 Ok(MemoryInfo {
338 total_mb: 0,
339 used_mb: 0,
340 available_mb: 0,
341 usage_percent: 0.0,
342 })
343 }
344 }
345
346 #[cfg(target_os = "linux")]
347 fn get_memory_usage_linux() -> Result<MemoryInfo> {
348 use std::fs;
349
350 let meminfo = fs::read_to_string("/proc/meminfo")
351 .map_err(|e| MlError::InvalidConfig(format!("Failed to read meminfo: {}", e)))?;
352
353 let mut total = 0u64;
354 let mut available = 0u64;
355
356 for line in meminfo.lines() {
357 if let Some(rest) = line.strip_prefix("MemTotal:") {
358 total = rest
359 .trim()
360 .split_whitespace()
361 .next()
362 .and_then(|s| s.parse::<u64>().ok())
363 .unwrap_or(0);
364 } else if let Some(rest) = line.strip_prefix("MemAvailable:") {
365 available = rest
366 .trim()
367 .split_whitespace()
368 .next()
369 .and_then(|s| s.parse::<u64>().ok())
370 .unwrap_or(0);
371 }
372 }
373
374 let total_mb = total / 1024;
375 let available_mb = available / 1024;
376 let used_mb = total_mb.saturating_sub(available_mb);
377 let usage_percent = if total_mb > 0 {
378 (used_mb as f32 / total_mb as f32) * 100.0
379 } else {
380 0.0
381 };
382
383 Ok(MemoryInfo {
384 total_mb,
385 used_mb,
386 available_mb,
387 usage_percent,
388 })
389 }
390
391 #[cfg(target_os = "macos")]
392 fn get_memory_usage_macos() -> Result<MemoryInfo> {
393 Self::get_memory_usage_sysinfo()
396 }
397
398 #[cfg(target_os = "windows")]
399 fn get_memory_usage_windows() -> Result<MemoryInfo> {
400 Self::get_memory_usage_sysinfo()
403 }
404
405 #[cfg(any(target_os = "macos", target_os = "windows"))]
412 fn get_memory_usage_sysinfo() -> Result<MemoryInfo> {
413 use sysinfo::System;
414
415 let mut system = System::new();
416 system.refresh_memory();
417
418 let total_bytes = system.total_memory();
420 if total_bytes == 0 {
421 return Err(MlError::InvalidConfig(
422 "sysinfo reported zero total memory".to_string(),
423 ));
424 }
425 let available_bytes = system.available_memory();
426
427 let total_mb = total_bytes / (1024 * 1024);
428 let available_mb = available_bytes / (1024 * 1024);
429 let used_mb = total_mb.saturating_sub(available_mb);
430 let usage_percent = ((total_bytes.saturating_sub(available_bytes)) as f64
431 / total_bytes as f64
432 * 100.0) as f32;
433
434 Ok(MemoryInfo {
435 total_mb,
436 used_mb,
437 available_mb,
438 usage_percent,
439 })
440 }
441
442 fn deploy_replace(&mut self, version_id: &str) -> Result<()> {
445 debug!("Deploying with replace strategy");
446
447 if let Ok(mut active) = self.active_version.write() {
448 *active = version_id.to_string();
449 }
450
451 if let Ok(mut routing) = self.routing.write() {
452 *routing = RoutingStrategy::Single {
453 version: version_id.to_string(),
454 };
455 }
456
457 info!("Version {} deployed successfully", version_id);
458 Ok(())
459 }
460
461 fn deploy_blue_green(&mut self, version_id: &str) -> Result<()> {
462 debug!("Deploying with blue-green strategy");
463
464 self.deploy_replace(version_id)
467 }
468
469 fn deploy_canary(
470 &mut self,
471 version_id: &str,
472 initial_percent: u8,
473 _step_percent: u8,
474 ) -> Result<()> {
475 debug!(
476 "Deploying with canary strategy ({}% initial)",
477 initial_percent
478 );
479
480 let stable_version = self.active_version();
481
482 if let Ok(mut routing) = self.routing.write() {
483 *routing = RoutingStrategy::Canary {
484 stable: stable_version,
485 canary: version_id.to_string(),
486 canary_percent: initial_percent,
487 };
488 }
489
490 info!("Canary deployment started for version {}", version_id);
491 Ok(())
492 }
493
494 fn deploy_ab_test(&mut self, version_id: &str, split_percent: u8) -> Result<()> {
495 debug!("Deploying with A/B test ({}% split)", split_percent);
496
497 let stable_version = self.active_version();
498 let mut weights = HashMap::new();
499 weights.insert(stable_version, 100 - split_percent);
500 weights.insert(version_id.to_string(), split_percent);
501
502 if let Ok(mut routing) = self.routing.write() {
503 *routing = RoutingStrategy::Weighted { weights };
504 }
505
506 info!("A/B test started for version {}", version_id);
507 Ok(())
508 }
509
510 fn deploy_shadow(&mut self, version_id: &str) -> Result<()> {
511 debug!("Deploying in shadow mode");
512
513 let stable_version = self.active_version();
520
521 if let Ok(mut routing) = self.routing.write() {
522 *routing = RoutingStrategy::Shadow {
523 stable: stable_version,
524 shadow: version_id.to_string(),
525 };
526 }
527
528 info!("Version {} deployed in shadow mode", version_id);
529 Ok(())
530 }
531
532 pub fn increase_canary_traffic(&mut self, increment: u8) -> Result<()> {
537 let mut routing = self
538 .routing
539 .write()
540 .map_err(|_| MlError::InvalidConfig("Failed to acquire routing lock".to_string()))?;
541
542 match &mut *routing {
543 RoutingStrategy::Canary { canary_percent, .. } => {
544 *canary_percent = (*canary_percent + increment).min(100);
545 info!("Increased canary traffic to {}%", canary_percent);
546 Ok(())
547 }
548 _ => Err(MlError::InvalidConfig(
549 "Not in canary deployment mode".to_string(),
550 )),
551 }
552 }
553
554 pub fn promote_canary(&mut self) -> Result<()> {
559 let routing = self
560 .routing
561 .read()
562 .map_err(|_| MlError::InvalidConfig("Failed to acquire routing lock".to_string()))?;
563
564 if let RoutingStrategy::Canary { canary, .. } = &*routing {
565 let canary_version = canary.clone();
566 drop(routing); self.deploy_replace(&canary_version)?;
568 info!("Canary promoted to stable");
569 Ok(())
570 } else {
571 Err(MlError::InvalidConfig(
572 "Not in canary deployment mode".to_string(),
573 ))
574 }
575 }
576}
577
578#[derive(Debug, Clone, Copy, PartialEq, Eq)]
580pub enum HealthStatus {
581 Healthy,
583 Degraded,
585 Unhealthy,
587 Unknown,
589}
590
591#[derive(Debug, Clone)]
593struct MemoryInfo {
594 total_mb: u64,
596 used_mb: u64,
598 available_mb: u64,
600 usage_percent: f32,
602}
603
604#[cfg(test)]
605mod tests {
606 use super::*;
607
608 #[test]
609 fn test_server_config_default() {
610 let config = ServerConfig::default();
611 assert_eq!(config.max_concurrent, 100);
612 assert_eq!(config.timeout_ms, 30000);
613 assert!(config.enable_queue);
614 }
615
616 #[test]
617 fn test_deployment_strategy_variants() {
618 let strategies = vec![
619 DeploymentStrategy::Replace,
620 DeploymentStrategy::BlueGreen,
621 DeploymentStrategy::Canary {
622 initial_percent: 10,
623 step_percent: 10,
624 },
625 DeploymentStrategy::ABTest { split_percent: 50 },
626 DeploymentStrategy::Shadow,
627 ];
628
629 for strategy in strategies {
630 let _ = format!("{:?}", strategy);
632 }
633 }
634
635 #[test]
636 fn test_model_server_creation() {
637 let config = ServerConfig::default();
638 let server = ModelServer::new(config);
639 assert_eq!(server.active_version(), "");
640 }
641
642 #[test]
643 fn test_health_status() {
644 assert_eq!(HealthStatus::Healthy, HealthStatus::Healthy);
645 assert_ne!(HealthStatus::Healthy, HealthStatus::Degraded);
646 }
647
648 #[test]
649 fn test_memory_pressure_status_thresholds() {
650 assert_eq!(ModelServer::memory_pressure_status(50.0), None);
652 assert_eq!(ModelServer::memory_pressure_status(90.0), None);
653 assert_eq!(
655 ModelServer::memory_pressure_status(92.0),
656 Some(HealthStatus::Degraded)
657 );
658 assert_eq!(
660 ModelServer::memory_pressure_status(97.0),
661 Some(HealthStatus::Unhealthy)
662 );
663 }
664
665 #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
666 #[test]
667 fn test_get_memory_usage_is_live_not_constant() {
668 let info = ModelServer::get_memory_usage().expect("memory usage query");
671 assert!(info.total_mb > 0, "total memory should be positive");
672 assert!(
673 info.usage_percent >= 0.0 && info.usage_percent <= 100.0,
674 "usage_percent out of range: {}",
675 info.usage_percent
676 );
677 assert!(info.available_mb <= info.total_mb);
678 }
679
680 #[test]
681 fn test_deploy_shadow_records_state() {
682 use std::io::Write;
683
684 let dir = std::env::temp_dir();
686 let stable_path = dir.join("oxigdal_ml_shadow_stable.onnx");
687 let shadow_path = dir.join("oxigdal_ml_shadow_new.onnx");
688 for p in [&stable_path, &shadow_path] {
689 let mut f = std::fs::File::create(p).expect("create temp model file");
690 f.write_all(b"onnx").expect("write temp model");
691 }
692
693 let mut server = ModelServer::new(ServerConfig::default());
694 server
695 .register_version("v1", stable_path.clone(), HashMap::new())
696 .expect("register v1");
697 server
698 .register_version("v2", shadow_path.clone(), HashMap::new())
699 .expect("register v2");
700
701 server
703 .deploy("v1", DeploymentStrategy::Replace)
704 .expect("deploy v1");
705 assert_eq!(server.active_version(), "v1");
706
707 server
710 .deploy("v2", DeploymentStrategy::Shadow)
711 .expect("deploy v2 shadow");
712 assert_eq!(
713 server.active_version(),
714 "v1",
715 "shadow must not become active"
716 );
717
718 let routing = server.routing.read().expect("read routing");
719 match &*routing {
720 RoutingStrategy::Shadow { stable, shadow } => {
721 assert_eq!(stable, "v1");
722 assert_eq!(shadow, "v2");
723 }
724 other => panic!("expected Shadow routing, got {:?}", other),
725 }
726 drop(routing);
727
728 let _ = std::fs::remove_file(stable_path);
729 let _ = std::fs::remove_file(shadow_path);
730 }
731
732 #[test]
733 fn test_version_metrics() {
734 let metrics = VersionMetrics {
735 requests: 1000,
736 avg_latency_ms: 50.0,
737 success_rate: 0.99,
738 avg_cpu_usage: 45.0,
739 avg_memory_mb: 512.0,
740 };
741
742 assert_eq!(metrics.requests, 1000);
743 assert!((metrics.success_rate - 0.99).abs() < 0.01);
744 }
745}