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 crate::clock::{SharedClock, SystemClock};
25
26use http_body_util::Full;
27use hyper::body::Bytes;
28use hyper::server::conn::http1;
29use hyper::service::service_fn;
30use hyper::{Method, Request, Response, StatusCode};
31use tokio::net::TcpListener;
32use tokio::sync::watch;
33use tokio::task::JoinSet;
34
35pub type RenderFn = Arc<dyn Fn() -> String + Send + Sync>;
37
38#[derive(Clone, Copy, Debug)]
40pub struct HealthThresholds {
41 pub heartbeat_stale: Duration,
44 pub watermark_stuck: Duration,
47}
48
49impl Default for HealthThresholds {
50 fn default() -> Self {
51 HealthThresholds {
52 heartbeat_stale: Duration::from_secs(30),
53 watermark_stuck: Duration::from_secs(300),
54 }
55 }
56}
57
58pub struct HealthState {
61 clock: SharedClock,
62 epoch: Instant,
63 thresholds: HealthThresholds,
64 assignment_received: AtomicBool,
65 sinks_connected: AtomicBool,
66 heartbeats: Vec<AtomicU64>,
68 watermark_age_ms: AtomicU64,
70 data_flowing: AtomicBool,
71}
72
73impl std::fmt::Debug for HealthState {
74 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75 f.debug_struct("HealthState")
76 .field("epoch", &self.epoch)
77 .field("thresholds", &self.thresholds)
78 .field("assignment_received", &self.assignment_received)
79 .field("sinks_connected", &self.sinks_connected)
80 .field("heartbeats", &self.heartbeats)
81 .field("watermark_age_ms", &self.watermark_age_ms)
82 .field("data_flowing", &self.data_flowing)
83 .finish_non_exhaustive()
84 }
85}
86
87impl HealthState {
88 #[must_use]
91 pub fn new(pipeline_threads: usize, thresholds: HealthThresholds) -> Arc<Self> {
92 Self::with_clock(pipeline_threads, thresholds, Arc::new(SystemClock))
93 }
94
95 pub(crate) fn with_clock(
97 pipeline_threads: usize,
98 thresholds: HealthThresholds,
99 clock: SharedClock,
100 ) -> Arc<Self> {
101 Arc::new(HealthState {
102 epoch: clock.now(),
103 clock,
104 thresholds,
105 assignment_received: AtomicBool::new(false),
106 sinks_connected: AtomicBool::new(false),
107 heartbeats: (0..pipeline_threads).map(|_| AtomicU64::new(0)).collect(),
108 watermark_age_ms: AtomicU64::new(0),
109 data_flowing: AtomicBool::new(false),
110 })
111 }
112
113 fn millis_since_epoch(&self, now: Instant) -> u64 {
114 u64::try_from(now.saturating_duration_since(self.epoch).as_millis()).unwrap_or(u64::MAX)
115 }
116
117 pub fn heartbeat(&self, thread: usize) {
121 if let Some(hb) = self.heartbeats.get(thread) {
122 hb.store(self.millis_since_epoch(self.clock.now()), Ordering::Relaxed);
123 }
124 }
125
126 pub fn set_assignment_received(&self, received: bool) {
128 self.assignment_received.store(received, Ordering::Relaxed);
129 }
130
131 pub fn set_sinks_connected(&self, connected: bool) {
133 self.sinks_connected.store(connected, Ordering::Relaxed);
134 }
135
136 pub fn report_watermark(&self, age: Duration, data_flowing: bool) {
140 self.watermark_age_ms.store(
141 u64::try_from(age.as_millis()).unwrap_or(u64::MAX),
142 Ordering::Relaxed,
143 );
144 self.data_flowing.store(data_flowing, Ordering::Relaxed);
145 }
146
147 #[must_use]
149 pub fn ready(&self) -> bool {
150 self.assignment_received.load(Ordering::Relaxed)
151 && self.sinks_connected.load(Ordering::Relaxed)
152 }
153
154 #[must_use]
157 pub fn healthy(&self) -> bool {
158 self.healthy_at(self.clock.now())
159 }
160
161 #[must_use]
163 pub fn healthy_at(&self, now: Instant) -> bool {
164 let now_ms = self.millis_since_epoch(now);
165 let stale = u64::try_from(self.thresholds.heartbeat_stale.as_millis()).unwrap_or(u64::MAX);
166 let hearts_fresh = self
167 .heartbeats
168 .iter()
169 .all(|hb| now_ms.saturating_sub(hb.load(Ordering::Relaxed)) <= stale);
170
171 let stuck_ms =
172 u64::try_from(self.thresholds.watermark_stuck.as_millis()).unwrap_or(u64::MAX);
173 let watermark_stuck = self.data_flowing.load(Ordering::Relaxed)
174 && self.watermark_age_ms.load(Ordering::Relaxed) > stuck_ms;
175
176 hearts_fresh && !watermark_stuck
177 }
178}
179
180pub struct AdminServer {
182 listener: TcpListener,
183 local_addr: SocketAddr,
184 render: Option<RenderFn>,
185 health: Arc<HealthState>,
186}
187
188impl std::fmt::Debug for AdminServer {
189 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
190 f.debug_struct("AdminServer")
191 .field("local_addr", &self.local_addr)
192 .field("health", &self.health)
193 .finish_non_exhaustive()
194 }
195}
196
197impl AdminServer {
198 pub async fn bind(
202 addr: SocketAddr,
203 render: Option<RenderFn>,
204 health: Arc<HealthState>,
205 ) -> io::Result<Self> {
206 let listener = TcpListener::bind(addr).await?;
207 let local_addr = listener.local_addr()?;
208 Ok(AdminServer {
209 listener,
210 local_addr,
211 render,
212 health,
213 })
214 }
215
216 #[must_use]
218 pub fn local_addr(&self) -> SocketAddr {
219 self.local_addr
220 }
221
222 pub async fn run(self, mut shutdown: watch::Receiver<bool>) -> io::Result<()> {
225 let mut conns = JoinSet::new();
226 let result = loop {
227 tokio::select! {
228 accepted = self.listener.accept() => {
229 let (stream, _peer) = match accepted {
230 Ok(accepted) => accepted,
231 Err(err) => break Err(err),
232 };
233 let render = self.render.clone();
234 let health = Arc::clone(&self.health);
235 #[expect(
236 clippy::disallowed_methods,
237 reason = "the set is local to `run`, so returning drops it and aborts every open connection"
238 )]
239 conns.spawn(async move {
240 let io = hyper_util::rt::TokioIo::new(stream);
241 let service = service_fn(move |req| {
242 let render = render.clone();
243 let health = Arc::clone(&health);
244 async move { respond(&req, render.as_ref(), &health) }
245 });
246 if let Err(err) = http1::Builder::new()
247 .serve_connection(io, service)
248 .await
249 {
250 tracing::debug!(error = %err, "admin connection error");
251 }
252 });
253 }
254 Some(joined) = conns.join_next(), if !conns.is_empty() => {
255 if let Err(err) = joined
256 && let Ok(payload) = err.try_into_panic()
257 {
258 tracing::error!(
259 panic = crate::task::panic_text(&*payload),
260 "admin connection panicked"
261 );
262 }
263 }
264 changed = shutdown.changed() => {
265 if changed.is_err() || *shutdown.borrow() {
266 break Ok(());
267 }
268 }
269 }
270 };
271 conns.shutdown().await;
274 result
275 }
276}
277
278fn respond(
279 req: &Request<hyper::body::Incoming>,
280 render: Option<&RenderFn>,
281 health: &HealthState,
282) -> Result<Response<Full<Bytes>>, hyper::Error> {
283 if req.method() != Method::GET {
284 return Ok(plain(StatusCode::METHOD_NOT_ALLOWED, "method not allowed"));
285 }
286 Ok(match (req.uri().path(), render) {
287 ("/metrics", Some(render)) => {
288 let body = render();
289 Response::builder()
290 .status(StatusCode::OK)
291 .header(
292 hyper::header::CONTENT_TYPE,
293 "text/plain; version=0.0.4; charset=utf-8",
294 )
295 .body(Full::new(Bytes::from(body)))
296 .expect("static response parts are valid")
297 }
298 ("/healthz", _) => probe(health.healthy()),
299 ("/readyz", _) => probe(health.ready()),
300 _ => plain(StatusCode::NOT_FOUND, "not found"),
301 })
302}
303
304fn probe(ok: bool) -> Response<Full<Bytes>> {
305 if ok {
306 plain(StatusCode::OK, "ok")
307 } else {
308 plain(StatusCode::SERVICE_UNAVAILABLE, "unavailable")
309 }
310}
311
312fn plain(status: StatusCode, body: &'static str) -> Response<Full<Bytes>> {
313 Response::builder()
314 .status(status)
315 .header(hyper::header::CONTENT_TYPE, "text/plain; charset=utf-8")
316 .body(Full::new(Bytes::from_static(body.as_bytes())))
317 .expect("static response parts are valid")
318}
319
320#[expect(
321 clippy::disallowed_methods,
322 reason = "a test task ends when the test's runtime drops"
323)]
324#[cfg(test)]
325mod tests {
326 use super::*;
327 use tokio::sync::{mpsc, oneshot};
328
329 fn thresholds(stale_ms: u64, stuck_ms: u64) -> HealthThresholds {
330 HealthThresholds {
331 heartbeat_stale: Duration::from_millis(stale_ms),
332 watermark_stuck: Duration::from_millis(stuck_ms),
333 }
334 }
335
336 #[test]
339 fn a_heartbeat_reads_the_injected_clock() {
340 let clock = crate::clock::TestClock::frozen();
341 let health = HealthState::with_clock(1, thresholds(1000, 60_000), clock.clone());
342 clock.advance(Duration::from_millis(300));
343 health.heartbeat(0);
344 assert!(health.healthy_at(health.epoch + Duration::from_millis(1300)));
345 assert!(!health.healthy_at(health.epoch + Duration::from_millis(1301)));
346 }
347
348 #[test]
351 fn liveness_reads_the_injected_clock() {
352 let clock = crate::clock::TestClock::frozen();
353 let health = HealthState::with_clock(1, thresholds(1000, 60_000), clock.clone());
354 health.heartbeat(0);
355 assert!(health.healthy());
356 clock.advance(Duration::from_millis(1001));
357 assert!(!health.healthy());
358 }
359
360 #[test]
363 fn health_state_stays_unwind_safe() {
364 fn unwind_safe<T: std::panic::UnwindSafe + std::panic::RefUnwindSafe>() {}
365 unwind_safe::<HealthState>();
366 }
367
368 #[test]
370 fn debug_names_the_health_state_and_its_thresholds() {
371 let text = format!("{:?}", HealthState::new(1, thresholds(1000, 2000)));
372 assert!(text.starts_with("HealthState {"), "{text}");
373 assert!(text.contains("heartbeat_stale: 1s"), "{text}");
374 }
375
376 #[test]
377 fn readiness_requires_assignment_and_sinks() {
378 let health = HealthState::new(2, HealthThresholds::default());
379 assert!(!health.ready());
380 health.set_assignment_received(true);
381 assert!(!health.ready());
382 health.set_sinks_connected(true);
383 assert!(health.ready());
384 health.set_sinks_connected(false);
385 assert!(!health.ready());
386 }
387
388 #[test]
389 fn liveness_detects_a_stale_heartbeat() {
390 let health = HealthState::new(2, thresholds(1_000, 300_000));
391 let start = health.epoch;
392 assert!(health.healthy_at(start + Duration::from_millis(500)));
394 health.heartbeat(1);
396 assert!(!health.healthy_at(start + Duration::from_millis(1_501)));
397 health.heartbeat(0);
399 health.heartbeat(1);
400 assert!(health.healthy_at(Instant::now()));
401 health.heartbeat(99);
403 }
404
405 #[test]
406 fn liveness_detects_a_stuck_watermark_only_while_data_flows() {
407 let health = HealthState::new(0, thresholds(60_000, 1_000));
408 let now = Instant::now();
409 health.report_watermark(Duration::from_secs(5), false);
410 assert!(health.healthy_at(now), "idle pipeline is not stuck");
411 health.report_watermark(Duration::from_secs(5), true);
412 assert!(!health.healthy_at(now), "wedged watermark under flow");
413 health.report_watermark(Duration::from_millis(500), true);
414 assert!(health.healthy_at(now));
415 }
416
417 async fn get(addr: SocketAddr, path: &str) -> (u16, String) {
420 let stream = tokio::net::TcpStream::connect(addr).await.expect("connect");
421 let req = format!("GET {path} HTTP/1.1\r\nHost: admin\r\nConnection: close\r\n\r\n");
422 let mut written = 0;
423 while written < req.len() {
424 stream.writable().await.expect("writable");
425 match stream.try_write(&req.as_bytes()[written..]) {
426 Ok(n) => written += n,
427 Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
428 Err(e) => panic!("write failed: {e}"),
429 }
430 }
431 let mut buf = Vec::new();
432 loop {
433 stream.readable().await.expect("readable");
434 let mut chunk = [0u8; 4096];
435 match stream.try_read(&mut chunk) {
436 Ok(0) => break,
437 Ok(n) => buf.extend_from_slice(&chunk[..n]),
438 Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
439 Err(e) => panic!("read failed: {e}"),
440 }
441 }
442 let text = String::from_utf8_lossy(&buf).into_owned();
443 let status = text
444 .split_whitespace()
445 .nth(1)
446 .and_then(|s| s.parse().ok())
447 .expect("status code");
448 let body = text
449 .split_once("\r\n\r\n")
450 .map(|(_, b)| b.to_owned())
451 .unwrap_or_default();
452 (status, body)
453 }
454
455 async fn write_all(stream: &tokio::net::TcpStream, bytes: &[u8]) {
457 let mut written = 0;
458 while written < bytes.len() {
459 stream.writable().await.expect("writable");
460 match stream.try_write(&bytes[written..]) {
461 Ok(n) => written += n,
462 Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
463 Err(e) => panic!("write failed: {e}"),
464 }
465 }
466 }
467
468 async fn read_until(stream: &tokio::net::TcpStream, done: impl Fn(&[u8]) -> bool) -> Vec<u8> {
471 let mut buf = Vec::new();
472 while !done(&buf) {
473 stream.readable().await.expect("readable");
474 let mut chunk = [0u8; 4096];
475 match stream.try_read(&mut chunk) {
476 Ok(0) => break,
477 Ok(n) => buf.extend_from_slice(&chunk[..n]),
478 Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
479 Err(e) if e.kind() == std::io::ErrorKind::ConnectionReset => break,
480 Err(e) => panic!("read failed: {e}"),
481 }
482 }
483 buf
484 }
485
486 #[tokio::test]
490 async fn a_kept_alive_connection_ends_when_run_returns() {
491 let health = HealthState::new(0, HealthThresholds::default());
492 let server = AdminServer::bind(
493 "127.0.0.1:0".parse().expect("addr"),
494 None,
495 Arc::clone(&health),
496 )
497 .await
498 .expect("bind");
499 let addr = server.local_addr();
500 let (shutdown_tx, shutdown_rx) = watch::channel(false);
501 let (client_tx, client_rx) = oneshot::channel();
502 let _client = tokio::spawn(async move {
503 let stream = tokio::net::TcpStream::connect(addr).await.expect("connect");
504 write_all(&stream, b"GET /healthz HTTP/1.1\r\nHost: admin\r\n\r\n").await;
505 let response = read_until(&stream, |b| b.ends_with(b"\r\n\r\nok")).await;
506 shutdown_tx.send(true).expect("signal shutdown");
507 let _ = client_tx.send((stream, response));
508 });
509
510 server.run(shutdown_rx).await.expect("clean shutdown");
513 assert_eq!(
514 Arc::strong_count(&health),
515 1,
516 "the connection still holds the health state"
517 );
518
519 let (stream, response) = client_rx.await.expect("the client finished");
520 assert!(
521 response.starts_with(b"HTTP/1.1 200"),
522 "{}",
523 String::from_utf8_lossy(&response)
524 );
525 let rest = tokio::time::timeout(Duration::from_secs(30), read_until(&stream, |_| false))
526 .await
527 .expect("the connection closes when run returns");
528 assert_eq!(rest, b"");
529 }
530
531 #[test]
534 fn a_panicking_connection_is_logged() {
535 #[derive(Clone)]
536 struct Lines(mpsc::UnboundedSender<String>);
537 impl std::io::Write for Lines {
538 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
539 let _ = self.0.send(String::from_utf8_lossy(bytes).into_owned());
540 Ok(bytes.len())
541 }
542 fn flush(&mut self) -> std::io::Result<()> {
543 Ok(())
544 }
545 }
546 let (lines_tx, mut lines_rx) = mpsc::unbounded_channel();
547 let writer = Lines(lines_tx);
548 let subscriber = tracing_subscriber::fmt()
549 .with_writer(move || writer.clone())
550 .with_ansi(false)
551 .without_time()
552 .with_target(false)
553 .with_max_level(tracing::Level::ERROR)
554 .finish();
555 let runtime = tokio::runtime::Builder::new_current_thread()
556 .enable_all()
557 .build()
558 .expect("runtime");
559 tracing::subscriber::with_default(subscriber, || {
560 runtime.block_on(async {
561 let health = HealthState::new(0, HealthThresholds::default());
562 let render: RenderFn = Arc::new(|| panic!("render boom"));
563 let server =
564 AdminServer::bind("127.0.0.1:0".parse().expect("addr"), Some(render), health)
565 .await
566 .expect("bind");
567 let addr = server.local_addr();
568 let (shutdown_tx, shutdown_rx) = watch::channel(false);
569 let running = tokio::spawn(server.run(shutdown_rx));
570
571 let stream = tokio::net::TcpStream::connect(addr).await.expect("connect");
572 write_all(&stream, b"GET /metrics HTTP/1.1\r\nHost: admin\r\n\r\n").await;
573 let line = tokio::time::timeout(Duration::from_secs(30), lines_rx.recv())
574 .await
575 .expect("a line within the deadline")
576 .expect("the subscriber is alive");
577 assert_eq!(
578 line.trim(),
579 r#"ERROR admin connection panicked panic="render boom""#
580 );
581
582 let (status, _) = get(addr, "/healthz").await;
583 assert_eq!(
584 status, 200,
585 "the server serves on after a connection panics"
586 );
587 shutdown_tx.send(true).expect("signal shutdown");
588 running
589 .await
590 .expect("server task joins")
591 .expect("clean shutdown");
592 assert!(lines_rx.try_recv().is_err(), "one line only");
593 });
594 });
595 }
596
597 #[tokio::test]
598 async fn serves_probes_and_metrics_and_shuts_down() {
599 let health = HealthState::new(0, HealthThresholds::default());
600 let render: RenderFn = Arc::new(|| "spate_pipeline_info 1\n".to_owned());
601 let server = AdminServer::bind(
602 "127.0.0.1:0".parse().expect("addr"),
603 Some(render),
604 Arc::clone(&health),
605 )
606 .await
607 .expect("bind");
608 let addr = server.local_addr();
609 let (shutdown_tx, shutdown_rx) = watch::channel(false);
610 let running = tokio::spawn(server.run(shutdown_rx));
611
612 let (status, body) = get(addr, "/metrics").await;
613 assert_eq!(status, 200);
614 assert!(body.contains("spate_pipeline_info"));
615
616 let (status, _) = get(addr, "/readyz").await;
617 assert_eq!(status, 503, "not ready before assignment + sinks");
618 health.set_assignment_received(true);
619 health.set_sinks_connected(true);
620 let (status, body) = get(addr, "/readyz").await;
621 assert_eq!(status, 200);
622 assert_eq!(body, "ok");
623
624 let (status, _) = get(addr, "/healthz").await;
625 assert_eq!(status, 200);
626
627 let (status, _) = get(addr, "/nope").await;
628 assert_eq!(status, 404);
629
630 shutdown_tx.send(true).expect("signal shutdown");
631 running
632 .await
633 .expect("server task joins")
634 .expect("clean shutdown");
635 }
636
637 #[tokio::test]
641 async fn probes_serve_without_an_exporter_and_metrics_does_not() {
642 let health = HealthState::new(0, HealthThresholds::default());
643 let server = AdminServer::bind("127.0.0.1:0".parse().expect("addr"), None, health)
644 .await
645 .expect("bind");
646 let addr = server.local_addr();
647 let (shutdown_tx, shutdown_rx) = watch::channel(false);
648 let running = tokio::spawn(server.run(shutdown_rx));
649
650 let (status, _) = get(addr, "/metrics").await;
651 assert_eq!(status, 404, "no exporter, no exposition");
652
653 let (status, _) = get(addr, "/healthz").await;
654 assert_eq!(status, 200, "liveness does not depend on the exporter");
655 let (status, _) = get(addr, "/readyz").await;
656 assert_eq!(status, 503, "readiness does not depend on the exporter");
657
658 shutdown_tx.send(true).expect("signal shutdown");
659 running
660 .await
661 .expect("server task joins")
662 .expect("clean shutdown");
663 }
664}