use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use axum::extract::{Request, State};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Extension, Router};
use http::HeaderValue;
use http::header::RETRY_AFTER;
use super::AppState;
use crate::ntw::error::Error as NetError;
pub(super) fn router<S>() -> Router<S>
where
S: Clone + Send + Sync + 'static,
{
Router::new().route("/ready", get(ready_handler))
}
async fn ready_handler(Extension(state): Extension<AppState>) -> Result<(), NetError> {
community_readiness(&state).await
}
pub async fn community_readiness(state: &AppState) -> Result<(), NetError> {
if !state.readiness.ready.load(Ordering::SeqCst) {
return Err(NetError::NotReady);
}
if let Some(max_age) = state.readiness.max_heartbeat_age {
let age = state.datastore.node_heartbeat_age().await.map_err(|err| {
tracing::error!("Readiness check could not read the node heartbeat: {err}");
NetError::InvalidStorage
})?;
if age > max_age {
tracing::warn!("Node heartbeat is stale ({age:?} > {max_age:?}); reporting not ready");
return Err(NetError::NotReady);
}
}
Ok(())
}
fn always_available(path: &str) -> bool {
matches!(path, "/" | "/status" | "/health" | "/version" | "/ready" | "/metrics")
}
pub(super) async fn readiness_gate(
State(ready): State<Arc<AtomicBool>>,
request: Request,
next: Next,
) -> Response {
if ready.load(Ordering::SeqCst) || always_available(request.uri().path()) {
return next.run(request).await;
}
let mut response = NetError::NotReady.into_response();
response.headers_mut().insert(RETRY_AFTER, HeaderValue::from_static("1"));
response
}
#[cfg(test)]
mod tests {
use axum::Router;
use axum::body::Body;
use axum::middleware::from_fn_with_state;
use axum::routing::get;
use http::header::RETRY_AFTER;
use http::{Request, StatusCode};
use tower::ServiceExt;
use super::*;
fn app(ready: bool) -> Router {
let flag = Arc::new(AtomicBool::new(ready));
Router::new()
.route("/", get(|| async { "ok" }))
.route("/status", get(|| async {}))
.route("/health", get(|| async { "ok" }))
.route("/version", get(|| async { "ok" }))
.route("/ready", get(|| async { "ok" }))
.route("/metrics", get(|| async { "ok" }))
.route("/sql", get(|| async { "ok" }))
.route("/rpc", get(|| async { "ok" }))
.route("/key/{tb}/{id}", get(|| async { "ok" }))
.layer(from_fn_with_state(flag, readiness_gate))
}
async fn status_of(app: Router, uri: &str) -> StatusCode {
app.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
.await
.unwrap()
.status()
}
#[tokio::test]
async fn allowlisted_routes_serve_while_starting() {
for uri in ["/", "/status", "/health", "/version", "/ready", "/metrics"] {
assert_eq!(
status_of(app(false), uri).await,
StatusCode::OK,
"{uri} should bypass the readiness gate while starting"
);
}
}
#[tokio::test]
async fn query_routes_are_gated_while_starting() {
for uri in ["/sql", "/rpc", "/key/users/1"] {
assert_eq!(
status_of(app(false), uri).await,
StatusCode::SERVICE_UNAVAILABLE,
"{uri} should be gated while starting"
);
}
}
#[tokio::test]
async fn gate_sets_retry_after_header() {
let res = app(false)
.oneshot(Request::builder().uri("/sql").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(res.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(res.headers().get(RETRY_AFTER).unwrap().to_str().unwrap(), "1");
}
#[tokio::test]
async fn all_routes_serve_once_ready() {
for uri in ["/sql", "/rpc", "/key/users/1", "/status", "/health", "/ready"] {
assert_eq!(
status_of(app(true), uri).await,
StatusCode::OK,
"{uri} should serve once ready"
);
}
}
}