Skip to main content

oximedia_distributed/
discovery.rs

1//! Worker discovery and health monitoring.
2//!
3//! This module provides:
4//! - Worker discovery via mDNS, etcd, Consul, or static configuration
5//! - Health monitoring and heartbeat tracking
6//! - Automatic worker registration
7//! - Capacity tracking and load balancing
8//! - Geographic distribution awareness
9
10#![allow(dead_code)]
11
12use crate::{DiscoveryMethod, DistributedError, Result};
13use dashmap::DashMap;
14use std::collections::HashMap;
15use std::net::SocketAddr;
16use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
17use std::sync::Arc;
18use std::time::{Duration, SystemTime};
19use tokio::sync::RwLock;
20use tracing::{debug, error, info, warn};
21
22/// Worker discovery service
23pub struct DiscoveryService {
24    /// Discovery method
25    method: DiscoveryMethod,
26
27    /// Discovered workers
28    workers: Arc<DashMap<String, WorkerEndpoint>>,
29
30    /// Service configuration
31    config: DiscoveryConfig,
32
33    /// Health check state
34    health_checker: Arc<HealthChecker>,
35
36    /// Running flag
37    running: Arc<AtomicBool>,
38}
39
40/// Discovery service configuration
41#[derive(Debug, Clone)]
42pub struct DiscoveryConfig {
43    /// Service name for discovery
44    pub service_name: String,
45
46    /// Service port
47    pub service_port: u16,
48
49    /// Discovery interval
50    pub discovery_interval: Duration,
51
52    /// Health check interval
53    pub health_check_interval: Duration,
54
55    /// Worker timeout
56    pub worker_timeout: Duration,
57
58    /// etcd endpoints (for etcd discovery)
59    pub etcd_endpoints: Vec<String>,
60
61    /// Consul address (for Consul discovery)
62    pub consul_address: String,
63
64    /// Static worker addresses
65    pub static_workers: Vec<String>,
66}
67
68impl Default for DiscoveryConfig {
69    fn default() -> Self {
70        Self {
71            service_name: "oximedia-worker".to_string(),
72            service_port: 50052,
73            discovery_interval: Duration::from_secs(30),
74            health_check_interval: Duration::from_secs(10),
75            worker_timeout: Duration::from_secs(90),
76            etcd_endpoints: vec!["http://127.0.0.1:2379".to_string()],
77            consul_address: "http://127.0.0.1:8500".to_string(),
78            static_workers: Vec::new(),
79        }
80    }
81}
82
83/// Worker endpoint information
84#[derive(Debug, Clone)]
85pub struct WorkerEndpoint {
86    /// Worker ID
87    pub worker_id: String,
88
89    /// Worker address
90    pub address: SocketAddr,
91
92    /// Hostname
93    pub hostname: String,
94
95    /// Capabilities
96    pub capabilities: WorkerCapabilities,
97
98    /// Health status
99    pub health: HealthStatus,
100
101    /// Last seen timestamp
102    pub last_seen: SystemTime,
103
104    /// Geographic location (optional)
105    pub location: Option<GeoLocation>,
106
107    /// Metadata
108    pub metadata: HashMap<String, String>,
109}
110
111/// Worker capabilities
112#[derive(Debug, Clone)]
113pub struct WorkerCapabilities {
114    /// CPU cores
115    pub cpu_cores: u32,
116
117    /// Memory in bytes
118    pub memory_bytes: u64,
119
120    /// GPU devices
121    pub gpu_devices: Vec<String>,
122
123    /// Supported codecs
124    pub codecs: Vec<String>,
125
126    /// Maximum concurrent jobs
127    pub max_jobs: u32,
128
129    /// Performance score (relative)
130    pub performance_score: f32,
131}
132
133impl Default for WorkerCapabilities {
134    fn default() -> Self {
135        Self {
136            cpu_cores: 1,
137            memory_bytes: 1_073_741_824,
138            gpu_devices: Vec::new(),
139            codecs: vec!["h264".to_string()],
140            max_jobs: 2,
141            performance_score: 1.0,
142        }
143    }
144}
145
146/// Health status
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum HealthStatus {
149    Healthy,
150    Degraded,
151    Unhealthy,
152    Unknown,
153}
154
155/// Geographic location
156#[derive(Debug, Clone)]
157pub struct GeoLocation {
158    /// Region (e.g., "us-west-2")
159    pub region: String,
160
161    /// Availability zone
162    pub zone: Option<String>,
163
164    /// Data center
165    pub datacenter: Option<String>,
166
167    /// Latitude
168    pub latitude: Option<f64>,
169
170    /// Longitude
171    pub longitude: Option<f64>,
172}
173
174/// Health checker
175pub struct HealthChecker {
176    /// Health check results
177    health_results: Arc<DashMap<String, HealthCheckResult>>,
178
179    /// Statistics
180    stats: HealthStats,
181}
182
183/// Health check result
184#[derive(Debug, Clone)]
185struct HealthCheckResult {
186    worker_id: String,
187    status: HealthStatus,
188    latency: Duration,
189    last_check: SystemTime,
190    consecutive_failures: u32,
191}
192
193/// Health statistics
194#[derive(Debug, Default)]
195struct HealthStats {
196    total_checks: AtomicU64,
197    successful_checks: AtomicU64,
198    failed_checks: AtomicU64,
199}
200
201impl DiscoveryService {
202    /// Create a new discovery service
203    #[must_use]
204    pub fn new(method: DiscoveryMethod, config: DiscoveryConfig) -> Self {
205        Self {
206            method,
207            workers: Arc::new(DashMap::new()),
208            config,
209            health_checker: Arc::new(HealthChecker::new()),
210            running: Arc::new(AtomicBool::new(false)),
211        }
212    }
213
214    /// Start the discovery service
215    pub async fn start(&self) -> Result<()> {
216        info!("Starting discovery service with method: {:?}", self.method);
217        self.running.store(true, Ordering::Relaxed);
218
219        // Start discovery loop
220        let service = self.clone_refs();
221        tokio::spawn(async move {
222            service.discovery_loop().await;
223        });
224
225        // Start health check loop
226        let service = self.clone_refs();
227        tokio::spawn(async move {
228            service.health_check_loop().await;
229        });
230
231        Ok(())
232    }
233
234    fn clone_refs(&self) -> Self {
235        Self {
236            method: self.method,
237            workers: self.workers.clone(),
238            config: self.config.clone(),
239            health_checker: self.health_checker.clone(),
240            running: self.running.clone(),
241        }
242    }
243
244    /// Discovery loop
245    async fn discovery_loop(&self) {
246        let mut interval = tokio::time::interval(self.config.discovery_interval);
247
248        while self.running.load(Ordering::Relaxed) {
249            interval.tick().await;
250
251            if let Err(e) = self.discover_workers().await {
252                error!("Worker discovery failed: {}", e);
253            }
254        }
255    }
256
257    /// Discover workers based on configured method
258    async fn discover_workers(&self) -> Result<()> {
259        match self.method {
260            DiscoveryMethod::Static => self.discover_static().await,
261            DiscoveryMethod::MDNS => self.discover_mdns().await,
262            DiscoveryMethod::Etcd => self.discover_etcd().await,
263            DiscoveryMethod::Consul => self.discover_consul().await,
264        }
265    }
266
267    /// Static worker discovery
268    async fn discover_static(&self) -> Result<()> {
269        debug!("Discovering static workers");
270
271        for addr_str in &self.config.static_workers {
272            let addr: SocketAddr = addr_str
273                .parse()
274                .map_err(|e| DistributedError::Discovery(format!("Invalid address: {e}")))?;
275
276            let worker_id = format!("static-{addr}");
277
278            let endpoint = WorkerEndpoint {
279                worker_id: worker_id.clone(),
280                address: addr,
281                hostname: addr.ip().to_string(),
282                capabilities: WorkerCapabilities::default(),
283                health: HealthStatus::Unknown,
284                last_seen: SystemTime::now(),
285                location: None,
286                metadata: HashMap::new(),
287            };
288
289            self.workers.insert(worker_id, endpoint);
290        }
291
292        info!(
293            "Discovered {} static workers",
294            self.config.static_workers.len()
295        );
296        Ok(())
297    }
298
299    /// mDNS-based discovery
300    async fn discover_mdns(&self) -> Result<()> {
301        debug!("Discovering workers via mDNS");
302
303        // In production, would use mdns crate to discover services
304        // For now, simulate discovery
305        info!("mDNS discovery completed");
306        Ok(())
307    }
308
309    /// etcd-based discovery
310    async fn discover_etcd(&self) -> Result<()> {
311        debug!("Discovering workers via etcd");
312
313        // In production, would query etcd for worker registrations
314        // For now, simulate discovery
315        info!("etcd discovery completed");
316        Ok(())
317    }
318
319    /// Consul-based discovery
320    async fn discover_consul(&self) -> Result<()> {
321        debug!("Discovering workers via Consul");
322
323        // In production, would query Consul service catalog
324        // For now, simulate discovery
325        info!("Consul discovery completed");
326        Ok(())
327    }
328
329    /// Health check loop
330    async fn health_check_loop(&self) {
331        let mut interval = tokio::time::interval(self.config.health_check_interval);
332
333        while self.running.load(Ordering::Relaxed) {
334            interval.tick().await;
335
336            self.check_worker_health().await;
337        }
338    }
339
340    /// Check health of all workers
341    async fn check_worker_health(&self) {
342        let workers: Vec<_> = self
343            .workers
344            .iter()
345            .map(|e| (e.key().clone(), e.value().clone()))
346            .collect();
347
348        for (worker_id, endpoint) in workers {
349            let result = self.health_checker.check_worker(&endpoint).await;
350
351            // Update worker health status
352            if let Some(mut worker) = self.workers.get_mut(&worker_id) {
353                worker.health = result.status;
354                worker.last_seen = SystemTime::now();
355            }
356
357            // Remove unhealthy workers after timeout
358            if result.status == HealthStatus::Unhealthy && result.consecutive_failures > 3 {
359                warn!("Removing unhealthy worker: {}", worker_id);
360                self.workers.remove(&worker_id);
361            }
362        }
363    }
364
365    /// Register a worker manually
366    pub fn register_worker(&self, endpoint: WorkerEndpoint) -> Result<()> {
367        info!("Registering worker: {}", endpoint.worker_id);
368        self.workers.insert(endpoint.worker_id.clone(), endpoint);
369        Ok(())
370    }
371
372    /// Unregister a worker
373    pub fn unregister_worker(&self, worker_id: &str) -> Result<()> {
374        info!("Unregistering worker: {}", worker_id);
375        self.workers.remove(worker_id);
376        Ok(())
377    }
378
379    /// Get all discovered workers
380    #[must_use]
381    pub fn get_workers(&self) -> Vec<WorkerEndpoint> {
382        self.workers.iter().map(|e| e.value().clone()).collect()
383    }
384
385    /// Get healthy workers
386    #[must_use]
387    pub fn get_healthy_workers(&self) -> Vec<WorkerEndpoint> {
388        self.workers
389            .iter()
390            .filter(|e| e.value().health == HealthStatus::Healthy)
391            .map(|e| e.value().clone())
392            .collect()
393    }
394
395    /// Get worker by ID
396    #[must_use]
397    pub fn get_worker(&self, worker_id: &str) -> Option<WorkerEndpoint> {
398        self.workers.get(worker_id).map(|e| e.value().clone())
399    }
400
401    /// Find workers by capability
402    #[must_use]
403    pub fn find_workers_by_capability(&self, required_codec: &str) -> Vec<WorkerEndpoint> {
404        self.workers
405            .iter()
406            .filter(|e| {
407                e.value().health == HealthStatus::Healthy
408                    && e.value()
409                        .capabilities
410                        .codecs
411                        .iter()
412                        .any(|c| c == required_codec)
413            })
414            .map(|e| e.value().clone())
415            .collect()
416    }
417
418    /// Find workers by location
419    #[must_use]
420    pub fn find_workers_by_region(&self, region: &str) -> Vec<WorkerEndpoint> {
421        self.workers
422            .iter()
423            .filter(|e| {
424                e.value()
425                    .location
426                    .as_ref()
427                    .is_some_and(|l| l.region == region)
428            })
429            .map(|e| e.value().clone())
430            .collect()
431    }
432
433    /// Get capacity statistics
434    #[must_use]
435    pub fn get_capacity_stats(&self) -> CapacityStats {
436        let workers = self.get_healthy_workers();
437
438        let total_cpu: u32 = workers.iter().map(|w| w.capabilities.cpu_cores).sum();
439        let total_memory: u64 = workers.iter().map(|w| w.capabilities.memory_bytes).sum();
440        let total_gpus: usize = workers
441            .iter()
442            .map(|w| w.capabilities.gpu_devices.len())
443            .sum();
444        let total_job_slots: u32 = workers.iter().map(|w| w.capabilities.max_jobs).sum();
445
446        CapacityStats {
447            total_workers: workers.len(),
448            total_cpu_cores: total_cpu,
449            total_memory_bytes: total_memory,
450            total_gpu_devices: total_gpus,
451            total_job_slots,
452            average_performance: workers
453                .iter()
454                .map(|w| w.capabilities.performance_score)
455                .sum::<f32>()
456                / workers.len().max(1) as f32,
457        }
458    }
459
460    /// Stop the discovery service
461    pub async fn stop(&self) -> Result<()> {
462        info!("Stopping discovery service");
463        self.running.store(false, Ordering::Relaxed);
464        Ok(())
465    }
466}
467
468/// Capacity statistics
469#[derive(Debug, Clone)]
470pub struct CapacityStats {
471    pub total_workers: usize,
472    pub total_cpu_cores: u32,
473    pub total_memory_bytes: u64,
474    pub total_gpu_devices: usize,
475    pub total_job_slots: u32,
476    pub average_performance: f32,
477}
478
479impl HealthChecker {
480    /// Create a new health checker
481    fn new() -> Self {
482        Self {
483            health_results: Arc::new(DashMap::new()),
484            stats: HealthStats::default(),
485        }
486    }
487
488    /// Check worker health
489    async fn check_worker(&self, endpoint: &WorkerEndpoint) -> HealthCheckResult {
490        self.stats.total_checks.fetch_add(1, Ordering::Relaxed);
491
492        let start = SystemTime::now();
493
494        // Perform health check (simplified)
495        let status = self.perform_health_check(endpoint).await;
496
497        let latency = start.elapsed().unwrap_or(Duration::ZERO);
498
499        let mut result = HealthCheckResult {
500            worker_id: endpoint.worker_id.clone(),
501            status,
502            latency,
503            last_check: SystemTime::now(),
504            consecutive_failures: 0,
505        };
506
507        // Update consecutive failures
508        if let Some(prev) = self.health_results.get(&endpoint.worker_id) {
509            if status == HealthStatus::Unhealthy {
510                result.consecutive_failures = prev.consecutive_failures + 1;
511            }
512        } else if status == HealthStatus::Unhealthy {
513            result.consecutive_failures = 1;
514        }
515
516        // Update statistics
517        if status == HealthStatus::Healthy {
518            self.stats.successful_checks.fetch_add(1, Ordering::Relaxed);
519        } else {
520            self.stats.failed_checks.fetch_add(1, Ordering::Relaxed);
521        }
522
523        self.health_results
524            .insert(endpoint.worker_id.clone(), result.clone());
525
526        result
527    }
528
529    /// Perform actual health check
530    async fn perform_health_check(&self, _endpoint: &WorkerEndpoint) -> HealthStatus {
531        // In production, would send HTTP/gRPC health check request
532        // For now, simulate based on last_seen time
533        HealthStatus::Healthy
534    }
535
536    /// Get health check statistics
537    pub fn statistics(&self) -> HealthCheckStats {
538        HealthCheckStats {
539            total_checks: self.stats.total_checks.load(Ordering::Relaxed),
540            successful_checks: self.stats.successful_checks.load(Ordering::Relaxed),
541            failed_checks: self.stats.failed_checks.load(Ordering::Relaxed),
542        }
543    }
544}
545
546/// Health check statistics
547#[derive(Debug, Clone)]
548pub struct HealthCheckStats {
549    pub total_checks: u64,
550    pub successful_checks: u64,
551    pub failed_checks: u64,
552}
553
554impl HealthCheckStats {
555    /// Get success rate
556    #[must_use]
557    pub fn success_rate(&self) -> f64 {
558        if self.total_checks == 0 {
559            return 0.0;
560        }
561        self.successful_checks as f64 / self.total_checks as f64
562    }
563}
564
565/// Worker registry for persistent storage
566pub struct WorkerRegistry {
567    /// Registered workers
568    workers: Arc<RwLock<HashMap<String, RegisteredWorker>>>,
569
570    /// Registry backend
571    backend: RegistryBackend,
572}
573
574/// Registered worker with persistence
575#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
576pub struct RegisteredWorker {
577    worker_id: String,
578    address: String,
579    hostname: String,
580    registered_at: u64,
581    last_heartbeat: u64,
582    capabilities: serde_json::Value,
583    metadata: HashMap<String, String>,
584}
585
586/// Registry backend
587#[derive(Debug, Clone, Copy)]
588pub enum RegistryBackend {
589    Memory,
590    Etcd,
591    Consul,
592}
593
594impl WorkerRegistry {
595    /// Create a new worker registry
596    #[must_use]
597    pub fn new(backend: RegistryBackend) -> Self {
598        Self {
599            workers: Arc::new(RwLock::new(HashMap::new())),
600            backend,
601        }
602    }
603
604    /// Register a worker
605    pub async fn register(&self, worker_id: String, endpoint: WorkerEndpoint) -> Result<()> {
606        info!("Registering worker in registry: {}", worker_id);
607
608        let unix_now = SystemTime::now()
609            .duration_since(SystemTime::UNIX_EPOCH)
610            .unwrap_or(Duration::ZERO)
611            .as_secs();
612
613        let worker = RegisteredWorker {
614            worker_id: worker_id.clone(),
615            address: endpoint.address.to_string(),
616            hostname: endpoint.hostname,
617            registered_at: unix_now,
618            last_heartbeat: unix_now,
619            capabilities: serde_json::json!({}),
620            metadata: endpoint.metadata,
621        };
622
623        let mut workers = self.workers.write().await;
624        workers.insert(worker_id.clone(), worker.clone());
625
626        // Persist to backend
627        self.persist_worker(&worker).await?;
628
629        Ok(())
630    }
631
632    /// Unregister a worker
633    pub async fn unregister(&self, worker_id: &str) -> Result<()> {
634        info!("Unregistering worker from registry: {}", worker_id);
635
636        let mut workers = self.workers.write().await;
637        workers.remove(worker_id);
638
639        // Remove from backend
640        self.remove_worker(worker_id).await?;
641
642        Ok(())
643    }
644
645    /// Update worker heartbeat
646    pub async fn update_heartbeat(&self, worker_id: &str) -> Result<()> {
647        let mut workers = self.workers.write().await;
648
649        if let Some(worker) = workers.get_mut(worker_id) {
650            worker.last_heartbeat = SystemTime::now()
651                .duration_since(SystemTime::UNIX_EPOCH)
652                .unwrap_or(Duration::ZERO)
653                .as_secs();
654        }
655
656        Ok(())
657    }
658
659    /// Persist worker to backend
660    async fn persist_worker(&self, _worker: &RegisteredWorker) -> Result<()> {
661        match self.backend {
662            RegistryBackend::Memory => Ok(()),
663            RegistryBackend::Etcd => {
664                // In production, write to etcd
665                Ok(())
666            }
667            RegistryBackend::Consul => {
668                // In production, write to Consul
669                Ok(())
670            }
671        }
672    }
673
674    /// Remove worker from backend
675    async fn remove_worker(&self, _worker_id: &str) -> Result<()> {
676        match self.backend {
677            RegistryBackend::Memory => Ok(()),
678            RegistryBackend::Etcd => {
679                // In production, delete from etcd
680                Ok(())
681            }
682            RegistryBackend::Consul => {
683                // In production, delete from Consul
684                Ok(())
685            }
686        }
687    }
688
689    /// Get all workers
690    pub async fn get_all(&self) -> Vec<RegisteredWorker> {
691        let workers = self.workers.read().await;
692        workers.values().cloned().collect()
693    }
694}
695
696#[cfg(test)]
697mod tests {
698    use super::*;
699    use std::net::{IpAddr, Ipv4Addr};
700
701    #[test]
702    fn test_discovery_config() {
703        let config = DiscoveryConfig::default();
704        assert_eq!(config.service_name, "oximedia-worker");
705        assert_eq!(config.service_port, 50052);
706    }
707
708    #[test]
709    fn test_worker_capabilities() {
710        let caps = WorkerCapabilities::default();
711        assert_eq!(caps.cpu_cores, 1);
712        assert_eq!(caps.max_jobs, 2);
713        assert!(!caps.codecs.is_empty());
714    }
715
716    #[test]
717    fn test_health_status() {
718        assert_eq!(HealthStatus::Healthy, HealthStatus::Healthy);
719        assert_ne!(HealthStatus::Healthy, HealthStatus::Unhealthy);
720    }
721
722    #[test]
723    fn test_discovery_service_creation() {
724        let config = DiscoveryConfig::default();
725        let service = DiscoveryService::new(DiscoveryMethod::Static, config);
726        assert_eq!(service.method, DiscoveryMethod::Static);
727        assert_eq!(service.workers.len(), 0);
728    }
729
730    #[test]
731    fn test_worker_registration() {
732        let config = DiscoveryConfig::default();
733        let service = DiscoveryService::new(DiscoveryMethod::Static, config);
734
735        let endpoint = WorkerEndpoint {
736            worker_id: "test-worker".to_string(),
737            address: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 50052),
738            hostname: "localhost".to_string(),
739            capabilities: WorkerCapabilities::default(),
740            health: HealthStatus::Healthy,
741            last_seen: SystemTime::now(),
742            location: None,
743            metadata: HashMap::new(),
744        };
745
746        assert!(service.register_worker(endpoint).is_ok());
747        assert_eq!(service.workers.len(), 1);
748    }
749
750    #[test]
751    fn test_capacity_stats() {
752        let config = DiscoveryConfig::default();
753        let service = DiscoveryService::new(DiscoveryMethod::Static, config);
754
755        let stats = service.get_capacity_stats();
756        assert_eq!(stats.total_workers, 0);
757        assert_eq!(stats.total_cpu_cores, 0);
758    }
759
760    #[test]
761    fn test_health_check_stats() {
762        let stats = HealthCheckStats {
763            total_checks: 100,
764            successful_checks: 95,
765            failed_checks: 5,
766        };
767
768        assert_eq!(stats.success_rate(), 0.95);
769    }
770
771    #[test]
772    fn test_geo_location() {
773        let location = GeoLocation {
774            region: "us-west-2".to_string(),
775            zone: Some("us-west-2a".to_string()),
776            datacenter: None,
777            latitude: Some(37.7749),
778            longitude: Some(-122.4194),
779        };
780
781        assert_eq!(location.region, "us-west-2");
782        assert!(location.latitude.is_some());
783    }
784}