use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WriteAction {
None,
Demote,
}
pub struct LcdHealth {
failure_threshold: u32,
exit_after: Duration,
log_interval: Duration,
consecutive_failures: u32,
last_success: Option<Instant>,
last_error_log: Option<Instant>,
}
impl LcdHealth {
pub fn new(failure_threshold: u32, exit_after: Duration, log_interval: Duration) -> Self {
Self {
failure_threshold,
exit_after,
log_interval,
consecutive_failures: 0,
last_success: None,
last_error_log: None,
}
}
pub fn consecutive_failures(&self) -> u32 {
self.consecutive_failures
}
pub fn record_success(&mut self, now: Instant) {
self.consecutive_failures = 0;
self.last_success = Some(now);
}
pub fn record_failure(&mut self) -> WriteAction {
self.consecutive_failures += 1;
if self.consecutive_failures == self.failure_threshold {
WriteAction::Demote
} else {
WriteAction::None
}
}
pub fn should_exit(&self, now: Instant) -> bool {
if self.exit_after.is_zero() {
return false;
}
matches!(self.last_success, Some(t) if now.duration_since(t) >= self.exit_after)
}
pub fn should_log(&mut self, now: Instant) -> Option<u32> {
let due = match self.last_error_log {
None => true,
Some(t) => now.duration_since(t) >= self.log_interval,
};
if due {
self.last_error_log = Some(now);
Some(self.consecutive_failures)
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn health() -> LcdHealth {
LcdHealth::new(3, Duration::from_secs(300), Duration::from_secs(60))
}
#[test]
fn transient_failures_below_threshold_do_not_demote() {
let mut h = health();
assert_eq!(h.record_failure(), WriteAction::None);
assert_eq!(h.record_failure(), WriteAction::None);
}
#[test]
fn threshold_failures_demote() {
let mut h = health();
h.record_failure();
h.record_failure();
assert_eq!(h.record_failure(), WriteAction::Demote);
assert_eq!(h.consecutive_failures(), 3);
}
#[test]
fn success_resets_failure_streak() {
let t = Instant::now();
let mut h = health();
h.record_failure();
h.record_failure();
h.record_success(t);
assert_eq!(h.consecutive_failures(), 0);
assert_eq!(h.record_failure(), WriteAction::None);
}
#[test]
fn exit_only_arms_after_a_success_then_goes_dark() {
let t = Instant::now();
let mut h = health();
assert!(!h.should_exit(t + Duration::from_secs(10_000)));
h.record_success(t);
assert!(!h.should_exit(t + Duration::from_secs(299)));
assert!(h.should_exit(t + Duration::from_secs(300)));
}
#[test]
fn exit_after_zero_disables_escalation() {
let t = Instant::now();
let mut h = LcdHealth::new(3, Duration::ZERO, Duration::from_secs(60));
h.record_success(t);
assert!(!h.should_exit(t + Duration::from_secs(100_000)));
}
#[test]
fn log_throttle_logs_first_then_once_per_interval() {
let t = Instant::now();
let mut h = health();
h.record_failure();
assert_eq!(h.should_log(t), Some(1)); h.record_failure();
assert_eq!(h.should_log(t + Duration::from_secs(1)), None); h.record_failure();
assert_eq!(h.should_log(t + Duration::from_secs(60)), Some(3)); }
#[test]
fn flapping_device_does_not_defeat_the_log_throttle() {
let t = Instant::now();
let mut h = health();
h.record_failure();
assert_eq!(h.should_log(t), Some(1));
for i in 1..10 {
let now = t + Duration::from_secs(i * 5);
h.record_success(now);
h.record_failure();
assert_eq!(h.should_log(now), None, "flap at {i} should stay throttled");
}
h.record_failure();
assert!(h.should_log(t + Duration::from_secs(61)).is_some());
}
}