pub mod events;
pub mod sampler;
pub mod stream;
pub mod transitions;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use tokio::sync::{RwLock, broadcast};
use trusty_common::console_metrics::ConsoleMetricsReport;
use trusty_common::host_metrics::HostMetrics;
use trusty_common::host_metrics::history::{
HOST_HISTORY_CAPACITY, HOST_SAMPLE_INTERVAL_SECS, MetricRing,
};
use events::HistoryEvent;
use transitions::{SERVICE_REPORT_GRACE_SECS, ServiceTransition, TransitionTracker};
pub const MACHINE_HISTORY_SCHEMA_VERSION: u32 = 1;
pub const TRANSITION_LOG_CAPACITY: usize = 256;
pub const EVENT_BUFFER: usize = 128;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistorySnapshot {
pub samples: Vec<HostMetrics>,
pub transitions: Vec<ServiceTransition>,
pub sample_capacity: usize,
pub transition_capacity: usize,
pub sample_interval_secs: u64,
pub schema_version: u32,
}
struct Inner {
samples: MetricRing<HostMetrics>,
transitions: MetricRing<ServiceTransition>,
tracker: TransitionTracker,
}
struct Shared {
inner: RwLock<Inner>,
events: broadcast::Sender<HistoryEvent>,
sample_interval_secs: AtomicU64,
}
#[derive(Clone)]
pub struct MachineHistory {
shared: Arc<Shared>,
}
impl Default for MachineHistory {
fn default() -> Self {
Self::new()
}
}
impl MachineHistory {
#[must_use]
pub fn new() -> Self {
Self::with_limits(
HOST_HISTORY_CAPACITY,
TRANSITION_LOG_CAPACITY,
EVENT_BUFFER,
Duration::from_secs(SERVICE_REPORT_GRACE_SECS),
)
}
#[must_use]
pub fn with_limits(
sample_capacity: usize,
transition_capacity: usize,
event_buffer: usize,
grace: Duration,
) -> Self {
let (events, _) = broadcast::channel(event_buffer.max(1));
Self {
shared: Arc::new(Shared {
inner: RwLock::new(Inner {
samples: MetricRing::new(sample_capacity),
transitions: MetricRing::new(transition_capacity),
tracker: TransitionTracker::new(grace),
}),
events,
sample_interval_secs: AtomicU64::new(HOST_SAMPLE_INTERVAL_SECS),
}),
}
}
pub fn set_sample_interval(&self, secs: u64) {
self.shared
.sample_interval_secs
.store(secs, Ordering::Relaxed);
}
pub async fn record_sample(&self, sample: HostMetrics) {
let mut inner = self.shared.inner.write().await;
inner.samples.push(sample.clone());
let _ = self
.shared
.events
.send(HistoryEvent::Sample(Box::new(sample)));
}
pub async fn observe_services(
&self,
reports: &[ConsoleMetricsReport],
) -> Vec<ServiceTransition> {
let now_unix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let mut inner = self.shared.inner.write().await;
let changes = inner.tracker.observe(reports, Instant::now(), now_unix);
for change in &changes {
inner.transitions.push(change.clone());
let _ = self
.shared
.events
.send(HistoryEvent::Transition(Box::new(change.clone())));
}
changes
}
pub async fn snapshot(&self) -> HistorySnapshot {
let inner = self.shared.inner.read().await;
self.snapshot_locked(&inner)
}
pub async fn subscribe(&self) -> (HistorySnapshot, broadcast::Receiver<HistoryEvent>) {
let inner = self.shared.inner.read().await;
let rx = self.shared.events.subscribe();
(self.snapshot_locked(&inner), rx)
}
fn snapshot_locked(&self, inner: &Inner) -> HistorySnapshot {
HistorySnapshot {
samples: inner.samples.snapshot(),
transitions: inner.transitions.snapshot(),
sample_capacity: inner.samples.capacity(),
transition_capacity: inner.transitions.capacity(),
sample_interval_secs: self.shared.sample_interval_secs.load(Ordering::Relaxed),
schema_version: MACHINE_HISTORY_SCHEMA_VERSION,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use trusty_common::console_metrics::{ServiceHealth, make_report};
use trusty_common::host_metrics::HostSampler;
fn sample() -> HostMetrics {
HostSampler::new().sample()
}
#[tokio::test]
async fn history_starts_empty() {
let h = MachineHistory::new();
let snap = h.snapshot().await;
assert!(snap.samples.is_empty());
assert!(snap.transitions.is_empty());
assert_eq!(snap.sample_capacity, HOST_HISTORY_CAPACITY);
assert_eq!(snap.transition_capacity, TRANSITION_LOG_CAPACITY);
assert_eq!(snap.sample_interval_secs, HOST_SAMPLE_INTERVAL_SECS);
assert_eq!(snap.schema_version, MACHINE_HISTORY_SCHEMA_VERSION);
}
#[tokio::test]
async fn recording_a_sample_fans_out_to_subscribers() {
let h = MachineHistory::new();
let (snap, mut rx) = h.subscribe().await;
assert!(snap.samples.is_empty());
h.record_sample(sample()).await;
match rx.try_recv() {
Ok(HistoryEvent::Sample(_)) => {}
other => panic!("expected a sample event, got {other:?}"),
}
assert_eq!(h.snapshot().await.samples.len(), 1);
}
#[tokio::test]
async fn the_ring_bounds_what_history_returns() {
let h = MachineHistory::with_limits(3, 4, 8, Duration::from_secs(60));
for _ in 0..4 {
h.record_sample(sample()).await;
}
let snap = h.snapshot().await;
assert_eq!(snap.samples.len(), 3, "the ring bounds the window");
assert_eq!(snap.sample_capacity, 3);
assert_eq!(snap.transition_capacity, 4);
}
#[tokio::test]
async fn an_unchanged_service_adds_nothing_to_the_log() {
let h = MachineHistory::new();
let ok = make_report(
"trusty-search",
"Trusty Search",
"1.0.0",
ServiceHealth::Ok,
serde_json::json!({}),
1,
);
for _ in 0..5 {
assert!(
h.observe_services(std::slice::from_ref(&ok))
.await
.is_empty()
);
}
assert!(h.snapshot().await.transitions.is_empty());
let degraded = make_report(
"trusty-search",
"Trusty Search",
"1.0.0",
ServiceHealth::Degraded,
serde_json::json!({}),
1,
);
let changes = h.observe_services(&[degraded]).await;
assert_eq!(changes.len(), 1);
let snap = h.snapshot().await;
assert_eq!(snap.transitions.len(), 1);
assert_eq!(snap.transitions[0].to, transitions::ServiceState::Degraded);
}
#[tokio::test]
async fn the_advertised_interval_follows_the_sampler() {
let h = MachineHistory::new();
h.set_sample_interval(30);
assert_eq!(h.snapshot().await.sample_interval_secs, 30);
}
fn tagged_sample(seq: u64) -> HostMetrics {
use trusty_common::host_metrics::{
CpuMetrics, DiskMetrics, MemoryMetrics, NetworkMetrics, Pressure,
};
HostMetrics {
cpu: CpuMetrics {
usage_pct: 0.0,
logical_cores: 1,
physical_cores: None,
pressure: Pressure::Nominal,
},
memory: MemoryMetrics {
total_bytes: 1,
used_bytes: 0,
available_bytes: 1,
usage_pct: 0.0,
swap_total_bytes: 0,
swap_used_bytes: 0,
pressure: Pressure::Nominal,
},
disks: DiskMetrics {
aggregate_total_bytes: 1,
aggregate_available_bytes: 1,
aggregate_used_bytes: 0,
aggregate_usage_pct: 0.0,
pressure: Pressure::Nominal,
mounts: Vec::new(),
},
network: NetworkMetrics {
rx_bytes_per_sec: 0.0,
tx_bytes_per_sec: 0.0,
rx_total_bytes: 0,
tx_total_bytes: 0,
window_secs: 1.0,
},
overall_pressure: Pressure::Nominal,
sampled_at_unix: Some(seq),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn every_sample_reaches_a_mid_run_subscriber_exactly_once() {
use tokio::sync::broadcast::error::TryRecvError;
const TOTAL: u64 = 20_000;
const MAX_SUBSCRIPTIONS: usize = 200_000;
let history = MachineHistory::with_limits(
8,
8,
TOTAL as usize * 2,
Duration::from_secs(60),
);
let writer = {
let history = history.clone();
tokio::spawn(async move {
for seq in 0..TOTAL {
history.record_sample(tagged_sample(seq)).await;
}
})
};
let mut taken: Vec<(Option<u64>, broadcast::Receiver<HistoryEvent>)> = Vec::new();
while !writer.is_finished() && taken.len() < MAX_SUBSCRIPTIONS {
let (snapshot, rx) = history.subscribe().await;
let last = snapshot
.samples
.last()
.map(|m| m.sampled_at_unix.expect("tagged sample"));
taken.push((last, rx));
}
writer.await.expect("writer task");
let mut mid_run = 0usize;
for (nth, (last_snapshotted, mut rx)) in taken.into_iter().enumerate() {
if last_snapshotted == Some(TOTAL - 1) {
continue;
}
let expected = last_snapshotted.map_or(0, |s| s + 1);
mid_run += 1;
let first_live = loop {
match rx.try_recv() {
Ok(HistoryEvent::Sample(m)) => break m.sampled_at_unix.expect("tagged sample"),
Ok(HistoryEvent::Transition(_)) => {}
Err(TryRecvError::Empty | TryRecvError::Closed) => panic!(
"subscription {nth} snapshotted through {last_snapshotted:?} of {TOTAL} \
samples and then received nothing live"
),
Err(TryRecvError::Lagged(n)) => panic!(
"subscription {nth} lagged by {n} — the buffer was sized to prevent it"
),
}
};
assert_eq!(
first_live, expected,
"subscription {nth} snapshotted through {last_snapshotted:?} and must resume live \
at {expected}; resuming later means a sample fell between the snapshot and the \
subscription, resuming at the same sequence means it landed in both"
);
}
assert!(
mid_run >= 500,
"only {mid_run} subscription(s) landed mid-run — the race this test exists to catch \
was never given a chance"
);
}
}