use std::io;
use std::process::ExitCode;
use kevy_resp_client::{Reply, RespClient};
pub enum Health {
Ok,
Drift {
detail: String,
},
NeedsTieBreak {
duplicates: u64,
},
Building,
}
pub struct TableHealth {
pub name: String,
pub health: Health,
pub reported: String,
}
pub(crate) fn fields(items: &[Reply]) -> Vec<(String, String)> {
let bulks: Vec<String> = items
.iter()
.map(|r| match r {
Reply::Bulk(b) => String::from_utf8_lossy(b).into_owned(),
Reply::Int(i) => i.to_string(),
_ => String::new(),
})
.collect();
bulks.chunks(2).filter(|c| c.len() == 2).map(|c| (c[0].clone(), c[1].clone())).collect()
}
pub fn table_names(client: &mut RespClient) -> io::Result<Vec<String>> {
let Reply::Array(tables) = client.request_borrowed(&[b"TABLE.LIST"])? else {
return Ok(Vec::new());
};
Ok(tables
.iter()
.filter_map(|t| {
let Reply::Array(items) = t else { return None };
fields(items).into_iter().find(|(k, _)| k == "name").map(|(_, v)| v)
})
.collect())
}
pub fn check_table(client: &mut RespClient, name: &str) -> io::Result<TableHealth> {
let reply = client.request_borrowed(&[b"TABLE.VERIFY", name.as_bytes()])?;
if let Reply::Error(e) = &reply {
let msg = String::from_utf8_lossy(e);
let health = if msg.starts_with("INDEXBUILDING") {
Health::Building
} else {
Health::Drift { detail: msg.into_owned() }
};
return Ok(TableHealth { name: name.to_string(), health, reported: String::new() });
}
let Reply::Array(groups) = reply else {
return Ok(TableHealth {
name: name.to_string(),
health: Health::Drift { detail: "unreadable VERIFY reply".into() },
reported: String::new(),
});
};
let mut sums: std::collections::BTreeMap<String, u64> = Default::default();
for g in &groups {
if let Reply::Array(items) = g {
for (k, v) in fields(items) {
if let Ok(n) = v.parse::<u64>() {
*sums.entry(k).or_insert(0) += n;
}
}
}
}
let get = |k: &str| sums.get(k).copied().unwrap_or(0);
let reported = format!(
"rows {} · entries {} · absent {} · excluded {} · coerce_failures {}",
get("rows"),
get("entries"),
get("absent"),
get("excluded"),
get("coerce_failures")
);
Ok(TableHealth { name: name.to_string(), health: classify(&groups), reported })
}
fn classify(groups: &[Reply]) -> Health {
let mut sums: std::collections::BTreeMap<String, u64> = Default::default();
for g in groups {
if let Reply::Array(items) = g {
for (k, v) in fields(items) {
if let Ok(n) = v.parse::<u64>() {
*sums.entry(k).or_insert(0) += n;
}
}
}
}
let get = |k: &str| sums.get(k).copied().unwrap_or(0);
let (drift, missing, dups) = (get("drift"), get("missing"), get("duplicates"));
if drift > 0 || missing > 0 {
Health::Drift { detail: format!("drift {drift}, missing {missing}") }
} else if dups > 0 {
Health::NeedsTieBreak { duplicates: dups }
} else {
Health::Ok
}
}
pub fn run(client: &mut RespClient, warn_is_failure: bool) -> io::Result<ExitCode> {
let names = table_names(client)?;
if names.is_empty() {
println!("doctor: no tables declared — nothing to verify");
return Ok(ExitCode::SUCCESS);
}
let (mut bad, mut warned, mut building) = (0u32, 0u32, 0u32);
for name in &names {
let h = check_table(client, name)?;
match &h.health {
Health::Ok => println!(" OK {name} ({})", h.reported),
Health::Building => {
building += 1;
println!(" BUILDING {name} — an index is still backfilling, not a verdict");
}
Health::NeedsTieBreak { duplicates } => {
warned += 1;
println!(
" WARN {name} duplicates {duplicates} — paging this path needs a \
bounded tie-break or pages repeat rows ({})",
h.reported
);
}
Health::Drift { detail } => {
bad += 1;
println!(" DRIFT {name} {detail} ({})", h.reported);
}
}
}
println!(
"doctor: {} table(s) — {bad} drifted, {warned} warned, {building} still building",
names.len()
);
Ok(if bad > 0 || (warn_is_failure && warned > 0) {
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
})
}
pub fn run_doctor_cli(args: &[String]) -> ExitCode {
let (mut host, mut port) = (crate::DEFAULT_HOST.to_string(), crate::DEFAULT_PORT);
let mut strict = false;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"-h" if i + 1 < args.len() => {
host = args[i + 1].clone();
i += 2;
}
"-p" if i + 1 < args.len() => {
port = args[i + 1].parse().unwrap_or(crate::DEFAULT_PORT);
i += 2;
}
"--warn-is-failure" => {
strict = true;
i += 1;
}
_ => i += 1,
}
}
let mut client = match RespClient::connect(&host, port) {
Ok(c) => c,
Err(e) => {
eprintln!("kevy-cli: could not connect to {host}:{port}: {e}");
return ExitCode::FAILURE;
}
};
match run(&mut client, strict) {
Ok(code) => code,
Err(e) => {
eprintln!("kevy-cli doctor: {e}");
ExitCode::FAILURE
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn arr(pairs: &[(&str, i64)]) -> Reply {
let mut items = Vec::new();
for (k, v) in pairs {
items.push(Reply::Bulk(k.as_bytes().to_vec()));
items.push(Reply::Bulk(v.to_string().into_bytes()));
}
Reply::Array(items)
}
#[test]
fn drift_and_missing_are_the_failing_counters() {
for k in ["drift", "missing"] {
let sums = [("rows", 10), (k, 1)];
let groups = vec![arr(&sums)];
let health = classify(&groups);
assert!(matches!(health, Health::Drift { .. }), "{k} must fail");
}
}
#[test]
fn duplicates_warn_rather_than_fail() {
let groups = vec![arr(&[("rows", 10), ("duplicates", 3), ("drift", 0)])];
assert!(matches!(classify(&groups), Health::NeedsTieBreak { duplicates: 3 }));
}
#[test]
fn exclusion_causes_never_fail() {
let groups =
vec![arr(&[("rows", 10), ("absent", 4), ("excluded", 2), ("coerce_failures", 1)])];
assert!(matches!(classify(&groups), Health::Ok));
}
}