Skip to main content

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