trusty_console/host_status.rs
1//! Background whole-machine host-metrics sampler + cache for the console (#6517).
2//!
3//! Why: the machine-status route must serve a host snapshot instantly, never
4//! blocking on a live sysinfo refresh. A background task owns the stateful
5//! [`HostSampler`] (CPU and network readings are deltas between refreshes, so
6//! the sampler must persist across polls) and writes each snapshot into a shared
7//! cache the route reads. This mirrors [`crate::metrics_poller`], which does the
8//! same for the per-service `ConsoleMetricsReport`s.
9//! What: [`HostMetricsCache`] is the `Arc<RwLock<Option<HostMetrics>>>` handle;
10//! [`start`] spawns the sampling loop. The whole-machine sampler itself lives in
11//! `trusty_common::host_metrics` (the shared, cross-crate capability); this
12//! module only schedules it and caches its output.
13//! Test: `cache_initialises_empty`, `cache_write_read_roundtrip` in this module.
14
15use std::sync::Arc;
16use std::time::Duration;
17
18use tokio::sync::RwLock;
19use tracing::{debug, info};
20use trusty_common::host_metrics::{HostMetrics, HostSampler};
21
22/// Shared read/write handle to the latest [`HostMetrics`] snapshot.
23///
24/// Why: the machine-status route reads without blocking; the background task
25/// writes. `None` means no sample has completed yet (first boot).
26/// What: wraps `Arc<RwLock<Option<HostMetrics>>>`.
27/// Test: `cache_initialises_empty`, `cache_write_read_roundtrip`.
28#[derive(Clone, Debug)]
29pub struct HostMetricsCache {
30 inner: Arc<RwLock<Option<HostMetrics>>>,
31}
32
33impl Default for HostMetricsCache {
34 fn default() -> Self {
35 Self::new()
36 }
37}
38
39impl HostMetricsCache {
40 /// Create a new, empty cache.
41 ///
42 /// Why: start empty so the route can distinguish "not yet sampled" (503)
43 /// from a real snapshot.
44 /// What: allocates `Arc<RwLock<None>>`.
45 /// Test: `cache_initialises_empty`.
46 #[must_use]
47 pub fn new() -> Self {
48 Self {
49 inner: Arc::new(RwLock::new(None)),
50 }
51 }
52
53 /// Read the latest snapshot (`None` before the first sample).
54 ///
55 /// Why: the machine-status route calls this to serve without blocking.
56 /// What: acquires a read lock, clones the value, releases the lock.
57 /// Test: `cache_write_read_roundtrip`.
58 pub async fn get(&self) -> Option<HostMetrics> {
59 self.inner.read().await.clone()
60 }
61
62 /// Write a new snapshot into the cache.
63 ///
64 /// Why: the background task calls this after each sample.
65 /// What: acquires a write lock, replaces the inner value.
66 /// Test: `cache_write_read_roundtrip`.
67 pub async fn set(&self, metrics: HostMetrics) {
68 *self.inner.write().await = Some(metrics);
69 }
70}
71
72/// Spawn the background host-metrics sampling loop, writing into `cache`.
73///
74/// Why: one place owns the sampler and the sampling cadence. The sampler is
75/// stateful (CPU + network deltas), so it must live for the whole task rather
76/// than being reconstructed each tick.
77/// What: spawns a tokio task that constructs one [`HostSampler`], samples it
78/// immediately (to warm the cache before the first request), then re-samples
79/// every `interval`. Sampling never fails, so there is no error path to log —
80/// unlike `metrics_poller`, whose MCP poll can fail.
81/// Test: not tested directly (spawns a real OS sampler on a timer); the cache
82/// round-trip is covered in this module and the sampler in `trusty-common`.
83pub fn start(cache: HostMetricsCache, interval: Duration) {
84 tokio::spawn(async move {
85 info!(
86 "host_status: starting host-metrics sampler (interval={}s)",
87 interval.as_secs()
88 );
89 let mut sampler = HostSampler::new();
90 loop {
91 let metrics = sampler.sample();
92 debug!(
93 overall = ?metrics.overall_pressure,
94 cpu_pct = metrics.cpu.usage_pct,
95 mem_pct = metrics.memory.usage_pct,
96 "host_status: sampled host metrics"
97 );
98 cache.set(metrics).await;
99 tokio::time::sleep(interval).await;
100 }
101 });
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107
108 /// Why: a fresh cache must return `None` so the route can answer 503 before
109 /// the first sample.
110 /// What: creates a cache and asserts `get()` is `None`.
111 /// Test: this test.
112 #[tokio::test]
113 async fn cache_initialises_empty() {
114 let cache = HostMetricsCache::new();
115 assert!(cache.get().await.is_none(), "cache must start empty");
116 }
117
118 /// Why: after `set`, `get` must return the written snapshot.
119 /// What: samples a real snapshot, writes it, reads it back, asserts a core
120 /// field matches.
121 /// Test: this test.
122 #[tokio::test]
123 async fn cache_write_read_roundtrip() {
124 let cache = HostMetricsCache::new();
125 let metrics = HostSampler::new().sample();
126 let cores = metrics.cpu.logical_cores;
127 cache.set(metrics).await;
128 let got = cache.get().await.expect("must have a snapshot after set");
129 assert_eq!(got.cpu.logical_cores, cores);
130 }
131}