1use 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
31pub type RenderFn = Arc<dyn Fn() -> String + Send + Sync>;
33
34#[derive(Clone, Copy, Debug)]
36pub struct HealthThresholds {
37 pub heartbeat_stale: Duration,
40 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#[derive(Debug)]
57pub struct HealthState {
58 epoch: Instant,
59 thresholds: HealthThresholds,
60 assignment_received: AtomicBool,
61 sinks_connected: AtomicBool,
62 heartbeats: Vec<AtomicU64>,
64 watermark_age_ms: AtomicU64,
66 data_flowing: AtomicBool,
67}
68
69impl HealthState {
70 #[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 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 pub fn set_assignment_received(&self, received: bool) {
99 self.assignment_received.store(received, Ordering::Relaxed);
100 }
101
102 pub fn set_sinks_connected(&self, connected: bool) {
104 self.sinks_connected.store(connected, Ordering::Relaxed);
105 }
106
107 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 #[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 #[must_use]
127 pub fn healthy(&self) -> bool {
128 self.healthy_at(Instant::now())
129 }
130
131 #[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
150pub 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 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 #[must_use]
188 pub fn local_addr(&self) -> SocketAddr {
189 self.local_addr
190 }
191
192 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 assert!(health.healthy_at(start + Duration::from_millis(500)));
298 health.heartbeat(1);
300 assert!(!health.healthy_at(start + Duration::from_millis(1_501)));
301 health.heartbeat(0);
303 health.heartbeat(1);
304 assert!(health.healthy_at(Instant::now()));
305 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 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 #[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}