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