use std::fmt;
use std::num::NonZeroU64;
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use std::time::Duration;
use engenho_controllers::{
Controller, ControllerError, DeclaresReads, Reads, ReconcileOutcome, ReconcileReport,
ReconcileResult,
};
use engenho_substrate::{Freshness, Instant as WallInstant, StaleAfter};
use tokio::time::Instant;
use tracing::{info, warn};
use crate::health::wall_of;
pub const RELIST_PERIOD: Duration = Duration::from_secs(1);
pub const RELIST_THRESHOLD: StaleAfter = StaleAfter::from_secs(NonZeroU64::MIN.saturating_add(179));
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Relisted {
containers: usize,
}
impl Relisted {
#[must_use]
pub const fn new(containers: usize) -> Self {
Self { containers }
}
#[must_use]
pub const fn containers(self) -> usize {
self.containers
}
}
#[derive(Debug, Clone, thiserror::Error)]
#[error("the container runtime failed a relist: {cause}")]
pub struct RelistFault {
cause: Arc<dyn std::error::Error + Send + Sync>,
}
impl RelistFault {
#[must_use]
pub fn new(cause: impl std::error::Error + Send + Sync + 'static) -> Self {
Self {
cause: Arc::new(cause),
}
}
}
#[async_trait::async_trait]
pub trait Relist: Send + Sync {
async fn relist(&self) -> Result<Relisted, RelistFault>;
}
#[derive(Debug, Clone)]
pub enum Staleness {
Failing(RelistFault),
Silent,
}
impl fmt::Display for Staleness {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Failing(fault) => fault.fmt(f),
Self::Silent => f.write_str("no relist has finished"),
}
}
}
#[derive(Debug, Clone)]
pub enum RuntimeHealth {
NotYetObserved(Staleness),
Healthy {
last_relist: WallInstant,
},
Unhealthy {
last_relist: WallInstant,
why: Staleness,
},
}
impl RuntimeHealth {
#[must_use]
pub const fn is_healthy(&self) -> bool {
matches!(self, Self::Healthy { .. })
}
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::NotYetObserved(_) => "not_yet_observed",
Self::Healthy { .. } => "healthy",
Self::Unhealthy { .. } => "unhealthy",
}
}
}
impl fmt::Display for RuntimeHealth {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotYetObserved(why) => write!(f, "not yet observed: {why}"),
Self::Healthy { last_relist } => {
write!(
f,
"healthy: the last successful relist began at {last_relist}"
)
}
Self::Unhealthy { last_relist, why } => write!(
f,
"unhealthy: the last successful relist began at {last_relist}, over {}s ago: {why}",
RELIST_THRESHOLD.get().as_secs()
),
}
}
}
#[derive(Debug, Default)]
pub struct RelistLedger {
record: Mutex<Record>,
}
#[derive(Debug, Default)]
struct Record {
last_success: Option<Instant>,
last_fault: Option<RelistFault>,
}
impl RelistLedger {
#[must_use]
pub fn new() -> Self {
Self::default()
}
fn record(&self) -> MutexGuard<'_, Record> {
self.record.lock().unwrap_or_else(PoisonError::into_inner)
}
fn write(&self, began: Instant, outcome: &Result<Relisted, RelistFault>) {
let mut record = self.record();
match outcome {
Ok(_) => {
record.last_success = Some(record.last_success.map_or(began, |at| at.max(began)));
record.last_fault = None;
}
Err(fault) => record.last_fault = Some(fault.clone()),
}
}
#[must_use]
pub fn health(&self, now: Instant, wall_now: WallInstant) -> RuntimeHealth {
let record = self.record();
let why = || match &record.last_fault {
Some(fault) => Staleness::Failing(fault.clone()),
None => Staleness::Silent,
};
let Some(began) = record.last_success else {
return RuntimeHealth::NotYetObserved(why());
};
let last_relist = wall_of(began, now, wall_now);
match Freshness::judge(Some(last_relist), wall_now, RELIST_THRESHOLD) {
Freshness::Fresh => RuntimeHealth::Healthy { last_relist },
Freshness::Stale { since } => RuntimeHealth::Unhealthy {
last_relist: since,
why: why(),
},
Freshness::NeverObserved => RuntimeHealth::NotYetObserved(why()),
}
}
}
pub struct Relister {
relist: Arc<dyn Relist>,
ledger: Arc<RelistLedger>,
answered: Mutex<Option<bool>>,
}
impl Relister {
#[must_use]
pub fn new(relist: Arc<dyn Relist>, ledger: Arc<RelistLedger>) -> Self {
Self {
relist,
ledger,
answered: Mutex::new(None),
}
}
fn note(&self, outcome: &Result<Relisted, RelistFault>) {
let answered = outcome.is_ok();
let was = self
.answered
.lock()
.unwrap_or_else(PoisonError::into_inner)
.replace(answered);
if was == Some(answered) {
return;
}
match outcome {
Ok(seen) => info!(
containers = seen.containers(),
"the container runtime answers its relists"
),
Err(fault) => warn!(
%fault,
threshold_s = RELIST_THRESHOLD.get().as_secs(),
"the container runtime failed a relist; it is judged unhealthy once no relist has \
succeeded for the threshold"
),
}
}
}
#[async_trait::async_trait]
impl Controller for Relister {
fn name(&self) -> &'static str {
"runtime-relist"
}
async fn tick(&self) -> Result<ReconcileOutcome, ControllerError> {
let began = Instant::now();
let outcome = self.relist.relist().await;
self.ledger.write(began, &outcome);
self.note(&outcome);
match outcome {
Ok(seen) => Ok(ReconcileOutcome::new(
ReconcileReport {
objects_examined: seen.containers(),
..ReconcileReport::default()
},
ReconcileResult::Done,
)),
Err(fault) => Err(ControllerError::Internal(fault.to_string())),
}
}
}
impl DeclaresReads for Relister {
fn reads(&self) -> Reads {
Reads::nothing()
}
}
#[derive(Debug, Clone)]
pub enum RuntimeHealthSource {
Unobserved,
Relisted(Arc<RelistLedger>),
}
impl RuntimeHealthSource {
#[must_use]
pub fn sight(&self, now: Instant, wall_now: WallInstant) -> RuntimeSight {
match self {
Self::Unobserved => RuntimeSight::Blind,
Self::Relisted(ledger) => RuntimeSight::Judged(ledger.health(now, wall_now)),
}
}
}
#[derive(Debug, Clone)]
pub enum RuntimeSight {
Blind,
Judged(RuntimeHealth),
}
impl RuntimeSight {
#[must_use]
pub const fn permits_renewal(&self) -> bool {
match self {
Self::Blind => true,
Self::Judged(health) => health.is_healthy(),
}
}
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Blind => "blind",
Self::Judged(health) => health.as_str(),
}
}
}
impl fmt::Display for RuntimeSight {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Blind => f.write_str("not observed: nothing relists the container runtime"),
Self::Judged(health) => health.fmt(f),
}
}
}
#[cfg(test)]
mod tests {
use engenho_substrate::{Clock as _, WallClock};
use super::*;
use crate::testing::{RelistAnswer as Answer, ScriptedRuntime};
fn relister() -> (Arc<ScriptedRuntime>, Arc<RelistLedger>, Arc<Relister>) {
let runtime = Arc::new(ScriptedRuntime::default());
let ledger = Arc::new(RelistLedger::new());
let relister = Arc::new(Relister::new(runtime.clone(), ledger.clone()));
(runtime, ledger, relister)
}
fn health(ledger: &RelistLedger) -> RuntimeHealth {
ledger.health(Instant::now(), WallClock.now())
}
fn threshold() -> Duration {
RELIST_THRESHOLD.get()
}
async fn relist_for(relister: &Relister, span: Duration) {
let mut elapsed = Duration::ZERO;
while elapsed < span {
let _ = relister.tick().await;
tokio::time::advance(RELIST_PERIOD).await;
elapsed += RELIST_PERIOD;
}
}
#[tokio::test(start_paused = true)]
async fn a_runtime_no_relist_has_answered_is_not_yet_observed_and_not_healthy() {
let (_, ledger, _) = relister();
let seen = health(&ledger);
assert!(
matches!(seen, RuntimeHealth::NotYetObserved(Staleness::Silent)),
"{seen}"
);
assert!(
!seen.is_healthy(),
"a runtime nobody has relisted is healthy"
);
}
#[tokio::test(start_paused = true)]
async fn a_first_relist_that_fails_leaves_the_runtime_not_yet_observed_with_its_fault() {
let (runtime, ledger, relister) = relister();
runtime.set(Answer::Fails);
let tick = relister.tick().await;
assert!(
matches!(tick, Err(ControllerError::Internal(_))),
"a failed relist fails the tick, so it is counted: {tick:?}"
);
let seen = health(&ledger);
assert!(
matches!(seen, RuntimeHealth::NotYetObserved(Staleness::Failing(_))),
"{seen}"
);
assert!(
seen.to_string().contains("connection refused"),
"the runtime's own error reaches the reading: {seen}"
);
}
#[tokio::test(start_paused = true)]
async fn a_relist_the_runtime_answers_makes_it_healthy() {
let (_, ledger, relister) = relister();
let outcome = relister.tick().await.expect("the relist answered");
assert_eq!(outcome.report.objects_examined, ScriptedRuntime::CONTAINERS);
assert_eq!(outcome.result, ReconcileResult::Done);
let seen = health(&ledger);
assert!(seen.is_healthy(), "{seen}");
}
#[tokio::test(start_paused = true)]
async fn relists_that_fail_inside_the_threshold_leave_the_runtime_healthy() {
let (runtime, ledger, relister) = relister();
let _ = relister.tick().await;
runtime.set(Answer::Fails);
let mut elapsed = Duration::ZERO;
while elapsed < threshold() {
let _ = relister.tick().await;
let seen = health(&ledger);
assert!(seen.is_healthy(), "{elapsed:?} into the failures: {seen}");
tokio::time::advance(RELIST_PERIOD).await;
elapsed += RELIST_PERIOD;
}
}
#[tokio::test(start_paused = true)]
async fn a_runtime_whose_relists_fail_past_the_threshold_is_unhealthy_and_says_why() {
let (runtime, ledger, relister) = relister();
let _ = relister.tick().await;
assert!(
health(&ledger).is_healthy(),
"HARNESS PRECONDITION: the first relist answered"
);
runtime.set(Answer::Fails);
relist_for(&relister, threshold() + Duration::from_secs(2)).await;
let wall_now = WallClock.now();
let seen = ledger.health(Instant::now(), wall_now);
let RuntimeHealth::Unhealthy {
last_relist,
why: Staleness::Failing(fault),
} = &seen
else {
panic!("three minutes of failed relists read as {seen}");
};
let age_ms = wall_now.physical_ms.saturating_sub(last_relist.physical_ms);
assert!(
u128::from(age_ms) > threshold().as_millis(),
"the reading names the last success, not the last attempt, a second ago: {seen}"
);
assert!(fault.to_string().contains("connection refused"), "{seen}");
}
#[tokio::test(start_paused = true)]
async fn a_hung_relist_leaves_the_last_success_to_age_out_as_silence() {
let (runtime, ledger, relister) = relister();
let _ = relister.tick().await;
runtime.set(Answer::Hangs);
let hung = tokio::spawn({
let relister = relister.clone();
async move { relister.tick().await }
});
tokio::time::advance(threshold().saturating_sub(Duration::from_secs(1))).await;
assert!(
health(&ledger).is_healthy(),
"inside the threshold the hung relist is evidence of nothing yet"
);
tokio::time::advance(Duration::from_secs(2)).await;
let seen = health(&ledger);
assert!(
matches!(
seen,
RuntimeHealth::Unhealthy {
why: Staleness::Silent,
..
}
),
"a relist hung past the threshold reads as {seen}"
);
assert!(
!hung.is_finished(),
"HARNESS PRECONDITION: the relist hangs"
);
hung.abort();
}
#[tokio::test(start_paused = true)]
async fn a_runtime_that_answers_again_is_healthy_again() {
let (runtime, ledger, relister) = relister();
runtime.set(Answer::Fails);
relist_for(&relister, threshold() + Duration::from_secs(2)).await;
assert!(
!health(&ledger).is_healthy(),
"HARNESS PRECONDITION: the runtime was unhealthy"
);
runtime.set(Answer::Lists);
let _ = relister.tick().await;
let seen = health(&ledger);
assert!(seen.is_healthy(), "{seen}");
}
#[tokio::test(start_paused = true)]
async fn a_slow_relist_vouches_for_the_moment_it_asked() {
let (runtime, ledger, relister) = relister();
let slow = Duration::from_secs(10);
runtime.set(Answer::Slowly(slow));
let _ = relister.tick().await;
assert!(
health(&ledger).is_healthy(),
"HARNESS PRECONDITION: it answered"
);
tokio::time::advance(threshold().saturating_sub(slow) + Duration::from_secs(2)).await;
let seen = health(&ledger);
assert!(
!seen.is_healthy(),
"a success begun over the threshold ago still vouched for the runtime: {seen}"
);
}
#[tokio::test(start_paused = true)]
async fn the_lease_is_held_back_only_by_a_judged_runtime_that_is_not_healthy() {
let (runtime, ledger, relister) = relister();
let judged = RuntimeHealthSource::Relisted(ledger);
let now = || (Instant::now(), WallClock.now());
let (at, wall) = now();
assert!(
RuntimeHealthSource::Unobserved
.sight(at, wall)
.permits_renewal(),
"an unobserved runtime leaves the lease to the kubelet"
);
assert!(
!judged.sight(at, wall).permits_renewal(),
"a runtime no relist has answered holds the lease back"
);
let _ = relister.tick().await;
let (at, wall) = now();
assert!(
judged.sight(at, wall).permits_renewal(),
"a healthy runtime"
);
runtime.set(Answer::Fails);
relist_for(&relister, threshold() + Duration::from_secs(2)).await;
let (at, wall) = now();
assert!(
!judged.sight(at, wall).permits_renewal(),
"an unhealthy runtime holds the lease back"
);
}
}