use std::collections::HashMap;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use trusty_common::console_metrics::{ConsoleMetricsReport, ServiceHealth};
pub const SERVICE_REPORT_GRACE_SECS: u64 = 60;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ServiceState {
Up,
Degraded,
Down,
Unknown,
}
impl From<&ServiceHealth> for ServiceState {
fn from(h: &ServiceHealth) -> Self {
match h {
ServiceHealth::Ok => ServiceState::Up,
ServiceHealth::Degraded => ServiceState::Degraded,
ServiceHealth::Error => ServiceState::Down,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServiceTransition {
pub service_id: String,
pub display_name: String,
pub from: ServiceState,
pub to: ServiceState,
pub at_unix: u64,
}
struct Observed {
state: ServiceState,
display_name: String,
last_report: Instant,
}
pub struct TransitionTracker {
grace: Duration,
seen: HashMap<String, Observed>,
}
impl Default for TransitionTracker {
fn default() -> Self {
Self::new(Duration::from_secs(SERVICE_REPORT_GRACE_SECS))
}
}
impl TransitionTracker {
#[must_use]
pub fn new(grace: Duration) -> Self {
Self {
grace,
seen: HashMap::new(),
}
}
#[must_use]
pub fn state_of(&self, service_id: &str) -> ServiceState {
self.seen
.get(service_id)
.map_or(ServiceState::Unknown, |o| o.state)
}
pub fn observe(
&mut self,
reports: &[ConsoleMetricsReport],
now: Instant,
now_unix: u64,
) -> Vec<ServiceTransition> {
let mut out = Vec::new();
for report in reports {
let stale = report
.collected_at_unix
.is_some_and(|t| now_unix.saturating_sub(t) > self.grace.as_secs());
let state = if stale {
ServiceState::Down
} else {
ServiceState::from(&report.status)
};
self.apply(
&report.service_id,
&report.display_name,
state,
now,
now_unix,
&mut out,
);
}
let missing: Vec<(String, String)> = self
.seen
.iter()
.filter(|(id, o)| {
o.state != ServiceState::Down
&& now.duration_since(o.last_report) > self.grace
&& !reports.iter().any(|r| &r.service_id == *id)
})
.map(|(id, o)| (id.clone(), o.display_name.clone()))
.collect();
for (id, display_name) in missing {
self.mark_down(&id, &display_name, now_unix, &mut out);
}
out
}
fn apply(
&mut self,
service_id: &str,
display_name: &str,
state: ServiceState,
now: Instant,
now_unix: u64,
out: &mut Vec<ServiceTransition>,
) {
match self.seen.get_mut(service_id) {
None => {
self.seen.insert(
service_id.to_string(),
Observed {
state,
display_name: display_name.to_string(),
last_report: now,
},
);
}
Some(prev) => {
prev.last_report = now;
prev.display_name = display_name.to_string();
if prev.state != state {
let from = prev.state;
prev.state = state;
out.push(ServiceTransition {
service_id: service_id.to_string(),
display_name: display_name.to_string(),
from,
to: state,
at_unix: now_unix,
});
}
}
}
}
fn mark_down(
&mut self,
service_id: &str,
display_name: &str,
now_unix: u64,
out: &mut Vec<ServiceTransition>,
) {
if let Some(prev) = self.seen.get_mut(service_id) {
let from = prev.state;
prev.state = ServiceState::Down;
out.push(ServiceTransition {
service_id: service_id.to_string(),
display_name: display_name.to_string(),
from,
to: ServiceState::Down,
at_unix: now_unix,
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use trusty_common::console_metrics::make_report;
fn unix_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
fn report(status: ServiceHealth, collected_at: u64) -> ConsoleMetricsReport {
let mut r = make_report(
"trusty-search",
"Trusty Search",
"1.0.0",
status,
serde_json::json!({}),
1,
);
r.collected_at_unix = Some(collected_at);
r
}
#[test]
fn first_observation_seeds_without_a_transition() {
let mut t = TransitionTracker::default();
let now = Instant::now();
let unix = unix_now();
let out = t.observe(&[report(ServiceHealth::Ok, unix)], now, unix);
assert!(out.is_empty(), "the first observation must log nothing");
assert_eq!(t.state_of("trusty-search"), ServiceState::Up);
}
#[test]
fn repeated_identical_reports_log_one_transition() {
let mut t = TransitionTracker::default();
let start = Instant::now();
let unix = unix_now();
let mut log: Vec<ServiceTransition> = Vec::new();
for i in 0..5u64 {
log.extend(t.observe(
&[report(ServiceHealth::Ok, unix + i)],
start + Duration::from_secs(i),
unix + i,
));
}
assert!(
log.is_empty(),
"five identical reports must log nothing, got {log:?}"
);
log.extend(t.observe(
&[report(ServiceHealth::Degraded, unix + 5)],
start + Duration::from_secs(5),
unix + 5,
));
assert_eq!(log.len(), 1, "exactly one transition, got {log:?}");
assert_eq!(log[0].from, ServiceState::Up);
assert_eq!(log[0].to, ServiceState::Degraded);
assert_eq!(log[0].service_id, "trusty-search");
assert_eq!(log[0].display_name, "Trusty Search");
}
#[test]
fn a_stale_report_transitions_to_down() {
let mut t = TransitionTracker::new(Duration::from_secs(10));
let start = Instant::now();
let unix = unix_now();
assert!(
t.observe(&[report(ServiceHealth::Ok, unix)], start, unix)
.is_empty()
);
let out = t.observe(
&[report(ServiceHealth::Ok, unix)],
start + Duration::from_secs(30),
unix + 30,
);
assert_eq!(out.len(), 1, "a stale report is a transition, got {out:?}");
assert_eq!(out[0].to, ServiceState::Down);
assert_eq!(t.state_of("trusty-search"), ServiceState::Down);
}
#[test]
fn a_service_that_stops_reporting_goes_down_after_the_grace() {
let mut t = TransitionTracker::new(Duration::from_secs(10));
let start = Instant::now();
let unix = unix_now();
t.observe(&[report(ServiceHealth::Ok, unix)], start, unix);
let inside = t.observe(&[], start + Duration::from_secs(5), unix + 5);
assert!(inside.is_empty(), "inside the grace nothing changes");
let past = t.observe(&[], start + Duration::from_secs(30), unix + 30);
assert_eq!(past.len(), 1, "past the grace the service goes down");
assert_eq!(past[0].from, ServiceState::Up);
assert_eq!(past[0].to, ServiceState::Down);
let again = t.observe(&[], start + Duration::from_secs(60), unix + 60);
assert!(again.is_empty(), "down is not re-logged every tick");
}
#[test]
fn an_unseen_service_reads_unknown() {
let t = TransitionTracker::default();
assert_eq!(t.state_of("trusty-memory"), ServiceState::Unknown);
}
}