Skip to main content

spate_core/
admin.rs

1//! Admin HTTP server: `/metrics`, `/healthz`, and `/readyz`.
2//!
3//! One small hyper server per process, running on the I/O runtime. Probe
4//! semantics:
5//!
6//! - **`/readyz`** — 200 once the source has received its assignment and
7//!   the sinks are connected.
8//! - **`/healthz`** — 200 while every pipeline thread's poll-loop heartbeat
9//!   is fresh AND the checkpoint watermark is not stuck while data flows.
10//!   A stalled poll loop or a wedged watermark flips this to 503 so the
11//!   kubelet restarts the pod.
12//!
13//! The server takes the metrics renderer as a plain closure
14//! ([`RenderFn`]) and has no dependency on exporter internals. The probes
15//! are served regardless of the exporter; `/metrics` exists only when a
16//! renderer is supplied, and is a 404 otherwise.
17
18use std::io;
19use std::net::SocketAddr;
20use std::sync::Arc;
21use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
22use std::time::{Duration, Instant};
23
24use http_body_util::Full;
25use hyper::body::Bytes;
26use hyper::service::service_fn;
27use hyper::{Method, Request, Response, StatusCode};
28use tokio::net::TcpListener;
29use tokio::sync::watch;
30
31/// Renders the current metrics exposition snapshot.
32pub type RenderFn = Arc<dyn Fn() -> String + Send + Sync>;
33
34/// Staleness thresholds for the liveness probe.
35#[derive(Clone, Copy, Debug)]
36pub struct HealthThresholds {
37    /// A pipeline thread whose heartbeat is older than this is considered
38    /// stalled.
39    pub heartbeat_stale: Duration,
40    /// The watermark is considered stuck when its age exceeds this while
41    /// data is flowing.
42    pub watermark_stuck: Duration,
43}
44
45impl Default for HealthThresholds {
46    fn default() -> Self {
47        HealthThresholds {
48            heartbeat_stale: Duration::from_secs(30),
49            watermark_stuck: Duration::from_secs(300),
50        }
51    }
52}
53
54/// Shared health state, updated by the pipeline runtime and read by the
55/// probes. All methods are lock-free and callable from any thread.
56#[derive(Debug)]
57pub struct HealthState {
58    epoch: Instant,
59    thresholds: HealthThresholds,
60    assignment_received: AtomicBool,
61    sinks_connected: AtomicBool,
62    /// Per-pipeline-thread heartbeat, as millis since `epoch`.
63    heartbeats: Vec<AtomicU64>,
64    /// Watermark age reported by the checkpointer, in millis.
65    watermark_age_ms: AtomicU64,
66    data_flowing: AtomicBool,
67}
68
69impl HealthState {
70    /// Create state for `pipeline_threads` heartbeating threads. All
71    /// heartbeats start "fresh" at creation time.
72    #[must_use]
73    pub fn new(pipeline_threads: usize, thresholds: HealthThresholds) -> Arc<Self> {
74        Arc::new(HealthState {
75            epoch: Instant::now(),
76            thresholds,
77            assignment_received: AtomicBool::new(false),
78            sinks_connected: AtomicBool::new(false),
79            heartbeats: (0..pipeline_threads).map(|_| AtomicU64::new(0)).collect(),
80            watermark_age_ms: AtomicU64::new(0),
81            data_flowing: AtomicBool::new(false),
82        })
83    }
84
85    fn millis_since_epoch(&self, now: Instant) -> u64 {
86        u64::try_from(now.saturating_duration_since(self.epoch).as_millis()).unwrap_or(u64::MAX)
87    }
88
89    /// Beat thread `thread`'s heart. Called once per poll iteration; out of
90    /// range indices are ignored.
91    pub fn heartbeat(&self, thread: usize) {
92        if let Some(hb) = self.heartbeats.get(thread) {
93            hb.store(self.millis_since_epoch(Instant::now()), Ordering::Relaxed);
94        }
95    }
96
97    /// Mark whether the source has received its (initial) assignment.
98    pub fn set_assignment_received(&self, received: bool) {
99        self.assignment_received.store(received, Ordering::Relaxed);
100    }
101
102    /// Mark whether all sinks report connectivity.
103    pub fn set_sinks_connected(&self, connected: bool) {
104        self.sinks_connected.store(connected, Ordering::Relaxed);
105    }
106
107    /// Report the checkpointer's oldest-unacknowledged-batch age and
108    /// whether data is currently flowing (a stuck watermark only counts
109    /// against liveness while records move).
110    pub fn report_watermark(&self, age: Duration, data_flowing: bool) {
111        self.watermark_age_ms.store(
112            u64::try_from(age.as_millis()).unwrap_or(u64::MAX),
113            Ordering::Relaxed,
114        );
115        self.data_flowing.store(data_flowing, Ordering::Relaxed);
116    }
117
118    /// Readiness: assignment received and sinks connected.
119    #[must_use]
120    pub fn ready(&self) -> bool {
121        self.assignment_received.load(Ordering::Relaxed)
122            && self.sinks_connected.load(Ordering::Relaxed)
123    }
124
125    /// Liveness now.
126    #[must_use]
127    pub fn healthy(&self) -> bool {
128        self.healthy_at(Instant::now())
129    }
130
131    /// Liveness as of `now` (injectable for tests).
132    #[must_use]
133    pub fn healthy_at(&self, now: Instant) -> bool {
134        let now_ms = self.millis_since_epoch(now);
135        let stale = u64::try_from(self.thresholds.heartbeat_stale.as_millis()).unwrap_or(u64::MAX);
136        let hearts_fresh = self
137            .heartbeats
138            .iter()
139            .all(|hb| now_ms.saturating_sub(hb.load(Ordering::Relaxed)) <= stale);
140
141        let stuck_ms =
142            u64::try_from(self.thresholds.watermark_stuck.as_millis()).unwrap_or(u64::MAX);
143        let watermark_stuck = self.data_flowing.load(Ordering::Relaxed)
144            && self.watermark_age_ms.load(Ordering::Relaxed) > stuck_ms;
145
146        hearts_fresh && !watermark_stuck
147    }
148}
149
150/// The admin server, bound and ready to run.
151pub struct AdminServer {
152    listener: TcpListener,
153    local_addr: SocketAddr,
154    render: Option<RenderFn>,
155    health: Arc<HealthState>,
156}
157
158impl std::fmt::Debug for AdminServer {
159    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160        f.debug_struct("AdminServer")
161            .field("local_addr", &self.local_addr)
162            .field("health", &self.health)
163            .finish_non_exhaustive()
164    }
165}
166
167impl AdminServer {
168    /// Bind the admin socket. Use port 0 to let the OS choose (tests).
169    ///
170    /// `render` of `None` serves the probes without `/metrics`.
171    pub async fn bind(
172        addr: SocketAddr,
173        render: Option<RenderFn>,
174        health: Arc<HealthState>,
175    ) -> io::Result<Self> {
176        let listener = TcpListener::bind(addr).await?;
177        let local_addr = listener.local_addr()?;
178        Ok(AdminServer {
179            listener,
180            local_addr,
181            render,
182            health,
183        })
184    }
185
186    /// The bound address (relevant with port 0).
187    #[must_use]
188    pub fn local_addr(&self) -> SocketAddr {
189        self.local_addr
190    }
191
192    /// Accept and serve connections until `shutdown` observes `true`.
193    /// Probe connections are short-lived; in-flight requests finish on
194    /// their detached tasks after this returns.
195    pub async fn run(self, mut shutdown: watch::Receiver<bool>) -> io::Result<()> {
196        loop {
197            tokio::select! {
198                accepted = self.listener.accept() => {
199                    let (stream, _peer) = accepted?;
200                    let render = self.render.clone();
201                    let health = Arc::clone(&self.health);
202                    tokio::spawn(async move {
203                        let io = hyper_util::rt::TokioIo::new(stream);
204                        let service = service_fn(move |req| {
205                            let render = render.clone();
206                            let health = Arc::clone(&health);
207                            async move { respond(&req, render.as_ref(), &health) }
208                        });
209                        if let Err(err) = hyper::server::conn::http1::Builder::new()
210                            .serve_connection(io, service)
211                            .await
212                        {
213                            tracing::debug!(error = %err, "admin connection error");
214                        }
215                    });
216                }
217                changed = shutdown.changed() => {
218                    if changed.is_err() || *shutdown.borrow() {
219                        return Ok(());
220                    }
221                }
222            }
223        }
224    }
225}
226
227fn respond(
228    req: &Request<hyper::body::Incoming>,
229    render: Option<&RenderFn>,
230    health: &HealthState,
231) -> Result<Response<Full<Bytes>>, hyper::Error> {
232    if req.method() != Method::GET {
233        return Ok(plain(StatusCode::METHOD_NOT_ALLOWED, "method not allowed"));
234    }
235    Ok(match (req.uri().path(), render) {
236        ("/metrics", Some(render)) => {
237            let body = render();
238            Response::builder()
239                .status(StatusCode::OK)
240                .header(
241                    hyper::header::CONTENT_TYPE,
242                    "text/plain; version=0.0.4; charset=utf-8",
243                )
244                .body(Full::new(Bytes::from(body)))
245                .expect("static response parts are valid")
246        }
247        ("/healthz", _) => probe(health.healthy()),
248        ("/readyz", _) => probe(health.ready()),
249        _ => plain(StatusCode::NOT_FOUND, "not found"),
250    })
251}
252
253fn probe(ok: bool) -> Response<Full<Bytes>> {
254    if ok {
255        plain(StatusCode::OK, "ok")
256    } else {
257        plain(StatusCode::SERVICE_UNAVAILABLE, "unavailable")
258    }
259}
260
261fn plain(status: StatusCode, body: &'static str) -> Response<Full<Bytes>> {
262    Response::builder()
263        .status(status)
264        .header(hyper::header::CONTENT_TYPE, "text/plain; charset=utf-8")
265        .body(Full::new(Bytes::from_static(body.as_bytes())))
266        .expect("static response parts are valid")
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    fn thresholds(stale_ms: u64, stuck_ms: u64) -> HealthThresholds {
274        HealthThresholds {
275            heartbeat_stale: Duration::from_millis(stale_ms),
276            watermark_stuck: Duration::from_millis(stuck_ms),
277        }
278    }
279
280    #[test]
281    fn readiness_requires_assignment_and_sinks() {
282        let health = HealthState::new(2, HealthThresholds::default());
283        assert!(!health.ready());
284        health.set_assignment_received(true);
285        assert!(!health.ready());
286        health.set_sinks_connected(true);
287        assert!(health.ready());
288        health.set_sinks_connected(false);
289        assert!(!health.ready());
290    }
291
292    #[test]
293    fn liveness_detects_a_stale_heartbeat() {
294        let health = HealthState::new(2, thresholds(1_000, 300_000));
295        let start = health.epoch;
296        // Fresh at creation.
297        assert!(health.healthy_at(start + Duration::from_millis(500)));
298        // Thread 1 beats, thread 0 does not: stale after the threshold.
299        health.heartbeat(1);
300        assert!(!health.healthy_at(start + Duration::from_millis(1_501)));
301        // Both beating again: healthy shortly after.
302        health.heartbeat(0);
303        health.heartbeat(1);
304        assert!(health.healthy_at(Instant::now()));
305        // Out-of-range heartbeat is ignored, not a panic.
306        health.heartbeat(99);
307    }
308
309    #[test]
310    fn liveness_detects_a_stuck_watermark_only_while_data_flows() {
311        let health = HealthState::new(0, thresholds(60_000, 1_000));
312        let now = Instant::now();
313        health.report_watermark(Duration::from_secs(5), false);
314        assert!(health.healthy_at(now), "idle pipeline is not stuck");
315        health.report_watermark(Duration::from_secs(5), true);
316        assert!(!health.healthy_at(now), "wedged watermark under flow");
317        health.report_watermark(Duration::from_millis(500), true);
318        assert!(health.healthy_at(now));
319    }
320
321    /// Minimal HTTP/1.1 GET over the readiness API. Hand-rolled to avoid
322    /// adding tokio's `io-util` feature for one test.
323    async fn get(addr: SocketAddr, path: &str) -> (u16, String) {
324        let stream = tokio::net::TcpStream::connect(addr).await.expect("connect");
325        let req = format!("GET {path} HTTP/1.1\r\nHost: admin\r\nConnection: close\r\n\r\n");
326        let mut written = 0;
327        while written < req.len() {
328            stream.writable().await.expect("writable");
329            match stream.try_write(&req.as_bytes()[written..]) {
330                Ok(n) => written += n,
331                Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
332                Err(e) => panic!("write failed: {e}"),
333            }
334        }
335        let mut buf = Vec::new();
336        loop {
337            stream.readable().await.expect("readable");
338            let mut chunk = [0u8; 4096];
339            match stream.try_read(&mut chunk) {
340                Ok(0) => break,
341                Ok(n) => buf.extend_from_slice(&chunk[..n]),
342                Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
343                Err(e) => panic!("read failed: {e}"),
344            }
345        }
346        let text = String::from_utf8_lossy(&buf).into_owned();
347        let status = text
348            .split_whitespace()
349            .nth(1)
350            .and_then(|s| s.parse().ok())
351            .expect("status code");
352        let body = text
353            .split_once("\r\n\r\n")
354            .map(|(_, b)| b.to_owned())
355            .unwrap_or_default();
356        (status, body)
357    }
358
359    #[tokio::test]
360    async fn serves_probes_and_metrics_and_shuts_down() {
361        let health = HealthState::new(0, HealthThresholds::default());
362        let render: RenderFn = Arc::new(|| "spate_pipeline_info 1\n".to_owned());
363        let server = AdminServer::bind(
364            "127.0.0.1:0".parse().expect("addr"),
365            Some(render),
366            Arc::clone(&health),
367        )
368        .await
369        .expect("bind");
370        let addr = server.local_addr();
371        let (shutdown_tx, shutdown_rx) = watch::channel(false);
372        let running = tokio::spawn(server.run(shutdown_rx));
373
374        let (status, body) = get(addr, "/metrics").await;
375        assert_eq!(status, 200);
376        assert!(body.contains("spate_pipeline_info"));
377
378        let (status, _) = get(addr, "/readyz").await;
379        assert_eq!(status, 503, "not ready before assignment + sinks");
380        health.set_assignment_received(true);
381        health.set_sinks_connected(true);
382        let (status, body) = get(addr, "/readyz").await;
383        assert_eq!(status, 200);
384        assert_eq!(body, "ok");
385
386        let (status, _) = get(addr, "/healthz").await;
387        assert_eq!(status, 200);
388
389        let (status, _) = get(addr, "/nope").await;
390        assert_eq!(status, 404);
391
392        shutdown_tx.send(true).expect("signal shutdown");
393        running
394            .await
395            .expect("server task joins")
396            .expect("clean shutdown");
397    }
398
399    /// With no exporter there is no exposition, and `/metrics` says so. A 200
400    /// carrying an empty body would read to a scraper as a healthy target
401    /// delivering no series.
402    #[tokio::test]
403    async fn probes_serve_without_an_exporter_and_metrics_does_not() {
404        let health = HealthState::new(0, HealthThresholds::default());
405        let server = AdminServer::bind("127.0.0.1:0".parse().expect("addr"), None, health)
406            .await
407            .expect("bind");
408        let addr = server.local_addr();
409        let (shutdown_tx, shutdown_rx) = watch::channel(false);
410        let running = tokio::spawn(server.run(shutdown_rx));
411
412        let (status, _) = get(addr, "/metrics").await;
413        assert_eq!(status, 404, "no exporter, no exposition");
414
415        let (status, _) = get(addr, "/healthz").await;
416        assert_eq!(status, 200, "liveness does not depend on the exporter");
417        let (status, _) = get(addr, "/readyz").await;
418        assert_eq!(status, 503, "readiness does not depend on the exporter");
419
420        shutdown_tx.send(true).expect("signal shutdown");
421        running
422            .await
423            .expect("server task joins")
424            .expect("clean shutdown");
425    }
426}