1use dashmap::DashMap;
4use std::collections::{BTreeMap, HashMap};
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7use uuid::Uuid;
8
9#[derive(Clone, Debug, PartialEq, Eq, Hash)]
11pub struct Endpoint {
12 pub uri: String,
13}
14
15#[derive(Clone, Debug, PartialEq, Eq)]
17pub enum EndpointKind {
18 Tcp(std::net::SocketAddr),
20 Uds(std::path::PathBuf),
22 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 #[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#[derive(Clone, Debug)]
82pub struct InstanceRuntimeState {
83 pub last_heartbeat: Instant,
84 pub state: InstanceState,
85}
86
87#[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 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 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 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 #[must_use]
212 pub fn state(&self) -> InstanceState {
213 self.inner.read().state
214 }
215
216 #[must_use]
218 pub fn last_heartbeat(&self) -> Instant {
219 self.inner.read().last_heartbeat
220 }
221}
222
223#[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 reg_lock: parking_lot::Mutex<()>,
249 service_owners: parking_lot::RwLock<HashMap<String, String>>,
257}
258
259#[derive(Debug, Clone, PartialEq, Eq)]
266pub struct GrpcServiceNameConflict {
267 pub service_name: String,
269 pub owner: String,
271 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 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 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 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 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 fn check_grpc_service_ownership(
434 &self,
435 instance: &GearInstance,
436 ) -> Result<(), GrpcServiceNameConflict> {
437 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 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 for entry in &self.inner {
464 if entry.key() == &instance.gear {
465 continue; }
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 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 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 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 if state.state == InstanceState::Registered {
504 state.state = InstanceState::Healthy;
505 }
506 }
507 }
508
509 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 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 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 #[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 #[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 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 if age >= self.hb_ttl && !matches!(state.state, Quarantined | Draining) {
576 drop(state); inst.inner.write().state = Quarantined;
578 return true; }
580
581 if state.state == Quarantined && age >= self.hb_ttl + self.hb_grace {
583 return false; }
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 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 #[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 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 #[must_use]
647 pub fn pick_service_round_robin(
648 &self,
649 service_name: &str,
650 ) -> Option<(String, Arc<GearInstance>, Endpoint)> {
651 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 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 #[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 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 #[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 #[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 #[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 seed(
804 &dir,
805 Arc::new(
806 GearInstance::new("shard-gear", instance_id).with_labels(labels(&[("shard", "7")])),
807 ),
808 );
809
810 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 #[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 #[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 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 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 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(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 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 assert!(ids.contains(&id1));
1123 assert!(ids.contains(&id2));
1124 assert_eq!(picked1.instance_id, picked3.instance_id);
1126 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 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 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 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 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 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 dir.update_heartbeat("test_gear", id1, Instant::now());
1345 dir.update_heartbeat("test_gear", id2, Instant::now());
1346
1347 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 assert_eq!(inst1.instance_id, inst3.instance_id);
1362 assert_ne!(inst1.instance_id, inst2.instance_id);
1364 assert_ne!(ep1, ep2);
1366 }
1367
1368 #[test]
1369 fn pick_service_falls_back_to_not_ready() {
1370 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 mgr.set_grpc_service_owners(HashMap::from([(service.to_owned(), "authz".to_owned())]));
1650
1651 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 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 #[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 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 mgr.set_grpc_service_owners(HashMap::from([(service.to_owned(), "authz".to_owned())]));
1697
1698 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 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 #[test]
1734 fn authoritative_owners_override_conflicting_config_and_keep_the_rest() {
1735 let mgr = GearManager::new();
1736
1737 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 mgr.merge_authoritative_grpc_service_owners(HashMap::from([(
1746 "cf.authz.v1.AuthzService".to_owned(),
1747 "authz-resolver".to_owned(),
1748 )]));
1749
1750 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 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 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 #[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 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 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 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 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}