use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
#[derive(Debug)]
pub struct CronStatus {
last_reconcile_ok: AtomicI64,
last_claim_ok: AtomicI64,
renewal_failures: AtomicU64,
db_unavailable: AtomicBool,
oldest_pending_secs: AtomicI64,
}
impl Default for CronStatus {
fn default() -> Self {
Self {
last_reconcile_ok: AtomicI64::new(0),
last_claim_ok: AtomicI64::new(0),
renewal_failures: AtomicU64::new(0),
db_unavailable: AtomicBool::new(false),
oldest_pending_secs: AtomicI64::new(-1),
}
}
}
impl CronStatus {
pub fn new() -> Self {
Self::default()
}
fn now() -> i64 {
chrono::Utc::now().timestamp()
}
pub fn record_reconcile_ok(&self) {
self.last_reconcile_ok.store(Self::now(), Ordering::Relaxed);
self.db_unavailable.store(false, Ordering::Relaxed);
}
pub fn record_claim_ok(&self) {
self.last_claim_ok.store(Self::now(), Ordering::Relaxed);
self.db_unavailable.store(false, Ordering::Relaxed);
}
pub fn record_db_unavailable(&self) {
self.db_unavailable.store(true, Ordering::Relaxed);
}
pub fn record_renewal_failure(&self) {
self.renewal_failures.fetch_add(1, Ordering::Relaxed);
}
pub fn set_oldest_pending_secs(&self, secs: Option<i64>) {
self.oldest_pending_secs
.store(secs.unwrap_or(-1), Ordering::Relaxed);
}
pub fn last_reconcile_ok(&self) -> Option<i64> {
match self.last_reconcile_ok.load(Ordering::Relaxed) {
0 => None,
secs => Some(secs),
}
}
pub fn last_claim_ok(&self) -> Option<i64> {
match self.last_claim_ok.load(Ordering::Relaxed) {
0 => None,
secs => Some(secs),
}
}
pub fn renewal_failures(&self) -> u64 {
self.renewal_failures.load(Ordering::Relaxed)
}
pub fn db_unavailable(&self) -> bool {
self.db_unavailable.load(Ordering::Relaxed)
}
pub fn oldest_pending_secs(&self) -> Option<i64> {
match self.oldest_pending_secs.load(Ordering::Relaxed) {
secs if secs < 0 => None,
secs => Some(secs),
}
}
pub fn reconcile_age_secs(&self) -> Option<i64> {
self.last_reconcile_ok()
.map(|at| Ord::max(Self::now() - at, 0))
}
pub fn is_degraded(&self, poll_interval: std::time::Duration) -> bool {
if self.db_unavailable() {
return true;
}
let Some(age) = self.reconcile_age_secs() else {
return false;
};
age > Ord::max(poll_interval.as_secs().saturating_mul(10), 60) as i64
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn a_fresh_scheduler_is_not_degraded() {
let status = CronStatus::new();
assert_eq!(status.last_reconcile_ok(), None);
assert!(
!status.is_degraded(Duration::from_secs(1)),
"a node whose first pass has not run yet is starting, not broken"
);
}
#[test]
fn a_recent_pass_is_healthy() {
let status = CronStatus::new();
status.record_reconcile_ok();
assert!(!status.is_degraded(Duration::from_secs(1)));
assert_eq!(status.reconcile_age_secs(), Some(0));
}
#[test]
fn a_stale_pass_is_degraded_against_the_poll_interval() {
let status = CronStatus::new();
status
.last_reconcile_ok
.store(CronStatus::now() - 120, Ordering::Relaxed);
assert!(
status.is_degraded(Duration::from_secs(1)),
"two minutes without a pass on a one-second interval is broken"
);
assert!(!status.is_degraded(Duration::from_secs(60)));
}
#[test]
fn a_database_failure_degrades_immediately_and_recovers_on_success() {
let status = CronStatus::new();
status.record_reconcile_ok();
status.record_db_unavailable();
assert!(status.is_degraded(Duration::from_secs(1)));
status.record_claim_ok();
assert!(!status.is_degraded(Duration::from_secs(1)));
}
#[test]
fn the_backlog_gauge_distinguishes_empty_from_zero_age() {
let status = CronStatus::new();
assert_eq!(status.oldest_pending_secs(), None);
status.set_oldest_pending_secs(Some(0));
assert_eq!(status.oldest_pending_secs(), Some(0));
status.set_oldest_pending_secs(None);
assert_eq!(status.oldest_pending_secs(), None);
}
#[test]
fn renewal_failures_accumulate() {
let status = CronStatus::new();
status.record_renewal_failure();
status.record_renewal_failure();
assert_eq!(status.renewal_failures(), 2);
}
}