Skip to main content

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, ServiceLifecycle, 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                lifecycle: self.lifecycle(),
223            }
224        }
225    }
226
227    fn make_connectors() -> Arc<Vec<Box<dyn ServiceConnector>>> {
228        Arc::new(vec![
229            Box::new(StubConnector {
230                id: "trusty-search",
231                url: Some("http://127.0.0.1:7878"),
232            }),
233            Box::new(StubConnector {
234                id: "trusty-memory",
235                url: None,
236            }),
237        ])
238    }
239
240    /// Why: after poll_once, the snapshot must contain an entry for every
241    /// connector in order.
242    /// What: calls poll_once with two stubs, asserts length and first entry id.
243    /// Test: this test itself.
244    #[tokio::test]
245    async fn test_cache_initialises_with_connectors() {
246        let cache = PollerCache::new();
247        let connectors = make_connectors();
248        assert!(cache.snapshot().await.is_none(), "should start empty");
249
250        let snap = cache.poll_once(Arc::clone(&connectors)).await;
251        assert_eq!(snap.services.len(), 2);
252        assert_eq!(snap.services[0].id, "trusty-search");
253        assert_eq!(snap.services[1].id, "trusty-memory");
254
255        // The shared cache must also be updated.
256        let cached = cache.snapshot().await.expect("snapshot after poll");
257        assert_eq!(cached.services.len(), 2);
258    }
259
260    /// Why: url_map must only include services that have a URL (i.e. Running).
261    /// What: constructs a snapshot with one Running and one Absent service,
262    /// checks the url_map length and content.
263    /// Test: this test itself.
264    #[test]
265    fn test_snapshot_url_map() {
266        let snap = CachedSnapshot {
267            services: vec![
268                ServiceInfo {
269                    id: "trusty-search".to_string(),
270                    display_name: "Search".to_string(),
271                    status: ServiceStatus::Running,
272                    version: Some("1.0.0".to_string()),
273                    url: Some("http://127.0.0.1:7878".to_string()),
274                    hint: None,
275                    lifecycle: ServiceLifecycle::Daemon,
276                },
277                ServiceInfo {
278                    id: "trusty-memory".to_string(),
279                    display_name: "Memory".to_string(),
280                    status: ServiceStatus::Absent,
281                    version: None,
282                    url: None,
283                    hint: None,
284                    lifecycle: ServiceLifecycle::Daemon,
285                },
286            ],
287            refreshed_at: Instant::now(),
288        };
289
290        let map = snap.url_map();
291        assert_eq!(map.len(), 1);
292        assert_eq!(map["trusty-search"], "http://127.0.0.1:7878");
293        assert!(!map.contains_key("trusty-memory"));
294    }
295}