use std::sync::Arc;
use jiff::{Timestamp, ToSpan};
use serde_json::Value;
use tokio_postgres::{Client as PgClient, types::ToSql};
use super::query_error_check;
use crate::doctor::Stat;
use crate::doctor::check::Check;
pub fn humanise_age(secs: i64) -> String {
let secs = secs.max(0) as u64;
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m", secs / 60)
} else if secs < 86400 {
format!("{}h", secs / 3600)
} else {
format!("{}d", secs / 86400)
}
}
const REPORT_CAP: usize = 100;
const FETCH_CAP: usize = REPORT_CAP + 1;
fn wrap(sql: &str) -> String {
format!(
"SELECT to_jsonb(sub) AS row, count(*) OVER () AS total FROM ( {sql} ) sub LIMIT {FETCH_CAP}"
)
}
pub struct RowSet {
pub rows: Vec<Value>,
pub truncated: bool,
pub total: u64,
}
impl RowSet {
pub fn is_empty(&self) -> bool {
self.total == 0
}
pub fn count(&self) -> Value {
Value::from(self.total)
}
}
pub async fn fetch_rows(
client: &Arc<PgClient>,
sql: &str,
params: &[&(dyn ToSql + Sync)],
) -> Result<RowSet, tokio_postgres::Error> {
let wrapped = wrap(sql);
let raw = client.query(&wrapped, params).await?;
let truncated = raw.len() > REPORT_CAP;
let total = raw
.first()
.map_or(0, |r| r.get::<_, i64>("total").max(0) as u64);
let rows = raw
.into_iter()
.take(REPORT_CAP)
.map(|r| r.get::<_, Value>("row"))
.collect();
Ok(RowSet {
rows,
truncated,
total,
})
}
#[expect(
clippy::too_many_arguments,
reason = "shared query helper; each parameter is a distinct knob the call sites set"
)]
pub async fn tiered_rows_check(
client: &Arc<PgClient>,
name: &'static str,
summary_pass: &str,
summary_prefix: &str,
sql: &str,
lookback_hours: i64,
warn_min: usize,
fail_min: usize,
) -> Check {
let since = Timestamp::now() - lookback_hours.hours();
match fetch_rows(client, sql, &[&since]).await {
Ok(set) => {
let n = set.total as usize;
let count = set.count();
let count_stat = Stat::gauge("count", set.total as f64)
.help(format!("Error rows in the last {lookback_hours}h"));
if n < warn_min {
return Check::pass(name, summary_pass.to_string()).with_stat(count_stat);
}
let summary = format!("{summary_prefix}{count}");
let reason = format!("{count} matching row(s)");
let check = if n >= fail_min {
Check::fail(name, summary, reason)
} else {
Check::warning(name, summary, reason)
};
check
.with_detail("rows", Value::Array(set.rows))
.with_detail("truncated", set.truncated)
.with_detail("count", count)
.with_stat(count_stat)
}
Err(err) => query_error_check(name, &err),
}
}
#[cfg(test)]
mod tests {
fn tier(n: usize, warn_min: usize, fail_min: usize) -> &'static str {
if n >= fail_min {
"fail"
} else if n >= warn_min {
"warning"
} else {
"pass"
}
}
#[test]
fn error_stream_boundaries() {
assert_eq!(tier(0, 1, 10), "pass");
assert_eq!(tier(1, 1, 10), "warning");
assert_eq!(tier(9, 1, 10), "warning");
assert_eq!(tier(10, 1, 10), "fail");
assert_eq!(tier(100, 1, 10), "fail");
}
#[test]
fn wrap_counts_outside_the_row_cap() {
let sql = super::wrap("SELECT 1");
assert!(sql.contains("count(*) OVER () AS total"));
assert!(sql.ends_with(&format!("LIMIT {}", super::FETCH_CAP)));
}
#[test]
fn a_truncated_row_set_still_counts_exactly() {
let set = super::RowSet {
rows: vec![serde_json::Value::from(1); super::REPORT_CAP],
truncated: true,
total: 4321,
};
assert_eq!(set.count(), serde_json::Value::from(4321u64));
assert!(!set.is_empty());
}
#[test]
fn a_row_set_with_no_matches_is_empty() {
let set = super::RowSet {
rows: Vec::new(),
truncated: false,
total: 0,
};
assert!(set.is_empty());
}
}