Skip to main content

trusty_console/
metrics_poller.rs

1//! Background metrics poller for supervised stdio MCP connections (epic #1104).
2//!
3//! Why: The trusty-console needs to periodically fetch `ConsoleMetricsReport`
4//! from each local service over a persistent stdio MCP connection and cache the
5//! latest result so the `/api/console/metrics/analyze` route can respond
6//! instantly without blocking on an MCP round-trip.
7//! What: `MetricsCache` is the read/write handle (Arc<RwLock<Option<…>>>).
8//! `start` spawns a background task that calls `McpServiceHandle::poll_metrics`
9//! every `interval` seconds and writes the result into the cache. On failure
10//! the previous cached value is retained and a warning is logged.
11//! Test: `test_metrics_cache_initialises_empty` and
12//! `test_metrics_cache_write_read_roundtrip` in this module.
13
14use std::sync::Arc;
15use std::time::Duration;
16
17use tokio::sync::RwLock;
18use tracing::{debug, info, warn};
19use trusty_common::console_metrics::ConsoleMetricsReport;
20
21use crate::mcp_handle::McpServiceHandle;
22
23// ─── cache ───────────────────────────────────────────────────────────────────
24
25/// Shared read/write handle to a cached `ConsoleMetricsReport`.
26///
27/// Why: The route handler must never block on an MCP call. The background
28/// poller writes to this cache; route handlers read from it.
29/// What: Wraps `Arc<RwLock<Option<ConsoleMetricsReport>>>`. `None` means no
30/// successful poll has completed yet (first boot or service absent).
31/// Test: `test_metrics_cache_initialises_empty` and
32/// `test_metrics_cache_write_read_roundtrip`.
33#[derive(Clone, Debug)]
34pub struct MetricsCache {
35    inner: Arc<RwLock<Option<ConsoleMetricsReport>>>,
36}
37
38impl Default for MetricsCache {
39    /// Why: Required by clippy's `new_without_default` lint.
40    /// What: Delegates to `MetricsCache::new()`.
41    /// Test: Implicitly tested wherever `MetricsCache::new()` is called.
42    fn default() -> Self {
43        Self::new()
44    }
45}
46
47impl MetricsCache {
48    /// Create a new, empty `MetricsCache`.
49    ///
50    /// Why: Start empty so the route can distinguish "not yet polled" from a
51    /// successful but trivially empty report.
52    /// What: Allocates `Arc<RwLock<None>>`.
53    /// Test: `test_metrics_cache_initialises_empty`.
54    pub fn new() -> Self {
55        Self {
56            inner: Arc::new(RwLock::new(None)),
57        }
58    }
59
60    /// Read the latest cached report (may be `None` before the first poll).
61    ///
62    /// Why: Route handlers call this to serve the report without blocking.
63    /// What: Acquires a read lock, clones the value, releases the lock.
64    /// Test: `test_metrics_cache_write_read_roundtrip`.
65    pub async fn get(&self) -> Option<ConsoleMetricsReport> {
66        self.inner.read().await.clone()
67    }
68
69    /// Write a new report into the cache.
70    ///
71    /// Why: The poller calls this after each successful poll.
72    /// What: Acquires a write lock, replaces the inner value.
73    /// Test: `test_metrics_cache_write_read_roundtrip`.
74    pub async fn set(&self, report: ConsoleMetricsReport) {
75        *self.inner.write().await = Some(report);
76    }
77}
78
79// ─── background task ────────────────────────────────────────────────────────
80
81/// Run one poll cycle against `handle` and update `cache` on success.
82///
83/// Why: Extracted so the loop body is easy to reason about in isolation.
84/// What: Calls `handle.poll_metrics()`. On success writes to cache and logs
85/// `debug!`. On failure retains the previous cache value and logs `warn!`.
86/// Test: Covered by end-to-end smoke test (no live binary available in unit tests).
87///
88// #6360: `pub(crate)` so a completed delete can refresh the roster it changed
89// immediately, instead of leaving the dashboard on a cache written up to one
90// poll interval ago. See `routes::deletes::refresh_metrics`.
91pub(crate) async fn poll_once(handle: &McpServiceHandle, cache: &MetricsCache) {
92    match handle.poll_metrics().await {
93        Ok(report) => {
94            debug!(
95                service_id = %report.service_id,
96                status = ?report.status,
97                "metrics_poller: poll succeeded"
98            );
99            cache.set(report).await;
100        }
101        Err(e) => {
102            warn!(error = %e, "metrics_poller: poll failed — retaining previous cache");
103        }
104    }
105}
106
107/// Spawn the background metrics poll loop for `handle`, writing into `cache`.
108///
109/// Why: This is the single place where the poll interval and error-logging
110/// policy are set for the metrics poller, mirroring the services `poller::start`.
111/// Accepts `Arc<McpServiceHandle>` so the caller (lib.rs::run_serve) can share
112/// the same handle with on-demand routes (e.g. the analyze visualize route)
113/// without starting a second child process for the same binary.
114/// What: Spawns a tokio task that immediately calls `poll_once`, then repeats
115/// every `interval`. The spawned task logs `error!` if the loop ever exits
116/// (panic-safe: the outer `tokio::spawn` will not propagate the panic to the
117/// caller).
118/// Test: Not tested directly (requires a live binary); the cache and handle
119/// logic are tested in their respective modules.
120pub fn start(handle: Arc<McpServiceHandle>, cache: MetricsCache, interval: Duration) {
121    tokio::spawn(async move {
122        info!(
123            "metrics_poller: starting (interval={}s)",
124            interval.as_secs()
125        );
126        loop {
127            poll_once(&handle, &cache).await;
128            tokio::time::sleep(interval).await;
129        }
130    });
131}
132
133// ─── tests ──────────────────────────────────────────────────────────────────
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use trusty_common::console_metrics::{ServiceHealth, make_report};
139
140    /// Why: A freshly-constructed cache must return `None` before any poll
141    /// completes, so the route can distinguish "no data yet" from a real report.
142    /// What: Creates a new cache and asserts `get()` is `None`.
143    /// Test: This test.
144    #[tokio::test]
145    async fn test_metrics_cache_initialises_empty() {
146        let cache = MetricsCache::new();
147        assert!(cache.get().await.is_none(), "cache must start empty");
148    }
149
150    /// Why: After `set`, `get` must return the same report (full round-trip).
151    /// What: Calls `set` with a synthetic report, then `get` and asserts
152    /// all fields match.
153    /// Test: This test.
154    #[tokio::test]
155    async fn test_metrics_cache_write_read_roundtrip() {
156        let cache = MetricsCache::new();
157        let report = make_report(
158            "trusty-analyze",
159            "Trusty Analyze",
160            "0.7.0",
161            ServiceHealth::Ok,
162            serde_json::json!({ "search_reachable": true }),
163            1,
164        );
165        cache.set(report.clone()).await;
166        let got = cache.get().await.expect("must have report after set");
167        assert_eq!(got.service_id, "trusty-analyze");
168        assert_eq!(got.display_name, "Trusty Analyze");
169        assert_eq!(got.version, "0.7.0");
170        assert_eq!(got.status, ServiceHealth::Ok);
171        assert_eq!(got.metrics["search_reachable"], true);
172        assert_eq!(got.metrics_schema_version, 1);
173    }
174
175    /// Why: A second `set` must replace the previous value so the route always
176    /// sees the freshest report.
177    /// What: Calls `set` twice with different reports, asserts the final
178    /// `get` reflects the second write.
179    /// Test: This test.
180    #[tokio::test]
181    async fn test_metrics_cache_overwrite() {
182        let cache = MetricsCache::new();
183        cache
184            .set(make_report(
185                "trusty-analyze",
186                "Trusty Analyze",
187                "0.6.0",
188                ServiceHealth::Degraded,
189                serde_json::json!({}),
190                1,
191            ))
192            .await;
193        cache
194            .set(make_report(
195                "trusty-analyze",
196                "Trusty Analyze",
197                "0.7.0",
198                ServiceHealth::Ok,
199                serde_json::json!({ "search_reachable": true }),
200                1,
201            ))
202            .await;
203        let got = cache.get().await.expect("must have report");
204        assert_eq!(got.version, "0.7.0");
205        assert_eq!(got.status, ServiceHealth::Ok);
206    }
207}