use std::{
collections::HashMap,
sync::{Mutex, OnceLock},
time::{Duration, Instant},
};
use futures::future::BoxFuture;
use tracing::debug;
use super::checks::SweepContext;
pub type HealFn = fn(SweepContext) -> BoxFuture<'static, HealOutcome>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HealOutcome {
Healed,
Deferred,
Failed,
}
const MIN_INTERVAL: Duration = Duration::from_secs(5 * 60);
const MAX_INTERVAL: Duration = Duration::from_secs(60 * 60);
#[derive(Default)]
struct Attempt {
in_flight: bool,
next_allowed: Option<Instant>,
failures: u32,
}
fn registry() -> &'static Mutex<HashMap<&'static str, Attempt>> {
static STATE: OnceLock<Mutex<HashMap<&'static str, Attempt>>> = OnceLock::new();
STATE.get_or_init(|| Mutex::new(HashMap::new()))
}
fn backoff_delay(failures: u32) -> Duration {
let steps = failures.saturating_sub(1).min(u32::BITS - 1);
MIN_INTERVAL
.saturating_mul(2u32.saturating_pow(steps))
.min(MAX_INTERVAL)
}
pub fn spawn_if_due(name: &'static str, heal: HealFn, ctx: SweepContext) {
if !try_begin(name) {
return;
}
debug!(check = name, "spawning self-heal attempt");
tokio::spawn(async move {
let outcome = heal(ctx).await;
finish(name, outcome);
});
}
fn try_begin(name: &'static str) -> bool {
let mut map = registry().lock().expect("heal registry poisoned");
let attempt = map.entry(name).or_default();
if attempt.in_flight {
return false;
}
if let Some(next) = attempt.next_allowed
&& Instant::now() < next
{
return false;
}
attempt.in_flight = true;
true
}
fn finish(name: &'static str, outcome: HealOutcome) {
let mut map = registry().lock().expect("heal registry poisoned");
let attempt = map.entry(name).or_default();
attempt.in_flight = false;
match outcome {
HealOutcome::Healed => {
attempt.failures = 0;
attempt.next_allowed = None;
}
HealOutcome::Deferred | HealOutcome::Failed => {
attempt.failures = attempt.failures.saturating_add(1);
attempt.next_allowed = Some(Instant::now() + backoff_delay(attempt.failures));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backoff_grows_and_caps() {
assert_eq!(backoff_delay(0), MIN_INTERVAL);
assert_eq!(backoff_delay(1), MIN_INTERVAL);
assert_eq!(backoff_delay(2), MIN_INTERVAL * 2);
assert_eq!(backoff_delay(3), MIN_INTERVAL * 4);
assert_eq!(backoff_delay(100), MAX_INTERVAL);
}
#[test]
fn at_most_one_attempt_in_flight() {
let name = "test_in_flight";
assert!(try_begin(name), "first attempt is due");
assert!(
!try_begin(name),
"a second attempt is refused while in flight"
);
finish(name, HealOutcome::Healed);
assert!(try_begin(name), "after a heal the check is due again");
finish(name, HealOutcome::Healed);
}
#[test]
fn deferred_attempt_backs_off() {
let name = "test_backoff";
assert!(try_begin(name), "first attempt is due");
finish(name, HealOutcome::Deferred);
assert!(
!try_begin(name),
"a deferred attempt backs off rather than retrying immediately"
);
}
}