1use 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
29pub type RenderFn = Arc<dyn Fn() -> String + Send + Sync>;
31
32#[derive(Clone, Copy, Debug)]
34pub struct HealthThresholds {
35 pub heartbeat_stale: Duration,
38 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#[derive(Debug)]
55pub struct HealthState {
56 epoch: Instant,
57 thresholds: HealthThresholds,
58 assignment_received: AtomicBool,
59 sinks_connected: AtomicBool,
60 heartbeats: Vec<AtomicU64>,
62 watermark_age_ms: AtomicU64,
64 data_flowing: AtomicBool,
65}
66
67impl HealthState {
68 #[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 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 pub fn set_assignment_received(&self, received: bool) {
97 self.assignment_received.store(received, Ordering::Relaxed);
98 }
99
100 pub fn set_sinks_connected(&self, connected: bool) {
102 self.sinks_connected.store(connected, Ordering::Relaxed);
103 }
104
105 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 #[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 #[must_use]
125 pub fn healthy(&self) -> bool {
126 self.healthy_at(Instant::now())
127 }
128
129 #[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
148pub 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 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 #[must_use]
184 pub fn local_addr(&self) -> SocketAddr {
185 self.local_addr
186 }
187
188 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 assert!(health.healthy_at(start + Duration::from_millis(500)));
294 health.heartbeat(1);
296 assert!(!health.healthy_at(start + Duration::from_millis(1_501)));
297 health.heartbeat(0);
299 health.heartbeat(1);
300 assert!(health.healthy_at(Instant::now()));
301 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 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}