Skip to main content

rust_zero_core/
discov.rs

1use std::{
2    collections::{BTreeMap, BTreeSet, HashMap},
3    fmt,
4    future::Future,
5    pin::Pin,
6    sync::{Arc, Mutex},
7    time::Duration,
8};
9
10use tokio::sync::broadcast;
11
12const EVENT_BUFFER_SIZE: usize = 128;
13const MAX_ENDPOINT_WEIGHT: u32 = 1_000;
14
15/// Capped exponential delay used by reconnecting discovery backends.
16///
17/// Jitter is an absolute upper bound added to each exponential delay. Keeping it as a duration
18/// makes the policy fully comparable and avoids floating-point configuration edge cases.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct DiscoveryReconnectBackoff {
21    initial: Duration,
22    max: Duration,
23    jitter: Duration,
24}
25
26impl Default for DiscoveryReconnectBackoff {
27    fn default() -> Self {
28        Self {
29            initial: Duration::from_millis(200),
30            max: Duration::from_secs(10),
31            jitter: Duration::from_millis(200),
32        }
33    }
34}
35
36impl DiscoveryReconnectBackoff {
37    pub fn new(initial: Duration, max: Duration, jitter: Duration) -> Self {
38        assert!(
39            !initial.is_zero(),
40            "discovery reconnect delay must be positive"
41        );
42        assert!(
43            max >= initial,
44            "discovery reconnect maximum must not be less than its initial delay"
45        );
46        Self {
47            initial,
48            max,
49            jitter,
50        }
51    }
52
53    pub fn initial(self) -> Duration {
54        self.initial
55    }
56
57    pub fn max(self) -> Duration {
58        self.max
59    }
60
61    pub fn jitter(self) -> Duration {
62        self.jitter
63    }
64
65    /// Returns the delay for a zero-based retry attempt and caller-provided jitter sample.
66    /// Supplying the sample makes reconnect schedules straightforward to test deterministically.
67    pub fn delay(self, attempt: u32, jitter_sample: u64) -> Duration {
68        let multiplier = 1_u32.checked_shl(attempt.min(31)).unwrap_or(u32::MAX);
69        let base = self.initial.saturating_mul(multiplier).min(self.max);
70        let jitter_nanos = self.jitter.as_nanos();
71        if jitter_nanos == 0 {
72            return base;
73        }
74        let sampled = u128::from(jitter_sample) % (jitter_nanos + 1);
75        base.saturating_add(Duration::from_nanos(
76            u64::try_from(sampled).unwrap_or(u64::MAX),
77        ))
78    }
79}
80
81#[cfg(test)]
82mod reconnect_tests {
83    use super::*;
84
85    #[test]
86    fn reconnect_backoff_is_exponential_capped_and_deterministic() {
87        let policy = DiscoveryReconnectBackoff::new(
88            Duration::from_millis(10),
89            Duration::from_millis(40),
90            Duration::from_millis(5),
91        );
92        assert_eq!(policy.delay(0, 0), Duration::from_millis(10));
93        assert_eq!(policy.delay(1, 1_000_000), Duration::from_millis(21));
94        assert_eq!(policy.delay(2, 2_000_000), Duration::from_millis(42));
95        assert_eq!(policy.delay(20, 5_000_001), Duration::from_millis(40));
96        assert_eq!(policy.delay(2, 123), policy.delay(2, 123));
97    }
98
99    #[test]
100    #[should_panic(expected = "discovery reconnect delay must be positive")]
101    fn reconnect_backoff_rejects_zero_initial_delay() {
102        DiscoveryReconnectBackoff::new(Duration::ZERO, Duration::from_secs(1), Duration::ZERO);
103    }
104}
105
106/// Transport-neutral metadata attached to a discovered service endpoint.
107///
108/// Weights are relative: an endpoint with weight `3` receives roughly three times as many
109/// selections as one with weight `1`. The upper bound prevents a malformed registry value from
110/// creating an unbounded number of balancing entries.
111#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
112pub struct DiscoveredEndpoint {
113    uri: String,
114    weight: u32,
115    metadata: BTreeMap<String, String>,
116}
117
118impl DiscoveredEndpoint {
119    pub fn new(uri: impl Into<String>) -> Result<Self, DiscoveryError> {
120        Self::weighted(uri, 1)
121    }
122
123    pub fn weighted(uri: impl Into<String>, weight: u32) -> Result<Self, DiscoveryError> {
124        Ok(Self {
125            uri: validate_endpoint(uri.into())?,
126            weight: validate_weight(weight)?,
127            metadata: BTreeMap::new(),
128        })
129    }
130
131    pub fn with_metadata(mut self, metadata: impl IntoIterator<Item = (String, String)>) -> Self {
132        self.metadata = metadata.into_iter().collect();
133        self
134    }
135
136    pub fn uri(&self) -> &str {
137        &self.uri
138    }
139
140    pub fn weight(&self) -> u32 {
141        self.weight
142    }
143
144    pub fn metadata(&self) -> &BTreeMap<String, String> {
145        &self.metadata
146    }
147}
148
149/// Future returned while waiting for a discovery snapshot to change.
150pub type EndpointChangeFuture<'a, E> =
151    Pin<Box<dyn Future<Output = Result<Vec<String>, E>> + Send + 'a>>;
152
153/// A live, complete snapshot of the endpoints for one logical service.
154///
155/// Discovery backends expose snapshots rather than backend-specific add/remove events so
156/// transports can recover consistently after a watch reconnect or a lagged consumer.
157pub trait EndpointSubscription: Send + 'static {
158    type Error: Send + 'static;
159
160    /// Returns the latest complete endpoint snapshot in stable order.
161    fn endpoints(&self) -> Vec<String>;
162
163    /// Returns endpoint metadata when the backend provides it.
164    ///
165    /// Existing discovery implementations remain source-compatible and receive weight `1`.
166    fn discovered_endpoints(&self) -> Vec<DiscoveredEndpoint> {
167        self.endpoints()
168            .into_iter()
169            .filter_map(|uri| DiscoveredEndpoint::new(uri).ok())
170            .collect()
171    }
172
173    /// Waits until a new complete endpoint snapshot is available.
174    fn changed(&mut self) -> EndpointChangeFuture<'_, Self::Error>;
175}
176
177/// A local service registry with reference-counted endpoint leases and change subscriptions.
178#[derive(Clone)]
179pub struct ServiceRegistry {
180    state: Arc<RegistryState>,
181}
182
183impl Default for ServiceRegistry {
184    fn default() -> Self {
185        let (changes, _) = broadcast::channel(EVENT_BUFFER_SIZE);
186        Self {
187            state: Arc::new(RegistryState {
188                services: Mutex::new(HashMap::new()),
189                changes,
190            }),
191        }
192    }
193}
194
195impl ServiceRegistry {
196    pub fn new() -> Self {
197        Self::default()
198    }
199
200    /// Publishes an endpoint until the returned lease is dropped or explicitly released.
201    pub fn publish(
202        &self,
203        service: impl Into<String>,
204        endpoint: impl Into<String>,
205    ) -> Result<ServiceLease, DiscoveryError> {
206        self.publish_endpoint(service, DiscoveredEndpoint::new(endpoint)?)
207    }
208
209    /// Publishes a weighted endpoint until the returned lease is released.
210    pub fn publish_weighted(
211        &self,
212        service: impl Into<String>,
213        endpoint: impl Into<String>,
214        weight: u32,
215    ) -> Result<ServiceLease, DiscoveryError> {
216        self.publish_endpoint(service, DiscoveredEndpoint::weighted(endpoint, weight)?)
217    }
218
219    /// Publishes a fully described endpoint until the returned lease is released.
220    pub fn publish_endpoint(
221        &self,
222        service: impl Into<String>,
223        endpoint: DiscoveredEndpoint,
224    ) -> Result<ServiceLease, DiscoveryError> {
225        let service = validate_service(service.into())?;
226        let uri = endpoint.uri.clone();
227        let added = {
228            let mut services = self
229                .state
230                .services
231                .lock()
232                .expect("service registry mutex poisoned");
233            let endpoints = services.entry(service.clone()).or_default();
234            match endpoints.get_mut(&uri) {
235                Some(entry) if entry.endpoint != endpoint => {
236                    return Err(DiscoveryError::ConflictingEndpointMetadata(uri));
237                }
238                Some(entry) => {
239                    entry.references += 1;
240                    false
241                }
242                None => {
243                    endpoints.insert(
244                        uri.clone(),
245                        RegistryEndpoint {
246                            endpoint,
247                            references: 1,
248                        },
249                    );
250                    true
251                }
252            }
253        };
254
255        if added {
256            let _ = self.state.changes.send(ServiceEvent::Added {
257                service: service.clone(),
258                endpoint: uri.clone(),
259            });
260        }
261
262        Ok(ServiceLease {
263            state: Arc::clone(&self.state),
264            service,
265            endpoint: uri,
266            active: true,
267        })
268    }
269
270    /// Returns the currently published endpoints for a service in stable order.
271    pub fn endpoints(&self, service: &str) -> Result<Vec<String>, DiscoveryError> {
272        let service = validate_service(service.to_owned())?;
273        let services = self
274            .state
275            .services
276            .lock()
277            .expect("service registry mutex poisoned");
278        Ok(services
279            .get(&service)
280            .into_iter()
281            .flat_map(|endpoints| endpoints.keys())
282            .cloned()
283            .collect())
284    }
285
286    /// Returns the currently published endpoint metadata in stable URI order.
287    pub fn discovered_endpoints(
288        &self,
289        service: &str,
290    ) -> Result<Vec<DiscoveredEndpoint>, DiscoveryError> {
291        let service = validate_service(service.to_owned())?;
292        let services = self
293            .state
294            .services
295            .lock()
296            .expect("service registry mutex poisoned");
297        Ok(services
298            .get(&service)
299            .into_iter()
300            .flat_map(|endpoints| endpoints.values())
301            .map(|entry| entry.endpoint.clone())
302            .collect())
303    }
304
305    /// Subscribes to endpoint changes and includes the current endpoint snapshot.
306    pub fn subscribe(
307        &self,
308        service: impl Into<String>,
309    ) -> Result<ServiceSubscription, DiscoveryError> {
310        let service = validate_service(service.into())?;
311        let receiver = self.state.changes.subscribe();
312        let endpoints = self
313            .state
314            .services
315            .lock()
316            .expect("service registry mutex poisoned")
317            .get(&service)
318            .into_iter()
319            .flat_map(|endpoints| endpoints.keys())
320            .cloned()
321            .collect();
322
323        Ok(ServiceSubscription {
324            service,
325            endpoints,
326            receiver,
327            state: Arc::clone(&self.state),
328        })
329    }
330}
331
332struct RegistryEndpoint {
333    endpoint: DiscoveredEndpoint,
334    references: usize,
335}
336
337struct RegistryState {
338    services: Mutex<HashMap<String, BTreeMap<String, RegistryEndpoint>>>,
339    changes: broadcast::Sender<ServiceEvent>,
340}
341
342/// A published endpoint. Dropping it withdraws the endpoint when its final lease is released.
343pub struct ServiceLease {
344    state: Arc<RegistryState>,
345    service: String,
346    endpoint: String,
347    active: bool,
348}
349
350impl fmt::Debug for ServiceLease {
351    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
352        formatter
353            .debug_struct("ServiceLease")
354            .field("service", &self.service)
355            .field("endpoint", &self.endpoint)
356            .field("active", &self.active)
357            .finish_non_exhaustive()
358    }
359}
360
361impl ServiceLease {
362    /// Withdraws this lease early. Further calls are harmless.
363    pub fn release(&mut self) {
364        if !self.active {
365            return;
366        }
367        self.active = false;
368
369        let removed = {
370            let mut services = self
371                .state
372                .services
373                .lock()
374                .expect("service registry mutex poisoned");
375            let Some(endpoints) = services.get_mut(&self.service) else {
376                return;
377            };
378            let Some(entry) = endpoints.get_mut(&self.endpoint) else {
379                return;
380            };
381
382            entry.references -= 1;
383            if entry.references > 0 {
384                false
385            } else {
386                endpoints.remove(&self.endpoint);
387                if endpoints.is_empty() {
388                    services.remove(&self.service);
389                }
390                true
391            }
392        };
393
394        if removed {
395            let _ = self.state.changes.send(ServiceEvent::Removed {
396                service: self.service.clone(),
397                endpoint: self.endpoint.clone(),
398            });
399        }
400    }
401}
402
403impl Drop for ServiceLease {
404    fn drop(&mut self) {
405        self.release();
406    }
407}
408
409/// Receives dynamic endpoint changes for one service.
410pub struct ServiceSubscription {
411    service: String,
412    endpoints: BTreeSet<String>,
413    receiver: broadcast::Receiver<ServiceEvent>,
414    state: Arc<RegistryState>,
415}
416
417impl ServiceSubscription {
418    /// Returns the known endpoint set in stable order.
419    pub fn endpoints(&self) -> Vec<String> {
420        self.endpoints.iter().cloned().collect()
421    }
422
423    pub fn discovered_endpoints(&self) -> Vec<DiscoveredEndpoint> {
424        self.state
425            .services
426            .lock()
427            .expect("service registry mutex poisoned")
428            .get(&self.service)
429            .into_iter()
430            .flat_map(|endpoints| endpoints.values())
431            .map(|entry| entry.endpoint.clone())
432            .collect()
433    }
434
435    /// Replaces the local snapshot with the registry's current endpoints after a lagged stream.
436    pub fn resync(&mut self) {
437        self.endpoints = self
438            .state
439            .services
440            .lock()
441            .expect("service registry mutex poisoned")
442            .get(&self.service)
443            .into_iter()
444            .flat_map(|endpoints| endpoints.keys())
445            .cloned()
446            .collect();
447    }
448
449    /// Waits for the next effective endpoint change.
450    pub async fn recv(&mut self) -> Result<ServiceEvent, DiscoveryError> {
451        loop {
452            match self.receiver.recv().await {
453                Ok(event) if event.service() == self.service => {
454                    let changed = match &event {
455                        ServiceEvent::Added { endpoint, .. } => {
456                            self.endpoints.insert(endpoint.clone())
457                        }
458                        ServiceEvent::Removed { endpoint, .. } => self.endpoints.remove(endpoint),
459                    };
460                    if changed {
461                        return Ok(event);
462                    }
463                }
464                Ok(_) => {}
465                Err(broadcast::error::RecvError::Lagged(skipped)) => {
466                    return Err(DiscoveryError::SubscriptionLagged(skipped));
467                }
468                Err(broadcast::error::RecvError::Closed) => {
469                    return Err(DiscoveryError::RegistryClosed);
470                }
471            }
472        }
473    }
474}
475
476impl EndpointSubscription for ServiceSubscription {
477    type Error = DiscoveryError;
478
479    fn endpoints(&self) -> Vec<String> {
480        ServiceSubscription::endpoints(self)
481    }
482
483    fn discovered_endpoints(&self) -> Vec<DiscoveredEndpoint> {
484        ServiceSubscription::discovered_endpoints(self)
485    }
486
487    fn changed(&mut self) -> EndpointChangeFuture<'_, Self::Error> {
488        Box::pin(async move {
489            match self.recv().await {
490                Ok(_) => Ok(self.endpoints()),
491                Err(DiscoveryError::SubscriptionLagged(_)) => {
492                    self.resync();
493                    Ok(self.endpoints())
494                }
495                Err(error) => Err(error),
496            }
497        })
498    }
499}
500
501/// A service endpoint change.
502#[derive(Debug, Clone, PartialEq, Eq)]
503pub enum ServiceEvent {
504    Added { service: String, endpoint: String },
505    Removed { service: String, endpoint: String },
506}
507
508impl ServiceEvent {
509    fn service(&self) -> &str {
510        match self {
511            Self::Added { service, .. } | Self::Removed { service, .. } => service,
512        }
513    }
514}
515
516/// Errors produced while publishing or subscribing to services.
517#[derive(Debug, Clone, PartialEq, Eq)]
518pub enum DiscoveryError {
519    EmptyService,
520    EmptyEndpoint,
521    InvalidEndpointWeight(u32),
522    ConflictingEndpointMetadata(String),
523    SubscriptionLagged(u64),
524    RegistryClosed,
525}
526
527impl fmt::Display for DiscoveryError {
528    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
529        match self {
530            Self::EmptyService => formatter.write_str("service name cannot be empty"),
531            Self::EmptyEndpoint => formatter.write_str("service endpoint cannot be empty"),
532            Self::InvalidEndpointWeight(weight) => write!(
533                formatter,
534                "service endpoint weight must be between 1 and {MAX_ENDPOINT_WEIGHT}, got {weight}"
535            ),
536            Self::ConflictingEndpointMetadata(endpoint) => write!(
537                formatter,
538                "service endpoint {endpoint} is already published with different metadata"
539            ),
540            Self::SubscriptionLagged(skipped) => {
541                write!(formatter, "service subscription lagged by {skipped} events")
542            }
543            Self::RegistryClosed => formatter.write_str("service registry has closed"),
544        }
545    }
546}
547
548impl std::error::Error for DiscoveryError {}
549
550fn validate_service(service: String) -> Result<String, DiscoveryError> {
551    if service.trim().is_empty() {
552        Err(DiscoveryError::EmptyService)
553    } else {
554        Ok(service)
555    }
556}
557
558fn validate_endpoint(endpoint: String) -> Result<String, DiscoveryError> {
559    if endpoint.trim().is_empty() {
560        Err(DiscoveryError::EmptyEndpoint)
561    } else {
562        Ok(endpoint)
563    }
564}
565
566fn validate_weight(weight: u32) -> Result<u32, DiscoveryError> {
567    if (1..=MAX_ENDPOINT_WEIGHT).contains(&weight) {
568        Ok(weight)
569    } else {
570        Err(DiscoveryError::InvalidEndpointWeight(weight))
571    }
572}
573
574#[cfg(test)]
575mod tests {
576    use super::{
577        DiscoveredEndpoint, DiscoveryError, EndpointSubscription, ServiceEvent, ServiceRegistry,
578    };
579
580    #[tokio::test]
581    async fn publishes_and_withdraws_endpoints() {
582        let registry = ServiceRegistry::new();
583        let mut subscription = registry.subscribe("users").unwrap();
584        let mut first = registry.publish("users", "http://users-a:8080").unwrap();
585        let second = registry.publish("users", "http://users-b:8080").unwrap();
586
587        assert_eq!(
588            subscription.recv().await.unwrap(),
589            ServiceEvent::Added {
590                service: "users".to_owned(),
591                endpoint: "http://users-a:8080".to_owned(),
592            }
593        );
594        assert_eq!(
595            subscription.recv().await.unwrap(),
596            ServiceEvent::Added {
597                service: "users".to_owned(),
598                endpoint: "http://users-b:8080".to_owned(),
599            }
600        );
601        assert_eq!(
602            subscription.endpoints(),
603            vec![
604                "http://users-a:8080".to_owned(),
605                "http://users-b:8080".to_owned()
606            ]
607        );
608
609        first.release();
610        assert_eq!(
611            subscription.recv().await.unwrap(),
612            ServiceEvent::Removed {
613                service: "users".to_owned(),
614                endpoint: "http://users-a:8080".to_owned(),
615            }
616        );
617        assert_eq!(
618            registry.endpoints("users").unwrap(),
619            vec!["http://users-b:8080"]
620        );
621
622        drop(second);
623    }
624
625    #[tokio::test]
626    async fn keeps_endpoint_published_until_its_last_lease_is_released() {
627        let registry = ServiceRegistry::new();
628        let mut subscription = registry.subscribe("users").unwrap();
629        let first = registry.publish("users", "http://users-a:8080").unwrap();
630        let second = registry.publish("users", "http://users-a:8080").unwrap();
631
632        assert!(matches!(
633            subscription.recv().await,
634            Ok(ServiceEvent::Added { .. })
635        ));
636        drop(first);
637        assert_eq!(
638            registry.endpoints("users").unwrap(),
639            vec!["http://users-a:8080"]
640        );
641
642        drop(second);
643        assert!(matches!(
644            subscription.recv().await,
645            Ok(ServiceEvent::Removed { .. })
646        ));
647    }
648
649    #[test]
650    fn rejects_empty_service_names_and_endpoints() {
651        let registry = ServiceRegistry::new();
652
653        assert_eq!(
654            registry.publish("", "http://users:8080").unwrap_err(),
655            DiscoveryError::EmptyService
656        );
657        assert_eq!(
658            registry.publish("users", " ").unwrap_err(),
659            DiscoveryError::EmptyEndpoint
660        );
661        assert_eq!(
662            registry
663                .publish_weighted("users", "http://users:8080", 0)
664                .unwrap_err(),
665            DiscoveryError::InvalidEndpointWeight(0)
666        );
667    }
668
669    #[test]
670    fn preserves_weighted_endpoint_metadata_in_subscriptions() {
671        let registry = ServiceRegistry::new();
672        let endpoint = DiscoveredEndpoint::weighted("http://users:8080", 3)
673            .unwrap()
674            .with_metadata([("zone".to_owned(), "east".to_owned())]);
675        let _lease = registry
676            .publish_endpoint("users", endpoint.clone())
677            .unwrap();
678        let subscription = registry.subscribe("users").unwrap();
679
680        assert_eq!(subscription.discovered_endpoints(), vec![endpoint.clone()]);
681        assert_eq!(
682            EndpointSubscription::discovered_endpoints(&subscription),
683            vec![endpoint]
684        );
685    }
686
687    #[test]
688    fn rejects_conflicting_metadata_for_the_same_live_endpoint() {
689        let registry = ServiceRegistry::new();
690        let _lease = registry
691            .publish_weighted("users", "http://users:8080", 2)
692            .unwrap();
693
694        assert_eq!(
695            registry
696                .publish_weighted("users", "http://users:8080", 3)
697                .unwrap_err(),
698            DiscoveryError::ConflictingEndpointMetadata("http://users:8080".to_owned())
699        );
700    }
701}