Skip to main content

oxigdal_ml/
serving.rs

1//! Model serving and deployment utilities
2//!
3//! This module provides production-ready model serving capabilities including
4//! model versioning, A/B testing, canary deployments, and load balancing.
5
6use crate::error::{MlError, Result};
7// use crate::models::Model;
8use std::collections::HashMap;
9use std::path::PathBuf;
10use std::sync::{Arc, RwLock};
11use tracing::{debug, info};
12
13/// Model version information
14#[derive(Debug, Clone)]
15pub struct ModelVersion {
16    /// Version identifier
17    pub version: String,
18    /// Model file path
19    pub path: PathBuf,
20    /// Deployment timestamp
21    pub deployed_at: std::time::SystemTime,
22    /// Model metadata
23    pub metadata: HashMap<String, String>,
24    /// Performance metrics
25    pub metrics: VersionMetrics,
26}
27
28/// Performance metrics for a model version
29#[derive(Debug, Clone, Default)]
30pub struct VersionMetrics {
31    /// Total requests served
32    pub requests: u64,
33    /// Average latency in milliseconds
34    pub avg_latency_ms: f32,
35    /// Success rate (0.0 to 1.0)
36    pub success_rate: f32,
37    /// Average CPU usage percentage
38    pub avg_cpu_usage: f32,
39    /// Average memory usage in MB
40    pub avg_memory_mb: f32,
41}
42
43/// Deployment strategy
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum DeploymentStrategy {
46    /// Direct replacement
47    Replace,
48    /// Blue-green deployment
49    BlueGreen,
50    /// Canary deployment with gradual rollout
51    Canary {
52        /// Initial traffic percentage (0-100)
53        initial_percent: u8,
54        /// Step size for traffic increase
55        step_percent: u8,
56    },
57    /// A/B testing
58    ABTest {
59        /// Traffic split percentage for new version
60        split_percent: u8,
61    },
62    /// Shadow mode: the version is recorded as deployed for observation while
63    /// the stable version keeps serving all user-facing traffic. The shadow
64    /// version never returns user-facing results.
65    Shadow,
66}
67
68/// Model server configuration
69#[derive(Debug, Clone)]
70pub struct ServerConfig {
71    /// Maximum concurrent requests
72    pub max_concurrent: usize,
73    /// Request timeout in milliseconds
74    pub timeout_ms: u64,
75    /// Enable request queuing
76    pub enable_queue: bool,
77    /// Queue size limit
78    pub queue_size: usize,
79    /// Enable health checks
80    pub health_check: bool,
81    /// Health check interval in seconds
82    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
98/// Model server for production deployment
99pub 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/// Traffic routing strategy
107#[derive(Debug, Clone)]
108enum RoutingStrategy {
109    /// Single version
110    Single {
111        /// Version ID
112        version: String,
113    },
114    /// Weighted routing
115    Weighted {
116        /// Version weights (version -> percentage)
117        weights: HashMap<String, u8>,
118    },
119    /// Canary routing
120    Canary {
121        /// Stable version
122        stable: String,
123        /// Canary version
124        canary: String,
125        /// Canary traffic percentage
126        canary_percent: u8,
127    },
128    /// Shadow deployment: `stable` serves all user-facing traffic while `shadow`
129    /// is recorded as deployed for observation only. This records deployment
130    /// intent/state; it does not itself mirror live requests (this module has no
131    /// request-serving path).
132    Shadow {
133        /// User-facing stable version
134        stable: String,
135        /// Shadow version (never serves user-facing results)
136        shadow: String,
137    },
138}
139
140impl ModelServer {
141    /// Creates a new model server
142    #[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    /// Registers a new model version
156    ///
157    /// # Errors
158    /// Returns an error if version registration fails
159    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    /// Deploys a model version using the specified strategy
190    ///
191    /// # Errors
192    /// Returns an error if deployment fails
193    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        // Verify version exists
200        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    /// Rolls back to a previous version
228    ///
229    /// # Errors
230    /// Returns an error if rollback fails
231    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    /// Returns metrics for all versions
237    #[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    /// Returns the active version
251    #[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    /// Performs health check on active version
260    #[must_use]
261    pub fn health_check(&self) -> HealthStatus {
262        if !self.config.health_check {
263            return HealthStatus::Unknown;
264        }
265
266        // Check if any model is loaded
267        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        // Check if active version exists
274        let active_version = self.active_version();
275        if active_version.is_empty() {
276            return HealthStatus::Degraded;
277        }
278
279        // Verify active version is in versions map
280        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        // Check live memory pressure. The most severe threshold must be tested
291        // first so that > 95% reports Unhealthy rather than being shadowed by the
292        // > 90% Degraded branch.
293        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    /// Maps a memory-usage percentage to a degraded/unhealthy health status.
303    ///
304    /// Returns `None` when usage is within safe limits. Extracted as a pure
305    /// function so the degradation thresholds are unit-testable without
306    /// fabricating system memory.
307    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    /// Gets current memory usage information
318    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            // Default fallback for unsupported platforms
337            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        // Query live memory via the Pure-Rust `sysinfo` crate (already a
394        // dependency), which reads real system statistics on macOS.
395        Self::get_memory_usage_sysinfo()
396    }
397
398    #[cfg(target_os = "windows")]
399    fn get_memory_usage_windows() -> Result<MemoryInfo> {
400        // Query live memory via the Pure-Rust `sysinfo` crate (already a
401        // dependency), which wraps GlobalMemoryStatusEx on Windows.
402        Self::get_memory_usage_sysinfo()
403    }
404
405    /// Reads live memory statistics via the Pure-Rust `sysinfo` crate.
406    ///
407    /// `sysinfo` reports memory in bytes; values are converted to MB. This is
408    /// used on platforms without a bespoke reader (macOS, Windows) so that
409    /// `health_check` observes real memory pressure instead of a fabricated
410    /// constant.
411    #[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        // sysinfo returns bytes (>= 0.30).
419        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    // Private deployment methods
443
444    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        // In blue-green, we prepare the new version first
465        // Then switch traffic atomically
466        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        // Shadow mode records the new version as deployed-for-observation while
514        // the current active (stable) version keeps serving all user-facing
515        // traffic. `active_version` is deliberately NOT changed so the shadow
516        // version never becomes user-facing. This records deployment state so it
517        // is introspectable and consistent with the other deploy_* methods; it
518        // does not mirror live requests (no request-serving path exists here).
519        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    /// Increases canary traffic percentage
533    ///
534    /// # Errors
535    /// Returns an error if not in canary mode
536    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    /// Promotes canary to stable
555    ///
556    /// # Errors
557    /// Returns an error if not in canary mode
558    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); // Release read lock
567            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/// Health status
579#[derive(Debug, Clone, Copy, PartialEq, Eq)]
580pub enum HealthStatus {
581    /// Service is healthy
582    Healthy,
583    /// Service is degraded but operational
584    Degraded,
585    /// Service is unhealthy
586    Unhealthy,
587    /// Health status unknown
588    Unknown,
589}
590
591/// Memory usage information
592#[derive(Debug, Clone)]
593struct MemoryInfo {
594    /// Total memory in MB
595    total_mb: u64,
596    /// Used memory in MB
597    used_mb: u64,
598    /// Available memory in MB
599    available_mb: u64,
600    /// Memory usage percentage
601    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            // Just verify they can be created
631            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        // Below 90%: healthy (None).
651        assert_eq!(ModelServer::memory_pressure_status(50.0), None);
652        assert_eq!(ModelServer::memory_pressure_status(90.0), None);
653        // Between 90% and 95%: degraded.
654        assert_eq!(
655            ModelServer::memory_pressure_status(92.0),
656            Some(HealthStatus::Degraded)
657        );
658        // Above 95%: unhealthy (must NOT be shadowed by the degraded branch).
659        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        // On supported platforms, memory stats must be real (non-zero total,
669        // usage within 0..=100), not the old fabricated 50% / 16384 MB constant.
670        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        // register_version requires the model file to exist on disk.
685        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        // v1 becomes the active/stable version.
702        server
703            .deploy("v1", DeploymentStrategy::Replace)
704            .expect("deploy v1");
705        assert_eq!(server.active_version(), "v1");
706
707        // Deploy v2 in shadow mode: routing state must reflect it, and the active
708        // (user-facing) version must stay v1.
709        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}