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    ///
126    /// One process registers an instance from two places: the gRPC hub with
127    /// its services, the host with its REST endpoint and `OpenAPI` spec. Each
128    /// writes only its own half, and they race, so an absent half keeps the
129    /// stored one instead of clearing it, as labels do.
130    fn with_metadata_of(&self, other: &GearInstance) -> GearInstance {
131        GearInstance {
132            gear: other.gear.clone(),
133            instance_id: other.instance_id,
134            control: other.control.clone(),
135            grpc_services: if other.grpc_services.is_empty() {
136                self.grpc_services.clone()
137            } else {
138                other.grpc_services.clone()
139            },
140            version: other.version.clone(),
141            rest_endpoint: other
142                .rest_endpoint
143                .clone()
144                .or_else(|| self.rest_endpoint.clone()),
145            openapi_spec: other
146                .openapi_spec
147                .clone()
148                .or_else(|| self.openapi_spec.clone()),
149            // Empty incoming labels mean "keep the stored set", not "clear"; a
150            // non-empty set replaces it. See `RegisterInstanceInfo::with_labels`
151            // for why (self-heal / REST-augmentation re-registers omit labels).
152            labels: if other.labels.is_empty() {
153                self.labels.clone()
154            } else {
155                other.labels.clone()
156            },
157            inner: Arc::clone(&self.inner),
158        }
159    }
160}
161
162impl GearInstance {
163    pub fn new(gear: impl Into<String>, instance_id: Uuid) -> Self {
164        Self {
165            gear: gear.into(),
166            instance_id,
167            control: None,
168            grpc_services: HashMap::new(),
169            version: None,
170            rest_endpoint: None,
171            openapi_spec: None,
172            labels: BTreeMap::new(),
173            inner: Arc::new(parking_lot::RwLock::new(InstanceRuntimeState {
174                last_heartbeat: Instant::now(),
175                state: InstanceState::Registered,
176            })),
177        }
178    }
179
180    pub fn with_control(mut self, ep: Endpoint) -> Self {
181        self.control = Some(ep);
182        self
183    }
184
185    pub fn with_version(mut self, v: impl Into<String>) -> Self {
186        self.version = Some(v.into());
187        self
188    }
189
190    pub fn with_grpc_service(mut self, name: impl Into<String>, ep: Endpoint) -> Self {
191        self.grpc_services.insert(name.into(), ep);
192        self
193    }
194
195    pub fn with_rest_endpoint(mut self, ep: Endpoint) -> Self {
196        self.rest_endpoint = Some(ep);
197        self
198    }
199
200    pub fn with_openapi_spec(mut self, spec: impl Into<String>) -> Self {
201        self.openapi_spec = Some(spec.into());
202        self
203    }
204
205    pub fn with_labels(mut self, labels: BTreeMap<String, String>) -> Self {
206        self.labels = labels;
207        self
208    }
209
210    /// Get the current state of this instance
211    #[must_use]
212    pub fn state(&self) -> InstanceState {
213        self.inner.read().state
214    }
215
216    /// Get the last heartbeat timestamp
217    #[must_use]
218    pub fn last_heartbeat(&self) -> Instant {
219        self.inner.read().last_heartbeat
220    }
221}
222
223/// Central registry that tracks all running gear instances in the system.
224/// Provides discovery, health tracking, and round-robin load balancing.
225///
226/// Always shared as an `Arc<GearManager>` and NOT `Clone`: a by-value clone
227/// would deep-copy the `DashMap` directories while sharing the guards, so the
228/// registration lock would no longer serialize the map it guards.
229#[must_use]
230pub struct GearManager {
231    inner: DashMap<String, Vec<Arc<GearInstance>>>,
232    rr_counters: DashMap<String, usize>,
233    hb_ttl: Duration,
234    hb_grace: Duration,
235    /// Serializes gRPC-service-name ownership check + insert in
236    /// [`register_instance`](Self::register_instance) so two
237    /// gears cannot both pass an "unowned" check and then both commit the same
238    /// name. `DashMap` locks per entry, giving no boundary across the
239    /// cross-gear ownership scan and the single-entry write; this gate provides
240    /// it.
241    ///
242    /// Contract: every operation that *creates* a name→gear binding must hold
243    /// this lock — today `register_instance`, `set_grpc_service_owners`, and
244    /// `merge_authoritative_grpc_service_owners`. `deregister` / `evict_stale`
245    /// only *free* names and `mark_*` / `update_heartbeat` only touch instance
246    /// state, so they cannot create a second owner and need not take it. Any
247    /// future mutator that can bind a name must.
248    reg_lock: parking_lot::Mutex<()>,
249    /// Authoritative gRPC-service-name -> owning-gear map. When a name appears
250    /// here, ownership is fixed to the named gear: only that gear may advertise
251    /// it and no other gear can ever claim it, regardless of which registered
252    /// first — the stable ownership source the dynamic registration scan is
253    /// not. Populated by the runtime at startup (see
254    /// [`set_grpc_service_owners`](Self::set_grpc_service_owners)); names absent
255    /// from the map fall back to first-registration ownership.
256    service_owners: parking_lot::RwLock<HashMap<String, String>>,
257}
258
259/// Returned by [`GearManager::register_instance`] when an instance
260/// advertises a gRPC service name already owned by a *different* gear. The
261/// store is left unchanged; the caller maps this onto a gRPC status (see
262/// `DirectoryServiceNameConflict`), whose code depends on [`recoverable`].
263///
264/// [`recoverable`]: Self::recoverable
265#[derive(Debug, Clone, PartialEq, Eq)]
266pub struct GrpcServiceNameConflict {
267    /// The gRPC service name that is already owned.
268    pub service_name: String,
269    /// The gear that currently owns `service_name`.
270    pub owner: String,
271    /// Whether waiting could clear the conflict: `false` when the name is pinned
272    /// to `owner` by the authoritative ownership map (permanent), `true` when
273    /// `owner` merely currently advertises it (clears when it deregisters). Set
274    /// at the two rejection sites in [`GearManager::register_instance`].
275    pub recoverable: bool,
276}
277
278impl std::fmt::Display for GrpcServiceNameConflict {
279    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
280        write!(
281            f,
282            "gRPC service name '{}' is already owned by gear '{}'",
283            self.service_name, self.owner
284        )
285    }
286}
287
288impl std::error::Error for GrpcServiceNameConflict {}
289
290impl std::fmt::Debug for GearManager {
291    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
292        let gears: Vec<String> = self.inner.iter().map(|e| e.key().clone()).collect();
293        f.debug_struct("GearManager")
294            .field("instances_count", &self.inner.len())
295            .field("gears", &gears)
296            .field("heartbeat_ttl", &self.hb_ttl)
297            .field("heartbeat_grace", &self.hb_grace)
298            .finish_non_exhaustive()
299    }
300}
301
302impl GearManager {
303    pub fn new() -> Self {
304        Self {
305            inner: DashMap::new(),
306            rr_counters: DashMap::new(),
307            hb_ttl: Duration::from_secs(15),
308            hb_grace: Duration::from_secs(30),
309            reg_lock: parking_lot::Mutex::new(()),
310            service_owners: parking_lot::RwLock::new(HashMap::new()),
311        }
312    }
313
314    pub fn with_heartbeat_policy(mut self, ttl: Duration, grace: Duration) -> Self {
315        self.hb_ttl = ttl;
316        self.hb_grace = grace;
317        self
318    }
319
320    /// Install the authoritative gRPC-service-name -> owning-gear map.
321    ///
322    /// A listed name may be advertised only by its owner (enforced in
323    /// [`register_instance`](Self::register_instance)), closing the hole where a
324    /// gear could squat another's service name by registering first; unlisted
325    /// names keep first-registration ownership. Seeded from operator config, then
326    /// layered with the compiled-in owners via
327    /// [`merge_authoritative_grpc_service_owners`](Self::merge_authoritative_grpc_service_owners) —
328    /// both at startup, before any gear self-registers.
329    ///
330    /// Taken under `reg_lock` so it can't interleave with an in-flight
331    /// [`register_instance`](Self::register_instance) check + commit.
332    pub fn set_grpc_service_owners(&self, owners: HashMap<String, String>) {
333        let _gate = self.reg_lock.lock();
334        *self.service_owners.write() = owners;
335    }
336
337    /// Merge a compiled-in `service_name -> gear` map into the ownership map,
338    /// overriding any conflicting operator-configured entry.
339    ///
340    /// The compiled binary is ground truth — a gear physically *provides* the
341    /// service — so config must not reassign that name elsewhere (that would
342    /// re-open the squat); a disagreeing entry is overridden and warned. Names
343    /// absent here (e.g. remote / out-of-process gears) keep their configured
344    /// owner.
345    ///
346    /// Runs once in the runtime's gRPC phase before self-registration, under
347    /// `reg_lock` like [`set_grpc_service_owners`](Self::set_grpc_service_owners).
348    pub fn merge_authoritative_grpc_service_owners(&self, authoritative: HashMap<String, String>) {
349        let _gate = self.reg_lock.lock();
350        let mut owners = self.service_owners.write();
351        for (service_name, gear) in authoritative {
352            if let Some(configured) = owners.get(&service_name)
353                && configured != &gear
354            {
355                tracing::warn!(
356                    service = %service_name,
357                    configured_owner = %configured,
358                    compiled_owner = %gear,
359                    "grpc-service-owners: operator config assigned a compiled-in service name to a \
360                     different gear; the compiled-in provider is authoritative and overrides it"
361                );
362            }
363            owners.insert(service_name, gear);
364        }
365    }
366
367    /// Register or update a gear instance, enforcing single-gear ownership of
368    /// every gRPC service name it advertises — atomically.
369    ///
370    /// The ownership check ([`check_grpc_service_ownership`](Self::check_grpc_service_ownership))
371    /// and the insert run under one registration lock, so two gears cannot both
372    /// pass an "unowned" check and then both commit the same name — a
373    /// check-then-write race the per-entry `DashMap` locks do not prevent. On a
374    /// conflict the store is left untouched.
375    ///
376    /// Re-registering an existing `instance_id` is an idempotent endpoint /
377    /// metadata refresh, NOT a liveness reset: the existing runtime state
378    /// (`last_heartbeat` + [`InstanceState`]) is carried over onto the new
379    /// record by preserving the same `Arc<RwLock<InstanceRuntimeState>>`.
380    /// Without this, a periodic self-heal re-registration (see
381    /// `oop_registration::presence_loop`) would knock a `Healthy` instance back
382    /// to `Registered`, dropping it out of gRPC round-robin until the next
383    /// heartbeat. It would also race with concurrent `update_heartbeat` calls.
384    ///
385    /// # Errors
386    /// Returns [`GrpcServiceNameConflict`] if an advertised gRPC service name is
387    /// owned by, or currently advertised by, another gear.
388    pub fn register_instance(
389        &self,
390        instance: Arc<GearInstance>,
391    ) -> Result<(), GrpcServiceNameConflict> {
392        let _gate = self.reg_lock.lock();
393        self.check_grpc_service_ownership(&instance)?;
394
395        let gear = instance.gear.clone();
396        let mut vec = self.inner.entry(gear).or_default();
397        // replace by instance_id if it already exists
398        if let Some(pos) = vec
399            .iter()
400            .position(|i| i.instance_id == instance.instance_id)
401        {
402            vec[pos] = Arc::new(vec[pos].with_metadata_of(&instance));
403        } else {
404            vec.push(instance);
405        }
406        Ok(())
407    }
408
409    /// Authorize every gRPC service name `instance` advertises, resolving
410    /// ownership in two tiers:
411    ///
412    /// 1. **Declared (authoritative).** If a name appears in the configured
413    ///    ownership map (see
414    ///    [`set_grpc_service_owners`](Self::set_grpc_service_owners)), only the
415    ///    declared owner may advertise it — any other gear is rejected even if
416    ///    it registers first. This stops a gear from squatting a name it was
417    ///    never assigned.
418    /// 2. **First-registration (fallback).** For names absent from that map, the
419    ///    first gear to register the name owns it.
420    ///
421    /// Authorization is necessary but not sufficient: a name currently
422    /// advertised by a *different* gear always conflicts, so even the declared
423    /// owner is refused while a stale advertiser still holds it (e.g. one that
424    /// registered under a previous ownership map before
425    /// [`set_grpc_service_owners`](Self::set_grpc_service_owners) reassigned the
426    /// name). This keeps ownership from splitting across two advertisers; the
427    /// declared owner succeeds once the stale holder deregisters or expires. A
428    /// name held by the *same* gear is always fine (re-register / additional
429    /// instance).
430    ///
431    /// Must be called with `reg_lock` held so the check stays atomic with the
432    /// caller's subsequent insert.
433    fn check_grpc_service_ownership(
434        &self,
435        instance: &GearInstance,
436    ) -> Result<(), GrpcServiceNameConflict> {
437        // Tier 1: reject a declared name claimed by a non-owner; the rest (the
438        // declared owner, and undeclared names) still need the tier-2 scan.
439        let declared = self.service_owners.read();
440        let mut to_scan: Vec<&str> = Vec::new();
441        for service_name in instance.grpc_services.keys() {
442            match declared.get(service_name) {
443                Some(owner) if *owner != instance.gear => {
444                    // Tier 1: the name is pinned to another gear by the
445                    // authoritative map — permanent, waiting cannot clear it.
446                    return Err(GrpcServiceNameConflict {
447                        service_name: service_name.clone(),
448                        owner: owner.clone(),
449                        recoverable: false,
450                    });
451                }
452                _ => to_scan.push(service_name),
453            }
454        }
455        drop(declared);
456        if to_scan.is_empty() {
457            return Ok(());
458        }
459
460        // Tier 2: one pass over the directory, checking every candidate name per
461        // instance visited rather than re-scanning once per name. A name already
462        // held by a *different* gear conflicts.
463        for entry in &self.inner {
464            if entry.key() == &instance.gear {
465                continue; // same gear: re-register / additional instance is fine
466            }
467            for existing in entry.value() {
468                if let Some(&name) = to_scan
469                    .iter()
470                    .find(|&&n| existing.grpc_services.contains_key(n))
471                {
472                    // Tier 2: a *different* gear currently advertises the name —
473                    // recoverable, it clears when that holder deregisters/expires.
474                    return Err(GrpcServiceNameConflict {
475                        service_name: name.to_owned(),
476                        owner: entry.key().clone(),
477                        recoverable: true,
478                    });
479                }
480            }
481        }
482        Ok(())
483    }
484
485    /// Mark an instance as ready
486    pub fn mark_ready(&self, gear: &str, instance_id: Uuid) {
487        if let Some(mut vec) = self.inner.get_mut(gear)
488            && let Some(inst) = vec.iter_mut().find(|i| i.instance_id == instance_id)
489        {
490            let mut state = inst.inner.write();
491            state.state = InstanceState::Ready;
492        }
493    }
494
495    /// Update the heartbeat timestamp for an instance
496    pub fn update_heartbeat(&self, gear: &str, instance_id: Uuid, at: Instant) {
497        if let Some(mut vec) = self.inner.get_mut(gear)
498            && let Some(inst) = vec.iter_mut().find(|i| i.instance_id == instance_id)
499        {
500            let mut state = inst.inner.write();
501            state.last_heartbeat = at;
502            // Transition Registered -> Healthy on first heartbeat
503            if state.state == InstanceState::Registered {
504                state.state = InstanceState::Healthy;
505            }
506        }
507    }
508
509    /// Mark an instance as quarantined
510    pub fn mark_quarantined(&self, gear: &str, instance_id: Uuid) {
511        if let Some(mut vec) = self.inner.get_mut(gear)
512            && let Some(inst) = vec.iter_mut().find(|i| i.instance_id == instance_id)
513        {
514            inst.inner.write().state = InstanceState::Quarantined;
515        }
516    }
517
518    /// Mark an instance as draining (graceful shutdown in progress)
519    pub fn mark_draining(&self, gear: &str, instance_id: Uuid) {
520        if let Some(mut vec) = self.inner.get_mut(gear)
521            && let Some(inst) = vec.iter_mut().find(|i| i.instance_id == instance_id)
522        {
523            inst.inner.write().state = InstanceState::Draining;
524        }
525    }
526
527    /// Remove an instance from the directory
528    pub fn deregister(&self, gear: &str, instance_id: Uuid) {
529        let mut remove_gear = false;
530        {
531            if let Some(mut vec) = self.inner.get_mut(gear) {
532                let list = vec.value_mut();
533                list.retain(|inst| inst.instance_id != instance_id);
534                if list.is_empty() {
535                    remove_gear = true;
536                }
537            }
538        }
539
540        if remove_gear {
541            self.inner.remove(gear);
542            self.rr_counters.remove(gear);
543            self.rr_counters.remove(&format!("rest:{gear}"));
544        }
545    }
546
547    /// Get all instances of a specific gear
548    #[must_use]
549    pub fn instances_of(&self, gear: &str) -> Vec<Arc<GearInstance>> {
550        self.inner.get(gear).map(|v| v.clone()).unwrap_or_default()
551    }
552
553    /// Get all instances across all gears
554    #[must_use]
555    pub fn all_instances(&self) -> Vec<Arc<GearInstance>> {
556        self.inner
557            .iter()
558            .flat_map(|entry| entry.value().clone())
559            .collect()
560    }
561
562    /// Quarantine or evict stale instances based on heartbeat policy
563    pub fn evict_stale(&self, now: Instant) {
564        use InstanceState::{Draining, Quarantined};
565        let mut empty_gears = Vec::new();
566
567        for mut entry in self.inner.iter_mut() {
568            let gear = entry.key().clone();
569            let vec = entry.value_mut();
570            vec.retain(|inst| {
571                let state = inst.inner.read();
572                let age = now.saturating_duration_since(state.last_heartbeat);
573
574                // Quarantine instances that have exceeded TTL
575                if age >= self.hb_ttl && !matches!(state.state, Quarantined | Draining) {
576                    drop(state); // Release read lock before write
577                    inst.inner.write().state = Quarantined;
578                    return true; // Keep quarantined instances for now
579                }
580
581                // Evict quarantined instances that exceed grace period
582                if state.state == Quarantined && age >= self.hb_ttl + self.hb_grace {
583                    return false; // Remove from directory
584                }
585
586                true
587            });
588
589            if vec.is_empty() {
590                empty_gears.push(gear);
591            }
592        }
593
594        for gear in empty_gears {
595            self.inner.remove(&gear);
596            self.rr_counters.remove(&gear);
597            self.rr_counters.remove(&format!("rest:{gear}"));
598        }
599    }
600
601    /// From a candidate set, return the serving subset (`Healthy`/`Ready`); if
602    /// none are serving, return the whole set so resolution falls back to the
603    /// not-ready instances (`Registered`/`Quarantined`/`Draining`) rather than
604    /// failing closed. `context` names the pool for the cold-path `debug!`.
605    fn prefer_serving(candidates: Vec<Arc<GearInstance>>, context: &str) -> Vec<Arc<GearInstance>> {
606        let serving: Vec<Arc<GearInstance>> = candidates
607            .iter()
608            .filter(|inst| matches!(inst.state(), InstanceState::Healthy | InstanceState::Ready))
609            .cloned()
610            .collect();
611        if serving.is_empty() {
612            tracing::debug!(
613                context,
614                "no serving (Ready/Healthy) instance available; round-robining over the \
615                 not-ready set instead of returning None"
616            );
617            candidates
618        } else {
619            serving
620        }
621    }
622
623    /// Pick an instance using round-robin selection, preferring healthy instances
624    #[must_use]
625    pub fn pick_instance_round_robin(&self, gear: &str) -> Option<Arc<GearInstance>> {
626        let instances_entry = self.inner.get(gear)?;
627        let instances = instances_entry.value();
628
629        if instances.is_empty() {
630            return None;
631        }
632
633        // Non-empty in => non-empty out, so `candidates` is guaranteed non-empty.
634        let candidates = Self::prefer_serving(instances.clone(), gear);
635
636        let len = candidates.len();
637        let mut counter = self.rr_counters.entry(gear.to_owned()).or_insert(0);
638        let idx = *counter % len;
639        *counter = (*counter + 1) % len;
640
641        candidates.get(idx).cloned()
642    }
643
644    /// Pick a service endpoint using round-robin, returning (gear, instance, endpoint).
645    /// Prefers healthy/ready instances and automatically rotates among them.
646    #[must_use]
647    pub fn pick_service_round_robin(
648        &self,
649        service_name: &str,
650    ) -> Option<(String, Arc<GearInstance>, Endpoint)> {
651        // Collect the `Arc` of every instance that provides this service, then
652        // apply the shared serving-preference fallback so gRPC resolution
653        // survives an all-not-ready gear instead of failing closed. Cloning an
654        // `Arc` is a refcount bump; the gear name and endpoint are cloned once,
655        // for the winner, after the index is chosen.
656        let mut providing: Vec<Arc<GearInstance>> = Vec::new();
657        for entry in &self.inner {
658            for inst in entry.value() {
659                if inst.grpc_services.contains_key(service_name) {
660                    providing.push(inst.clone());
661                }
662            }
663        }
664
665        if providing.is_empty() {
666            return None;
667        }
668
669        let mut candidates = Self::prefer_serving(providing, service_name);
670
671        // Use a counter keyed by service name for round-robin
672        let len = candidates.len();
673        let service_key = service_name.to_owned();
674        let mut counter = self.rr_counters.entry(service_key).or_insert(0);
675        let idx = *counter % len;
676        *counter = (*counter + 1) % len;
677
678        let inst = candidates.swap_remove(idx);
679        let endpoint = inst.grpc_services.get(service_name)?.clone();
680        let gear = inst.gear.clone();
681        Some((gear, inst, endpoint))
682    }
683
684    /// Resolve a REST endpoint for a gear using round-robin over instances that
685    /// expose one, preferring healthy/ready instances.
686    #[must_use]
687    pub fn pick_rest_endpoint_round_robin(&self, gear: &str) -> Option<Endpoint> {
688        let instances_entry = self.inner.get(gear)?;
689        let instances = instances_entry.value();
690
691        // Only instances that actually expose a REST endpoint are candidates.
692        let with_rest: Vec<_> = instances
693            .iter()
694            .filter(|inst| inst.rest_endpoint.is_some())
695            .cloned()
696            .collect();
697
698        if with_rest.is_empty() {
699            return None;
700        }
701
702        let candidates = Self::prefer_serving(with_rest, gear);
703
704        let len = candidates.len();
705        let rr_key = format!("rest:{gear}");
706        let mut counter = self.rr_counters.entry(rr_key).or_insert(0);
707        let idx = *counter % len;
708        *counter = (*counter + 1) % len;
709
710        candidates
711            .get(idx)
712            .and_then(|inst| inst.rest_endpoint.clone())
713    }
714
715    /// Return a gear that currently advertises the gRPC service `service_name`,
716    /// if any (any instance state counts); in the misconfigured multi-owner case
717    /// the first match in iteration order.
718    ///
719    /// Test-only: [`register_instance`](Self::register_instance) enforces single
720    /// ownership with its own inline scan, so this is not part of the public
721    /// surface — it exists purely to let the tests below assert who owns a name.
722    #[cfg(test)]
723    #[must_use]
724    fn grpc_service_owner(&self, service_name: &str) -> Option<String> {
725        for entry in &self.inner {
726            if entry
727                .value()
728                .iter()
729                .any(|inst| inst.grpc_services.contains_key(service_name))
730            {
731                return Some(entry.key().clone());
732            }
733        }
734        None
735    }
736
737    /// Retrieve the `OpenAPI` spec of a gear, taken from the first registered
738    /// instance that published one.
739    #[must_use]
740    pub fn openapi_spec_of(&self, gear: &str) -> Option<String> {
741        let instances_entry = self.inner.get(gear)?;
742        instances_entry
743            .value()
744            .iter()
745            .find_map(|inst| inst.openapi_spec.clone())
746    }
747}
748
749impl Default for GearManager {
750    fn default() -> Self {
751        Self::new()
752    }
753}
754
755#[cfg(test)]
756#[cfg_attr(coverage_nightly, coverage(off))]
757mod tests {
758    use super::*;
759    use std::thread::sleep;
760    use std::time::Duration;
761
762    #[test]
763    fn test_register_and_retrieve_instances() {
764        let dir = GearManager::new();
765        let instance_id = Uuid::new_v4();
766        let instance = Arc::new(
767            GearInstance::new("test_gear", instance_id)
768                .with_control(Endpoint::http("localhost", 8080))
769                .with_version("1.0.0"),
770        );
771
772        seed(&dir, instance);
773
774        let instances = dir.instances_of("test_gear");
775        assert_eq!(instances.len(), 1);
776        assert_eq!(instances[0].instance_id, instance_id);
777        assert_eq!(instances[0].gear, "test_gear");
778        assert_eq!(instances[0].version, Some("1.0.0".to_owned()));
779    }
780
781    /// Seed the registry in a test. The fixture is assumed to advertise no
782    /// conflicting gRPC service name, so a conflict is a bug in the test, not
783    /// an expected outcome — surface it loudly rather than swallowing it.
784    #[track_caller]
785    fn seed(mgr: &GearManager, instance: Arc<GearInstance>) {
786        mgr.register_instance(instance)
787            .expect("test fixture must not create a gRPC service-name conflict");
788    }
789
790    fn labels(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
791        pairs
792            .iter()
793            .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
794            .collect()
795    }
796
797    #[test]
798    fn reregister_without_labels_preserves_stored_labels() {
799        let dir = GearManager::new();
800        let instance_id = Uuid::new_v4();
801
802        // Initial registration carries a shard label.
803        seed(
804            &dir,
805            Arc::new(
806                GearInstance::new("shard-gear", instance_id).with_labels(labels(&[("shard", "7")])),
807            ),
808        );
809
810        // A label-less re-registration (e.g. periodic self-heal / REST
811        // augmentation) must NOT wipe the stored labels.
812        seed(
813            &dir,
814            Arc::new(GearInstance::new("shard-gear", instance_id).with_version("2.0.0")),
815        );
816
817        let registered = dir.instances_of("shard-gear");
818        assert_eq!(registered.len(), 1);
819        assert_eq!(
820            registered[0].labels.get("shard"),
821            Some(&"7".to_owned()),
822            "label-less re-registration must preserve stored labels"
823        );
824        assert_eq!(registered[0].version, Some("2.0.0".to_owned()));
825    }
826
827    /// The hub's gRPC record and the host's REST record of one instance, in
828    /// either order: each keeps the other's half.
829    #[test]
830    fn grpc_and_rest_halves_of_an_instance_survive_each_other() {
831        let grpc = |id| {
832            GearInstance::new("both", id)
833                .with_grpc_service("pkg.Svc", Endpoint::from_uri("http://127.0.0.1:50051"))
834        };
835        let rest = |id| {
836            GearInstance::new("both", id)
837                .with_rest_endpoint(Endpoint::from_uri("http://127.0.0.1:8080"))
838                .with_openapi_spec("{}")
839        };
840        for grpc_first in [true, false] {
841            let dir = GearManager::new();
842            let id = Uuid::new_v4();
843            let (first, second) = if grpc_first {
844                (grpc(id), rest(id))
845            } else {
846                (rest(id), grpc(id))
847            };
848            seed(&dir, Arc::new(first));
849            seed(&dir, Arc::new(second));
850
851            let registered = dir.instances_of("both");
852            assert_eq!(registered.len(), 1);
853            assert!(
854                registered[0].grpc_services.contains_key("pkg.Svc"),
855                "gRPC kept (grpc_first={grpc_first})"
856            );
857            assert_eq!(
858                registered[0].rest_endpoint.as_ref().map(|e| e.uri.as_str()),
859                Some("http://127.0.0.1:8080"),
860                "REST kept (grpc_first={grpc_first})"
861            );
862            assert_eq!(registered[0].openapi_spec.as_deref(), Some("{}"));
863        }
864    }
865
866    /// A present half still replaces the stored one.
867    #[test]
868    fn a_present_half_replaces_the_stored_one() {
869        let dir = GearManager::new();
870        let id = Uuid::new_v4();
871        seed(
872            &dir,
873            Arc::new(
874                GearInstance::new("moved", id)
875                    .with_rest_endpoint(Endpoint::from_uri("http://127.0.0.1:8080")),
876            ),
877        );
878        seed(
879            &dir,
880            Arc::new(
881                GearInstance::new("moved", id)
882                    .with_rest_endpoint(Endpoint::from_uri("http://127.0.0.1:9090")),
883            ),
884        );
885        assert_eq!(
886            dir.instances_of("moved")[0]
887                .rest_endpoint
888                .as_ref()
889                .map(|e| e.uri.as_str()),
890            Some("http://127.0.0.1:9090")
891        );
892    }
893
894    #[test]
895    fn reregister_with_labels_replaces_stored_labels() {
896        let dir = GearManager::new();
897        let instance_id = Uuid::new_v4();
898
899        seed(
900            &dir,
901            Arc::new(
902                GearInstance::new("shard-gear", instance_id).with_labels(labels(&[("shard", "7")])),
903            ),
904        );
905        // An explicit non-empty set replaces the stored one wholesale.
906        seed(
907            &dir,
908            Arc::new(
909                GearInstance::new("shard-gear", instance_id).with_labels(labels(&[("shard", "8")])),
910            ),
911        );
912
913        let registered = dir.instances_of("shard-gear");
914        assert_eq!(registered.len(), 1);
915        assert_eq!(registered[0].labels.get("shard"), Some(&"8".to_owned()));
916    }
917
918    #[test]
919    fn test_register_multiple_instances() {
920        let dir = GearManager::new();
921
922        let id1 = Uuid::new_v4();
923        let id2 = Uuid::new_v4();
924        let instance1 = Arc::new(GearInstance::new("test_gear", id1));
925        let instance2 = Arc::new(GearInstance::new("test_gear", id2));
926
927        seed(&dir, instance1);
928        seed(&dir, instance2);
929
930        let registered = dir.instances_of("test_gear");
931        assert_eq!(registered.len(), 2);
932
933        let ids: Vec<_> = registered.iter().map(|i| i.instance_id).collect();
934        assert!(ids.contains(&id1));
935        assert!(ids.contains(&id2));
936    }
937
938    #[test]
939    fn test_update_existing_instance() {
940        let dir = GearManager::new();
941        let instance_id = Uuid::new_v4();
942
943        let initial_instance =
944            Arc::new(GearInstance::new("test_gear", instance_id).with_version("1.0.0"));
945        seed(&dir, initial_instance);
946
947        let updated_instance =
948            Arc::new(GearInstance::new("test_gear", instance_id).with_version("2.0.0"));
949        seed(&dir, updated_instance);
950
951        let registered = dir.instances_of("test_gear");
952        assert_eq!(registered.len(), 1, "Should not duplicate instance");
953        assert_eq!(registered[0].version, Some("2.0.0".to_owned()));
954    }
955
956    #[test]
957    fn test_reregistration_preserves_liveness_state() {
958        let dir = GearManager::new();
959        let instance_id = Uuid::new_v4();
960
961        // Register, then heartbeat so the instance is Healthy (routable).
962        seed(
963            &dir,
964            Arc::new(GearInstance::new("test_gear", instance_id).with_version("1.0.0")),
965        );
966        dir.update_heartbeat("test_gear", instance_id, Instant::now());
967        assert!(matches!(
968            dir.instances_of("test_gear")[0].state(),
969            InstanceState::Healthy
970        ));
971
972        // A periodic self-heal re-registration must NOT reset liveness back to
973        // Registered — otherwise the instance drops out of gRPC round-robin
974        // until the next heartbeat (the "split-brain" flap).
975        seed(
976            &dir,
977            Arc::new(GearInstance::new("test_gear", instance_id).with_version("2.0.0")),
978        );
979
980        let instances = dir.instances_of("test_gear");
981        assert_eq!(instances.len(), 1);
982        assert!(
983            matches!(instances[0].state(), InstanceState::Healthy),
984            "re-registration must preserve the Healthy state"
985        );
986        assert_eq!(
987            instances[0].version,
988            Some("2.0.0".to_owned()),
989            "re-registration must still refresh metadata/endpoints"
990        );
991    }
992
993    #[test]
994    fn test_concurrent_reregister_and_heartbeat_preserves_state() {
995        let dir = GearManager::new();
996        let instance_id = Uuid::new_v4();
997
998        let initial = Arc::new(GearInstance::new("test_gear", instance_id).with_version("1.0.0"));
999        seed(&dir, initial);
1000        dir.update_heartbeat("test_gear", instance_id, Instant::now());
1001        assert!(matches!(
1002            dir.instances_of("test_gear")[0].state(),
1003            InstanceState::Healthy
1004        ));
1005
1006        let start = Instant::now();
1007
1008        std::thread::scope(|s| {
1009            s.spawn(|| {
1010                for _ in 0..1000 {
1011                    dir.update_heartbeat("test_gear", instance_id, Instant::now());
1012                }
1013            });
1014            s.spawn(|| {
1015                for i in 0..1000 {
1016                    let version = if i % 2 == 0 { "2.0.0" } else { "3.0.0" };
1017                    let reinst = Arc::new(
1018                        GearInstance::new("test_gear", instance_id)
1019                            .with_version(version)
1020                            .with_rest_endpoint(Endpoint::http(
1021                                "127.0.0.1",
1022                                8000u16 + u16::try_from(i % 10).expect("i % 10 fits in u16"),
1023                            )),
1024                    );
1025                    seed(&dir, reinst);
1026                }
1027            });
1028        });
1029
1030        let instances = dir.instances_of("test_gear");
1031        assert_eq!(instances.len(), 1);
1032        assert!(
1033            matches!(instances[0].state(), InstanceState::Healthy),
1034            "concurrent re-registration must not reset Healthy state"
1035        );
1036        assert!(
1037            instances[0].last_heartbeat() >= start,
1038            "concurrent re-registration must not lose heartbeat updates"
1039        );
1040    }
1041
1042    #[test]
1043    fn test_mark_ready() {
1044        let dir = GearManager::new();
1045        let instance_id = Uuid::new_v4();
1046        let instance = Arc::new(GearInstance::new("test_gear", instance_id));
1047
1048        seed(&dir, instance);
1049
1050        dir.mark_ready("test_gear", instance_id);
1051
1052        let instances = dir.instances_of("test_gear");
1053        assert_eq!(instances.len(), 1);
1054        assert!(matches!(instances[0].state(), InstanceState::Ready));
1055    }
1056
1057    #[test]
1058    fn test_update_heartbeat() {
1059        let dir = GearManager::new();
1060        let instance_id = Uuid::new_v4();
1061        let instance = Arc::new(GearInstance::new("test_gear", instance_id));
1062        let initial_heartbeat = instance.last_heartbeat();
1063
1064        seed(&dir, instance);
1065
1066        // Sleep to ensure time difference
1067        sleep(Duration::from_millis(10));
1068
1069        let new_heartbeat = Instant::now();
1070        dir.update_heartbeat("test_gear", instance_id, new_heartbeat);
1071
1072        let instances = dir.instances_of("test_gear");
1073        assert!(instances[0].last_heartbeat() > initial_heartbeat);
1074        assert!(matches!(instances[0].state(), InstanceState::Healthy));
1075    }
1076
1077    #[test]
1078    fn test_all_instances() {
1079        let dir = GearManager::new();
1080
1081        let instance1 = Arc::new(GearInstance::new("gear_a", Uuid::new_v4()));
1082        let instance2 = Arc::new(GearInstance::new("gear_b", Uuid::new_v4()));
1083        let instance3 = Arc::new(GearInstance::new("gear_a", Uuid::new_v4()));
1084
1085        seed(&dir, instance1);
1086        seed(&dir, instance2);
1087        seed(&dir, instance3);
1088
1089        let all = dir.all_instances();
1090        assert_eq!(all.len(), 3);
1091
1092        let gears: Vec<_> = all.iter().map(|i| i.gear.as_str()).collect();
1093        assert_eq!(gears.iter().filter(|&m| *m == "gear_a").count(), 2);
1094        assert_eq!(gears.iter().filter(|&m| *m == "gear_b").count(), 1);
1095    }
1096
1097    #[test]
1098    fn test_pick_instance_round_robin() {
1099        let dir = GearManager::new();
1100
1101        let id1 = Uuid::new_v4();
1102        let id2 = Uuid::new_v4();
1103        let instance1 = Arc::new(GearInstance::new("test_gear", id1));
1104        let instance2 = Arc::new(GearInstance::new("test_gear", id2));
1105
1106        seed(&dir, instance1);
1107        seed(&dir, instance2);
1108
1109        // Pick three times to verify round-robin behavior
1110        let picked1 = dir.pick_instance_round_robin("test_gear").unwrap();
1111        let picked2 = dir.pick_instance_round_robin("test_gear").unwrap();
1112        let picked3 = dir.pick_instance_round_robin("test_gear").unwrap();
1113
1114        let ids = [
1115            picked1.instance_id,
1116            picked2.instance_id,
1117            picked3.instance_id,
1118        ];
1119
1120        // With 2 instances, we expect round-robin pattern like A, B, A
1121        // Check that both instance IDs appear and that at least one repeats
1122        assert!(ids.contains(&id1));
1123        assert!(ids.contains(&id2));
1124        // First and third pick should be the same (round-robin wraps)
1125        assert_eq!(picked1.instance_id, picked3.instance_id);
1126        // Second pick should be different from the first
1127        assert_ne!(picked1.instance_id, picked2.instance_id);
1128    }
1129
1130    #[test]
1131    fn test_pick_instance_none_available() {
1132        let dir = GearManager::new();
1133        let picked = dir.pick_instance_round_robin("nonexistent_gear");
1134        assert!(picked.is_none());
1135    }
1136
1137    #[test]
1138    fn test_endpoint_creation() {
1139        let plain_ep = Endpoint::http("localhost", 8080);
1140        assert_eq!(plain_ep.uri, "http://localhost:8080");
1141
1142        let secure_ep = Endpoint::https("localhost", 8443);
1143        assert_eq!(secure_ep.uri, "https://localhost:8443");
1144
1145        let uds_ep = Endpoint::uds("/tmp/socket.sock");
1146        assert!(uds_ep.uri.starts_with("unix://"));
1147        assert!(uds_ep.uri.contains("socket.sock"));
1148
1149        let custom_ep = Endpoint::from_uri("http://example.com");
1150        assert_eq!(custom_ep.uri, "http://example.com");
1151    }
1152
1153    #[test]
1154    fn test_endpoint_kind() {
1155        let plain_ep = Endpoint::http("127.0.0.1", 8080);
1156        match plain_ep.kind() {
1157            EndpointKind::Tcp(addr) => {
1158                assert_eq!(addr.ip().to_string(), "127.0.0.1");
1159                assert_eq!(addr.port(), 8080);
1160            }
1161            _ => panic!("Expected TCP endpoint for http"),
1162        }
1163
1164        let secure_ep = Endpoint::https("127.0.0.1", 8443);
1165        match secure_ep.kind() {
1166            EndpointKind::Tcp(addr) => {
1167                assert_eq!(addr.ip().to_string(), "127.0.0.1");
1168                assert_eq!(addr.port(), 8443);
1169            }
1170            _ => panic!("Expected TCP endpoint for https"),
1171        }
1172
1173        let uds_ep = Endpoint::uds("/tmp/test.sock");
1174        match uds_ep.kind() {
1175            EndpointKind::Uds(path) => {
1176                assert!(path.to_string_lossy().contains("test.sock"));
1177            }
1178            _ => panic!("Expected UDS endpoint"),
1179        }
1180
1181        let other_ep = Endpoint::from_uri("grpc://example.com");
1182        match other_ep.kind() {
1183            EndpointKind::Other(uri) => {
1184                assert_eq!(uri, "grpc://example.com");
1185            }
1186            _ => panic!("Expected Other endpoint"),
1187        }
1188    }
1189
1190    #[test]
1191    fn test_gear_instance_builder() {
1192        let instance_id = Uuid::new_v4();
1193        let instance = GearInstance::new("test_gear", instance_id)
1194            .with_control(Endpoint::http("localhost", 8080))
1195            .with_version("1.2.3")
1196            .with_grpc_service("service1", Endpoint::http("localhost", 8082))
1197            .with_grpc_service("service2", Endpoint::http("localhost", 8083));
1198
1199        assert_eq!(instance.gear, "test_gear");
1200        assert_eq!(instance.instance_id, instance_id);
1201        assert!(instance.control.is_some());
1202        assert_eq!(instance.version, Some("1.2.3".to_owned()));
1203        assert_eq!(instance.grpc_services.len(), 2);
1204        assert!(instance.grpc_services.contains_key("service1"));
1205        assert!(instance.grpc_services.contains_key("service2"));
1206        assert!(matches!(instance.state(), InstanceState::Registered));
1207    }
1208
1209    #[test]
1210    fn test_quarantine_and_evict() {
1211        let ttl = Duration::from_millis(50);
1212        let grace = Duration::from_millis(50);
1213        let dir = GearManager::new().with_heartbeat_policy(ttl, grace);
1214
1215        let now = Instant::now();
1216        let instance = GearInstance::new("test_gear", Uuid::new_v4());
1217        // Set the last heartbeat to be stale
1218        instance.inner.write().last_heartbeat = now
1219            .checked_sub(ttl)
1220            .and_then(|t| t.checked_sub(Duration::from_millis(10)))
1221            .expect("test duration subtraction should not underflow");
1222
1223        seed(&dir, Arc::new(instance));
1224
1225        dir.evict_stale(now);
1226        let instances = dir.instances_of("test_gear");
1227        assert_eq!(instances.len(), 1);
1228        assert!(matches!(instances[0].state(), InstanceState::Quarantined));
1229
1230        let later = now + grace + Duration::from_millis(10);
1231        dir.evict_stale(later);
1232
1233        let instances_after = dir.instances_of("test_gear");
1234        assert!(instances_after.is_empty());
1235    }
1236
1237    #[test]
1238    fn test_instances_of_empty() {
1239        let dir = GearManager::new();
1240        let instances = dir.instances_of("nonexistent");
1241        assert!(instances.is_empty());
1242    }
1243
1244    #[test]
1245    fn test_rr_prefers_healthy() {
1246        let dir = GearManager::new();
1247
1248        // Create two instances: one healthy, one quarantined
1249        let healthy_id = Uuid::new_v4();
1250        let healthy = Arc::new(GearInstance::new("test_gear", healthy_id));
1251        seed(&dir, healthy);
1252        dir.update_heartbeat("test_gear", healthy_id, Instant::now());
1253
1254        let quarantined_id = Uuid::new_v4();
1255        let quarantined = Arc::new(GearInstance::new("test_gear", quarantined_id));
1256        seed(&dir, quarantined);
1257        dir.mark_quarantined("test_gear", quarantined_id);
1258
1259        // RR should only pick the healthy instance
1260        for _ in 0..5 {
1261            let picked = dir.pick_instance_round_robin("test_gear").unwrap();
1262            assert_eq!(picked.instance_id, healthy_id);
1263        }
1264    }
1265
1266    #[test]
1267    fn test_pick_rest_endpoint_and_openapi() {
1268        let dir = GearManager::new();
1269        let id = Uuid::new_v4();
1270        let instance = Arc::new(
1271            GearInstance::new("billing", id)
1272                .with_rest_endpoint(Endpoint::http("billing", 8080))
1273                .with_openapi_spec("{\"openapi\":\"3.1.0\"}"),
1274        );
1275        seed(&dir, instance);
1276
1277        let rest = dir.pick_rest_endpoint_round_robin("billing").unwrap();
1278        assert_eq!(rest.uri, "http://billing:8080");
1279
1280        let spec = dir.openapi_spec_of("billing").unwrap();
1281        assert!(spec.contains("openapi"));
1282    }
1283
1284    #[test]
1285    fn test_pick_rest_endpoint_none_when_absent() {
1286        let dir = GearManager::new();
1287        let id = Uuid::new_v4();
1288        // Instance exposes only a gRPC service, no REST endpoint.
1289        let instance = Arc::new(
1290            GearInstance::new("grpc_only", id)
1291                .with_grpc_service("some.Service", Endpoint::http("127.0.0.1", 9000)),
1292        );
1293        seed(&dir, instance);
1294
1295        assert!(dir.pick_rest_endpoint_round_robin("grpc_only").is_none());
1296        assert!(dir.openapi_spec_of("grpc_only").is_none());
1297        assert!(dir.pick_rest_endpoint_round_robin("missing").is_none());
1298    }
1299
1300    #[test]
1301    fn test_pick_rest_endpoint_round_robin_rotates() {
1302        let dir = GearManager::new();
1303        let id1 = Uuid::new_v4();
1304        let id2 = Uuid::new_v4();
1305        let inst1 = Arc::new(
1306            GearInstance::new("web", id1).with_rest_endpoint(Endpoint::http("127.0.0.1", 8001)),
1307        );
1308        let inst2 = Arc::new(
1309            GearInstance::new("web", id2).with_rest_endpoint(Endpoint::http("127.0.0.1", 8002)),
1310        );
1311        seed(&dir, inst1);
1312        seed(&dir, inst2);
1313        dir.update_heartbeat("web", id1, Instant::now());
1314        dir.update_heartbeat("web", id2, Instant::now());
1315
1316        let ep1 = dir.pick_rest_endpoint_round_robin("web").unwrap();
1317        let ep2 = dir.pick_rest_endpoint_round_robin("web").unwrap();
1318        let ep3 = dir.pick_rest_endpoint_round_robin("web").unwrap();
1319
1320        assert_ne!(ep1, ep2);
1321        assert_eq!(ep1, ep3);
1322    }
1323
1324    #[test]
1325    fn test_pick_service_round_robin() {
1326        let dir = GearManager::new();
1327
1328        let id1 = Uuid::new_v4();
1329        let id2 = Uuid::new_v4();
1330        // Register two instances providing the same service
1331        let inst1 = Arc::new(
1332            GearInstance::new("test_gear", id1)
1333                .with_grpc_service("test.Service", Endpoint::http("127.0.0.1", 8001)),
1334        );
1335        let inst2 = Arc::new(
1336            GearInstance::new("test_gear", id2)
1337                .with_grpc_service("test.Service", Endpoint::http("127.0.0.1", 8002)),
1338        );
1339
1340        seed(&dir, inst1);
1341        seed(&dir, inst2);
1342
1343        // Mark both as healthy
1344        dir.update_heartbeat("test_gear", id1, Instant::now());
1345        dir.update_heartbeat("test_gear", id2, Instant::now());
1346
1347        // Pick should rotate between instances
1348        let pick1 = dir.pick_service_round_robin("test.Service");
1349        let pick2 = dir.pick_service_round_robin("test.Service");
1350        let pick3 = dir.pick_service_round_robin("test.Service");
1351
1352        assert!(pick1.is_some());
1353        assert!(pick2.is_some());
1354        assert!(pick3.is_some());
1355
1356        let (_, inst1, ep1) = pick1.unwrap();
1357        let (_, inst2, ep2) = pick2.unwrap();
1358        let (_, inst3, _) = pick3.unwrap();
1359
1360        // First and third should be the same (round-robin)
1361        assert_eq!(inst1.instance_id, inst3.instance_id);
1362        // First and second should be different
1363        assert_ne!(inst1.instance_id, inst2.instance_id);
1364        // Endpoints should differ
1365        assert_ne!(ep1, ep2);
1366    }
1367
1368    #[test]
1369    fn pick_service_falls_back_to_not_ready() {
1370        // A gear whose only service-providing instance is not yet serving
1371        // (Registered, no heartbeat) must still resolve, mirroring the REST and
1372        // instance pickers' not-ready fallback rather than failing closed.
1373        let dir = GearManager::new();
1374        let id = Uuid::new_v4();
1375        seed(
1376            &dir,
1377            Arc::new(
1378                GearInstance::new("worker", id)
1379                    .with_grpc_service("worker.Svc", Endpoint::http("127.0.0.1", 9000)),
1380            ),
1381        );
1382        // No heartbeat: the instance stays Registered (not serving).
1383        assert!(matches!(
1384            dir.instances_of("worker")[0].state(),
1385            InstanceState::Registered
1386        ));
1387
1388        let picked = dir.pick_service_round_robin("worker.Svc");
1389        assert!(
1390            picked.is_some(),
1391            "gRPC service resolution must fall back to the not-ready instance"
1392        );
1393        let (gear, _, ep) = picked.unwrap();
1394        assert_eq!(gear, "worker");
1395        assert_eq!(ep, Endpoint::http("127.0.0.1", 9000));
1396    }
1397
1398    #[test]
1399    fn grpc_service_owner_reports_owning_gear_regardless_of_health() {
1400        let dir = GearManager::new();
1401
1402        // A freshly-registered (not-yet-serving) instance still owns its name.
1403        seed(
1404            &dir,
1405            Arc::new(
1406                GearInstance::new("authz-resolver", Uuid::new_v4()).with_grpc_service(
1407                    "cf.authz.v1.AuthzService",
1408                    Endpoint::http("127.0.0.1", 9000),
1409                ),
1410            ),
1411        );
1412
1413        assert_eq!(
1414            dir.grpc_service_owner("cf.authz.v1.AuthzService")
1415                .as_deref(),
1416            Some("authz-resolver"),
1417            "the advertising gear owns the name even while only Registered"
1418        );
1419        assert!(
1420            dir.grpc_service_owner("unowned.Service").is_none(),
1421            "an unadvertised name has no owner"
1422        );
1423    }
1424
1425    /// The production invariant behind the above: a `Registered`-but-not-healthy
1426    /// owner still blocks another gear's claim of the same name through
1427    /// `register_instance` (ownership does not wait on the owner becoming healthy).
1428    #[test]
1429    fn registered_but_unhealthy_owner_blocks_another_gears_claim() {
1430        let dir = GearManager::new();
1431        let owner_id = Uuid::new_v4();
1432
1433        // Owner claims the name and never heartbeats -> stays Registered.
1434        dir.register_instance(Arc::new(
1435            GearInstance::new("authz-resolver", owner_id).with_grpc_service(
1436                "cf.authz.v1.AuthzService",
1437                Endpoint::http("127.0.0.1", 9000),
1438            ),
1439        ))
1440        .expect("first claim of an unowned name succeeds");
1441        assert!(matches!(
1442            dir.instances_of("authz-resolver")[0].state(),
1443            InstanceState::Registered
1444        ));
1445
1446        // A different gear is rejected via the production path, health regardless.
1447        let conflict = dir
1448            .register_instance(Arc::new(
1449                GearInstance::new("impostor", Uuid::new_v4()).with_grpc_service(
1450                    "cf.authz.v1.AuthzService",
1451                    Endpoint::http("127.0.0.1", 9001),
1452                ),
1453            ))
1454            .unwrap_err();
1455        assert_eq!(conflict.owner, "authz-resolver");
1456    }
1457
1458    #[test]
1459    fn deregister_releases_grpc_service_name_to_another_gear() {
1460        let dir = GearManager::new();
1461        let service = "cf.authz.v1.AuthzService";
1462        let owner_id = Uuid::new_v4();
1463
1464        seed(
1465            &dir,
1466            Arc::new(
1467                GearInstance::new("authz-resolver", owner_id)
1468                    .with_grpc_service(service, Endpoint::http("127.0.0.1", 9000)),
1469            ),
1470        );
1471
1472        // While the owner is registered, a different gear cannot claim the name.
1473        dir.register_instance(Arc::new(
1474            GearInstance::new("successor", Uuid::new_v4())
1475                .with_grpc_service(service, Endpoint::http("127.0.0.1", 9001)),
1476        ))
1477        .unwrap_err();
1478
1479        // Deregistering the owner releases the name...
1480        dir.deregister("authz-resolver", owner_id);
1481        assert!(
1482            dir.grpc_service_owner(service).is_none(),
1483            "the name is unowned once its owner deregisters"
1484        );
1485
1486        // ...so a different gear may now claim it.
1487        dir.register_instance(Arc::new(
1488            GearInstance::new("successor", Uuid::new_v4())
1489                .with_grpc_service(service, Endpoint::http("127.0.0.1", 9001)),
1490        ))
1491        .expect("a released gRPC service name must be claimable by another gear");
1492        assert_eq!(
1493            dir.grpc_service_owner(service).as_deref(),
1494            Some("successor")
1495        );
1496    }
1497
1498    #[test]
1499    fn eviction_releases_grpc_service_name_only_after_full_evict() {
1500        let ttl = Duration::from_millis(50);
1501        let grace = Duration::from_millis(50);
1502        let dir = GearManager::new().with_heartbeat_policy(ttl, grace);
1503        let service = "cf.authz.v1.AuthzService";
1504
1505        let now = Instant::now();
1506        let owner = GearInstance::new("authz-resolver", Uuid::new_v4())
1507            .with_grpc_service(service, Endpoint::http("127.0.0.1", 9000));
1508        // Stale from the start so the first eviction pass quarantines it.
1509        owner.inner.write().last_heartbeat = now
1510            .checked_sub(ttl)
1511            .and_then(|t| t.checked_sub(Duration::from_millis(10)))
1512            .expect("test duration subtraction should not underflow");
1513        seed(&dir, Arc::new(owner));
1514
1515        // First pass only *quarantines* the dead gear — it is not yet evicted,
1516        // so it still owns the name and a successor is rejected. A dead gear
1517        // keeps blocking its gRPC name (regardless of health) until fully
1518        // evicted, i.e. for hb_ttl + hb_grace.
1519        dir.evict_stale(now);
1520        assert!(matches!(
1521            dir.instances_of("authz-resolver")[0].state(),
1522            InstanceState::Quarantined
1523        ));
1524        let conflict = dir
1525            .register_instance(Arc::new(
1526                GearInstance::new("successor", Uuid::new_v4())
1527                    .with_grpc_service(service, Endpoint::http("127.0.0.1", 9001)),
1528            ))
1529            .unwrap_err();
1530        assert_eq!(
1531            conflict.owner, "authz-resolver",
1532            "a quarantined-but-not-evicted owner still holds the name"
1533        );
1534
1535        // Second pass past the grace period fully evicts it, releasing the name
1536        // so another gear may take it over.
1537        dir.evict_stale(now + grace + Duration::from_millis(10));
1538        assert!(
1539            dir.grpc_service_owner(service).is_none(),
1540            "eviction hands the name back once the grace period lapses"
1541        );
1542        dir.register_instance(Arc::new(
1543            GearInstance::new("successor", Uuid::new_v4())
1544                .with_grpc_service(service, Endpoint::http("127.0.0.1", 9002)),
1545        ))
1546        .expect("an evicted owner's gRPC name must be claimable by another gear");
1547        assert_eq!(
1548            dir.grpc_service_owner(service).as_deref(),
1549            Some("successor")
1550        );
1551    }
1552
1553    #[test]
1554    fn test_deregister_clears_rr_counters() {
1555        let dir = GearManager::new();
1556        let id = Uuid::new_v4();
1557        let instance = Arc::new(
1558            GearInstance::new("web", id).with_rest_endpoint(Endpoint::http("127.0.0.1", 8001)),
1559        );
1560        seed(&dir, instance);
1561        dir.update_heartbeat("web", id, Instant::now());
1562
1563        // Exercise both round-robin counters so the keys are created.
1564        assert!(dir.pick_instance_round_robin("web").is_some());
1565        assert!(dir.pick_rest_endpoint_round_robin("web").is_some());
1566
1567        assert!(dir.rr_counters.contains_key("web"));
1568        assert!(dir.rr_counters.contains_key("rest:web"));
1569
1570        dir.deregister("web", id);
1571
1572        assert!(!dir.rr_counters.contains_key("web"));
1573        assert!(!dir.rr_counters.contains_key("rest:web"));
1574    }
1575
1576    #[test]
1577    fn test_evict_stale_clears_rr_counters() {
1578        let ttl = Duration::from_millis(50);
1579        let grace = Duration::from_millis(50);
1580        let dir = GearManager::new().with_heartbeat_policy(ttl, grace);
1581
1582        let now = Instant::now();
1583        let id = Uuid::new_v4();
1584        let instance = Arc::new(
1585            GearInstance::new("web", id).with_rest_endpoint(Endpoint::http("127.0.0.1", 8001)),
1586        );
1587        // Set the last heartbeat to be stale so the instance is quarantined then evicted.
1588        instance.inner.write().last_heartbeat = now
1589            .checked_sub(ttl)
1590            .and_then(|t| t.checked_sub(Duration::from_millis(10)))
1591            .expect("test duration subtraction should not underflow");
1592
1593        seed(&dir, instance);
1594        assert!(dir.pick_rest_endpoint_round_robin("web").is_some());
1595
1596        assert!(dir.rr_counters.contains_key("rest:web"));
1597
1598        // First eviction pass quarantines the stale instance.
1599        dir.evict_stale(now);
1600        let instances = dir.instances_of("web");
1601        assert_eq!(instances.len(), 1);
1602        assert!(matches!(instances[0].state(), InstanceState::Quarantined));
1603
1604        // Second pass evicts quarantined instances that exceeded the grace period.
1605        let later = now + grace + Duration::from_millis(10);
1606        dir.evict_stale(later);
1607
1608        assert!(dir.instances_of("web").is_empty());
1609        assert!(!dir.rr_counters.contains_key("web"));
1610        assert!(!dir.rr_counters.contains_key("rest:web"));
1611    }
1612
1613    #[test]
1614    fn register_instance_rejects_cross_gear_grpc_name() {
1615        let mgr = GearManager::new();
1616        let service = "cf.authz.v1.AuthzService";
1617
1618        mgr.register_instance(Arc::new(
1619            GearInstance::new("authz-resolver", Uuid::new_v4())
1620                .with_grpc_service(service, Endpoint::http("127.0.0.1", 9000)),
1621        ))
1622        .expect("claiming an unowned service name must succeed");
1623
1624        // A different gear claiming the same name is rejected, store untouched.
1625        let conflict = mgr
1626            .register_instance(Arc::new(
1627                GearInstance::new("evil", Uuid::new_v4())
1628                    .with_grpc_service(service, Endpoint::http("127.0.0.1", 9001)),
1629            ))
1630            .unwrap_err();
1631        assert_eq!(conflict.service_name, service);
1632        assert_eq!(conflict.owner, "authz-resolver");
1633        assert!(mgr.instances_of("evil").is_empty());
1634
1635        // The owning gear may add another instance under the same name.
1636        mgr.register_instance(Arc::new(
1637            GearInstance::new("authz-resolver", Uuid::new_v4())
1638                .with_grpc_service(service, Endpoint::http("127.0.0.1", 9002)),
1639        ))
1640        .expect("the owning gear may add another instance for its own service");
1641    }
1642
1643    #[test]
1644    fn declared_ownership_beats_registration_order() {
1645        let mgr = GearManager::new();
1646        let service = "cf.authz.v1.AuthzService";
1647
1648        // Config declares the name is owned by `authz`.
1649        mgr.set_grpc_service_owners(HashMap::from([(service.to_owned(), "authz".to_owned())]));
1650
1651        // A squatter registering *first* cannot claim a declared name; it is
1652        // rejected with the declared owner, and the store is untouched.
1653        let conflict = mgr
1654            .register_instance(Arc::new(
1655                GearInstance::new("evil", Uuid::new_v4())
1656                    .with_grpc_service(service, Endpoint::http("127.0.0.1", 9001)),
1657            ))
1658            .unwrap_err();
1659        assert_eq!(conflict.service_name, service);
1660        assert_eq!(conflict.owner, "authz");
1661        assert!(
1662            !conflict.recoverable,
1663            "a declared-owner conflict is pinned by config (permanent)"
1664        );
1665        assert!(mgr.instances_of("evil").is_empty());
1666
1667        // The declared owner is always admitted, even coming in second.
1668        mgr.register_instance(Arc::new(
1669            GearInstance::new("authz", Uuid::new_v4())
1670                .with_grpc_service(service, Endpoint::http("127.0.0.1", 9000)),
1671        ))
1672        .expect("the declared owner must be admitted");
1673        assert_eq!(mgr.grpc_service_owner(service).as_deref(), Some("authz"));
1674    }
1675
1676    /// A gear can own an undeclared name via first-registration, after which a
1677    /// `set_grpc_service_owners` call reassigns that name to a different declared
1678    /// owner. The declared owner is authorized, but must not begin advertising
1679    /// the name while the original holder is still serving it — that would split
1680    /// ownership between two advertisers. It is refused until the stale holder is
1681    /// gone, then admitted.
1682    #[test]
1683    fn declared_owner_refused_while_a_stale_advertiser_still_holds_the_name() {
1684        let mgr = GearManager::new();
1685        let service = "cf.authz.v1.AuthzService";
1686        let holder = Uuid::new_v4();
1687
1688        // `first` claims the name via first-registration (not yet declared).
1689        mgr.register_instance(Arc::new(
1690            GearInstance::new("first", holder)
1691                .with_grpc_service(service, Endpoint::http("127.0.0.1", 9000)),
1692        ))
1693        .expect("claiming an undeclared, unowned name must succeed");
1694
1695        // The map is (re)installed, declaring the name is owned by `authz`.
1696        mgr.set_grpc_service_owners(HashMap::from([(service.to_owned(), "authz".to_owned())]));
1697
1698        // The declared owner is authorized, but `first` is still advertising the
1699        // name: admitting `authz` now would split ownership, so it is rejected.
1700        let conflict = mgr
1701            .register_instance(Arc::new(
1702                GearInstance::new("authz", Uuid::new_v4())
1703                    .with_grpc_service(service, Endpoint::http("127.0.0.1", 9001)),
1704            ))
1705            .unwrap_err();
1706        assert_eq!(conflict.service_name, service);
1707        assert_eq!(
1708            conflict.owner, "first",
1709            "the current holder is the conflicting owner"
1710        );
1711        assert!(
1712            conflict.recoverable,
1713            "a stale-advertiser conflict clears once the holder deregisters"
1714        );
1715        assert!(mgr.instances_of("authz").is_empty());
1716
1717        // Once the stale holder is gone, the declared owner is admitted and owns
1718        // the name outright — no split.
1719        mgr.deregister("first", holder);
1720        mgr.register_instance(Arc::new(
1721            GearInstance::new("authz", Uuid::new_v4())
1722                .with_grpc_service(service, Endpoint::http("127.0.0.1", 9002)),
1723        ))
1724        .expect("the declared owner must be admitted once the stale holder is gone");
1725        assert_eq!(mgr.grpc_service_owner(service).as_deref(), Some("authz"));
1726    }
1727
1728    /// The compiled-in (authoritative) map overrides a conflicting operator
1729    /// entry — the binary that *provides* a service name wins over config that
1730    /// tries to reassign it — while operator entries for names the binary does
1731    /// not know about are preserved. A subsequent registration is governed by
1732    /// the merged result.
1733    #[test]
1734    fn authoritative_owners_override_conflicting_config_and_keep_the_rest() {
1735        let mgr = GearManager::new();
1736
1737        // Operator config: one name that a compiled gear also provides (a
1738        // reassignment attempt), and one the binary does not compile in.
1739        mgr.set_grpc_service_owners(HashMap::from([
1740            ("cf.authz.v1.AuthzService".to_owned(), "impostor".to_owned()),
1741            ("remote.only.Service".to_owned(), "remote-gear".to_owned()),
1742        ]));
1743
1744        // Compiled registry says authz-resolver provides the authz service.
1745        mgr.merge_authoritative_grpc_service_owners(HashMap::from([(
1746            "cf.authz.v1.AuthzService".to_owned(),
1747            "authz-resolver".to_owned(),
1748        )]));
1749
1750        // Compiled-in provider wins the conflict; the remote-only entry stays.
1751        let conflict = mgr
1752            .register_instance(Arc::new(
1753                GearInstance::new("impostor", Uuid::new_v4()).with_grpc_service(
1754                    "cf.authz.v1.AuthzService",
1755                    Endpoint::http("127.0.0.1", 9000),
1756                ),
1757            ))
1758            .unwrap_err();
1759        assert_eq!(conflict.owner, "authz-resolver");
1760        assert!(
1761            !conflict.recoverable,
1762            "a pinned compiled-in owner is permanent"
1763        );
1764
1765        mgr.register_instance(Arc::new(
1766            GearInstance::new("authz-resolver", Uuid::new_v4()).with_grpc_service(
1767                "cf.authz.v1.AuthzService",
1768                Endpoint::http("127.0.0.1", 9001),
1769            ),
1770        ))
1771        .expect("the compiled-in provider owns its service name");
1772
1773        // The operator entry for the name absent from the compiled map is intact.
1774        let remote_conflict = mgr
1775            .register_instance(Arc::new(
1776                GearInstance::new("squatter", Uuid::new_v4())
1777                    .with_grpc_service("remote.only.Service", Endpoint::http("127.0.0.1", 9002)),
1778            ))
1779            .unwrap_err();
1780        assert_eq!(remote_conflict.owner, "remote-gear");
1781    }
1782
1783    #[test]
1784    fn names_absent_from_declared_map_keep_first_registration_ownership() {
1785        let mgr = GearManager::new();
1786        // The map declares one name but says nothing about `worker.Svc`.
1787        mgr.set_grpc_service_owners(HashMap::from([(
1788            "cf.authz.v1.AuthzService".to_owned(),
1789            "authz".to_owned(),
1790        )]));
1791        let service = "worker.Svc";
1792
1793        mgr.register_instance(Arc::new(
1794            GearInstance::new("worker-a", Uuid::new_v4())
1795                .with_grpc_service(service, Endpoint::http("127.0.0.1", 9000)),
1796        ))
1797        .expect("claiming an undeclared, unowned name must succeed");
1798
1799        let conflict = mgr
1800            .register_instance(Arc::new(
1801                GearInstance::new("worker-b", Uuid::new_v4())
1802                    .with_grpc_service(service, Endpoint::http("127.0.0.1", 9001)),
1803            ))
1804            .unwrap_err();
1805        assert_eq!(conflict.owner, "worker-a");
1806        assert!(
1807            conflict.recoverable,
1808            "a first-registration conflict clears if the holder leaves"
1809        );
1810    }
1811
1812    /// Under contention, the atomic check+insert admits exactly one owner for a
1813    /// gRPC service name: N gears race to claim the same unowned name and only
1814    /// one commits, closing the check-then-write race the per-entry `DashMap`
1815    /// locks leave open.
1816    #[test]
1817    fn concurrent_register_admits_exactly_one_owner() {
1818        use std::sync::Barrier;
1819
1820        let mgr = Arc::new(GearManager::new());
1821        let service = "cf.authz.v1.AuthzService";
1822        let racers = 16;
1823        let barrier = Arc::new(Barrier::new(racers));
1824
1825        #[expect(
1826            clippy::needless_collect,
1827            reason = "a lazy iterator would join before all racers spawn, deadlocking the Barrier"
1828        )]
1829        let handles: Vec<_> = (0..racers)
1830            .map(|i| {
1831                let mgr = Arc::clone(&mgr);
1832                let barrier = Arc::clone(&barrier);
1833                std::thread::spawn(move || {
1834                    let gear = format!("gear-{i}");
1835                    let instance = Arc::new(
1836                        GearInstance::new(gear.clone(), Uuid::new_v4()).with_grpc_service(
1837                            service,
1838                            Endpoint::http("127.0.0.1", 9000 + u16::try_from(i).unwrap()),
1839                        ),
1840                    );
1841                    // Release all threads at once to maximise contention.
1842                    barrier.wait();
1843                    (gear, mgr.register_instance(instance))
1844                })
1845            })
1846            .collect();
1847
1848        let results: Vec<(String, Result<(), GrpcServiceNameConflict>)> = handles
1849            .into_iter()
1850            .map(|h| h.join().expect("registration thread must not panic"))
1851            .collect();
1852
1853        // Exactly one gear wins the race.
1854        let winners: Vec<&str> = results
1855            .iter()
1856            .filter(|(_, r)| r.is_ok())
1857            .map(|(gear, _)| gear.as_str())
1858            .collect();
1859        assert_eq!(
1860            winners.len(),
1861            1,
1862            "exactly one competing registration must win"
1863        );
1864        let winner = winners[0];
1865
1866        // Every loser is rejected with a conflict naming the winning gear — the
1867        // `owner` surfaced in the server-side warn log and the operator's
1868        // remediation hint, so it must be the gear that actually committed, not
1869        // just any error.
1870        for (gear, result) in &results {
1871            if gear == winner {
1872                continue;
1873            }
1874            let conflict = result
1875                .as_ref()
1876                .expect_err("a losing registration must be rejected, not silently dropped");
1877            assert_eq!(conflict.service_name, service);
1878            assert_eq!(
1879                conflict.owner.as_str(),
1880                winner,
1881                "every loser's conflict must name the single winning gear as owner"
1882            );
1883        }
1884
1885        // ...and the store resolves the contested name to exactly that winner.
1886        assert_eq!(
1887            mgr.grpc_service_owner(service).as_deref(),
1888            Some(winner),
1889            "the contested name must resolve to the one gear that won"
1890        );
1891    }
1892}