trusty_console/poller.rs
1//! Background health-poll cache for all registered daemon connectors.
2//!
3//! Why: Per-request detection probes up to 4 daemons synchronously (TCP +
4//! HTTP), which adds latency and blocks the async runtime. Moving detection
5//! to a background task lets `/api/console/services` return instantly from a
6//! cached snapshot while the poller refreshes the data every ~15 s.
7//! What: Spawns a single `tokio::task` that calls every connector's `detect()`
8//! in a blocking thread, then writes the results into an
9//! `Arc<RwLock<CachedSnapshot>>`. The snapshot also records the last poll
10//! timestamp so callers can surface staleness in the UI.
11//! Test: `tests::test_cache_initialises_with_connectors` and
12//! `tests::test_snapshot_url_map` below.
13
14use std::collections::HashMap;
15use std::sync::Arc;
16use std::time::{Duration, Instant};
17
18use tokio::sync::RwLock;
19use tracing::{debug, error, info};
20
21use crate::connector::{ServiceConnector, ServiceInfo};
22
23// ─── public types ────────────────────────────────────────────────────────────
24
25/// A point-in-time snapshot of all service statuses.
26///
27/// Why: Separating the cached data from the lock makes it cheap to clone the
28/// snapshot out of the lock and work with it without holding the lock.
29/// What: Contains the service info list (same order as the connector list) and
30/// the instant when the poll completed.
31/// Test: Constructed by `PollerCache::poll_once`; inspected by tests below.
32#[derive(Debug, Clone)]
33pub struct CachedSnapshot {
34 /// Service info for each connector, in connector-list order.
35 pub services: Vec<ServiceInfo>,
36 /// Wall-clock instant when this snapshot was produced.
37 /// Reserved for future staleness-reporting (P2+); not yet surfaced in the API.
38 #[allow(dead_code)]
39 pub refreshed_at: Instant,
40}
41
42impl CachedSnapshot {
43 /// Build a URL map from daemon id → base URL for running daemons.
44 ///
45 /// Why: The proxy router needs to resolve a daemon name to its live base URL
46 /// quickly, without re-scanning the snapshot on every request.
47 /// What: Iterates `services` and collects only those with a `url`.
48 /// Test: `test_snapshot_url_map` below.
49 pub fn url_map(&self) -> HashMap<String, String> {
50 self.services
51 .iter()
52 .filter_map(|s| s.url.as_ref().map(|u| (s.id.clone(), u.clone())))
53 .collect()
54 }
55}
56
57// ─── poller ──────────────────────────────────────────────────────────────────
58
59/// Shared handle to the background poll cache.
60///
61/// Why: The `Arc<RwLock<…>>` is shared between the background task and every
62/// request handler, so all handlers read from the same live snapshot without
63/// contention.
64/// What: Wraps an `Arc<RwLock<Option<CachedSnapshot>>>`. `None` means the
65/// first poll has not completed yet; handlers must fall back to a suitable
66/// loading state in that case.
67/// Test: Constructed in `start`; cloned into request handlers via `AppState`.
68#[derive(Clone, Debug)]
69pub struct PollerCache {
70 inner: Arc<RwLock<Option<CachedSnapshot>>>,
71}
72
73impl Default for PollerCache {
74 /// Why: Required by clippy's `new_without_default` lint when `new()` takes
75 /// no arguments — also convenient for test setup.
76 /// What: Delegates to `PollerCache::new()`.
77 /// Test: Implicitly tested wherever `PollerCache::new()` is called.
78 fn default() -> Self {
79 Self::new()
80 }
81}
82
83impl PollerCache {
84 /// Create a new, empty `PollerCache`.
85 ///
86 /// Why: Start with `None` so handlers can detect "first poll not done yet".
87 /// What: Allocates the `Arc<RwLock<None>>`.
88 /// Test: Called by `start`.
89 pub fn new() -> Self {
90 Self {
91 inner: Arc::new(RwLock::new(None)),
92 }
93 }
94
95 /// Read the current snapshot (may be `None` on first startup).
96 ///
97 /// Why: Routes call this to serve `/api/console/services` from cache.
98 /// What: Acquires a read lock, clones the snapshot, releases the lock.
99 /// Test: `test_cache_initialises_with_connectors`.
100 pub async fn snapshot(&self) -> Option<CachedSnapshot> {
101 self.inner.read().await.clone()
102 }
103
104 /// Run one poll cycle for the given connectors (passed as an `Arc`).
105 ///
106 /// Why: Lets tests and the initial eager-load before the first HTTP request
107 /// force a synchronous poll without spinning up the background task.
108 /// What: Calls each connector's `detect()` in a `spawn_blocking` task
109 /// (connectors are `Send + Sync` so the `Arc` can cross the thread
110 /// boundary), writes the result into the shared cache, and returns the
111 /// new snapshot.
112 /// Test: `test_cache_initialises_with_connectors`.
113 pub async fn poll_once(
114 &self,
115 connectors: Arc<Vec<Box<dyn ServiceConnector>>>,
116 ) -> CachedSnapshot {
117 // Clone the Arc so it can be sent into the blocking thread.
118 let c = Arc::clone(&connectors);
119 let services: Vec<ServiceInfo> =
120 tokio::task::spawn_blocking(move || c.iter().map(|conn| conn.detect()).collect())
121 .await
122 .unwrap_or_else(|e| {
123 error!("poller: detection task panicked: {e}");
124 vec![]
125 });
126
127 let snap = CachedSnapshot {
128 services,
129 refreshed_at: Instant::now(),
130 };
131 *self.inner.write().await = Some(snap.clone());
132 snap
133 }
134}
135
136// ─── background task ─────────────────────────────────────────────────────────
137
138/// Run the poll loop body once and log any panic from the detection thread.
139///
140/// Why: Isolates the async iteration so `start` can detect if it returns
141/// (which should never happen under normal operation).
142/// What: Calls `poll_once` — whose internal `spawn_blocking` already recovers
143/// panics in detection threads — logs the refresh count, then sleeps.
144/// Test: `PollerCache::poll_once` is tested independently; this wrapper is
145/// exercised implicitly whenever the daemon runs.
146async fn poll_loop(
147 cache: &PollerCache,
148 connectors: &Arc<Vec<Box<dyn ServiceConnector>>>,
149 interval: Duration,
150) {
151 loop {
152 let snap = cache.poll_once(Arc::clone(connectors)).await;
153 let running_count = snap.services.iter().filter(|s| s.url.is_some()).count();
154 debug!(
155 "poller: refreshed {} services, {} running",
156 snap.services.len(),
157 running_count
158 );
159 tokio::time::sleep(interval).await;
160 }
161}
162
163/// Spawn the background poll loop.
164///
165/// Why: This is the only place in the codebase where the polling interval is
166/// set; changing it here changes it everywhere.
167/// What: Polls immediately (so the first HTTP request does not hit a cold
168/// cache), then repeats every `interval` by calling `poll_loop`. The spawned
169/// task logs `tracing::error!` if `poll_loop` ever returns — which indicates an
170/// unexpected exit (e.g. a future cancellation or a logic change that breaks
171/// the infinite loop), making silent cache-freeze failures visible in the
172/// daemon log rather than going unnoticed.
173/// Test: Not tested directly (requires a live tokio runtime); the `PollerCache`
174/// logic is tested independently.
175pub fn start(
176 cache: PollerCache,
177 connectors: Arc<Vec<Box<dyn ServiceConnector>>>,
178 interval: Duration,
179) {
180 tokio::spawn(async move {
181 info!(
182 "poller: starting background health-poll (interval={}s)",
183 interval.as_secs()
184 );
185 poll_loop(&cache, &connectors, interval).await;
186 error!("poller: background health-poll loop exited unexpectedly — cache will not refresh");
187 });
188}
189
190// ─── tests ───────────────────────────────────────────────────────────────────
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195 use crate::connector::{ServiceInfo, ServiceStatus};
196
197 /// A stub connector for tests — always returns a fixed `ServiceInfo`.
198 struct StubConnector {
199 id: &'static str,
200 url: Option<&'static str>,
201 }
202
203 impl ServiceConnector for StubConnector {
204 fn id(&self) -> &'static str {
205 self.id
206 }
207 fn display_name(&self) -> &'static str {
208 "Stub"
209 }
210 fn detect(&self) -> ServiceInfo {
211 ServiceInfo {
212 id: self.id.to_string(),
213 display_name: "Stub".to_string(),
214 status: if self.url.is_some() {
215 ServiceStatus::Running
216 } else {
217 ServiceStatus::Absent
218 },
219 version: None,
220 url: self.url.map(|u| u.to_string()),
221 hint: None,
222 }
223 }
224 }
225
226 fn make_connectors() -> Arc<Vec<Box<dyn ServiceConnector>>> {
227 Arc::new(vec![
228 Box::new(StubConnector {
229 id: "trusty-search",
230 url: Some("http://127.0.0.1:7878"),
231 }),
232 Box::new(StubConnector {
233 id: "trusty-memory",
234 url: None,
235 }),
236 ])
237 }
238
239 /// Why: after poll_once, the snapshot must contain an entry for every
240 /// connector in order.
241 /// What: calls poll_once with two stubs, asserts length and first entry id.
242 /// Test: this test itself.
243 #[tokio::test]
244 async fn test_cache_initialises_with_connectors() {
245 let cache = PollerCache::new();
246 let connectors = make_connectors();
247 assert!(cache.snapshot().await.is_none(), "should start empty");
248
249 let snap = cache.poll_once(Arc::clone(&connectors)).await;
250 assert_eq!(snap.services.len(), 2);
251 assert_eq!(snap.services[0].id, "trusty-search");
252 assert_eq!(snap.services[1].id, "trusty-memory");
253
254 // The shared cache must also be updated.
255 let cached = cache.snapshot().await.expect("snapshot after poll");
256 assert_eq!(cached.services.len(), 2);
257 }
258
259 /// Why: url_map must only include services that have a URL (i.e. Running).
260 /// What: constructs a snapshot with one Running and one Absent service,
261 /// checks the url_map length and content.
262 /// Test: this test itself.
263 #[test]
264 fn test_snapshot_url_map() {
265 let snap = CachedSnapshot {
266 services: vec![
267 ServiceInfo {
268 id: "trusty-search".to_string(),
269 display_name: "Search".to_string(),
270 status: ServiceStatus::Running,
271 version: Some("1.0.0".to_string()),
272 url: Some("http://127.0.0.1:7878".to_string()),
273 hint: None,
274 },
275 ServiceInfo {
276 id: "trusty-memory".to_string(),
277 display_name: "Memory".to_string(),
278 status: ServiceStatus::Absent,
279 version: None,
280 url: None,
281 hint: None,
282 },
283 ],
284 refreshed_at: Instant::now(),
285 };
286
287 let map = snap.url_map();
288 assert_eq!(map.len(), 1);
289 assert_eq!(map["trusty-search"], "http://127.0.0.1:7878");
290 assert!(!map.contains_key("trusty-memory"));
291 }
292}