use std::sync::Arc;
use async_trait::async_trait;
use parking_lot::Mutex;
use rskit_bootstrap::{Component, Health};
use rskit_errors::{AppError, AppResult, ErrorCode};
use rskit_resilience::{ConstantBackoff, Policy, RetryPolicy};
use tracing::{debug, info, warn};
use crate::config::DiscoveryConfig;
use crate::factory::DiscoveryRegistry;
use crate::traits::{Discovery, Registry};
pub struct DiscoveryComponent {
config: DiscoveryConfig,
providers: DiscoveryRegistry,
registry: Mutex<Option<Arc<dyn Registry>>>,
discovery: Mutex<Option<Arc<dyn Discovery>>>,
instance_id: Mutex<Option<String>>,
}
impl DiscoveryComponent {
pub fn new(config: DiscoveryConfig) -> Self {
Self {
config,
providers: DiscoveryRegistry::builtins(),
registry: Mutex::new(None),
discovery: Mutex::new(None),
instance_id: Mutex::new(None),
}
}
#[must_use]
pub fn with_registry(config: DiscoveryConfig, providers: DiscoveryRegistry) -> Self {
Self {
config,
providers,
registry: Mutex::new(None),
discovery: Mutex::new(None),
instance_id: Mutex::new(None),
}
}
pub fn registry(&self) -> Option<Arc<dyn Registry>> {
self.registry.lock().clone()
}
pub fn discovery(&self) -> Option<Arc<dyn Discovery>> {
self.discovery.lock().clone()
}
}
#[async_trait]
impl Component for DiscoveryComponent {
fn name(&self) -> &str {
"discovery"
}
async fn start(&self) -> AppResult<()> {
let mut config = self.config.clone();
config.apply_defaults();
if !config.enabled {
debug!("Discovery disabled — using static provider");
let cfg_static = DiscoveryConfig {
provider: "static".to_string(),
..config
};
let (reg, disc) = self.providers.create(&cfg_static)?;
*self.registry.lock() = Some(reg);
*self.discovery.lock() = Some(disc);
return Ok(());
}
config.validate()?;
let (reg, disc) = self.providers.create(&config)?;
*self.registry.lock() = Some(reg.clone());
*self.discovery.lock() = Some(disc);
if config.registration.enabled {
let mut instance = config.build_instance();
if config.health.enabled {
let addr = if instance.address.is_empty() {
"localhost".to_string()
} else {
instance.address.clone()
};
let health_url = format!("http://{}:{}{}", addr, instance.port, config.health.path);
instance
.metadata
.insert("health_url".to_string(), health_url);
}
info!(
id = %instance.id,
name = %instance.name,
address = %instance.address,
port = instance.port,
"Registering with service discovery"
);
let instance_id = instance.id.clone();
let max_retries = config.registration.max_retries.max(1) as usize;
let retry_policy = RetryPolicy::new()
.with_max_attempts(max_retries)
.with_constant_backoff(ConstantBackoff::new(config.registration.retry_duration()?))
.with_jitter(false)
.with_on_retry({
let instance_id = instance_id.clone();
move |attempt, error| {
warn!(
error = %error,
service_id = %instance_id,
attempt,
max_retries,
"failed to register service"
);
}
});
let registration_policy = Policy::new().with_retry(retry_policy);
let registration = registration_policy
.execute(|| async { reg.register(&instance).await })
.await;
if let Err(err) = registration {
if config.registration.required {
return Err(AppError::new(
ErrorCode::Internal,
format!("discovery: register self after {max_retries} retries: {err}"),
));
}
warn!(
service_id = %instance_id,
"failed to register with discovery — continuing in degraded mode"
);
} else {
*self.instance_id.lock() = Some(instance_id.clone());
}
}
debug!(provider = %config.provider, "Discovery component started");
Ok(())
}
async fn stop(&self) -> AppResult<()> {
debug!("Discovery component stopping");
let instance_id = self.instance_id.lock().take();
if let Some(id) = instance_id
&& let Some(reg) = self.registry()
&& let Err(e) = reg.deregister(&id).await
{
warn!(error = %e, id = %id, "Failed to deregister on stop");
}
Ok(())
}
fn health(&self) -> Health {
if self.discovery.lock().is_none() {
return Health::unhealthy("discovery", "not initialized");
}
if !self.config.enabled {
return Health::healthy("discovery (static)");
}
if self.instance_id.lock().is_some() {
Health::healthy("discovery")
} else {
Health::degraded("discovery", "no services registered")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::instance::ServiceInstance;
use async_trait::async_trait;
struct FailingRegistry {
fail_register: bool,
deregistered: Arc<Mutex<Vec<String>>>,
}
#[async_trait]
impl Registry for FailingRegistry {
async fn register(&self, _instance: &ServiceInstance) -> AppResult<()> {
if self.fail_register {
Err(AppError::new(ErrorCode::Internal, "registration failed"))
} else {
Ok(())
}
}
async fn deregister(&self, id: &str) -> AppResult<()> {
self.deregistered.lock().push(id.to_string());
Ok(())
}
}
#[async_trait]
impl Discovery for FailingRegistry {
async fn resolve(&self, _service: &str) -> AppResult<Vec<ServiceInstance>> {
Ok(Vec::new())
}
}
#[tokio::test]
async fn disabled_component_uses_static() {
let config = DiscoveryConfig {
enabled: false,
..Default::default()
};
let comp = DiscoveryComponent::new(config);
comp.start().await.unwrap();
assert!(comp.discovery().is_some());
assert!(comp.registry().is_some());
assert!(comp.health().is_healthy());
comp.stop().await.unwrap();
}
#[tokio::test]
async fn static_provider_with_registration() {
let config = DiscoveryConfig {
enabled: true,
provider: "static".to_string(),
registration: crate::config::RegistrationConfig {
enabled: true,
service_name: "test-svc".to_string(),
service_id: "test-svc-1".to_string(),
service_address: "127.0.0.1".to_string(),
service_port: 8080,
..Default::default()
},
..Default::default()
};
let comp = DiscoveryComponent::new(config);
comp.start().await.unwrap();
assert!(comp.instance_id.lock().is_some());
comp.stop().await.unwrap();
assert!(comp.instance_id.lock().is_none());
}
#[test]
fn with_registry_initial_health_is_unhealthy_and_accessors_are_empty() {
let comp =
DiscoveryComponent::with_registry(DiscoveryConfig::default(), DiscoveryRegistry::new());
assert_eq!(comp.name(), "discovery");
assert!(comp.registry().is_none());
assert!(comp.discovery().is_none());
assert!(!comp.health().is_healthy());
}
#[tokio::test]
async fn optional_registration_failure_starts_degraded_without_instance_id() {
let provider = Arc::new(FailingRegistry {
fail_register: true,
deregistered: Arc::new(Mutex::new(Vec::new())),
});
let mut providers = DiscoveryRegistry::new();
providers.register(
"fake",
Box::new({
let provider = provider.clone();
move |_| Ok((provider.clone(), provider.clone()))
}),
);
let comp = DiscoveryComponent::with_registry(
DiscoveryConfig {
enabled: true,
provider: "fake".to_string(),
registration: crate::config::RegistrationConfig {
enabled: true,
required: false,
service_name: "svc".to_string(),
service_port: 8080,
max_retries: 1,
retry_interval: "1ms".to_string(),
..Default::default()
},
..Default::default()
},
providers,
);
comp.start().await.unwrap();
assert!(comp.registry().is_some());
assert!(comp.discovery().is_some());
assert!(comp.instance_id.lock().is_none());
assert!(!comp.health().is_healthy());
}
#[tokio::test]
async fn required_registration_failure_returns_start_error() {
let provider = Arc::new(FailingRegistry {
fail_register: true,
deregistered: Arc::new(Mutex::new(Vec::new())),
});
let mut providers = DiscoveryRegistry::new();
providers.register(
"fake",
Box::new({
let provider = provider.clone();
move |_| Ok((provider.clone(), provider.clone()))
}),
);
let comp = DiscoveryComponent::with_registry(
DiscoveryConfig {
enabled: true,
provider: "fake".to_string(),
registration: crate::config::RegistrationConfig {
enabled: true,
required: true,
service_name: "svc".to_string(),
service_port: 8080,
max_retries: 1,
retry_interval: "1ms".to_string(),
..Default::default()
},
..Default::default()
},
providers,
);
let err = comp.start().await.unwrap_err();
assert_eq!(err.code(), ErrorCode::Internal);
assert!(err.to_string().contains("register self"));
}
#[tokio::test]
async fn successful_registration_adds_health_url_and_deregisters_on_stop() {
let deregistered = Arc::new(Mutex::new(Vec::new()));
let provider = Arc::new(FailingRegistry {
fail_register: false,
deregistered: deregistered.clone(),
});
let mut providers = DiscoveryRegistry::new();
providers.register(
"fake",
Box::new({
let provider = provider.clone();
move |_| Ok((provider.clone(), provider.clone()))
}),
);
let comp = DiscoveryComponent::with_registry(
DiscoveryConfig {
enabled: true,
provider: "fake".to_string(),
registration: crate::config::RegistrationConfig {
enabled: true,
service_name: "svc".to_string(),
service_id: "svc-1".to_string(),
service_port: 8080,
max_retries: 1,
retry_interval: "1ms".to_string(),
..Default::default()
},
..Default::default()
},
providers,
);
comp.start().await.unwrap();
assert!(comp.health().is_healthy());
comp.stop().await.unwrap();
assert_eq!(*deregistered.lock(), vec!["svc-1".to_string()]);
}
}