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 {
126 GearInstance {
127 gear: other.gear.clone(),
128 instance_id: other.instance_id,
129 control: other.control.clone(),
130 grpc_services: other.grpc_services.clone(),
131 version: other.version.clone(),
132 rest_endpoint: other.rest_endpoint.clone(),
133 openapi_spec: other.openapi_spec.clone(),
134 labels: if other.labels.is_empty() {
138 self.labels.clone()
139 } else {
140 other.labels.clone()
141 },
142 inner: Arc::clone(&self.inner),
143 }
144 }
145}
146
147impl GearInstance {
148 pub fn new(gear: impl Into<String>, instance_id: Uuid) -> Self {
149 Self {
150 gear: gear.into(),
151 instance_id,
152 control: None,
153 grpc_services: HashMap::new(),
154 version: None,
155 rest_endpoint: None,
156 openapi_spec: None,
157 labels: BTreeMap::new(),
158 inner: Arc::new(parking_lot::RwLock::new(InstanceRuntimeState {
159 last_heartbeat: Instant::now(),
160 state: InstanceState::Registered,
161 })),
162 }
163 }
164
165 pub fn with_control(mut self, ep: Endpoint) -> Self {
166 self.control = Some(ep);
167 self
168 }
169
170 pub fn with_version(mut self, v: impl Into<String>) -> Self {
171 self.version = Some(v.into());
172 self
173 }
174
175 pub fn with_grpc_service(mut self, name: impl Into<String>, ep: Endpoint) -> Self {
176 self.grpc_services.insert(name.into(), ep);
177 self
178 }
179
180 pub fn with_rest_endpoint(mut self, ep: Endpoint) -> Self {
181 self.rest_endpoint = Some(ep);
182 self
183 }
184
185 pub fn with_openapi_spec(mut self, spec: impl Into<String>) -> Self {
186 self.openapi_spec = Some(spec.into());
187 self
188 }
189
190 pub fn with_labels(mut self, labels: BTreeMap<String, String>) -> Self {
191 self.labels = labels;
192 self
193 }
194
195 #[must_use]
197 pub fn state(&self) -> InstanceState {
198 self.inner.read().state
199 }
200
201 #[must_use]
203 pub fn last_heartbeat(&self) -> Instant {
204 self.inner.read().last_heartbeat
205 }
206}
207
208#[derive(Clone)]
211#[must_use]
212pub struct GearManager {
213 inner: DashMap<String, Vec<Arc<GearInstance>>>,
214 rr_counters: DashMap<String, usize>,
215 hb_ttl: Duration,
216 hb_grace: Duration,
217}
218
219impl std::fmt::Debug for GearManager {
220 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
221 let gears: Vec<String> = self.inner.iter().map(|e| e.key().clone()).collect();
222 f.debug_struct("GearManager")
223 .field("instances_count", &self.inner.len())
224 .field("gears", &gears)
225 .field("heartbeat_ttl", &self.hb_ttl)
226 .field("heartbeat_grace", &self.hb_grace)
227 .finish_non_exhaustive()
228 }
229}
230
231impl GearManager {
232 pub fn new() -> Self {
233 Self {
234 inner: DashMap::new(),
235 rr_counters: DashMap::new(),
236 hb_ttl: Duration::from_secs(15),
237 hb_grace: Duration::from_secs(30),
238 }
239 }
240
241 pub fn with_heartbeat_policy(mut self, ttl: Duration, grace: Duration) -> Self {
242 self.hb_ttl = ttl;
243 self.hb_grace = grace;
244 self
245 }
246
247 pub fn register_instance(&self, instance: Arc<GearInstance>) {
258 let gear = instance.gear.clone();
259 let mut vec = self.inner.entry(gear).or_default();
260 if let Some(pos) = vec
262 .iter()
263 .position(|i| i.instance_id == instance.instance_id)
264 {
265 vec[pos] = Arc::new(vec[pos].with_metadata_of(&instance));
266 } else {
267 vec.push(instance);
268 }
269 }
270
271 pub fn mark_ready(&self, gear: &str, instance_id: Uuid) {
273 if let Some(mut vec) = self.inner.get_mut(gear)
274 && let Some(inst) = vec.iter_mut().find(|i| i.instance_id == instance_id)
275 {
276 let mut state = inst.inner.write();
277 state.state = InstanceState::Ready;
278 }
279 }
280
281 pub fn update_heartbeat(&self, gear: &str, instance_id: Uuid, at: Instant) {
283 if let Some(mut vec) = self.inner.get_mut(gear)
284 && let Some(inst) = vec.iter_mut().find(|i| i.instance_id == instance_id)
285 {
286 let mut state = inst.inner.write();
287 state.last_heartbeat = at;
288 if state.state == InstanceState::Registered {
290 state.state = InstanceState::Healthy;
291 }
292 }
293 }
294
295 pub fn mark_quarantined(&self, gear: &str, instance_id: Uuid) {
297 if let Some(mut vec) = self.inner.get_mut(gear)
298 && let Some(inst) = vec.iter_mut().find(|i| i.instance_id == instance_id)
299 {
300 inst.inner.write().state = InstanceState::Quarantined;
301 }
302 }
303
304 pub fn mark_draining(&self, gear: &str, instance_id: Uuid) {
306 if let Some(mut vec) = self.inner.get_mut(gear)
307 && let Some(inst) = vec.iter_mut().find(|i| i.instance_id == instance_id)
308 {
309 inst.inner.write().state = InstanceState::Draining;
310 }
311 }
312
313 pub fn deregister(&self, gear: &str, instance_id: Uuid) {
315 let mut remove_gear = false;
316 {
317 if let Some(mut vec) = self.inner.get_mut(gear) {
318 let list = vec.value_mut();
319 list.retain(|inst| inst.instance_id != instance_id);
320 if list.is_empty() {
321 remove_gear = true;
322 }
323 }
324 }
325
326 if remove_gear {
327 self.inner.remove(gear);
328 self.rr_counters.remove(gear);
329 self.rr_counters.remove(&format!("rest:{gear}"));
330 }
331 }
332
333 #[must_use]
335 pub fn instances_of(&self, gear: &str) -> Vec<Arc<GearInstance>> {
336 self.inner.get(gear).map(|v| v.clone()).unwrap_or_default()
337 }
338
339 #[must_use]
341 pub fn all_instances(&self) -> Vec<Arc<GearInstance>> {
342 self.inner
343 .iter()
344 .flat_map(|entry| entry.value().clone())
345 .collect()
346 }
347
348 pub fn evict_stale(&self, now: Instant) {
350 use InstanceState::{Draining, Quarantined};
351 let mut empty_gears = Vec::new();
352
353 for mut entry in self.inner.iter_mut() {
354 let gear = entry.key().clone();
355 let vec = entry.value_mut();
356 vec.retain(|inst| {
357 let state = inst.inner.read();
358 let age = now.saturating_duration_since(state.last_heartbeat);
359
360 if age >= self.hb_ttl && !matches!(state.state, Quarantined | Draining) {
362 drop(state); inst.inner.write().state = Quarantined;
364 return true; }
366
367 if state.state == Quarantined && age >= self.hb_ttl + self.hb_grace {
369 return false; }
371
372 true
373 });
374
375 if vec.is_empty() {
376 empty_gears.push(gear);
377 }
378 }
379
380 for gear in empty_gears {
381 self.inner.remove(&gear);
382 self.rr_counters.remove(&gear);
383 self.rr_counters.remove(&format!("rest:{gear}"));
384 }
385 }
386
387 fn prefer_serving(candidates: Vec<Arc<GearInstance>>, context: &str) -> Vec<Arc<GearInstance>> {
392 let serving: Vec<Arc<GearInstance>> = candidates
393 .iter()
394 .filter(|inst| matches!(inst.state(), InstanceState::Healthy | InstanceState::Ready))
395 .cloned()
396 .collect();
397 if serving.is_empty() {
398 tracing::debug!(
399 context,
400 "no serving (Ready/Healthy) instance available; round-robining over the \
401 not-ready set instead of returning None"
402 );
403 candidates
404 } else {
405 serving
406 }
407 }
408
409 #[must_use]
411 pub fn pick_instance_round_robin(&self, gear: &str) -> Option<Arc<GearInstance>> {
412 let instances_entry = self.inner.get(gear)?;
413 let instances = instances_entry.value();
414
415 if instances.is_empty() {
416 return None;
417 }
418
419 let candidates = Self::prefer_serving(instances.clone(), gear);
421
422 let len = candidates.len();
423 let mut counter = self.rr_counters.entry(gear.to_owned()).or_insert(0);
424 let idx = *counter % len;
425 *counter = (*counter + 1) % len;
426
427 candidates.get(idx).cloned()
428 }
429
430 #[must_use]
433 pub fn pick_service_round_robin(
434 &self,
435 service_name: &str,
436 ) -> Option<(String, Arc<GearInstance>, Endpoint)> {
437 let mut providing: Vec<Arc<GearInstance>> = Vec::new();
443 for entry in &self.inner {
444 for inst in entry.value() {
445 if inst.grpc_services.contains_key(service_name) {
446 providing.push(inst.clone());
447 }
448 }
449 }
450
451 if providing.is_empty() {
452 return None;
453 }
454
455 let mut candidates = Self::prefer_serving(providing, service_name);
456
457 let len = candidates.len();
459 let service_key = service_name.to_owned();
460 let mut counter = self.rr_counters.entry(service_key).or_insert(0);
461 let idx = *counter % len;
462 *counter = (*counter + 1) % len;
463
464 let inst = candidates.swap_remove(idx);
465 let endpoint = inst.grpc_services.get(service_name)?.clone();
466 let gear = inst.gear.clone();
467 Some((gear, inst, endpoint))
468 }
469
470 #[must_use]
473 pub fn pick_rest_endpoint_round_robin(&self, gear: &str) -> Option<Endpoint> {
474 let instances_entry = self.inner.get(gear)?;
475 let instances = instances_entry.value();
476
477 let with_rest: Vec<_> = instances
479 .iter()
480 .filter(|inst| inst.rest_endpoint.is_some())
481 .cloned()
482 .collect();
483
484 if with_rest.is_empty() {
485 return None;
486 }
487
488 let candidates = Self::prefer_serving(with_rest, gear);
489
490 let len = candidates.len();
491 let rr_key = format!("rest:{gear}");
492 let mut counter = self.rr_counters.entry(rr_key).or_insert(0);
493 let idx = *counter % len;
494 *counter = (*counter + 1) % len;
495
496 candidates
497 .get(idx)
498 .and_then(|inst| inst.rest_endpoint.clone())
499 }
500
501 #[must_use]
504 pub fn openapi_spec_of(&self, gear: &str) -> Option<String> {
505 let instances_entry = self.inner.get(gear)?;
506 instances_entry
507 .value()
508 .iter()
509 .find_map(|inst| inst.openapi_spec.clone())
510 }
511}
512
513impl Default for GearManager {
514 fn default() -> Self {
515 Self::new()
516 }
517}
518
519#[cfg(test)]
520#[cfg_attr(coverage_nightly, coverage(off))]
521mod tests {
522 use super::*;
523 use std::thread::sleep;
524 use std::time::Duration;
525
526 #[test]
527 fn test_register_and_retrieve_instances() {
528 let dir = GearManager::new();
529 let instance_id = Uuid::new_v4();
530 let instance = Arc::new(
531 GearInstance::new("test_gear", instance_id)
532 .with_control(Endpoint::http("localhost", 8080))
533 .with_version("1.0.0"),
534 );
535
536 dir.register_instance(instance);
537
538 let instances = dir.instances_of("test_gear");
539 assert_eq!(instances.len(), 1);
540 assert_eq!(instances[0].instance_id, instance_id);
541 assert_eq!(instances[0].gear, "test_gear");
542 assert_eq!(instances[0].version, Some("1.0.0".to_owned()));
543 }
544
545 fn labels(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
546 pairs
547 .iter()
548 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
549 .collect()
550 }
551
552 #[test]
553 fn reregister_without_labels_preserves_stored_labels() {
554 let dir = GearManager::new();
555 let instance_id = Uuid::new_v4();
556
557 dir.register_instance(Arc::new(
559 GearInstance::new("shard-gear", instance_id).with_labels(labels(&[("shard", "7")])),
560 ));
561
562 dir.register_instance(Arc::new(
565 GearInstance::new("shard-gear", instance_id).with_version("2.0.0"),
566 ));
567
568 let registered = dir.instances_of("shard-gear");
569 assert_eq!(registered.len(), 1);
570 assert_eq!(
571 registered[0].labels.get("shard"),
572 Some(&"7".to_owned()),
573 "label-less re-registration must preserve stored labels"
574 );
575 assert_eq!(registered[0].version, Some("2.0.0".to_owned()));
576 }
577
578 #[test]
579 fn reregister_with_labels_replaces_stored_labels() {
580 let dir = GearManager::new();
581 let instance_id = Uuid::new_v4();
582
583 dir.register_instance(Arc::new(
584 GearInstance::new("shard-gear", instance_id).with_labels(labels(&[("shard", "7")])),
585 ));
586 dir.register_instance(Arc::new(
588 GearInstance::new("shard-gear", instance_id).with_labels(labels(&[("shard", "8")])),
589 ));
590
591 let registered = dir.instances_of("shard-gear");
592 assert_eq!(registered.len(), 1);
593 assert_eq!(registered[0].labels.get("shard"), Some(&"8".to_owned()));
594 }
595
596 #[test]
597 fn test_register_multiple_instances() {
598 let dir = GearManager::new();
599
600 let id1 = Uuid::new_v4();
601 let id2 = Uuid::new_v4();
602 let instance1 = Arc::new(GearInstance::new("test_gear", id1));
603 let instance2 = Arc::new(GearInstance::new("test_gear", id2));
604
605 dir.register_instance(instance1);
606 dir.register_instance(instance2);
607
608 let registered = dir.instances_of("test_gear");
609 assert_eq!(registered.len(), 2);
610
611 let ids: Vec<_> = registered.iter().map(|i| i.instance_id).collect();
612 assert!(ids.contains(&id1));
613 assert!(ids.contains(&id2));
614 }
615
616 #[test]
617 fn test_update_existing_instance() {
618 let dir = GearManager::new();
619 let instance_id = Uuid::new_v4();
620
621 let initial_instance =
622 Arc::new(GearInstance::new("test_gear", instance_id).with_version("1.0.0"));
623 dir.register_instance(initial_instance);
624
625 let updated_instance =
626 Arc::new(GearInstance::new("test_gear", instance_id).with_version("2.0.0"));
627 dir.register_instance(updated_instance);
628
629 let registered = dir.instances_of("test_gear");
630 assert_eq!(registered.len(), 1, "Should not duplicate instance");
631 assert_eq!(registered[0].version, Some("2.0.0".to_owned()));
632 }
633
634 #[test]
635 fn test_reregistration_preserves_liveness_state() {
636 let dir = GearManager::new();
637 let instance_id = Uuid::new_v4();
638
639 dir.register_instance(Arc::new(
641 GearInstance::new("test_gear", instance_id).with_version("1.0.0"),
642 ));
643 dir.update_heartbeat("test_gear", instance_id, Instant::now());
644 assert!(matches!(
645 dir.instances_of("test_gear")[0].state(),
646 InstanceState::Healthy
647 ));
648
649 dir.register_instance(Arc::new(
653 GearInstance::new("test_gear", instance_id).with_version("2.0.0"),
654 ));
655
656 let instances = dir.instances_of("test_gear");
657 assert_eq!(instances.len(), 1);
658 assert!(
659 matches!(instances[0].state(), InstanceState::Healthy),
660 "re-registration must preserve the Healthy state"
661 );
662 assert_eq!(
663 instances[0].version,
664 Some("2.0.0".to_owned()),
665 "re-registration must still refresh metadata/endpoints"
666 );
667 }
668
669 #[test]
670 fn test_concurrent_reregister_and_heartbeat_preserves_state() {
671 let dir = GearManager::new();
672 let instance_id = Uuid::new_v4();
673
674 let initial = Arc::new(GearInstance::new("test_gear", instance_id).with_version("1.0.0"));
675 dir.register_instance(initial);
676 dir.update_heartbeat("test_gear", instance_id, Instant::now());
677 assert!(matches!(
678 dir.instances_of("test_gear")[0].state(),
679 InstanceState::Healthy
680 ));
681
682 let start = Instant::now();
683
684 std::thread::scope(|s| {
685 s.spawn(|| {
686 for _ in 0..1000 {
687 dir.update_heartbeat("test_gear", instance_id, Instant::now());
688 }
689 });
690 s.spawn(|| {
691 for i in 0..1000 {
692 let version = if i % 2 == 0 { "2.0.0" } else { "3.0.0" };
693 let reinst = Arc::new(
694 GearInstance::new("test_gear", instance_id)
695 .with_version(version)
696 .with_rest_endpoint(Endpoint::http(
697 "127.0.0.1",
698 8000u16 + u16::try_from(i % 10).expect("i % 10 fits in u16"),
699 )),
700 );
701 dir.register_instance(reinst);
702 }
703 });
704 });
705
706 let instances = dir.instances_of("test_gear");
707 assert_eq!(instances.len(), 1);
708 assert!(
709 matches!(instances[0].state(), InstanceState::Healthy),
710 "concurrent re-registration must not reset Healthy state"
711 );
712 assert!(
713 instances[0].last_heartbeat() >= start,
714 "concurrent re-registration must not lose heartbeat updates"
715 );
716 }
717
718 #[test]
719 fn test_mark_ready() {
720 let dir = GearManager::new();
721 let instance_id = Uuid::new_v4();
722 let instance = Arc::new(GearInstance::new("test_gear", instance_id));
723
724 dir.register_instance(instance);
725
726 dir.mark_ready("test_gear", instance_id);
727
728 let instances = dir.instances_of("test_gear");
729 assert_eq!(instances.len(), 1);
730 assert!(matches!(instances[0].state(), InstanceState::Ready));
731 }
732
733 #[test]
734 fn test_update_heartbeat() {
735 let dir = GearManager::new();
736 let instance_id = Uuid::new_v4();
737 let instance = Arc::new(GearInstance::new("test_gear", instance_id));
738 let initial_heartbeat = instance.last_heartbeat();
739
740 dir.register_instance(instance);
741
742 sleep(Duration::from_millis(10));
744
745 let new_heartbeat = Instant::now();
746 dir.update_heartbeat("test_gear", instance_id, new_heartbeat);
747
748 let instances = dir.instances_of("test_gear");
749 assert!(instances[0].last_heartbeat() > initial_heartbeat);
750 assert!(matches!(instances[0].state(), InstanceState::Healthy));
751 }
752
753 #[test]
754 fn test_all_instances() {
755 let dir = GearManager::new();
756
757 let instance1 = Arc::new(GearInstance::new("gear_a", Uuid::new_v4()));
758 let instance2 = Arc::new(GearInstance::new("gear_b", Uuid::new_v4()));
759 let instance3 = Arc::new(GearInstance::new("gear_a", Uuid::new_v4()));
760
761 dir.register_instance(instance1);
762 dir.register_instance(instance2);
763 dir.register_instance(instance3);
764
765 let all = dir.all_instances();
766 assert_eq!(all.len(), 3);
767
768 let gears: Vec<_> = all.iter().map(|i| i.gear.as_str()).collect();
769 assert_eq!(gears.iter().filter(|&m| *m == "gear_a").count(), 2);
770 assert_eq!(gears.iter().filter(|&m| *m == "gear_b").count(), 1);
771 }
772
773 #[test]
774 fn test_pick_instance_round_robin() {
775 let dir = GearManager::new();
776
777 let id1 = Uuid::new_v4();
778 let id2 = Uuid::new_v4();
779 let instance1 = Arc::new(GearInstance::new("test_gear", id1));
780 let instance2 = Arc::new(GearInstance::new("test_gear", id2));
781
782 dir.register_instance(instance1);
783 dir.register_instance(instance2);
784
785 let picked1 = dir.pick_instance_round_robin("test_gear").unwrap();
787 let picked2 = dir.pick_instance_round_robin("test_gear").unwrap();
788 let picked3 = dir.pick_instance_round_robin("test_gear").unwrap();
789
790 let ids = [
791 picked1.instance_id,
792 picked2.instance_id,
793 picked3.instance_id,
794 ];
795
796 assert!(ids.contains(&id1));
799 assert!(ids.contains(&id2));
800 assert_eq!(picked1.instance_id, picked3.instance_id);
802 assert_ne!(picked1.instance_id, picked2.instance_id);
804 }
805
806 #[test]
807 fn test_pick_instance_none_available() {
808 let dir = GearManager::new();
809 let picked = dir.pick_instance_round_robin("nonexistent_gear");
810 assert!(picked.is_none());
811 }
812
813 #[test]
814 fn test_endpoint_creation() {
815 let plain_ep = Endpoint::http("localhost", 8080);
816 assert_eq!(plain_ep.uri, "http://localhost:8080");
817
818 let secure_ep = Endpoint::https("localhost", 8443);
819 assert_eq!(secure_ep.uri, "https://localhost:8443");
820
821 let uds_ep = Endpoint::uds("/tmp/socket.sock");
822 assert!(uds_ep.uri.starts_with("unix://"));
823 assert!(uds_ep.uri.contains("socket.sock"));
824
825 let custom_ep = Endpoint::from_uri("http://example.com");
826 assert_eq!(custom_ep.uri, "http://example.com");
827 }
828
829 #[test]
830 fn test_endpoint_kind() {
831 let plain_ep = Endpoint::http("127.0.0.1", 8080);
832 match plain_ep.kind() {
833 EndpointKind::Tcp(addr) => {
834 assert_eq!(addr.ip().to_string(), "127.0.0.1");
835 assert_eq!(addr.port(), 8080);
836 }
837 _ => panic!("Expected TCP endpoint for http"),
838 }
839
840 let secure_ep = Endpoint::https("127.0.0.1", 8443);
841 match secure_ep.kind() {
842 EndpointKind::Tcp(addr) => {
843 assert_eq!(addr.ip().to_string(), "127.0.0.1");
844 assert_eq!(addr.port(), 8443);
845 }
846 _ => panic!("Expected TCP endpoint for https"),
847 }
848
849 let uds_ep = Endpoint::uds("/tmp/test.sock");
850 match uds_ep.kind() {
851 EndpointKind::Uds(path) => {
852 assert!(path.to_string_lossy().contains("test.sock"));
853 }
854 _ => panic!("Expected UDS endpoint"),
855 }
856
857 let other_ep = Endpoint::from_uri("grpc://example.com");
858 match other_ep.kind() {
859 EndpointKind::Other(uri) => {
860 assert_eq!(uri, "grpc://example.com");
861 }
862 _ => panic!("Expected Other endpoint"),
863 }
864 }
865
866 #[test]
867 fn test_gear_instance_builder() {
868 let instance_id = Uuid::new_v4();
869 let instance = GearInstance::new("test_gear", instance_id)
870 .with_control(Endpoint::http("localhost", 8080))
871 .with_version("1.2.3")
872 .with_grpc_service("service1", Endpoint::http("localhost", 8082))
873 .with_grpc_service("service2", Endpoint::http("localhost", 8083));
874
875 assert_eq!(instance.gear, "test_gear");
876 assert_eq!(instance.instance_id, instance_id);
877 assert!(instance.control.is_some());
878 assert_eq!(instance.version, Some("1.2.3".to_owned()));
879 assert_eq!(instance.grpc_services.len(), 2);
880 assert!(instance.grpc_services.contains_key("service1"));
881 assert!(instance.grpc_services.contains_key("service2"));
882 assert!(matches!(instance.state(), InstanceState::Registered));
883 }
884
885 #[test]
886 fn test_quarantine_and_evict() {
887 let ttl = Duration::from_millis(50);
888 let grace = Duration::from_millis(50);
889 let dir = GearManager::new().with_heartbeat_policy(ttl, grace);
890
891 let now = Instant::now();
892 let instance = GearInstance::new("test_gear", Uuid::new_v4());
893 instance.inner.write().last_heartbeat = now
895 .checked_sub(ttl)
896 .and_then(|t| t.checked_sub(Duration::from_millis(10)))
897 .expect("test duration subtraction should not underflow");
898
899 dir.register_instance(Arc::new(instance));
900
901 dir.evict_stale(now);
902 let instances = dir.instances_of("test_gear");
903 assert_eq!(instances.len(), 1);
904 assert!(matches!(instances[0].state(), InstanceState::Quarantined));
905
906 let later = now + grace + Duration::from_millis(10);
907 dir.evict_stale(later);
908
909 let instances_after = dir.instances_of("test_gear");
910 assert!(instances_after.is_empty());
911 }
912
913 #[test]
914 fn test_instances_of_empty() {
915 let dir = GearManager::new();
916 let instances = dir.instances_of("nonexistent");
917 assert!(instances.is_empty());
918 }
919
920 #[test]
921 fn test_rr_prefers_healthy() {
922 let dir = GearManager::new();
923
924 let healthy_id = Uuid::new_v4();
926 let healthy = Arc::new(GearInstance::new("test_gear", healthy_id));
927 dir.register_instance(healthy);
928 dir.update_heartbeat("test_gear", healthy_id, Instant::now());
929
930 let quarantined_id = Uuid::new_v4();
931 let quarantined = Arc::new(GearInstance::new("test_gear", quarantined_id));
932 dir.register_instance(quarantined);
933 dir.mark_quarantined("test_gear", quarantined_id);
934
935 for _ in 0..5 {
937 let picked = dir.pick_instance_round_robin("test_gear").unwrap();
938 assert_eq!(picked.instance_id, healthy_id);
939 }
940 }
941
942 #[test]
943 fn test_pick_rest_endpoint_and_openapi() {
944 let dir = GearManager::new();
945 let id = Uuid::new_v4();
946 let instance = Arc::new(
947 GearInstance::new("billing", id)
948 .with_rest_endpoint(Endpoint::http("billing", 8080))
949 .with_openapi_spec("{\"openapi\":\"3.1.0\"}"),
950 );
951 dir.register_instance(instance);
952
953 let rest = dir.pick_rest_endpoint_round_robin("billing").unwrap();
954 assert_eq!(rest.uri, "http://billing:8080");
955
956 let spec = dir.openapi_spec_of("billing").unwrap();
957 assert!(spec.contains("openapi"));
958 }
959
960 #[test]
961 fn test_pick_rest_endpoint_none_when_absent() {
962 let dir = GearManager::new();
963 let id = Uuid::new_v4();
964 let instance = Arc::new(
966 GearInstance::new("grpc_only", id)
967 .with_grpc_service("some.Service", Endpoint::http("127.0.0.1", 9000)),
968 );
969 dir.register_instance(instance);
970
971 assert!(dir.pick_rest_endpoint_round_robin("grpc_only").is_none());
972 assert!(dir.openapi_spec_of("grpc_only").is_none());
973 assert!(dir.pick_rest_endpoint_round_robin("missing").is_none());
974 }
975
976 #[test]
977 fn test_pick_rest_endpoint_round_robin_rotates() {
978 let dir = GearManager::new();
979 let id1 = Uuid::new_v4();
980 let id2 = Uuid::new_v4();
981 let inst1 = Arc::new(
982 GearInstance::new("web", id1).with_rest_endpoint(Endpoint::http("127.0.0.1", 8001)),
983 );
984 let inst2 = Arc::new(
985 GearInstance::new("web", id2).with_rest_endpoint(Endpoint::http("127.0.0.1", 8002)),
986 );
987 dir.register_instance(inst1);
988 dir.register_instance(inst2);
989 dir.update_heartbeat("web", id1, Instant::now());
990 dir.update_heartbeat("web", id2, Instant::now());
991
992 let ep1 = dir.pick_rest_endpoint_round_robin("web").unwrap();
993 let ep2 = dir.pick_rest_endpoint_round_robin("web").unwrap();
994 let ep3 = dir.pick_rest_endpoint_round_robin("web").unwrap();
995
996 assert_ne!(ep1, ep2);
997 assert_eq!(ep1, ep3);
998 }
999
1000 #[test]
1001 fn test_pick_service_round_robin() {
1002 let dir = GearManager::new();
1003
1004 let id1 = Uuid::new_v4();
1005 let id2 = Uuid::new_v4();
1006 let inst1 = Arc::new(
1008 GearInstance::new("test_gear", id1)
1009 .with_grpc_service("test.Service", Endpoint::http("127.0.0.1", 8001)),
1010 );
1011 let inst2 = Arc::new(
1012 GearInstance::new("test_gear", id2)
1013 .with_grpc_service("test.Service", Endpoint::http("127.0.0.1", 8002)),
1014 );
1015
1016 dir.register_instance(inst1);
1017 dir.register_instance(inst2);
1018
1019 dir.update_heartbeat("test_gear", id1, Instant::now());
1021 dir.update_heartbeat("test_gear", id2, Instant::now());
1022
1023 let pick1 = dir.pick_service_round_robin("test.Service");
1025 let pick2 = dir.pick_service_round_robin("test.Service");
1026 let pick3 = dir.pick_service_round_robin("test.Service");
1027
1028 assert!(pick1.is_some());
1029 assert!(pick2.is_some());
1030 assert!(pick3.is_some());
1031
1032 let (_, inst1, ep1) = pick1.unwrap();
1033 let (_, inst2, ep2) = pick2.unwrap();
1034 let (_, inst3, _) = pick3.unwrap();
1035
1036 assert_eq!(inst1.instance_id, inst3.instance_id);
1038 assert_ne!(inst1.instance_id, inst2.instance_id);
1040 assert_ne!(ep1, ep2);
1042 }
1043
1044 #[test]
1045 fn pick_service_falls_back_to_not_ready() {
1046 let dir = GearManager::new();
1050 let id = Uuid::new_v4();
1051 dir.register_instance(Arc::new(
1052 GearInstance::new("worker", id)
1053 .with_grpc_service("worker.Svc", Endpoint::http("127.0.0.1", 9000)),
1054 ));
1055 assert!(matches!(
1057 dir.instances_of("worker")[0].state(),
1058 InstanceState::Registered
1059 ));
1060
1061 let picked = dir.pick_service_round_robin("worker.Svc");
1062 assert!(
1063 picked.is_some(),
1064 "gRPC service resolution must fall back to the not-ready instance"
1065 );
1066 let (gear, _, ep) = picked.unwrap();
1067 assert_eq!(gear, "worker");
1068 assert_eq!(ep, Endpoint::http("127.0.0.1", 9000));
1069 }
1070
1071 #[test]
1072 fn test_deregister_clears_rr_counters() {
1073 let dir = GearManager::new();
1074 let id = Uuid::new_v4();
1075 let instance = Arc::new(
1076 GearInstance::new("web", id).with_rest_endpoint(Endpoint::http("127.0.0.1", 8001)),
1077 );
1078 dir.register_instance(instance);
1079 dir.update_heartbeat("web", id, Instant::now());
1080
1081 assert!(dir.pick_instance_round_robin("web").is_some());
1083 assert!(dir.pick_rest_endpoint_round_robin("web").is_some());
1084
1085 assert!(dir.rr_counters.contains_key("web"));
1086 assert!(dir.rr_counters.contains_key("rest:web"));
1087
1088 dir.deregister("web", id);
1089
1090 assert!(!dir.rr_counters.contains_key("web"));
1091 assert!(!dir.rr_counters.contains_key("rest:web"));
1092 }
1093
1094 #[test]
1095 fn test_evict_stale_clears_rr_counters() {
1096 let ttl = Duration::from_millis(50);
1097 let grace = Duration::from_millis(50);
1098 let dir = GearManager::new().with_heartbeat_policy(ttl, grace);
1099
1100 let now = Instant::now();
1101 let id = Uuid::new_v4();
1102 let instance = Arc::new(
1103 GearInstance::new("web", id).with_rest_endpoint(Endpoint::http("127.0.0.1", 8001)),
1104 );
1105 instance.inner.write().last_heartbeat = now
1107 .checked_sub(ttl)
1108 .and_then(|t| t.checked_sub(Duration::from_millis(10)))
1109 .expect("test duration subtraction should not underflow");
1110
1111 dir.register_instance(instance);
1112 assert!(dir.pick_rest_endpoint_round_robin("web").is_some());
1113
1114 assert!(dir.rr_counters.contains_key("rest:web"));
1115
1116 dir.evict_stale(now);
1118 let instances = dir.instances_of("web");
1119 assert_eq!(instances.len(), 1);
1120 assert!(matches!(instances[0].state(), InstanceState::Quarantined));
1121
1122 let later = now + grace + Duration::from_millis(10);
1124 dir.evict_stale(later);
1125
1126 assert!(dir.instances_of("web").is_empty());
1127 assert!(!dir.rr_counters.contains_key("web"));
1128 assert!(!dir.rr_counters.contains_key("rest:web"));
1129 }
1130}