Skip to main content

rskit_discovery/
component.rs

1//! Lifecycle-managed discovery component.
2//!
3//! Mirrors gokit's `discovery.Component` — handles provider creation, service registration on start,
4//! deregistration on stop, and health reporting.
5//! Services only need to add this component to the app registry.
6
7use std::sync::Arc;
8
9use async_trait::async_trait;
10use parking_lot::Mutex;
11use rskit_bootstrap::{Component, Health};
12use rskit_errors::{AppError, AppResult, ErrorCode};
13use rskit_resilience::{ConstantBackoff, Policy, RetryPolicy};
14use tracing::{debug, info, warn};
15
16use crate::config::DiscoveryConfig;
17use crate::factory::DiscoveryRegistry;
18use crate::traits::{Discovery, Registry};
19
20/// A lifecycle-managed discovery component.
21///
22/// Implements [`Component`] so it can be registered with the application component registry.
23/// On start it creates the provider via the factory, optionally registers the local service instance,
24/// and on stop it deregisters.
25pub struct DiscoveryComponent {
26    config: DiscoveryConfig,
27    providers: DiscoveryRegistry,
28    registry: Mutex<Option<Arc<dyn Registry>>>,
29    discovery: Mutex<Option<Arc<dyn Discovery>>>,
30    instance_id: Mutex<Option<String>>,
31}
32
33impl DiscoveryComponent {
34    /// Create a new discovery component from the given config.
35    pub fn new(config: DiscoveryConfig) -> Self {
36        Self {
37            config,
38            providers: DiscoveryRegistry::builtins(),
39            registry: Mutex::new(None),
40            discovery: Mutex::new(None),
41            instance_id: Mutex::new(None),
42        }
43    }
44
45    /// Create a discovery component with an explicit provider registry.
46    #[must_use]
47    pub fn with_registry(config: DiscoveryConfig, providers: DiscoveryRegistry) -> Self {
48        Self {
49            config,
50            providers,
51            registry: Mutex::new(None),
52            discovery: Mutex::new(None),
53            instance_id: Mutex::new(None),
54        }
55    }
56
57    /// Returns the registry, if the component has started.
58    pub fn registry(&self) -> Option<Arc<dyn Registry>> {
59        self.registry.lock().clone()
60    }
61
62    /// Returns the discovery client, if the component has started.
63    pub fn discovery(&self) -> Option<Arc<dyn Discovery>> {
64        self.discovery.lock().clone()
65    }
66}
67
68#[async_trait]
69impl Component for DiscoveryComponent {
70    fn name(&self) -> &str {
71        "discovery"
72    }
73
74    async fn start(&self) -> AppResult<()> {
75        let mut config = self.config.clone();
76        config.apply_defaults();
77
78        if !config.enabled {
79            debug!("Discovery disabled — using static provider");
80            let cfg_static = DiscoveryConfig {
81                provider: "static".to_string(),
82                ..config
83            };
84            let (reg, disc) = self.providers.create(&cfg_static)?;
85            *self.registry.lock() = Some(reg);
86            *self.discovery.lock() = Some(disc);
87            return Ok(());
88        }
89
90        config.validate()?;
91
92        let (reg, disc) = self.providers.create(&config)?;
93        *self.registry.lock() = Some(reg.clone());
94        *self.discovery.lock() = Some(disc);
95
96        // Auto-register when registration is enabled.
97        if config.registration.enabled {
98            let mut instance = config.build_instance();
99
100            // Enrich metadata with health URL when health checks are enabled.
101            if config.health.enabled {
102                let addr = if instance.address.is_empty() {
103                    "localhost".to_string()
104                } else {
105                    instance.address.clone()
106                };
107                let health_url = format!("http://{}:{}{}", addr, instance.port, config.health.path);
108                instance
109                    .metadata
110                    .insert("health_url".to_string(), health_url);
111            }
112
113            info!(
114                id = %instance.id,
115                name = %instance.name,
116                address = %instance.address,
117                port = instance.port,
118                "Registering with service discovery"
119            );
120
121            let instance_id = instance.id.clone();
122            let max_retries = config.registration.max_retries.max(1) as usize;
123            let retry_policy = RetryPolicy::new()
124                .with_max_attempts(max_retries)
125                .with_constant_backoff(ConstantBackoff::new(config.registration.retry_duration()?))
126                .with_jitter(false)
127                .with_on_retry({
128                    let instance_id = instance_id.clone();
129                    move |attempt, error| {
130                        warn!(
131                            error = %error,
132                            service_id = %instance_id,
133                            attempt,
134                            max_retries,
135                            "failed to register service"
136                        );
137                    }
138                });
139            let registration_policy = Policy::new().with_retry(retry_policy);
140
141            let registration = registration_policy
142                .execute(|| async { reg.register(&instance).await })
143                .await;
144
145            if let Err(err) = registration {
146                if config.registration.required {
147                    return Err(AppError::new(
148                        ErrorCode::Internal,
149                        format!("discovery: register self after {max_retries} retries: {err}"),
150                    ));
151                }
152                warn!(
153                    service_id = %instance_id,
154                    "failed to register with discovery — continuing in degraded mode"
155                );
156            } else {
157                *self.instance_id.lock() = Some(instance_id.clone());
158            }
159        }
160
161        debug!(provider = %config.provider, "Discovery component started");
162        Ok(())
163    }
164
165    async fn stop(&self) -> AppResult<()> {
166        debug!("Discovery component stopping");
167
168        let instance_id = self.instance_id.lock().take();
169        if let Some(id) = instance_id
170            && let Some(reg) = self.registry()
171            && let Err(e) = reg.deregister(&id).await
172        {
173            warn!(error = %e, id = %id, "Failed to deregister on stop");
174        }
175
176        Ok(())
177    }
178
179    fn health(&self) -> Health {
180        if self.discovery.lock().is_none() {
181            return Health::unhealthy("discovery", "not initialized");
182        }
183        if !self.config.enabled {
184            return Health::healthy("discovery (static)");
185        }
186        if self.instance_id.lock().is_some() {
187            Health::healthy("discovery")
188        } else {
189            Health::degraded("discovery", "no services registered")
190        }
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use crate::instance::ServiceInstance;
198    use async_trait::async_trait;
199
200    struct FailingRegistry {
201        fail_register: bool,
202        deregistered: Arc<Mutex<Vec<String>>>,
203    }
204
205    #[async_trait]
206    impl Registry for FailingRegistry {
207        async fn register(&self, _instance: &ServiceInstance) -> AppResult<()> {
208            if self.fail_register {
209                Err(AppError::new(ErrorCode::Internal, "registration failed"))
210            } else {
211                Ok(())
212            }
213        }
214
215        async fn deregister(&self, id: &str) -> AppResult<()> {
216            self.deregistered.lock().push(id.to_string());
217            Ok(())
218        }
219    }
220
221    #[async_trait]
222    impl Discovery for FailingRegistry {
223        async fn resolve(&self, _service: &str) -> AppResult<Vec<ServiceInstance>> {
224            Ok(Vec::new())
225        }
226    }
227
228    #[tokio::test]
229    async fn disabled_component_uses_static() {
230        let config = DiscoveryConfig {
231            enabled: false,
232            ..Default::default()
233        };
234        let comp = DiscoveryComponent::new(config);
235        comp.start().await.unwrap();
236        assert!(comp.discovery().is_some());
237        assert!(comp.registry().is_some());
238        assert!(comp.health().is_healthy());
239        comp.stop().await.unwrap();
240    }
241
242    #[tokio::test]
243    async fn static_provider_with_registration() {
244        let config = DiscoveryConfig {
245            enabled: true,
246            provider: "static".to_string(),
247            registration: crate::config::RegistrationConfig {
248                enabled: true,
249                service_name: "test-svc".to_string(),
250                service_id: "test-svc-1".to_string(),
251                service_address: "127.0.0.1".to_string(),
252                service_port: 8080,
253                ..Default::default()
254            },
255            ..Default::default()
256        };
257        let comp = DiscoveryComponent::new(config);
258        comp.start().await.unwrap();
259        assert!(comp.instance_id.lock().is_some());
260        comp.stop().await.unwrap();
261        assert!(comp.instance_id.lock().is_none());
262    }
263
264    #[test]
265    fn with_registry_initial_health_is_unhealthy_and_accessors_are_empty() {
266        let comp =
267            DiscoveryComponent::with_registry(DiscoveryConfig::default(), DiscoveryRegistry::new());
268
269        assert_eq!(comp.name(), "discovery");
270        assert!(comp.registry().is_none());
271        assert!(comp.discovery().is_none());
272        assert!(!comp.health().is_healthy());
273    }
274
275    #[tokio::test]
276    async fn optional_registration_failure_starts_degraded_without_instance_id() {
277        let provider = Arc::new(FailingRegistry {
278            fail_register: true,
279            deregistered: Arc::new(Mutex::new(Vec::new())),
280        });
281        let mut providers = DiscoveryRegistry::new();
282        providers.register(
283            "fake",
284            Box::new({
285                let provider = provider.clone();
286                move |_| Ok((provider.clone(), provider.clone()))
287            }),
288        );
289        let comp = DiscoveryComponent::with_registry(
290            DiscoveryConfig {
291                enabled: true,
292                provider: "fake".to_string(),
293                registration: crate::config::RegistrationConfig {
294                    enabled: true,
295                    required: false,
296                    service_name: "svc".to_string(),
297                    service_port: 8080,
298                    max_retries: 1,
299                    retry_interval: "1ms".to_string(),
300                    ..Default::default()
301                },
302                ..Default::default()
303            },
304            providers,
305        );
306
307        comp.start().await.unwrap();
308
309        assert!(comp.registry().is_some());
310        assert!(comp.discovery().is_some());
311        assert!(comp.instance_id.lock().is_none());
312        assert!(!comp.health().is_healthy());
313    }
314
315    #[tokio::test]
316    async fn required_registration_failure_returns_start_error() {
317        let provider = Arc::new(FailingRegistry {
318            fail_register: true,
319            deregistered: Arc::new(Mutex::new(Vec::new())),
320        });
321        let mut providers = DiscoveryRegistry::new();
322        providers.register(
323            "fake",
324            Box::new({
325                let provider = provider.clone();
326                move |_| Ok((provider.clone(), provider.clone()))
327            }),
328        );
329        let comp = DiscoveryComponent::with_registry(
330            DiscoveryConfig {
331                enabled: true,
332                provider: "fake".to_string(),
333                registration: crate::config::RegistrationConfig {
334                    enabled: true,
335                    required: true,
336                    service_name: "svc".to_string(),
337                    service_port: 8080,
338                    max_retries: 1,
339                    retry_interval: "1ms".to_string(),
340                    ..Default::default()
341                },
342                ..Default::default()
343            },
344            providers,
345        );
346
347        let err = comp.start().await.unwrap_err();
348        assert_eq!(err.code(), ErrorCode::Internal);
349        assert!(err.to_string().contains("register self"));
350    }
351
352    #[tokio::test]
353    async fn successful_registration_adds_health_url_and_deregisters_on_stop() {
354        let deregistered = Arc::new(Mutex::new(Vec::new()));
355        let provider = Arc::new(FailingRegistry {
356            fail_register: false,
357            deregistered: deregistered.clone(),
358        });
359        let mut providers = DiscoveryRegistry::new();
360        providers.register(
361            "fake",
362            Box::new({
363                let provider = provider.clone();
364                move |_| Ok((provider.clone(), provider.clone()))
365            }),
366        );
367        let comp = DiscoveryComponent::with_registry(
368            DiscoveryConfig {
369                enabled: true,
370                provider: "fake".to_string(),
371                registration: crate::config::RegistrationConfig {
372                    enabled: true,
373                    service_name: "svc".to_string(),
374                    service_id: "svc-1".to_string(),
375                    service_port: 8080,
376                    max_retries: 1,
377                    retry_interval: "1ms".to_string(),
378                    ..Default::default()
379                },
380                ..Default::default()
381            },
382            providers,
383        );
384
385        comp.start().await.unwrap();
386        assert!(comp.health().is_healthy());
387        comp.stop().await.unwrap();
388
389        assert_eq!(*deregistered.lock(), vec!["svc-1".to_string()]);
390    }
391}