use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use etcd_client::{Client, EventType, GetOptions, PutOptions, WatchOptions};
use futures::Stream;
use futures::future::BoxFuture;
use tokio::sync::Mutex as AsyncMutex;
use tokio_util::sync::CancellationToken;
use velo_ext::InstanceId;
use super::{ServiceDiscovery, ServiceEvent, ServiceRegistrationGuard};
const DEFAULT_LEASE_TTL: i64 = 30;
const KEEPALIVE_DIVISOR: u64 = 3;
pub struct EtcdServiceDiscovery {
client: Arc<AsyncMutex<Client>>,
cluster_id: String,
lease_ttl: i64,
}
impl EtcdServiceDiscovery {
pub async fn register_service(
&self,
service_name: &str,
instance_id: InstanceId,
) -> Result<EtcdServiceRegistrationGuard> {
let key = service_key(&self.cluster_id, service_name, instance_id);
let value = instance_id.to_string();
let lease_id = {
let mut client = self.client.lock().await;
let lease = client
.lease_grant(self.lease_ttl, None)
.await
.context("Failed to grant etcd lease")?;
let lease_id = lease.id();
client
.put(
key.as_bytes(),
value.as_bytes(),
Some(PutOptions::new().with_lease(lease_id)),
)
.await
.context("Failed to put service registration key")?;
lease_id
};
let cancel = CancellationToken::new();
let keepalive_interval =
Duration::from_secs((self.lease_ttl as u64 / KEEPALIVE_DIVISOR).max(1));
let client = self.client.clone();
let cancel_clone = cancel.clone();
tokio::spawn(async move {
run_keepalive_loop(client, lease_id, keepalive_interval, cancel_clone).await;
});
Ok(EtcdServiceRegistrationGuard {
client: self.client.clone(),
lease_id,
cancel,
})
}
fn parse_instances_from_kvs(kvs: &[etcd_client::KeyValue], prefix: &str) -> Vec<InstanceId> {
kvs.iter()
.filter_map(|kv| {
let key_str = kv.key_str().ok()?;
let id_str = key_str.strip_prefix(prefix)?;
uuid::Uuid::parse_str(id_str).ok().map(InstanceId::from)
})
.collect()
}
}
impl ServiceDiscovery for EtcdServiceDiscovery {
fn list_services(&self) -> BoxFuture<'_, Result<Vec<String>>> {
Box::pin(async move {
let prefix = services_prefix(&self.cluster_id);
let mut client = self.client.lock().await;
let resp = client
.get(prefix.as_bytes(), Some(GetOptions::new().with_prefix()))
.await
.context("Failed to list services from etcd")?;
let mut service_names: Vec<String> = resp
.kvs()
.iter()
.filter_map(|kv| {
let key_str = kv.key_str().ok()?;
let suffix = key_str.strip_prefix(&prefix)?;
let service_name = suffix.split('/').next()?;
Some(service_name.to_string())
})
.collect();
service_names.sort();
service_names.dedup();
Ok(service_names)
})
}
fn get_instances(&self, service_name: &str) -> BoxFuture<'_, Result<Vec<InstanceId>>> {
let prefix = service_prefix(&self.cluster_id, service_name);
Box::pin(async move {
let mut client = self.client.lock().await;
let resp = client
.get(prefix.as_bytes(), Some(GetOptions::new().with_prefix()))
.await
.context("Failed to get service instances from etcd")?;
Ok(Self::parse_instances_from_kvs(resp.kvs(), &prefix))
})
}
fn watch_instances(
&self,
service_name: &str,
) -> BoxFuture<'_, Result<Pin<Box<dyn Stream<Item = ServiceEvent> + Send>>>> {
let prefix = service_prefix(&self.cluster_id, service_name);
let client = self.client.clone();
Box::pin(async move {
let (initial, start_revision) = {
let mut c = client.lock().await;
let resp = c
.get(prefix.as_bytes(), Some(GetOptions::new().with_prefix()))
.await
.context("Failed to get initial service instances")?;
let revision = resp.header().map(|h| h.revision()).unwrap_or(0);
(
Self::parse_instances_from_kvs(resp.kvs(), &prefix),
revision,
)
};
let (_watcher, watch_stream) = {
let mut c = client.lock().await;
c.watch(
prefix.as_bytes(),
Some(
WatchOptions::new()
.with_prefix()
.with_start_revision(start_revision + 1),
),
)
.await
.context("Failed to start etcd watch")?
};
let prefix_owned = prefix;
let stream = async_stream::stream! {
yield ServiceEvent::Initial(initial);
let mut watch_stream = watch_stream;
loop {
match watch_stream.message().await {
Ok(Some(resp)) => {
for event in resp.events() {
if let Some(kv) = event.kv()
&& let Ok(key_str) = kv.key_str()
&& let Some(id_str) = key_str.strip_prefix(&prefix_owned)
&& let Ok(uuid) = uuid::Uuid::parse_str(id_str)
{
let instance_id = InstanceId::from(uuid);
match event.event_type() {
EventType::Put => {
yield ServiceEvent::Added(instance_id);
}
EventType::Delete => {
yield ServiceEvent::Removed(instance_id);
}
}
}
}
}
Ok(None) => {
tracing::warn!(prefix = %prefix_owned, "etcd watch stream closed by server");
yield ServiceEvent::Disconnected;
break;
}
Err(e) => {
tracing::warn!(prefix = %prefix_owned, error = %e, "etcd watch stream error");
yield ServiceEvent::Disconnected;
break;
}
}
}
};
Ok(Box::pin(stream) as Pin<Box<dyn Stream<Item = ServiceEvent> + Send>>)
})
}
}
pub struct EtcdServiceDiscoveryBuilder {
client: Client,
cluster_id: String,
lease_ttl: i64,
}
impl EtcdServiceDiscoveryBuilder {
pub fn new(client: Client, cluster_id: impl Into<String>) -> Self {
Self {
client,
cluster_id: cluster_id.into(),
lease_ttl: DEFAULT_LEASE_TTL,
}
}
pub fn lease_ttl(mut self, ttl: i64) -> Self {
self.lease_ttl = ttl;
self
}
pub fn build(self) -> EtcdServiceDiscovery {
EtcdServiceDiscovery {
client: Arc::new(AsyncMutex::new(self.client)),
cluster_id: self.cluster_id,
lease_ttl: self.lease_ttl,
}
}
}
pub struct EtcdServiceRegistrationGuard {
client: Arc<AsyncMutex<Client>>,
lease_id: i64,
cancel: CancellationToken,
}
impl ServiceRegistrationGuard for EtcdServiceRegistrationGuard {
fn unregister(&mut self) -> BoxFuture<'_, Result<()>> {
Box::pin(async move {
self.cancel.cancel();
let mut client = self.client.lock().await;
client
.lease_revoke(self.lease_id)
.await
.context("Failed to revoke etcd lease")?;
Ok(())
})
}
}
impl Drop for EtcdServiceRegistrationGuard {
fn drop(&mut self) {
self.cancel.cancel();
let client = self.client.clone();
let lease_id = self.lease_id;
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(async move {
let mut client = client.lock().await;
let _ = client.lease_revoke(lease_id).await;
});
}
}
}
async fn run_keepalive_loop(
client: Arc<AsyncMutex<Client>>,
lease_id: i64,
interval: Duration,
cancel: CancellationToken,
) {
let (mut keeper, mut stream) = {
let mut c = client.lock().await;
match c.lease_keep_alive(lease_id).await {
Ok(pair) => pair,
Err(e) => {
tracing::warn!(lease_id, error = %e, "Failed to start lease keep-alive");
return;
}
}
};
let mut tick = tokio::time::interval(interval);
tick.tick().await;
loop {
tokio::select! {
biased;
_ = cancel.cancelled() => {
tracing::debug!(lease_id, "Keep-alive cancelled");
break;
}
_ = tick.tick() => {
if let Err(e) = keeper.keep_alive().await {
tracing::warn!(lease_id, error = %e, "Lease keep-alive failed");
break;
}
let _ = stream.message().await;
}
}
}
}
fn services_prefix(cluster_id: &str) -> String {
format!("/velo/{cluster_id}/services/")
}
fn service_prefix(cluster_id: &str, service_name: &str) -> String {
format!("/velo/{cluster_id}/services/{service_name}/")
}
fn service_key(cluster_id: &str, service_name: &str, instance_id: InstanceId) -> String {
format!("/velo/{cluster_id}/services/{service_name}/{instance_id}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_service_key_format() {
let id = InstanceId::new_v4();
let key = service_key("my-cluster", "rhino-router", id);
assert!(key.starts_with("/velo/my-cluster/services/rhino-router/"));
assert!(key.ends_with(&id.to_string()));
}
#[test]
fn test_service_prefix_format() {
let prefix = service_prefix("prod", "data-loader");
assert_eq!(prefix, "/velo/prod/services/data-loader/");
}
#[test]
fn test_services_prefix_format() {
let prefix = services_prefix("prod");
assert_eq!(prefix, "/velo/prod/services/");
}
#[test]
fn test_cluster_isolation() {
let id = InstanceId::new_v4();
let key1 = service_key("cluster-a", "svc", id);
let key2 = service_key("cluster-b", "svc", id);
assert_ne!(key1, key2, "Different clusters must produce different keys");
}
}