Skip to content

Commit 4b4afd4

Browse files
committed
health: make /health exercise the database datapath
The old `/health` returned an unconditional 200, so a wedge where the asynchronous worker survives but the database cannot serve requests (e.g. a bottomless S3 snapshot or checkpoint holding the write lock) was could be reported healthy while every real request failed. `/health` now resolves the default namespace, opens a connection, and runs a `SELECT 1` through the same data path as a real request, all bounded by a 500ms timeout. Returns 503 when the data path cannot be exercised.
1 parent b5ebaba commit 4b4afd4

1 file changed

Lines changed: 74 additions & 5 deletions

File tree

  • libsql-server/src/http/user

libsql-server/src/http/user/mod.rs

Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ mod types;
1010
pub mod timing;
1111

1212
use std::sync::Arc;
13+
use std::time::Duration;
1314

1415
use anyhow::Context;
1516
use axum::extract::{FromRef, FromRequest, FromRequestParts, Path as AxumPath, State as AxumState};
@@ -36,17 +37,19 @@ use tower_http::compression::{DefaultPredicate, Predicate};
3637
use tower_http::{compression::CompressionLayer, cors};
3738

3839
use crate::auth::{Auth, AuthError, Authenticated, Jwt, Permission, UserAuthContext};
40+
use crate::connection::program::{Program, Step};
3941
use crate::connection::{Connection, RequestContext};
4042
use crate::error::Error;
4143
use crate::http::user::db_factory::MakeConnectionExtractorPath;
4244
use crate::http::user::timing::timings_middleware;
4345
use crate::http::user::types::HttpQuery;
4446
use crate::metrics::LEGACY_HTTP_CALL;
45-
use crate::namespace::NamespaceStore;
47+
use crate::namespace::{NamespaceName, NamespaceStore};
4648
use crate::net::Accept;
49+
use crate::query::Params;
4750
use crate::query::{self, Query};
4851
use crate::query_analysis::{predict_final_state, Statement, TxnStatus};
49-
use crate::query_result_builder::QueryResultBuilder;
52+
use crate::query_result_builder::{QueryResultBuilder, StepResult, StepResultsBuilder};
5053
use crate::rpc::proxy::rpc::proxy_server::{Proxy, ProxyServer};
5154
use crate::schema::{MigrationDetails, MigrationSummary};
5255
use crate::utils::services::idle_shutdown::IdleShutdownKicker;
@@ -163,9 +166,75 @@ async fn show_console(
163166
}
164167
}
165168

166-
async fn handle_health() -> Response<Body> {
167-
// return empty OK
168-
Response::new(Body::empty())
169+
/// How long we are willing to wait for the database to prove it is servable
170+
/// before declaring the instance unhealthy.
171+
///
172+
/// The default kubelet liveness probe timeout is 1s and the probe is usually
173+
/// run more frequently than the failure threshold, so the budget here must be
174+
/// comfortably inside the probe timeout: a healthy-but-busy database gets the
175+
/// full budget, a wedged one (e.g. a checkpoint or S3 path holding the write
176+
/// lock on the single async worker) times out and the probe returns 503.
177+
const HEALTH_QUERY_TIMEOUT: Duration = Duration::from_millis(500);
178+
179+
async fn handle_health(AxumState(state): AxumState<AppState>) -> impl IntoResponse {
180+
// The default namespace reports "is this whole process able to serve its
181+
// *data* path" — not merely "is HTTP up".
182+
match serve_default_namespace_health(&state).await {
183+
true => (StatusCode::OK, "").into_response(),
184+
false => (StatusCode::SERVICE_UNAVAILABLE, "database not servable").into_response(),
185+
}
186+
}
187+
188+
/// Runs a real, bounded read against the default namespace to prove the
189+
/// datapath is servable. Returns `true` when a query completed within the
190+
/// budget. The entire round-trip — resolving the namespace, opening the
191+
/// connection, and running the query — is bounded by [`HEALTH_QUERY_TIMEOUT`]
192+
/// so a wedge anywhere in the datapath (not just query execution) becomes a
193+
/// 503.
194+
async fn serve_default_namespace_health(state: &AppState) -> bool {
195+
let namespace = NamespaceName::default();
196+
197+
tokio::time::timeout(HEALTH_QUERY_TIMEOUT, async {
198+
// Mirror how a real request resolves the namespace so the check
199+
// exercises the same connection path (including the read-txn upgrade
200+
// path that contends on the connection-manager write lock during
201+
// checkpoints).
202+
let Ok(connection_maker) = state
203+
.namespaces
204+
.with(namespace.clone(), |ns| ns.db.connection_maker())
205+
.await
206+
else {
207+
return false;
208+
};
209+
let Ok(connection) = connection_maker.create().await else {
210+
return false;
211+
};
212+
let meta_store = state.namespaces.meta_store().clone();
213+
let ctx = RequestContext::new(Authenticated::FullAccess, namespace, meta_store);
214+
215+
// A single, pure read that exercises the same datapath as a real
216+
// request. `SELECT 1` is constant and must always parse.
217+
let query = Query {
218+
stmt: Statement::parse("SELECT 1")
219+
.next()
220+
.expect("constant health query must parse")
221+
.expect("constant health query must parse"),
222+
params: Params::empty(),
223+
want_rows: false,
224+
};
225+
let program = Program::new(vec![Step { cond: None, query }]);
226+
227+
// `SELECT 1` is a pure read: servable iff the single step succeeded.
228+
match connection
229+
.execute_program(program, ctx, StepResultsBuilder::default(), None)
230+
.await
231+
{
232+
Ok(builder) => matches!(builder.into_ret().as_slice(), [StepResult::Ok]),
233+
Err(_) => false,
234+
}
235+
})
236+
.await
237+
.unwrap_or(false)
169238
}
170239

171240
async fn handle_upgrade(

0 commit comments

Comments
 (0)