use std::collections::BTreeMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
use async_trait::async_trait;
use tracing::{debug, warn};
use super::{Component, ComponentObservation, ComponentState, Observed, StatusReason, StatusView};
use crate::convergence::{Clock, SystemClock};
use crate::telemetry::metrics;
pub const REFRESH_OBSERVED: &str = "observed";
pub const REFRESH_FAILED: &str = "failed";
pub const REFRESH_DISABLED: &str = "disabled";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StatusSettings {
pub refresh_interval: Duration,
pub probe_timeout: Duration,
pub staleness_budget: Duration,
pub enabled: Vec<Component>,
}
pub const MIN_REFRESH_INTERVAL: Duration = Duration::from_secs(1);
pub const EXPORT_INTERVAL: Duration = Duration::from_secs(15);
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum InvalidStatusSettings {
#[error("status refresh interval must be at least {}s", MIN_REFRESH_INTERVAL.as_secs())]
RefreshTooFast,
#[error("status probe timeout must be shorter than the refresh interval")]
ProbeTimeoutTooLong,
#[error("status staleness budget must be longer than the refresh interval")]
StalenessBudgetTooShort,
}
impl Default for StatusSettings {
fn default() -> Self {
Self {
refresh_interval: Duration::from_secs(10),
probe_timeout: Duration::from_secs(2),
staleness_budget: Duration::from_secs(60),
enabled: Vec::new(),
}
}
}
impl StatusSettings {
pub fn validate(&self) -> Result<(), InvalidStatusSettings> {
if self.refresh_interval < MIN_REFRESH_INTERVAL {
return Err(InvalidStatusSettings::RefreshTooFast);
}
if self.probe_timeout >= self.refresh_interval {
return Err(InvalidStatusSettings::ProbeTimeoutTooLong);
}
if self.staleness_budget <= self.refresh_interval {
return Err(InvalidStatusSettings::StalenessBudgetTooShort);
}
Ok(())
}
#[must_use]
pub fn merge(mut self, other: Self) -> Self {
self.refresh_interval = self.refresh_interval.max(other.refresh_interval);
self.probe_timeout = self.probe_timeout.max(other.probe_timeout);
self.staleness_budget = self.staleness_budget.max(other.staleness_budget);
for component in other.enabled {
if !self.enabled.contains(&component) {
self.enabled.push(component);
}
}
self
}
}
#[derive(Debug, Clone)]
struct Cached {
state: ComponentState,
reason: Option<StatusReason>,
observed_at: Instant,
}
pub struct CachedStatusRegistry {
settings: StatusSettings,
clock: Arc<dyn Clock>,
observations: RwLock<BTreeMap<Component, Cached>>,
}
impl CachedStatusRegistry {
pub fn new(settings: StatusSettings, clock: Arc<dyn Clock>) -> Self {
Self {
settings,
clock,
observations: RwLock::new(BTreeMap::new()),
}
}
pub fn stateless() -> Self {
Self::new(StatusSettings::default(), Arc::new(SystemClock))
}
pub fn settings(&self) -> &StatusSettings {
&self.settings
}
pub fn publish(&self, observation: ComponentObservation) {
let component = observation.component;
match (&observation.state, &observation.detail) {
(ComponentState::Ok, _) => debug!(component = component.as_str(), "component observed"),
(state, Some(detail)) => warn!(
component = component.as_str(),
state = state.as_str(),
reason = observation.reason.map(StatusReason::code),
detail = detail.as_str(),
"component degraded"
),
(state, None) => warn!(
component = component.as_str(),
state = state.as_str(),
reason = observation.reason.map(StatusReason::code),
"component degraded"
),
}
let now = self.clock.now();
self.observations
.write()
.expect("status observations lock poisoned")
.insert(
component,
Cached {
state: observation.state,
reason: observation.reason,
observed_at: now,
},
);
metrics::record_status_refresh(
component.as_str(),
match observation.state {
ComponentState::Ok => REFRESH_OBSERVED,
ComponentState::Disabled => REFRESH_DISABLED,
_ => REFRESH_FAILED,
},
);
}
pub fn view(&self) -> StatusView {
let now = self.clock.now();
let observations = self
.observations
.read()
.expect("status observations lock poisoned");
let components = Component::ALL
.iter()
.map(|component| {
let enabled = self.settings.enabled.contains(component);
match (enabled, observations.get(component)) {
(false, _) => Observed {
component: *component,
state: ComponentState::Disabled,
reason: Some(StatusReason::NotConfigured),
age: Duration::ZERO,
stale: false,
},
(true, None) => Observed {
component: *component,
state: ComponentState::Unavailable,
reason: Some(StatusReason::Unknown),
age: Duration::ZERO,
stale: false,
},
(true, Some(cached)) => {
let age = now.saturating_duration_since(cached.observed_at);
let stale = age > self.settings.staleness_budget;
let (state, reason) = match (stale, cached.state) {
(true, ComponentState::Ok | ComponentState::Degraded) => {
(ComponentState::Degraded, Some(StatusReason::Stale))
}
(true, state) => (state, Some(StatusReason::Stale)),
(false, state) => (state, cached.reason),
};
Observed {
component: *component,
state,
reason,
age,
stale,
}
}
}
})
.collect();
StatusView { components }
}
pub(super) fn export(&self) -> StatusView {
let view = self.view();
for observed in &view.components {
metrics::record_status_component(
observed.component.as_str(),
observed.state,
observed.age,
);
}
view
}
}
#[async_trait]
pub trait ComponentProbe: Send + Sync {
fn component(&self) -> Component;
fn begin<'a>(
&'a self,
fallback: Duration,
) -> (
Duration,
Pin<Box<dyn Future<Output = ComponentObservation> + Send + 'a>>,
) {
(fallback, Box::pin(self.observe()))
}
async fn observe(&self) -> ComponentObservation;
}
#[derive(Default)]
pub struct ObservationPlan {
pacing: StatusSettings,
probes: Vec<Arc<dyn ComponentProbe>>,
}
impl ObservationPlan {
pub fn stateless() -> Self {
Self::default()
}
pub fn observe(&mut self, probe: Arc<dyn ComponentProbe>, pacing: StatusSettings) {
self.pacing = std::mem::take(&mut self.pacing).merge(pacing);
self.probes.push(probe);
}
pub fn is_empty(&self) -> bool {
self.probes.is_empty()
}
pub fn pacing(&self) -> &StatusSettings {
&self.pacing
}
pub fn components(&self) -> &[Component] {
&self.pacing.enabled
}
pub fn into_parts(self) -> (StatusSettings, Vec<Arc<dyn ComponentProbe>>) {
(self.pacing, self.probes)
}
}
pub struct StatusRefresher {
registry: Arc<CachedStatusRegistry>,
probes: Vec<Arc<dyn ComponentProbe>>,
}
impl StatusRefresher {
pub fn new(registry: Arc<CachedStatusRegistry>, probes: Vec<Arc<dyn ComponentProbe>>) -> Self {
let enabled = registry.settings().enabled.clone();
Self {
registry,
probes: probes
.into_iter()
.filter(|probe| enabled.contains(&probe.component()))
.collect(),
}
}
pub async fn refresh_once(&self) {
let fallback = self.registry.settings().probe_timeout;
let observations = futures::future::join_all(self.probes.iter().map(|probe| async move {
let (timeout, observation) = probe.begin(fallback);
match tokio::time::timeout(timeout, observation).await {
Ok(observation) => observation,
Err(_) => ComponentObservation::unavailable(
probe.component(),
StatusReason::Timeout,
format!("probe exceeded {}ms", timeout.as_millis()),
),
}
}))
.await;
for observation in observations {
self.registry.publish(observation);
}
self.registry.export();
}
pub async fn run(self, shutdown: impl std::future::Future<Output = ()> + Send) {
let refresh_interval = self.registry.settings().refresh_interval;
let mut ticker = tokio::time::interval(refresh_interval);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let refreshing = async {
loop {
ticker.tick().await;
self.refresh_once().await;
}
};
let registry = Arc::clone(&self.registry);
let ageing = async move {
let mut ticker = tokio::time::interval(EXPORT_INTERVAL.min(refresh_interval));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
ticker.tick().await;
registry.export();
}
};
tokio::select! {
() = shutdown => {}
() = refreshing => {}
() = ageing => {}
}
}
}