Skip to main content

toolkit/runtime/
gear_manager.rs

1//! Gear Manager - tracks and manages all live gear instances in the runtime
2
3use dashmap::DashMap;
4use std::collections::HashMap;
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7use uuid::Uuid;
8
9/// Represents an endpoint where a gear instance can be reached
10#[derive(Clone, Debug, PartialEq, Eq, Hash)]
11pub struct Endpoint {
12    pub uri: String,
13}
14
15/// Typed view of an endpoint for parsing and matching
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub enum EndpointKind {
18    /// TCP endpoint with resolved socket address
19    Tcp(std::net::SocketAddr),
20    /// Unix domain socket with file path
21    Uds(std::path::PathBuf),
22    /// Other/unparsed endpoint URI
23    Other(String),
24}
25
26impl Endpoint {
27    pub fn from_uri<S: Into<String>>(s: S) -> Self {
28        Self { uri: s.into() }
29    }
30
31    pub fn uds(path: impl AsRef<std::path::Path>) -> Self {
32        Self {
33            uri: format!("unix://{}", path.as_ref().display()),
34        }
35    }
36
37    #[must_use]
38    pub fn http(host: &str, port: u16) -> Self {
39        Self {
40            uri: format!("http://{host}:{port}"),
41        }
42    }
43
44    #[must_use]
45    pub fn https(host: &str, port: u16) -> Self {
46        Self {
47            uri: format!("https://{host}:{port}"),
48        }
49    }
50
51    /// Parse the endpoint URI into a typed view
52    #[must_use]
53    pub fn kind(&self) -> EndpointKind {
54        if let Some(rest) = self.uri.strip_prefix("unix://") {
55            return EndpointKind::Uds(std::path::PathBuf::from(rest));
56        }
57        if let Some(rest) = self.uri.strip_prefix("http://")
58            && let Ok(addr) = rest.parse::<std::net::SocketAddr>()
59        {
60            return EndpointKind::Tcp(addr);
61        }
62        if let Some(rest) = self.uri.strip_prefix("https://")
63            && let Ok(addr) = rest.parse::<std::net::SocketAddr>()
64        {
65            return EndpointKind::Tcp(addr);
66        }
67        EndpointKind::Other(self.uri.clone())
68    }
69}
70
71#[derive(Clone, Copy, Debug, PartialEq, Eq)]
72pub enum InstanceState {
73    Registered,
74    Ready,
75    Healthy,
76    Quarantined,
77    Draining,
78}
79
80/// Runtime state of an instance (guarded by `RwLock` for safe mutation)
81#[derive(Clone, Debug)]
82pub struct InstanceRuntimeState {
83    pub last_heartbeat: Instant,
84    pub state: InstanceState,
85}
86
87/// Represents a single instance of a gear
88#[derive(Debug)]
89#[must_use]
90pub struct GearInstance {
91    pub gear: String,
92    pub instance_id: Uuid,
93    pub control: Option<Endpoint>,
94    pub grpc_services: HashMap<String, Endpoint>,
95    pub version: Option<String>,
96    pub rest_endpoint: Option<Endpoint>,
97    pub openapi_spec: Option<String>,
98    inner: Arc<parking_lot::RwLock<InstanceRuntimeState>>,
99}
100
101impl Clone for GearInstance {
102    fn clone(&self) -> Self {
103        Self {
104            gear: self.gear.clone(),
105            instance_id: self.instance_id,
106            control: self.control.clone(),
107            grpc_services: self.grpc_services.clone(),
108            version: self.version.clone(),
109            rest_endpoint: self.rest_endpoint.clone(),
110            openapi_spec: self.openapi_spec.clone(),
111            inner: Arc::clone(&self.inner),
112        }
113    }
114}
115
116impl GearInstance {
117    /// Build a new instance record using `other`'s metadata but preserving
118    /// `self`'s `inner` runtime-state lock. This makes re-registration atomic:
119    /// concurrent `update_heartbeat` calls continue to write to the same state
120    /// object instead of racing with a copied/swapped `InstanceRuntimeState`.
121    fn with_metadata_of(&self, other: &GearInstance) -> GearInstance {
122        GearInstance {
123            gear: other.gear.clone(),
124            instance_id: other.instance_id,
125            control: other.control.clone(),
126            grpc_services: other.grpc_services.clone(),
127            version: other.version.clone(),
128            rest_endpoint: other.rest_endpoint.clone(),
129            openapi_spec: other.openapi_spec.clone(),
130            inner: Arc::clone(&self.inner),
131        }
132    }
133}
134
135impl GearInstance {
136    pub fn new(gear: impl Into<String>, instance_id: Uuid) -> Self {
137        Self {
138            gear: gear.into(),
139            instance_id,
140            control: None,
141            grpc_services: HashMap::new(),
142            version: None,
143            rest_endpoint: None,
144            openapi_spec: None,
145            inner: Arc::new(parking_lot::RwLock::new(InstanceRuntimeState {
146                last_heartbeat: Instant::now(),
147                state: InstanceState::Registered,
148            })),
149        }
150    }
151
152    pub fn with_control(mut self, ep: Endpoint) -> Self {
153        self.control = Some(ep);
154        self
155    }
156
157    pub fn with_version(mut self, v: impl Into<String>) -> Self {
158        self.version = Some(v.into());
159        self
160    }
161
162    pub fn with_grpc_service(mut self, name: impl Into<String>, ep: Endpoint) -> Self {
163        self.grpc_services.insert(name.into(), ep);
164        self
165    }
166
167    pub fn with_rest_endpoint(mut self, ep: Endpoint) -> Self {
168        self.rest_endpoint = Some(ep);
169        self
170    }
171
172    pub fn with_openapi_spec(mut self, spec: impl Into<String>) -> Self {
173        self.openapi_spec = Some(spec.into());
174        self
175    }
176
177    /// Get the current state of this instance
178    #[must_use]
179    pub fn state(&self) -> InstanceState {
180        self.inner.read().state
181    }
182
183    /// Get the last heartbeat timestamp
184    #[must_use]
185    pub fn last_heartbeat(&self) -> Instant {
186        self.inner.read().last_heartbeat
187    }
188}
189
190/// Central registry that tracks all running gear instances in the system.
191/// Provides discovery, health tracking, and round-robin load balancing.
192#[derive(Clone)]
193#[must_use]
194pub struct GearManager {
195    inner: DashMap<String, Vec<Arc<GearInstance>>>,
196    rr_counters: DashMap<String, usize>,
197    hb_ttl: Duration,
198    hb_grace: Duration,
199}
200
201impl std::fmt::Debug for GearManager {
202    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203        let gears: Vec<String> = self.inner.iter().map(|e| e.key().clone()).collect();
204        f.debug_struct("GearManager")
205            .field("instances_count", &self.inner.len())
206            .field("gears", &gears)
207            .field("heartbeat_ttl", &self.hb_ttl)
208            .field("heartbeat_grace", &self.hb_grace)
209            .finish_non_exhaustive()
210    }
211}
212
213impl GearManager {
214    pub fn new() -> Self {
215        Self {
216            inner: DashMap::new(),
217            rr_counters: DashMap::new(),
218            hb_ttl: Duration::from_secs(15),
219            hb_grace: Duration::from_secs(30),
220        }
221    }
222
223    pub fn with_heartbeat_policy(mut self, ttl: Duration, grace: Duration) -> Self {
224        self.hb_ttl = ttl;
225        self.hb_grace = grace;
226        self
227    }
228
229    /// Register or update a gear instance
230    ///
231    /// Re-registering an existing `instance_id` is an idempotent endpoint /
232    /// metadata refresh, NOT a liveness reset: the existing runtime state
233    /// (`last_heartbeat` + [`InstanceState`]) is carried over onto the new
234    /// record by preserving the same `Arc<RwLock<InstanceRuntimeState>>`.
235    /// Without this, a periodic self-heal re-registration (see
236    /// `oop_registration::presence_loop`) would knock a `Healthy` instance back
237    /// to `Registered`, dropping it out of gRPC round-robin until the next
238    /// heartbeat. It would also race with concurrent `update_heartbeat` calls.
239    pub fn register_instance(&self, instance: Arc<GearInstance>) {
240        let gear = instance.gear.clone();
241        let mut vec = self.inner.entry(gear).or_default();
242        // replace by instance_id if it already exists
243        if let Some(pos) = vec
244            .iter()
245            .position(|i| i.instance_id == instance.instance_id)
246        {
247            vec[pos] = Arc::new(vec[pos].with_metadata_of(&instance));
248        } else {
249            vec.push(instance);
250        }
251    }
252
253    /// Mark an instance as ready
254    pub fn mark_ready(&self, gear: &str, instance_id: Uuid) {
255        if let Some(mut vec) = self.inner.get_mut(gear)
256            && let Some(inst) = vec.iter_mut().find(|i| i.instance_id == instance_id)
257        {
258            let mut state = inst.inner.write();
259            state.state = InstanceState::Ready;
260        }
261    }
262
263    /// Update the heartbeat timestamp for an instance
264    pub fn update_heartbeat(&self, gear: &str, instance_id: Uuid, at: Instant) {
265        if let Some(mut vec) = self.inner.get_mut(gear)
266            && let Some(inst) = vec.iter_mut().find(|i| i.instance_id == instance_id)
267        {
268            let mut state = inst.inner.write();
269            state.last_heartbeat = at;
270            // Transition Registered -> Healthy on first heartbeat
271            if state.state == InstanceState::Registered {
272                state.state = InstanceState::Healthy;
273            }
274        }
275    }
276
277    /// Mark an instance as quarantined
278    pub fn mark_quarantined(&self, gear: &str, instance_id: Uuid) {
279        if let Some(mut vec) = self.inner.get_mut(gear)
280            && let Some(inst) = vec.iter_mut().find(|i| i.instance_id == instance_id)
281        {
282            inst.inner.write().state = InstanceState::Quarantined;
283        }
284    }
285
286    /// Mark an instance as draining (graceful shutdown in progress)
287    pub fn mark_draining(&self, gear: &str, instance_id: Uuid) {
288        if let Some(mut vec) = self.inner.get_mut(gear)
289            && let Some(inst) = vec.iter_mut().find(|i| i.instance_id == instance_id)
290        {
291            inst.inner.write().state = InstanceState::Draining;
292        }
293    }
294
295    /// Remove an instance from the directory
296    pub fn deregister(&self, gear: &str, instance_id: Uuid) {
297        let mut remove_gear = false;
298        {
299            if let Some(mut vec) = self.inner.get_mut(gear) {
300                let list = vec.value_mut();
301                list.retain(|inst| inst.instance_id != instance_id);
302                if list.is_empty() {
303                    remove_gear = true;
304                }
305            }
306        }
307
308        if remove_gear {
309            self.inner.remove(gear);
310            self.rr_counters.remove(gear);
311            self.rr_counters.remove(&format!("rest:{gear}"));
312        }
313    }
314
315    /// Get all instances of a specific gear
316    #[must_use]
317    pub fn instances_of(&self, gear: &str) -> Vec<Arc<GearInstance>> {
318        self.inner.get(gear).map(|v| v.clone()).unwrap_or_default()
319    }
320
321    /// Get all instances across all gears
322    #[must_use]
323    pub fn all_instances(&self) -> Vec<Arc<GearInstance>> {
324        self.inner
325            .iter()
326            .flat_map(|entry| entry.value().clone())
327            .collect()
328    }
329
330    /// Quarantine or evict stale instances based on heartbeat policy
331    pub fn evict_stale(&self, now: Instant) {
332        use InstanceState::{Draining, Quarantined};
333        let mut empty_gears = Vec::new();
334
335        for mut entry in self.inner.iter_mut() {
336            let gear = entry.key().clone();
337            let vec = entry.value_mut();
338            vec.retain(|inst| {
339                let state = inst.inner.read();
340                let age = now.saturating_duration_since(state.last_heartbeat);
341
342                // Quarantine instances that have exceeded TTL
343                if age >= self.hb_ttl && !matches!(state.state, Quarantined | Draining) {
344                    drop(state); // Release read lock before write
345                    inst.inner.write().state = Quarantined;
346                    return true; // Keep quarantined instances for now
347                }
348
349                // Evict quarantined instances that exceed grace period
350                if state.state == Quarantined && age >= self.hb_ttl + self.hb_grace {
351                    return false; // Remove from directory
352                }
353
354                true
355            });
356
357            if vec.is_empty() {
358                empty_gears.push(gear);
359            }
360        }
361
362        for gear in empty_gears {
363            self.inner.remove(&gear);
364            self.rr_counters.remove(&gear);
365            self.rr_counters.remove(&format!("rest:{gear}"));
366        }
367    }
368
369    /// Pick an instance using round-robin selection, preferring healthy instances
370    #[must_use]
371    pub fn pick_instance_round_robin(&self, gear: &str) -> Option<Arc<GearInstance>> {
372        let instances_entry = self.inner.get(gear)?;
373        let instances = instances_entry.value();
374
375        if instances.is_empty() {
376            return None;
377        }
378
379        // Prefer healthy or ready instances
380        let healthy: Vec<_> = instances
381            .iter()
382            .filter(|inst| matches!(inst.state(), InstanceState::Healthy | InstanceState::Ready))
383            .cloned()
384            .collect();
385
386        let candidates: Vec<_> = if healthy.is_empty() {
387            instances.clone()
388        } else {
389            healthy
390        };
391
392        if candidates.is_empty() {
393            return None;
394        }
395
396        let len = candidates.len();
397        let mut counter = self.rr_counters.entry(gear.to_owned()).or_insert(0);
398        let idx = *counter % len;
399        *counter = (*counter + 1) % len;
400
401        candidates.get(idx).cloned()
402    }
403
404    /// Pick a service endpoint using round-robin, returning (gear, instance, endpoint).
405    /// Prefers healthy/ready instances and automatically rotates among them.
406    #[must_use]
407    pub fn pick_service_round_robin(
408        &self,
409        service_name: &str,
410    ) -> Option<(String, Arc<GearInstance>, Endpoint)> {
411        // Collect all instances that provide this service
412        let mut candidates = Vec::new();
413        for entry in &self.inner {
414            let gear = entry.key().clone();
415            for inst in entry.value() {
416                if let Some(ep) = inst.grpc_services.get(service_name) {
417                    let state = inst.state();
418                    if matches!(state, InstanceState::Healthy | InstanceState::Ready) {
419                        candidates.push((gear.clone(), inst.clone(), ep.clone()));
420                    }
421                }
422            }
423        }
424
425        if candidates.is_empty() {
426            return None;
427        }
428
429        // Use a counter keyed by service name for round-robin
430        let len = candidates.len();
431        let service_key = service_name.to_owned();
432        let mut counter = self.rr_counters.entry(service_key).or_insert(0);
433        let idx = *counter % len;
434        *counter = (*counter + 1) % len;
435
436        candidates.get(idx).cloned()
437    }
438
439    /// Resolve a REST endpoint for a gear using round-robin over instances that
440    /// expose one, preferring healthy/ready instances.
441    #[must_use]
442    pub fn pick_rest_endpoint_round_robin(&self, gear: &str) -> Option<Endpoint> {
443        let instances_entry = self.inner.get(gear)?;
444        let instances = instances_entry.value();
445
446        // Only instances that actually expose a REST endpoint are candidates.
447        let with_rest: Vec<_> = instances
448            .iter()
449            .filter(|inst| inst.rest_endpoint.is_some())
450            .cloned()
451            .collect();
452
453        if with_rest.is_empty() {
454            return None;
455        }
456
457        // Prefer healthy/ready instances, otherwise fall back to any with REST.
458        let healthy: Vec<_> = with_rest
459            .iter()
460            .filter(|inst| matches!(inst.state(), InstanceState::Healthy | InstanceState::Ready))
461            .cloned()
462            .collect();
463
464        let candidates = if healthy.is_empty() {
465            with_rest
466        } else {
467            healthy
468        };
469
470        let len = candidates.len();
471        let rr_key = format!("rest:{gear}");
472        let mut counter = self.rr_counters.entry(rr_key).or_insert(0);
473        let idx = *counter % len;
474        *counter = (*counter + 1) % len;
475
476        candidates
477            .get(idx)
478            .and_then(|inst| inst.rest_endpoint.clone())
479    }
480
481    /// Retrieve the `OpenAPI` spec of a gear, taken from the first registered
482    /// instance that published one.
483    #[must_use]
484    pub fn openapi_spec_of(&self, gear: &str) -> Option<String> {
485        let instances_entry = self.inner.get(gear)?;
486        instances_entry
487            .value()
488            .iter()
489            .find_map(|inst| inst.openapi_spec.clone())
490    }
491}
492
493impl Default for GearManager {
494    fn default() -> Self {
495        Self::new()
496    }
497}
498
499#[cfg(test)]
500#[cfg_attr(coverage_nightly, coverage(off))]
501mod tests {
502    use super::*;
503    use std::thread::sleep;
504    use std::time::Duration;
505
506    #[test]
507    fn test_register_and_retrieve_instances() {
508        let dir = GearManager::new();
509        let instance_id = Uuid::new_v4();
510        let instance = Arc::new(
511            GearInstance::new("test_gear", instance_id)
512                .with_control(Endpoint::http("localhost", 8080))
513                .with_version("1.0.0"),
514        );
515
516        dir.register_instance(instance);
517
518        let instances = dir.instances_of("test_gear");
519        assert_eq!(instances.len(), 1);
520        assert_eq!(instances[0].instance_id, instance_id);
521        assert_eq!(instances[0].gear, "test_gear");
522        assert_eq!(instances[0].version, Some("1.0.0".to_owned()));
523    }
524
525    #[test]
526    fn test_register_multiple_instances() {
527        let dir = GearManager::new();
528
529        let id1 = Uuid::new_v4();
530        let id2 = Uuid::new_v4();
531        let instance1 = Arc::new(GearInstance::new("test_gear", id1));
532        let instance2 = Arc::new(GearInstance::new("test_gear", id2));
533
534        dir.register_instance(instance1);
535        dir.register_instance(instance2);
536
537        let registered = dir.instances_of("test_gear");
538        assert_eq!(registered.len(), 2);
539
540        let ids: Vec<_> = registered.iter().map(|i| i.instance_id).collect();
541        assert!(ids.contains(&id1));
542        assert!(ids.contains(&id2));
543    }
544
545    #[test]
546    fn test_update_existing_instance() {
547        let dir = GearManager::new();
548        let instance_id = Uuid::new_v4();
549
550        let initial_instance =
551            Arc::new(GearInstance::new("test_gear", instance_id).with_version("1.0.0"));
552        dir.register_instance(initial_instance);
553
554        let updated_instance =
555            Arc::new(GearInstance::new("test_gear", instance_id).with_version("2.0.0"));
556        dir.register_instance(updated_instance);
557
558        let registered = dir.instances_of("test_gear");
559        assert_eq!(registered.len(), 1, "Should not duplicate instance");
560        assert_eq!(registered[0].version, Some("2.0.0".to_owned()));
561    }
562
563    #[test]
564    fn test_reregistration_preserves_liveness_state() {
565        let dir = GearManager::new();
566        let instance_id = Uuid::new_v4();
567
568        // Register, then heartbeat so the instance is Healthy (routable).
569        dir.register_instance(Arc::new(
570            GearInstance::new("test_gear", instance_id).with_version("1.0.0"),
571        ));
572        dir.update_heartbeat("test_gear", instance_id, Instant::now());
573        assert!(matches!(
574            dir.instances_of("test_gear")[0].state(),
575            InstanceState::Healthy
576        ));
577
578        // A periodic self-heal re-registration must NOT reset liveness back to
579        // Registered — otherwise the instance drops out of gRPC round-robin
580        // until the next heartbeat (the "split-brain" flap).
581        dir.register_instance(Arc::new(
582            GearInstance::new("test_gear", instance_id).with_version("2.0.0"),
583        ));
584
585        let instances = dir.instances_of("test_gear");
586        assert_eq!(instances.len(), 1);
587        assert!(
588            matches!(instances[0].state(), InstanceState::Healthy),
589            "re-registration must preserve the Healthy state"
590        );
591        assert_eq!(
592            instances[0].version,
593            Some("2.0.0".to_owned()),
594            "re-registration must still refresh metadata/endpoints"
595        );
596    }
597
598    #[test]
599    fn test_concurrent_reregister_and_heartbeat_preserves_state() {
600        let dir = GearManager::new();
601        let instance_id = Uuid::new_v4();
602
603        let initial = Arc::new(GearInstance::new("test_gear", instance_id).with_version("1.0.0"));
604        dir.register_instance(initial);
605        dir.update_heartbeat("test_gear", instance_id, Instant::now());
606        assert!(matches!(
607            dir.instances_of("test_gear")[0].state(),
608            InstanceState::Healthy
609        ));
610
611        let start = Instant::now();
612
613        std::thread::scope(|s| {
614            s.spawn(|| {
615                for _ in 0..1000 {
616                    dir.update_heartbeat("test_gear", instance_id, Instant::now());
617                }
618            });
619            s.spawn(|| {
620                for i in 0..1000 {
621                    let version = if i % 2 == 0 { "2.0.0" } else { "3.0.0" };
622                    let reinst = Arc::new(
623                        GearInstance::new("test_gear", instance_id)
624                            .with_version(version)
625                            .with_rest_endpoint(Endpoint::http(
626                                "127.0.0.1",
627                                8000u16 + u16::try_from(i % 10).expect("i % 10 fits in u16"),
628                            )),
629                    );
630                    dir.register_instance(reinst);
631                }
632            });
633        });
634
635        let instances = dir.instances_of("test_gear");
636        assert_eq!(instances.len(), 1);
637        assert!(
638            matches!(instances[0].state(), InstanceState::Healthy),
639            "concurrent re-registration must not reset Healthy state"
640        );
641        assert!(
642            instances[0].last_heartbeat() >= start,
643            "concurrent re-registration must not lose heartbeat updates"
644        );
645    }
646
647    #[test]
648    fn test_mark_ready() {
649        let dir = GearManager::new();
650        let instance_id = Uuid::new_v4();
651        let instance = Arc::new(GearInstance::new("test_gear", instance_id));
652
653        dir.register_instance(instance);
654
655        dir.mark_ready("test_gear", instance_id);
656
657        let instances = dir.instances_of("test_gear");
658        assert_eq!(instances.len(), 1);
659        assert!(matches!(instances[0].state(), InstanceState::Ready));
660    }
661
662    #[test]
663    fn test_update_heartbeat() {
664        let dir = GearManager::new();
665        let instance_id = Uuid::new_v4();
666        let instance = Arc::new(GearInstance::new("test_gear", instance_id));
667        let initial_heartbeat = instance.last_heartbeat();
668
669        dir.register_instance(instance);
670
671        // Sleep to ensure time difference
672        sleep(Duration::from_millis(10));
673
674        let new_heartbeat = Instant::now();
675        dir.update_heartbeat("test_gear", instance_id, new_heartbeat);
676
677        let instances = dir.instances_of("test_gear");
678        assert!(instances[0].last_heartbeat() > initial_heartbeat);
679        assert!(matches!(instances[0].state(), InstanceState::Healthy));
680    }
681
682    #[test]
683    fn test_all_instances() {
684        let dir = GearManager::new();
685
686        let instance1 = Arc::new(GearInstance::new("gear_a", Uuid::new_v4()));
687        let instance2 = Arc::new(GearInstance::new("gear_b", Uuid::new_v4()));
688        let instance3 = Arc::new(GearInstance::new("gear_a", Uuid::new_v4()));
689
690        dir.register_instance(instance1);
691        dir.register_instance(instance2);
692        dir.register_instance(instance3);
693
694        let all = dir.all_instances();
695        assert_eq!(all.len(), 3);
696
697        let gears: Vec<_> = all.iter().map(|i| i.gear.as_str()).collect();
698        assert_eq!(gears.iter().filter(|&m| *m == "gear_a").count(), 2);
699        assert_eq!(gears.iter().filter(|&m| *m == "gear_b").count(), 1);
700    }
701
702    #[test]
703    fn test_pick_instance_round_robin() {
704        let dir = GearManager::new();
705
706        let id1 = Uuid::new_v4();
707        let id2 = Uuid::new_v4();
708        let instance1 = Arc::new(GearInstance::new("test_gear", id1));
709        let instance2 = Arc::new(GearInstance::new("test_gear", id2));
710
711        dir.register_instance(instance1);
712        dir.register_instance(instance2);
713
714        // Pick three times to verify round-robin behavior
715        let picked1 = dir.pick_instance_round_robin("test_gear").unwrap();
716        let picked2 = dir.pick_instance_round_robin("test_gear").unwrap();
717        let picked3 = dir.pick_instance_round_robin("test_gear").unwrap();
718
719        let ids = [
720            picked1.instance_id,
721            picked2.instance_id,
722            picked3.instance_id,
723        ];
724
725        // With 2 instances, we expect round-robin pattern like A, B, A
726        // Check that both instance IDs appear and that at least one repeats
727        assert!(ids.contains(&id1));
728        assert!(ids.contains(&id2));
729        // First and third pick should be the same (round-robin wraps)
730        assert_eq!(picked1.instance_id, picked3.instance_id);
731        // Second pick should be different from the first
732        assert_ne!(picked1.instance_id, picked2.instance_id);
733    }
734
735    #[test]
736    fn test_pick_instance_none_available() {
737        let dir = GearManager::new();
738        let picked = dir.pick_instance_round_robin("nonexistent_gear");
739        assert!(picked.is_none());
740    }
741
742    #[test]
743    fn test_endpoint_creation() {
744        let plain_ep = Endpoint::http("localhost", 8080);
745        assert_eq!(plain_ep.uri, "http://localhost:8080");
746
747        let secure_ep = Endpoint::https("localhost", 8443);
748        assert_eq!(secure_ep.uri, "https://localhost:8443");
749
750        let uds_ep = Endpoint::uds("/tmp/socket.sock");
751        assert!(uds_ep.uri.starts_with("unix://"));
752        assert!(uds_ep.uri.contains("socket.sock"));
753
754        let custom_ep = Endpoint::from_uri("http://example.com");
755        assert_eq!(custom_ep.uri, "http://example.com");
756    }
757
758    #[test]
759    fn test_endpoint_kind() {
760        let plain_ep = Endpoint::http("127.0.0.1", 8080);
761        match plain_ep.kind() {
762            EndpointKind::Tcp(addr) => {
763                assert_eq!(addr.ip().to_string(), "127.0.0.1");
764                assert_eq!(addr.port(), 8080);
765            }
766            _ => panic!("Expected TCP endpoint for http"),
767        }
768
769        let secure_ep = Endpoint::https("127.0.0.1", 8443);
770        match secure_ep.kind() {
771            EndpointKind::Tcp(addr) => {
772                assert_eq!(addr.ip().to_string(), "127.0.0.1");
773                assert_eq!(addr.port(), 8443);
774            }
775            _ => panic!("Expected TCP endpoint for https"),
776        }
777
778        let uds_ep = Endpoint::uds("/tmp/test.sock");
779        match uds_ep.kind() {
780            EndpointKind::Uds(path) => {
781                assert!(path.to_string_lossy().contains("test.sock"));
782            }
783            _ => panic!("Expected UDS endpoint"),
784        }
785
786        let other_ep = Endpoint::from_uri("grpc://example.com");
787        match other_ep.kind() {
788            EndpointKind::Other(uri) => {
789                assert_eq!(uri, "grpc://example.com");
790            }
791            _ => panic!("Expected Other endpoint"),
792        }
793    }
794
795    #[test]
796    fn test_gear_instance_builder() {
797        let instance_id = Uuid::new_v4();
798        let instance = GearInstance::new("test_gear", instance_id)
799            .with_control(Endpoint::http("localhost", 8080))
800            .with_version("1.2.3")
801            .with_grpc_service("service1", Endpoint::http("localhost", 8082))
802            .with_grpc_service("service2", Endpoint::http("localhost", 8083));
803
804        assert_eq!(instance.gear, "test_gear");
805        assert_eq!(instance.instance_id, instance_id);
806        assert!(instance.control.is_some());
807        assert_eq!(instance.version, Some("1.2.3".to_owned()));
808        assert_eq!(instance.grpc_services.len(), 2);
809        assert!(instance.grpc_services.contains_key("service1"));
810        assert!(instance.grpc_services.contains_key("service2"));
811        assert!(matches!(instance.state(), InstanceState::Registered));
812    }
813
814    #[test]
815    fn test_quarantine_and_evict() {
816        let ttl = Duration::from_millis(50);
817        let grace = Duration::from_millis(50);
818        let dir = GearManager::new().with_heartbeat_policy(ttl, grace);
819
820        let now = Instant::now();
821        let instance = GearInstance::new("test_gear", Uuid::new_v4());
822        // Set the last heartbeat to be stale
823        instance.inner.write().last_heartbeat = now
824            .checked_sub(ttl)
825            .and_then(|t| t.checked_sub(Duration::from_millis(10)))
826            .expect("test duration subtraction should not underflow");
827
828        dir.register_instance(Arc::new(instance));
829
830        dir.evict_stale(now);
831        let instances = dir.instances_of("test_gear");
832        assert_eq!(instances.len(), 1);
833        assert!(matches!(instances[0].state(), InstanceState::Quarantined));
834
835        let later = now + grace + Duration::from_millis(10);
836        dir.evict_stale(later);
837
838        let instances_after = dir.instances_of("test_gear");
839        assert!(instances_after.is_empty());
840    }
841
842    #[test]
843    fn test_instances_of_empty() {
844        let dir = GearManager::new();
845        let instances = dir.instances_of("nonexistent");
846        assert!(instances.is_empty());
847    }
848
849    #[test]
850    fn test_rr_prefers_healthy() {
851        let dir = GearManager::new();
852
853        // Create two instances: one healthy, one quarantined
854        let healthy_id = Uuid::new_v4();
855        let healthy = Arc::new(GearInstance::new("test_gear", healthy_id));
856        dir.register_instance(healthy);
857        dir.update_heartbeat("test_gear", healthy_id, Instant::now());
858
859        let quarantined_id = Uuid::new_v4();
860        let quarantined = Arc::new(GearInstance::new("test_gear", quarantined_id));
861        dir.register_instance(quarantined);
862        dir.mark_quarantined("test_gear", quarantined_id);
863
864        // RR should only pick the healthy instance
865        for _ in 0..5 {
866            let picked = dir.pick_instance_round_robin("test_gear").unwrap();
867            assert_eq!(picked.instance_id, healthy_id);
868        }
869    }
870
871    #[test]
872    fn test_pick_rest_endpoint_and_openapi() {
873        let dir = GearManager::new();
874        let id = Uuid::new_v4();
875        let instance = Arc::new(
876            GearInstance::new("billing", id)
877                .with_rest_endpoint(Endpoint::http("billing", 8080))
878                .with_openapi_spec("{\"openapi\":\"3.1.0\"}"),
879        );
880        dir.register_instance(instance);
881
882        let rest = dir.pick_rest_endpoint_round_robin("billing").unwrap();
883        assert_eq!(rest.uri, "http://billing:8080");
884
885        let spec = dir.openapi_spec_of("billing").unwrap();
886        assert!(spec.contains("openapi"));
887    }
888
889    #[test]
890    fn test_pick_rest_endpoint_none_when_absent() {
891        let dir = GearManager::new();
892        let id = Uuid::new_v4();
893        // Instance exposes only a gRPC service, no REST endpoint.
894        let instance = Arc::new(
895            GearInstance::new("grpc_only", id)
896                .with_grpc_service("some.Service", Endpoint::http("127.0.0.1", 9000)),
897        );
898        dir.register_instance(instance);
899
900        assert!(dir.pick_rest_endpoint_round_robin("grpc_only").is_none());
901        assert!(dir.openapi_spec_of("grpc_only").is_none());
902        assert!(dir.pick_rest_endpoint_round_robin("missing").is_none());
903    }
904
905    #[test]
906    fn test_pick_rest_endpoint_round_robin_rotates() {
907        let dir = GearManager::new();
908        let id1 = Uuid::new_v4();
909        let id2 = Uuid::new_v4();
910        let inst1 = Arc::new(
911            GearInstance::new("web", id1).with_rest_endpoint(Endpoint::http("127.0.0.1", 8001)),
912        );
913        let inst2 = Arc::new(
914            GearInstance::new("web", id2).with_rest_endpoint(Endpoint::http("127.0.0.1", 8002)),
915        );
916        dir.register_instance(inst1);
917        dir.register_instance(inst2);
918        dir.update_heartbeat("web", id1, Instant::now());
919        dir.update_heartbeat("web", id2, Instant::now());
920
921        let ep1 = dir.pick_rest_endpoint_round_robin("web").unwrap();
922        let ep2 = dir.pick_rest_endpoint_round_robin("web").unwrap();
923        let ep3 = dir.pick_rest_endpoint_round_robin("web").unwrap();
924
925        assert_ne!(ep1, ep2);
926        assert_eq!(ep1, ep3);
927    }
928
929    #[test]
930    fn test_pick_service_round_robin() {
931        let dir = GearManager::new();
932
933        let id1 = Uuid::new_v4();
934        let id2 = Uuid::new_v4();
935        // Register two instances providing the same service
936        let inst1 = Arc::new(
937            GearInstance::new("test_gear", id1)
938                .with_grpc_service("test.Service", Endpoint::http("127.0.0.1", 8001)),
939        );
940        let inst2 = Arc::new(
941            GearInstance::new("test_gear", id2)
942                .with_grpc_service("test.Service", Endpoint::http("127.0.0.1", 8002)),
943        );
944
945        dir.register_instance(inst1);
946        dir.register_instance(inst2);
947
948        // Mark both as healthy
949        dir.update_heartbeat("test_gear", id1, Instant::now());
950        dir.update_heartbeat("test_gear", id2, Instant::now());
951
952        // Pick should rotate between instances
953        let pick1 = dir.pick_service_round_robin("test.Service");
954        let pick2 = dir.pick_service_round_robin("test.Service");
955        let pick3 = dir.pick_service_round_robin("test.Service");
956
957        assert!(pick1.is_some());
958        assert!(pick2.is_some());
959        assert!(pick3.is_some());
960
961        let (_, inst1, ep1) = pick1.unwrap();
962        let (_, inst2, ep2) = pick2.unwrap();
963        let (_, inst3, _) = pick3.unwrap();
964
965        // First and third should be the same (round-robin)
966        assert_eq!(inst1.instance_id, inst3.instance_id);
967        // First and second should be different
968        assert_ne!(inst1.instance_id, inst2.instance_id);
969        // Endpoints should differ
970        assert_ne!(ep1, ep2);
971    }
972
973    #[test]
974    fn test_deregister_clears_rr_counters() {
975        let dir = GearManager::new();
976        let id = Uuid::new_v4();
977        let instance = Arc::new(
978            GearInstance::new("web", id).with_rest_endpoint(Endpoint::http("127.0.0.1", 8001)),
979        );
980        dir.register_instance(instance);
981        dir.update_heartbeat("web", id, Instant::now());
982
983        // Exercise both round-robin counters so the keys are created.
984        assert!(dir.pick_instance_round_robin("web").is_some());
985        assert!(dir.pick_rest_endpoint_round_robin("web").is_some());
986
987        assert!(dir.rr_counters.contains_key("web"));
988        assert!(dir.rr_counters.contains_key("rest:web"));
989
990        dir.deregister("web", id);
991
992        assert!(!dir.rr_counters.contains_key("web"));
993        assert!(!dir.rr_counters.contains_key("rest:web"));
994    }
995
996    #[test]
997    fn test_evict_stale_clears_rr_counters() {
998        let ttl = Duration::from_millis(50);
999        let grace = Duration::from_millis(50);
1000        let dir = GearManager::new().with_heartbeat_policy(ttl, grace);
1001
1002        let now = Instant::now();
1003        let id = Uuid::new_v4();
1004        let instance = Arc::new(
1005            GearInstance::new("web", id).with_rest_endpoint(Endpoint::http("127.0.0.1", 8001)),
1006        );
1007        // Set the last heartbeat to be stale so the instance is quarantined then evicted.
1008        instance.inner.write().last_heartbeat = now
1009            .checked_sub(ttl)
1010            .and_then(|t| t.checked_sub(Duration::from_millis(10)))
1011            .expect("test duration subtraction should not underflow");
1012
1013        dir.register_instance(instance);
1014        assert!(dir.pick_rest_endpoint_round_robin("web").is_some());
1015
1016        assert!(dir.rr_counters.contains_key("rest:web"));
1017
1018        // First eviction pass quarantines the stale instance.
1019        dir.evict_stale(now);
1020        let instances = dir.instances_of("web");
1021        assert_eq!(instances.len(), 1);
1022        assert!(matches!(instances[0].state(), InstanceState::Quarantined));
1023
1024        // Second pass evicts quarantined instances that exceeded the grace period.
1025        let later = now + grace + Duration::from_millis(10);
1026        dir.evict_stale(later);
1027
1028        assert!(dir.instances_of("web").is_empty());
1029        assert!(!dir.rr_counters.contains_key("web"));
1030        assert!(!dir.rr_counters.contains_key("rest:web"));
1031    }
1032}