use crate::health::HealthStatus;
use std::future::Future;
pub(crate) fn log_cleanup_outcome(label: &'static str, result: Result<u64, sqlx::Error>) {
match result {
Ok(removed) if removed > 0 => {
tracing::debug!(removed, "{label} session cleanup");
}
Ok(_) => {}
Err(e) => {
tracing::warn!(error = %e, "{label} session cleanup failed");
}
}
}
pub(crate) async fn sql_health_probe<F, E>(label: &'static str, probe: F) -> HealthStatus
where
F: Future<Output = Result<i32, E>> + Send,
E: std::fmt::Display,
{
match tokio::time::timeout(std::time::Duration::from_secs(2), probe).await {
Ok(Ok(_)) => HealthStatus::Healthy,
Ok(Err(e)) => HealthStatus::Unhealthy(format!("{label} SELECT 1 failed: {e}")),
Err(_) => HealthStatus::Unhealthy(format!("{label} SELECT 1 timeout (2s)")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::mock_tracing::TracingCapture;
#[test]
fn ok_zero_does_not_log() {
let capture = TracingCapture::install();
log_cleanup_outcome("sqlite", Ok(0));
assert!(
!capture.contains_at_level(tracing::Level::DEBUG, "sqlite session cleanup"),
"zero-row sweep must stay silent; kills `true` / `== 0` / `>= 0` guard mutants"
);
}
#[test]
fn ok_positive_emits_debug() {
let capture = TracingCapture::install();
log_cleanup_outcome("postgres", Ok(42));
assert!(
capture.contains_at_level(tracing::Level::DEBUG, "postgres session cleanup"),
"non-zero sweep must log at debug; kills `false` / `< 0` guard mutants"
);
}
#[test]
fn err_emits_warn() {
let capture = TracingCapture::install();
log_cleanup_outcome("mysql", Err(sqlx::Error::PoolClosed));
assert!(
capture.contains_at_level(tracing::Level::WARN, "mysql session cleanup failed"),
"error must surface at warn; guards against accidental arm reordering"
);
}
#[tokio::test]
async fn health_probe_ok_is_healthy() {
let status = sql_health_probe("sqlite", async { Ok::<_, std::io::Error>(1i32) }).await;
assert_eq!(status, HealthStatus::Healthy);
}
#[tokio::test]
async fn health_probe_err_is_unhealthy_with_label() {
let probe = async { Err::<i32, _>(std::io::Error::other("boom")) };
let status = sql_health_probe("postgres", probe).await;
match status {
HealthStatus::Unhealthy(msg) => {
assert!(msg.starts_with("postgres SELECT 1 failed: "), "got: {msg}");
assert!(msg.contains("boom"), "got: {msg}");
}
other => panic!("expected Unhealthy, got {other:?}"),
}
}
#[tokio::test]
async fn health_probe_timeout_reports_two_second_window() {
tokio::time::pause();
let probe = std::future::pending::<Result<i32, std::io::Error>>();
let handle = tokio::spawn(sql_health_probe("mysql", probe));
tokio::time::advance(std::time::Duration::from_secs(3)).await;
let status = handle.await.expect("join");
assert_eq!(
status,
HealthStatus::Unhealthy("mysql SELECT 1 timeout (2s)".into()),
);
}
#[test]
fn label_is_interpolated() {
let capture = TracingCapture::install();
log_cleanup_outcome("sqlite", Ok(1));
assert!(
capture.contains_at_level(tracing::Level::DEBUG, "sqlite session cleanup"),
"label must appear in the message; guards against hard-coded label drift"
);
let capture2 = TracingCapture::install();
log_cleanup_outcome("postgres", Ok(1));
assert!(
capture2.contains_at_level(tracing::Level::DEBUG, "postgres session cleanup"),
"second-label call must use the new label, not a cached one"
);
}
}