use crate::bus::{KvStore, Revision};
use crate::error::BusError;
use bytes::Bytes;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::Duration;
use tokio::task::JoinHandle;
use tracing::Instrument as _;
pub const FAILOVER_ATTEMPT_CAP: u32 = 3;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LeaderEntry {
pub replica_id: String,
#[serde(with = "bytes_opt_serde", default)]
pub payload: Option<Bytes>,
#[serde(default)]
pub attempt: u32,
#[serde(default)]
pub principal: Option<String>,
}
impl LeaderEntry {
pub fn from_bytes(bytes: &[u8]) -> Self {
if let Ok(entry) = serde_json::from_slice::<LeaderEntry>(bytes) {
return entry;
}
let replica_id = std::str::from_utf8(bytes).unwrap_or("").to_string();
Self {
replica_id,
payload: None,
attempt: 0,
principal: None,
}
}
pub fn to_bytes(&self) -> Result<Bytes, BusError> {
let v = serde_json::to_vec(self)
.map_err(|e| BusError::Permanent(format!("leader-entry encode: {e}")))?;
Ok(Bytes::from(v))
}
}
mod bytes_opt_serde {
use bytes::Bytes;
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(v: &Option<Bytes>, s: S) -> Result<S::Ok, S::Error> {
match v {
Some(b) => s.serialize_some(b.as_ref()),
None => s.serialize_none(),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Bytes>, D::Error> {
let v: Option<Vec<u8>> = Option::deserialize(d)?;
Ok(v.map(Bytes::from))
}
}
#[derive(Clone)]
pub struct LeaderRegistry {
kv: Arc<dyn KvStore>,
bucket: String,
replica_id: String,
}
impl LeaderRegistry {
pub fn new(kv: Arc<dyn KvStore>, bucket: String, replica_id: String) -> Self {
Self {
kv,
bucket,
replica_id,
}
}
pub fn kv(&self) -> &Arc<dyn KvStore> {
&self.kv
}
pub fn bucket(&self) -> &str {
&self.bucket
}
#[tracing::instrument(
skip_all,
fields(
klieo.leader.key = %key,
db.system = "klieo-kv",
db.namespace = %self.bucket,
db.operation = "put",
),
err,
)]
pub async fn claim(
&self,
key: String,
ttl: Duration,
payload: Option<Bytes>,
principal: Option<String>,
) -> Result<LeaderHandle, BusError> {
self.claim_with_heartbeat(key, ttl, ttl / 2, payload, principal)
.await
}
#[tracing::instrument(
skip_all,
fields(
klieo.leader.key = %key,
klieo.leader.ttl_ms = %ttl.as_millis(),
klieo.leader.heartbeat_ms = %heartbeat.as_millis(),
db.system = "klieo-kv",
db.namespace = %self.bucket,
db.operation = "put",
),
err,
)]
pub async fn claim_with_heartbeat(
&self,
key: String,
ttl: Duration,
heartbeat: Duration,
payload: Option<Bytes>,
principal: Option<String>,
) -> Result<LeaderHandle, BusError> {
let _ = ttl; let entry = LeaderEntry {
replica_id: self.replica_id.clone(),
payload,
attempt: 0,
principal,
};
let bytes = entry.to_bytes()?;
self.kv.put(&self.bucket, &key, bytes.clone()).await?;
let heartbeat_task = self.spawn_heartbeat_with_interval(key.clone(), heartbeat, bytes);
Ok(LeaderHandle {
kv: self.kv.clone(),
bucket: self.bucket.clone(),
key,
replica_id: self.replica_id.clone(),
heartbeat: Some(heartbeat_task),
})
}
#[tracing::instrument(
skip_all,
fields(
klieo.leader.key = %key,
klieo.leader.expected_rev = %expected_rev,
klieo.leader.prior_attempt = %prior.attempt,
db.system = "klieo-kv",
db.namespace = %self.bucket,
db.operation = "cas",
),
err,
)]
pub async fn claim_with_attempt_cas(
&self,
key: String,
ttl: Duration,
expected_rev: Revision,
prior: &LeaderEntry,
) -> Result<LeaderHandle, BusError> {
self.claim_with_attempt_cas_and_heartbeat(key, ttl, ttl / 2, expected_rev, prior)
.await
}
#[tracing::instrument(
skip_all,
fields(
klieo.leader.key = %key,
klieo.leader.ttl_ms = %ttl.as_millis(),
klieo.leader.heartbeat_ms = %heartbeat.as_millis(),
klieo.leader.expected_rev = %expected_rev,
klieo.leader.prior_attempt = %prior.attempt,
db.system = "klieo-kv",
db.namespace = %self.bucket,
db.operation = "cas",
),
err,
)]
pub async fn claim_with_attempt_cas_and_heartbeat(
&self,
key: String,
ttl: Duration,
heartbeat: Duration,
expected_rev: Revision,
prior: &LeaderEntry,
) -> Result<LeaderHandle, BusError> {
let _ = ttl; let new_entry = LeaderEntry {
replica_id: self.replica_id.clone(),
payload: prior.payload.clone(),
attempt: prior.attempt + 1,
principal: prior.principal.clone(),
};
let bytes = new_entry.to_bytes()?;
self.kv
.cas(&self.bucket, &key, bytes.clone(), Some(expected_rev))
.await?;
let heartbeat_task = self.spawn_heartbeat_with_interval(key.clone(), heartbeat, bytes);
Ok(LeaderHandle {
kv: self.kv.clone(),
bucket: self.bucket.clone(),
key,
replica_id: self.replica_id.clone(),
heartbeat: Some(heartbeat_task),
})
}
fn spawn_heartbeat_with_interval(
&self,
key: String,
heartbeat: Duration,
entry_bytes: Bytes,
) -> JoinHandle<()> {
let kv = self.kv.clone();
let bucket = self.bucket.clone();
let key_for_task = key.clone();
tokio::spawn(
async move {
loop {
tokio::time::sleep(heartbeat).await;
if let Err(e) = kv.put(&bucket, &key_for_task, entry_bytes.clone()).await {
tracing::warn!(
target: "klieo.leader",
bucket = %bucket,
key = %key_for_task,
error = %e,
"leader heartbeat failed; leadership will lapse",
);
return;
}
tracing::debug!(
target: "klieo.leader",
bucket = %bucket,
key = %key_for_task,
"heartbeat",
);
}
}
.instrument(tracing::info_span!(
"leader_heartbeat_task",
klieo.leader.key = %key,
klieo.leader.heartbeat_interval_ms = %heartbeat.as_millis(),
)),
)
}
#[tracing::instrument(
skip_all,
fields(
klieo.leader.key = %key,
db.system = "klieo-kv",
db.namespace = %self.bucket,
db.operation = "get",
),
err,
level = "debug",
)]
pub async fn is_alive(&self, key: &str) -> Result<bool, BusError> {
Ok(self.kv.get(&self.bucket, key).await?.is_some())
}
#[tracing::instrument(
skip_all,
fields(
klieo.leader.key = %key,
db.system = "klieo-kv",
db.namespace = %self.bucket,
db.operation = "get",
),
err,
level = "debug",
)]
pub async fn lookup_entry(&self, key: &str) -> Result<Option<LeaderEntry>, BusError> {
match self.kv.get(&self.bucket, key).await? {
Some(e) => Ok(Some(LeaderEntry::from_bytes(&e.value))),
None => Ok(None),
}
}
#[tracing::instrument(
skip_all,
fields(
klieo.leader.key = %key,
db.system = "klieo-kv",
db.namespace = %self.bucket,
db.operation = "get",
),
err,
level = "debug",
)]
pub async fn lookup_entry_with_revision(
&self,
key: &str,
) -> Result<Option<(LeaderEntry, Revision)>, BusError> {
match self.kv.get(&self.bucket, key).await? {
Some(e) => Ok(Some((LeaderEntry::from_bytes(&e.value), e.revision))),
None => Ok(None),
}
}
}
pub struct LeaderHandle {
kv: Arc<dyn KvStore>,
bucket: String,
key: String,
replica_id: String,
heartbeat: Option<JoinHandle<()>>,
}
impl LeaderHandle {
pub fn replica_id(&self) -> &str {
&self.replica_id
}
}
impl Drop for LeaderHandle {
fn drop(&mut self) {
if let Some(task) = self.heartbeat.take() {
task.abort();
}
let kv = self.kv.clone();
let bucket = std::mem::take(&mut self.bucket);
let key = std::mem::take(&mut self.key);
match tokio::runtime::Handle::try_current() {
Ok(handle) => {
handle.spawn(async move {
if let Err(e) = kv.delete(&bucket, &key).await {
tracing::warn!(
target: "klieo.leader",
bucket = %bucket,
key = %key,
error = %e,
"leader delete failed; lapse to TTL",
);
}
});
}
Err(_) => {
tracing::warn!(
target: "klieo.leader",
bucket = %bucket,
key = %key,
"leader delete skipped: no tokio runtime active during Drop",
);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bus::{KvEntry, Lease, Revision};
use crate::test_utils::fake_kv;
use async_trait::async_trait;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
const BUCKET: &str = "klieo-leaders";
struct CountingKvStore {
inner: Arc<dyn KvStore>,
put_count: Arc<AtomicU64>,
}
impl CountingKvStore {
fn new(inner: Arc<dyn KvStore>) -> (Arc<Self>, Arc<AtomicU64>) {
let put_count = Arc::new(AtomicU64::new(0));
(
Arc::new(Self {
inner,
put_count: put_count.clone(),
}),
put_count,
)
}
}
#[async_trait]
impl KvStore for CountingKvStore {
async fn get(&self, bucket: &str, key: &str) -> Result<Option<KvEntry>, BusError> {
self.inner.get(bucket, key).await
}
async fn put(&self, bucket: &str, key: &str, value: Bytes) -> Result<Revision, BusError> {
self.put_count.fetch_add(1, Ordering::SeqCst);
self.inner.put(bucket, key, value).await
}
async fn cas(
&self,
bucket: &str,
key: &str,
value: Bytes,
expected: Option<Revision>,
) -> Result<Revision, BusError> {
self.inner.cas(bucket, key, value, expected).await
}
async fn delete(&self, bucket: &str, key: &str) -> Result<(), BusError> {
self.inner.delete(bucket, key).await
}
async fn lease(
&self,
bucket: &str,
key: &str,
ttl: std::time::Duration,
) -> Result<Lease, BusError> {
self.inner.lease(bucket, key, ttl).await
}
async fn keys(&self, bucket: &str) -> Result<Vec<String>, BusError> {
self.inner.keys(bucket).await
}
}
#[tokio::test]
async fn claim_writes_entry_and_is_alive_returns_true() {
let kv = fake_kv();
let reg = LeaderRegistry::new(kv.clone(), BUCKET.into(), "replica-a".into());
let _handle = reg
.claim("a2a.t-1".into(), Duration::from_secs(5), None, None)
.await
.unwrap();
assert!(reg.is_alive("a2a.t-1").await.unwrap());
}
#[tokio::test]
async fn drop_handle_deletes_entry() {
let kv = fake_kv();
let reg = LeaderRegistry::new(kv.clone(), BUCKET.into(), "replica-a".into());
{
let _handle = reg
.claim("a2a.t-2".into(), Duration::from_secs(5), None, None)
.await
.unwrap();
assert!(reg.is_alive("a2a.t-2").await.unwrap());
}
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(!reg.is_alive("a2a.t-2").await.unwrap());
}
#[tokio::test]
async fn is_alive_false_on_unknown_key() {
let kv = fake_kv();
let reg = LeaderRegistry::new(kv, BUCKET.into(), "replica-a".into());
assert!(!reg.is_alive("never").await.unwrap());
}
#[test]
fn leader_entry_round_trip_json() {
let entry = LeaderEntry {
replica_id: "replica-a".into(),
payload: Some(Bytes::from_static(b"{\"params\":1}")),
attempt: 2,
principal: Some("alice".into()),
};
let bytes = entry.to_bytes().unwrap();
let back = LeaderEntry::from_bytes(&bytes);
assert_eq!(back.replica_id, "replica-a");
assert_eq!(back.payload.as_deref().unwrap(), b"{\"params\":1}");
assert_eq!(back.attempt, 2);
assert_eq!(back.principal.as_deref(), Some("alice"));
}
#[test]
fn leader_entry_pre_024_raw_string_fallback() {
let bytes = b"replica-old";
let entry = LeaderEntry::from_bytes(bytes);
assert_eq!(entry.replica_id, "replica-old");
assert!(entry.payload.is_none());
assert_eq!(entry.attempt, 0);
assert!(entry.principal.is_none());
}
#[tokio::test]
async fn claim_writes_envelope_with_payload_and_principal() {
let kv = fake_kv();
let reg = LeaderRegistry::new(kv.clone(), BUCKET.into(), "replica-a".into());
let _handle = reg
.claim(
"a2a.t-payload".into(),
Duration::from_secs(5),
Some(Bytes::from_static(b"{\"params\":42}")),
Some("alice".into()),
)
.await
.unwrap();
let entry = reg.lookup_entry("a2a.t-payload").await.unwrap().unwrap();
assert_eq!(entry.replica_id, "replica-a");
assert_eq!(entry.payload.as_deref().unwrap(), b"{\"params\":42}");
assert_eq!(entry.attempt, 0);
assert_eq!(entry.principal.as_deref(), Some("alice"));
}
#[tokio::test]
async fn claim_with_attempt_cas_increments_attempt() {
let kv = fake_kv();
let reg_a = LeaderRegistry::new(kv.clone(), BUCKET.into(), "replica-a".into());
let reg_b = LeaderRegistry::new(kv.clone(), BUCKET.into(), "replica-b".into());
let original = reg_a
.claim(
"a2a.t-cas".into(),
Duration::from_secs(5),
Some(Bytes::from_static(b"{}")),
Some("alice".into()),
)
.await
.unwrap();
let (prior, rev) = reg_b
.lookup_entry_with_revision("a2a.t-cas")
.await
.unwrap()
.unwrap();
let _new = reg_b
.claim_with_attempt_cas("a2a.t-cas".into(), Duration::from_secs(5), rev, &prior)
.await
.unwrap();
std::mem::forget(original);
let updated = reg_b.lookup_entry("a2a.t-cas").await.unwrap().unwrap();
assert_eq!(updated.replica_id, "replica-b");
assert_eq!(updated.attempt, 1);
assert_eq!(updated.principal.as_deref(), Some("alice"));
}
#[tokio::test]
async fn claim_with_attempt_cas_rejects_on_revision_mismatch() {
let kv = fake_kv();
let reg_a = LeaderRegistry::new(kv.clone(), BUCKET.into(), "replica-a".into());
let reg_b = LeaderRegistry::new(kv.clone(), BUCKET.into(), "replica-b".into());
let original = reg_a
.claim("a2a.t-stale".into(), Duration::from_secs(5), None, None)
.await
.unwrap();
let stale_rev: Revision = 99_999;
let prior = LeaderEntry {
replica_id: "replica-a".into(),
payload: None,
attempt: 0,
principal: None,
};
let result = reg_b
.claim_with_attempt_cas(
"a2a.t-stale".into(),
Duration::from_secs(5),
stale_rev,
&prior,
)
.await;
match result {
Err(BusError::CasConflict { .. }) => {}
Err(other) => panic!("expected CasConflict, got {other:?}"),
Ok(_handle) => panic!("expected CasConflict, claim succeeded"),
}
std::mem::forget(original);
}
#[tokio::test(start_paused = true)]
async fn heartbeat_renews_lease_before_expiry() {
let base_kv = fake_kv();
let (spy_kv, put_count) = CountingKvStore::new(base_kv);
let reg = LeaderRegistry::new(spy_kv as Arc<dyn KvStore>, BUCKET.into(), "replica-a".into());
let heartbeat_interval = Duration::from_millis(100);
let _handle = reg
.claim_with_heartbeat(
"a2a.heartbeat-test".into(),
Duration::from_millis(500),
heartbeat_interval,
None,
None,
)
.await
.unwrap();
let puts_after_claim = put_count.load(Ordering::SeqCst);
assert!(puts_after_claim >= 1, "claim must have issued at least one put");
tokio::task::yield_now().await;
tokio::time::advance(heartbeat_interval * 3).await;
for _ in 0..10 {
tokio::task::yield_now().await;
}
let puts_after_heartbeat = put_count.load(Ordering::SeqCst);
assert!(
puts_after_heartbeat > puts_after_claim,
"heartbeat must have issued additional puts after time advance; \
puts after claim={puts_after_claim}, puts after advance={puts_after_heartbeat}"
);
}
#[tokio::test]
async fn lookup_entry_with_revision_returns_nonzero_revision() {
let kv = fake_kv();
let reg = LeaderRegistry::new(kv.clone(), BUCKET.into(), "replica-a".into());
let _handle = reg
.claim("a2a.rev-test".into(), Duration::from_secs(5), None, None)
.await
.unwrap();
let result = reg
.lookup_entry_with_revision("a2a.rev-test")
.await
.unwrap();
let (entry, revision) = result.expect("entry must be present after claim");
assert_eq!(entry.replica_id, "replica-a");
assert!(revision > 0, "revision must be non-zero after put");
}
}