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}