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(Clone, Copy)]
pub struct HealAction {
pub run: HealFn,
pub min_interval: Duration,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HealOutcome {
Healed,
Deferred,
Failed,
}
pub const DEFAULT_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, min_interval: Duration) -> Duration {
let steps = failures.saturating_sub(1).min(u32::BITS - 1);
min_interval
.saturating_mul(2u32.saturating_pow(steps))
.clamp(min_interval, MAX_INTERVAL.max(min_interval))
}
pub fn spawn_if_due(name: &'static str, action: HealAction, ctx: SweepContext) {
if !try_begin(name) {
return;
}
debug!(check = name, "spawning self-heal attempt");
tokio::spawn(async move {
let outcome = (action.run)(ctx).await;
finish(name, outcome, action.min_interval);
});
}
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, min_interval: Duration) {
let mut map = registry().lock().expect("heal registry poisoned");
let attempt = map.entry(name).or_default();
attempt.in_flight = false;
attempt.failures = match outcome {
HealOutcome::Healed => 0,
HealOutcome::Deferred | HealOutcome::Failed => attempt.failures.saturating_add(1),
};
attempt.next_allowed = Some(Instant::now() + backoff_delay(attempt.failures, min_interval));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backoff_grows_and_caps() {
let d = DEFAULT_MIN_INTERVAL;
assert_eq!(backoff_delay(0, d), d);
assert_eq!(backoff_delay(1, d), d);
assert_eq!(backoff_delay(2, d), d * 2);
assert_eq!(backoff_delay(3, d), d * 4);
assert_eq!(backoff_delay(100, d), MAX_INTERVAL);
}
#[test]
fn a_longer_min_interval_is_the_floor_and_cap() {
let hour = Duration::from_secs(60 * 60);
assert_eq!(backoff_delay(0, hour), hour);
assert_eq!(backoff_delay(1, hour), hour);
assert_eq!(backoff_delay(100, hour), hour);
}
#[test]
fn at_most_one_attempt_in_flight() {
let name = "test_in_flight";
let zero = Duration::ZERO;
assert!(try_begin(name), "first attempt is due");
assert!(
!try_begin(name),
"a second attempt is refused while in flight"
);
finish(name, HealOutcome::Healed, zero);
assert!(try_begin(name), "with no floor the check is due again");
finish(name, HealOutcome::Healed, zero);
}
#[test]
fn deferred_attempt_backs_off() {
let name = "test_backoff";
assert!(try_begin(name), "first attempt is due");
finish(name, HealOutcome::Deferred, DEFAULT_MIN_INTERVAL);
assert!(
!try_begin(name),
"a deferred attempt backs off rather than retrying immediately"
);
}
#[test]
fn a_successful_repair_still_waits_the_min_interval() {
let name = "test_heal_floor";
assert!(try_begin(name), "first attempt is due");
finish(name, HealOutcome::Healed, Duration::from_secs(60 * 60));
assert!(
!try_begin(name),
"a healed check waits its minimum interval before the next attempt"
);
}
}