Skip to main content

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}