use std::time::{Duration, Instant};
use futures::prelude::*;
use k8s_openapi::{api::coordination::v1::Lease, apimachinery::pkg::apis::meta::v1::MicroTime};
use rand::Rng;
use thiserror::Error;
use tokio::{
sync::{oneshot, watch},
task::JoinHandle,
time::timeout,
};
use kube::api::{Patch, PatchParams};
use kube::runtime::{
watcher::{watch_object, DefaultBackoff, Result as WatcherResult},
WatchStreamExt,
};
use kube::{Api, Client, Resource};
const JITTER_FACTOR: f64 = 1.2;
#[derive(Debug, Error)]
pub enum Error {
#[error("invalid leader election config: {0}")]
ConfigError(String),
#[error("timeout while updating api")]
TimeoutError,
#[error("client error from api call: {0}")]
ClientError(kube::Error),
#[error("error from the leader elector task: {0}")]
TaskError(String),
}
pub type Result<T, E = Error> = std::result::Result<T, E>;
#[derive(Clone, Debug)]
pub struct Config {
pub name: String,
pub namespace: String,
pub identity: String,
pub manager: String,
pub lease_duration: Duration,
pub renew_deadline: Duration,
pub retry_period: Duration,
pub api_timeout: Duration,
}
impl Default for Config {
fn default() -> Self {
Self {
name: String::new(),
namespace: String::new(),
identity: String::new(),
manager: String::new(),
lease_duration: Duration::from_secs(15),
renew_deadline: Duration::from_secs(10),
retry_period: Duration::from_secs(2),
api_timeout: Duration::from_secs(5),
}
}
}
impl Config {
#[must_use]
pub fn name(mut self, val: &str) -> Self {
self.name = val.to_string();
self
}
#[must_use]
pub fn namespace(mut self, val: &str) -> Self {
self.namespace = val.to_string();
self
}
#[must_use]
pub fn identity(mut self, val: &str) -> Self {
self.identity = val.to_string();
self
}
#[must_use]
pub fn manager(mut self, val: &str) -> Self {
self.manager = val.to_string();
self
}
#[must_use]
pub fn lease_duration(mut self, val: Duration) -> Self {
self.lease_duration = val;
self
}
#[must_use]
pub fn renew_deadline(mut self, val: Duration) -> Self {
self.renew_deadline = val;
self
}
#[must_use]
pub fn retry_period(mut self, val: Duration) -> Self {
self.retry_period = val;
self
}
#[must_use]
pub fn api_timeout(mut self, val: Duration) -> Self {
self.api_timeout = val;
self
}
pub fn validate(&self) -> Result<()> {
if self.name.is_empty() {
return Err(Error::ConfigError("name may not be empty".into()));
}
if self.namespace.is_empty() {
return Err(Error::ConfigError("namespace may not be empty".into()));
}
if self.identity.is_empty() {
return Err(Error::ConfigError("identity may not be empty".into()));
}
if self.manager.is_empty() {
return Err(Error::ConfigError("manager may not be empty".into()));
}
if self.lease_duration <= self.renew_deadline {
return Err(Error::ConfigError(
"lease_duration must be greater than renew_deadline".into(),
));
}
if self.renew_deadline
<= Duration::from_secs_f64(JITTER_FACTOR * self.retry_period.as_secs_f64())
{
return Err(Error::ConfigError(format!(
"renew_deadline must be greater than retry_period*{JITTER_FACTOR}"
)));
}
if self.lease_duration.as_secs() < 1 {
return Err(Error::ConfigError(
"lease_duration must be at least 1 second".into(),
));
}
if self.renew_deadline.as_secs() < 1 {
return Err(Error::ConfigError(
"renew_deadline must be at least 1 second".into(),
));
}
if self.retry_period.as_secs() < 1 {
return Err(Error::ConfigError(
"retry_period must be at least 1 second".into(),
));
}
if self.api_timeout.as_secs() < 1 {
return Err(Error::ConfigError(
"api_timeout must be at least 1 second".into(),
));
}
Ok(())
}
#[allow(clippy::cast_possible_truncation)]
fn lease(&self) -> Lease {
let mut lease = Lease::default();
let meta = lease.meta_mut();
meta.name = Some(self.name.clone());
meta.namespace = Some(self.namespace.clone());
let now = chrono::Utc::now();
let spec = lease.spec.get_or_insert_with(Default::default);
spec.lease_duration_seconds = Some(self.lease_duration.as_secs() as i32);
spec.renew_time = Some(MicroTime(now));
spec.holder_identity = Some(self.identity.clone());
spec.acquire_time = Some(MicroTime(now));
spec.lease_transitions = Some(0);
lease
}
}
pub struct LeaderElector {
api: Api<Lease>,
config: Config,
state: State,
state_tx: watch::Sender<LeaderState>,
shutdown: oneshot::Receiver<()>,
had_error_on_last_try: bool,
}
impl LeaderElector {
#[must_use = "handle must be used for observing state changes and graceful shutdown"]
pub fn spawn(config: Config, client: Client) -> Result<LeaderElectorHandle> {
config.validate()?;
let (state_tx, state_rx) = watch::channel(LeaderState::Standby);
let (shutdown_tx, shutdown_rx) = oneshot::channel();
let this = LeaderElector {
api: Api::namespaced(client, &config.namespace),
config,
state_tx,
state: State::Standby,
shutdown: shutdown_rx,
had_error_on_last_try: false,
};
let handle = tokio::spawn(this.run());
Ok(LeaderElectorHandle {
shutdown: shutdown_tx,
state: state_rx,
handle,
})
}
async fn run(mut self) {
tracing::info!("leader elector task started");
if let Err(err) = self.try_acquire_or_renew().await {
tracing::error!(error = ?err, "error attempting to acquire/renew lease");
}
tracing::info!("finished initial call to try_acquire_or_renew");
let lease_watcher =
watch_object(self.api.clone(), &self.config.name).backoff(DefaultBackoff::default());
tokio::pin!(lease_watcher);
loop {
let delay_duration = self.get_next_acquire_renew_time();
tracing::debug!("delaying for {}s", delay_duration.as_secs());
let delay = tokio::time::sleep(delay_duration);
tokio::select! {
Some(lease_change_res) = lease_watcher.next() => self.k8s_handle_lease_event(lease_change_res).await,
_ = delay => {
tracing::info!("delay elapsed, going to call try_acquire_or_renew");
if let Err(err) = self.try_acquire_or_renew().await {
tracing::error!(error = ?err, "error during call to try_acquire_or_renew");
self.had_error_on_last_try = true;
self.update_state(None);
}
}
_ = &mut self.shutdown => break,
}
}
tracing::info!("leader elector task terminated");
}
#[tracing::instrument(level = "debug", skip_all)]
async fn k8s_handle_lease_event(&mut self, res: WatcherResult<Option<Lease>>) {
let event = match res {
Ok(event) => event,
Err(err) => {
tracing::error!(error = ?err, "error from lease watcher stream");
return;
}
};
self.update_state(event);
}
#[tracing::instrument(level = "debug", skip_all)]
async fn k8s_fetch_lease_and_update(&mut self) -> Result<()> {
let lease_opt = timeout(self.config.api_timeout, self.api.get_opt(&self.config.name))
.await
.map_err(|_err| Error::TimeoutError)?
.map_err(Error::ClientError)?;
if let Some(lease) = lease_opt {
self.update_state(Some(lease));
return Ok(());
}
Ok(())
}
#[tracing::instrument(level = "debug", skip_all, err)]
#[allow(clippy::cast_possible_truncation)]
async fn try_acquire_or_renew(&mut self) -> Result<()> {
tracing::debug!("try_acquire_or_renew");
if matches!(&self.state, State::Following { .. } | State::Standby) {
self.k8s_fetch_lease_and_update().await?;
}
if matches!(&self.state, State::Following { .. }) && !self.is_lease_expired() {
return Ok(());
}
let now = chrono::Utc::now();
let mut lease = self
.state
.get_lease()
.cloned()
.unwrap_or_else(|| self.config.lease());
let spec = lease.spec.get_or_insert_with(Default::default);
spec.lease_duration_seconds = Some(self.config.lease_duration.as_secs() as i32);
spec.renew_time = Some(MicroTime(now));
if matches!(&self.state, State::Following { .. } | State::Standby) {
spec.holder_identity = Some(self.config.identity.clone());
spec.acquire_time = Some(MicroTime(now));
spec.lease_transitions = Some(spec.lease_transitions.map_or(0, |val| val + 1));
}
lease.metadata.managed_fields = None;
let mut params = PatchParams::apply(&self.config.manager);
params.force = true;
let lease_res = timeout(
self.config.api_timeout,
self.api
.patch(&self.config.name, ¶ms, &Patch::Apply(lease)),
)
.await;
let lease = lease_res
.map_err(|_err| Error::TimeoutError)?
.map_err(Error::ClientError)?;
self.update_state(Some(lease));
Ok(())
}
#[tracing::instrument(level = "debug", skip_all)]
fn update_state(&mut self, lease_opt: Option<Lease>) {
let updated_lease = if let Some(lease) = lease_opt {
if Some(&lease) == self.state.get_lease() {
return; }
lease
} else {
self.state = State::Standby;
let _res = self.state_tx.send_if_modified(|val| {
if matches!(val, LeaderState::Standby) {
false
} else {
*val = LeaderState::Standby;
true
}
});
return;
};
let holder = updated_lease
.spec
.as_ref()
.and_then(|spec| spec.holder_identity.as_deref())
.unwrap_or_default();
let (is_lease_held, now) = (holder == self.config.identity, Instant::now());
match &mut self.state {
val @ (State::Leading { .. } | State::Following { .. } | State::Standby)
if is_lease_held =>
{
*val = State::Leading {
lease: updated_lease,
last_updated: now,
};
}
val if holder.is_empty() => {
*val = State::Standby;
}
val => {
*val = State::Following {
leader: holder.into(),
lease: updated_lease,
last_updated: now,
};
}
};
self.state_tx.send_if_modified(|val| match &self.state {
State::Leading { .. } if !matches!(val, LeaderState::Leading) => {
*val = LeaderState::Leading;
true
}
State::Following { .. } if !matches!(val, LeaderState::Following) => {
*val = LeaderState::Following;
true
}
State::Standby if !matches!(val, LeaderState::Standby) => {
*val = LeaderState::Standby;
true
}
_ => false,
});
tracing::debug!("lease updated");
}
fn get_next_acquire_renew_time(&mut self) -> Duration {
let (last_updated, delay) = match &self.state {
State::Leading { last_updated, .. } => (last_updated, self.config.renew_deadline),
State::Following { last_updated, .. } => {
let rand_val: f64 = rand::thread_rng().gen_range(0.01..1.0);
let jitter = rand_val * JITTER_FACTOR * self.config.lease_duration.as_secs_f64();
let delay = self.config.lease_duration + Duration::from_secs_f64(jitter);
(last_updated, delay)
}
State::Standby if self.had_error_on_last_try => {
self.had_error_on_last_try = false;
let rand_val: f64 = rand::thread_rng().gen_range(0.5..1.5);
let jitter = rand_val * JITTER_FACTOR * self.config.retry_period.as_secs_f64();
return Duration::from_secs_f64(jitter);
}
State::Standby => return Duration::from_secs(0),
};
let now = Instant::now();
let deadline = *last_updated + delay;
if deadline > now {
deadline - now
} else {
Duration::from_secs(0)
}
}
fn is_lease_expired(&self) -> bool {
match &self.state {
State::Leading { last_updated, .. } | State::Following { last_updated, .. } => {
let ttl = *last_updated + self.config.lease_duration;
ttl <= Instant::now()
}
State::Standby => true,
}
}
}
#[derive(Clone, Debug, PartialEq)]
enum State {
Leading {
lease: Lease,
last_updated: Instant,
},
Following {
leader: String,
lease: Lease,
last_updated: Instant,
},
Standby,
}
impl State {
fn get_lease(&self) -> Option<&Lease> {
match self {
Self::Leading { lease, .. } | Self::Following { lease, .. } => Some(lease),
Self::Standby => None,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum LeaderState {
Leading,
Following,
Standby,
}
impl LeaderState {
#[allow(clippy::must_use_candidate)]
pub fn is_leader(&self) -> bool {
matches!(self, Self::Leading)
}
}
pub struct LeaderElectorHandle {
shutdown: oneshot::Sender<()>,
state: watch::Receiver<LeaderState>,
handle: JoinHandle<()>,
}
impl LeaderElectorHandle {
#[allow(clippy::must_use_candidate)]
pub fn state(&self) -> watch::Receiver<LeaderState> {
self.state.clone()
}
#[allow(clippy::must_use_candidate)]
pub fn shutdown(self) -> impl Future<Output = Result<()>> {
let _res = self.shutdown.send(());
self.handle.map_err(|res| Error::TaskError(res.to_string()))
}
}