use std::time::{Duration, Instant};
use async_trait::async_trait;
use dashmap::DashMap;
use etcd_client::{Client, Compare, CompareOp, ConnectOptions, PutOptions, Txn, TxnOp};
use super::LockBackend;
use crate::error::{Error, Result};
const KEY_PREFIX: &str = "ash-lock/";
struct Lease {
lease_id: i64,
granted_ttl: i64,
}
pub struct EtcdBackend {
client: Client,
leases: DashMap<(String, u64), Lease>,
}
impl EtcdBackend {
pub async fn connect(
endpoints: impl IntoIterator<Item = impl AsRef<str>>,
options: Option<ConnectOptions>,
) -> Result<Self> {
let endpoints: Vec<String> = endpoints
.into_iter()
.map(|e| e.as_ref().to_string())
.collect();
if endpoints.is_empty() {
return Err(Error::Config("etcd endpoints must be non-empty".into()));
}
let client = Client::connect(endpoints, options)
.await
.map_err(|e| Error::cluster(format!("etcd connect: {e}")))?;
Ok(Self {
client,
leases: DashMap::new(),
})
}
pub fn from_client(client: Client) -> Self {
Self {
client,
leases: DashMap::new(),
}
}
fn lock_key(key: &str) -> String {
format!("{KEY_PREFIX}{key}")
}
fn ttl_secs(ttl: Duration) -> i64 {
let mut secs = ttl.as_secs();
if ttl.subsec_nanos() > 0 {
secs += 1;
}
secs.max(1).min(i64::MAX as u64) as i64
}
async fn grant(&self, ttl: Duration) -> Result<(i64, i64)> {
let mut client = self.client.clone();
let resp = client
.lease_grant(Self::ttl_secs(ttl), None)
.await
.map_err(|e| Error::cluster(format!("etcd lease_grant: {e}")))?;
Ok((resp.id(), resp.ttl()))
}
async fn revoke(&self, lease_id: i64) {
let mut client = self.client.clone();
let _ = client.lease_revoke(lease_id).await;
}
}
#[async_trait]
impl LockBackend for EtcdBackend {
async fn try_acquire(&self, key: &str, ttl: Duration) -> Result<(u64, Instant)> {
let now = Instant::now();
let (lease_id, granted_ttl) = self.grant(ttl).await?;
let lock_key = Self::lock_key(key);
let txn = Txn::new()
.when([Compare::create_revision(
lock_key.clone(),
CompareOp::Equal,
0,
)])
.and_then([TxnOp::put(
lock_key,
lease_id.to_string(),
Some(PutOptions::new().with_lease(lease_id)),
)]);
let mut client = self.client.clone();
let resp = client
.txn(txn)
.await
.map_err(|e| Error::cluster(format!("etcd txn(acquire): {e}")))?;
if !resp.succeeded() {
self.revoke(lease_id).await;
return Err(Error::Contended {
resource: key.to_string(),
});
}
let token = resp
.header()
.map(|h| h.revision())
.filter(|r| *r > 0)
.ok_or_else(|| Error::cluster("etcd txn response missing header revision"))?
as u64;
self.leases.insert(
(key.to_string(), token),
Lease {
lease_id,
granted_ttl,
},
);
Ok((token, now + Duration::from_secs(granted_ttl as u64)))
}
async fn try_renew(&self, key: &str, token: u64, ttl: Duration) -> Result<Instant> {
let now = Instant::now();
let map_key = (key.to_string(), token);
let (lease_id, granted_ttl) = {
let lease = self.leases.get(&map_key).ok_or_else(|| Error::StaleToken {
resource: key.to_string(),
token,
current: 0,
})?;
(lease.lease_id, lease.granted_ttl)
};
if Self::ttl_secs(ttl) == granted_ttl {
let mut client = self.client.clone();
let (mut keeper, mut stream) = client
.lease_keep_alive(lease_id)
.await
.map_err(|e| Error::cluster(format!("etcd keep_alive: {e}")))?;
keeper
.keep_alive()
.await
.map_err(|e| Error::cluster(format!("etcd keep_alive send: {e}")))?;
let resp = stream
.message()
.await
.map_err(|e| Error::cluster(format!("etcd keep_alive recv: {e}")))?;
return match resp {
Some(r) if r.ttl() > 0 => Ok(now + Duration::from_secs(r.ttl() as u64)),
_ => {
self.leases.remove(&map_key);
Err(Error::LeaseExpired {
resource: key.to_string(),
})
}
};
}
let (new_lease_id, new_granted_ttl) = self.grant(ttl).await?;
let lock_key = Self::lock_key(key);
let txn = Txn::new()
.when([Compare::lease(lock_key.clone(), CompareOp::Equal, lease_id)])
.and_then([TxnOp::put(
lock_key,
new_lease_id.to_string(),
Some(PutOptions::new().with_lease(new_lease_id)),
)]);
let mut client = self.client.clone();
let resp = client
.txn(txn)
.await
.map_err(|e| Error::cluster(format!("etcd txn(rebind): {e}")))?;
if !resp.succeeded() {
self.revoke(new_lease_id).await;
self.leases.remove(&map_key);
return Err(Error::LeaseExpired {
resource: key.to_string(),
});
}
self.revoke(lease_id).await;
self.leases.insert(
map_key,
Lease {
lease_id: new_lease_id,
granted_ttl: new_granted_ttl,
},
);
Ok(now + Duration::from_secs(new_granted_ttl as u64))
}
async fn release(&self, key: &str, token: u64) -> Result<()> {
if let Some((_, lease)) = self.leases.remove(&(key.to_string(), token)) {
self.revoke(lease.lease_id).await;
}
Ok(())
}
fn name(&self) -> &'static str {
"etcd"
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
fn endpoints() -> Vec<String> {
std::env::var("ETCD_ENDPOINTS")
.unwrap_or_else(|_| "localhost:2379".to_string())
.split(',')
.map(str::to_string)
.collect()
}
#[tokio::test]
#[ignore = "requires a running etcd (set ETCD_ENDPOINTS, default localhost:2379)"]
async fn etcd_exclusive_acquire_release_and_monotonic_tokens() {
let backend = Arc::new(EtcdBackend::connect(endpoints(), None).await.unwrap());
let key = format!("it/{}", std::process::id());
let ttl = Duration::from_secs(5);
let (t1, _) = backend.try_acquire(&key, ttl).await.unwrap();
assert!(matches!(
backend.try_acquire(&key, ttl).await,
Err(Error::Contended { .. })
));
backend.try_renew(&key, t1, ttl).await.unwrap();
backend
.try_renew(&key, t1, Duration::from_secs(30))
.await
.unwrap();
backend.release(&key, t1).await.unwrap();
let (t2, _) = backend.try_acquire(&key, ttl).await.unwrap();
assert!(t2 > t1, "etcd revisions must be strictly monotonic");
assert!(backend.try_renew(&key, t1, ttl).await.is_err());
backend.release(&key, t2).await.unwrap();
}
}