Skip to main content

kevy_cli/
doctor.rs

1//! `doctor` — run every table's `VERIFY` and turn the counters into an
2//! exit code.
3//!
4//! Lesson 8 of the migration playbook: *make `VERIFY` part of
5//! operations, not part of the migration.* The counters are fresh on
6//! every call and cheap enough for a cron; what was missing is the
7//! shell that turns them into something a cron can act on.
8//!
9//! The mapping is the lesson's own words, not a new opinion:
10//!
11//! * `drift` and `missing` **should be zero forever** — non-zero is a
12//!   failure;
13//! * non-zero `duplicates` on an ORDERPATH means **pagination needs a
14//!   bounded tie-break** — a warning about a design choice, not a
15//!   corruption;
16//! * `absent` / `excluded` / `coerce_failures` **name the rows each
17//!   exclusion cause claimed** — reported, never failed on, because
18//!   every one of them is a legitimate state.
19//!
20//! And one thing the lesson could not have known: `TABLE.VERIFY`
21//! answers `-INDEXBUILDING` while a backfill is still running. A cron
22//! that read that as a failure would page someone every time an index
23//! was declared, so it is its own outcome.
24
25use std::io;
26use std::process::ExitCode;
27
28use kevy_resp_client::{Reply, RespClient};
29
30/// What `doctor` concluded about one table.
31pub enum Health {
32    /// Every counter where it should be.
33    Ok,
34    /// `drift` or `missing` is non-zero — the index and the keyspace
35    /// disagree, which is the thing VERIFY exists to make falsifiable.
36    Drift {
37        /// Which counters were non-zero, with their values.
38        detail: String,
39    },
40    /// Non-zero `duplicates`: not corruption, but pagination over this
41    /// path needs a bounded tie-break or pages will repeat rows.
42    NeedsTieBreak {
43        /// How many duplicate order values were found.
44        duplicates: u64,
45    },
46    /// A backfill is still running. Not a verdict either way.
47    Building,
48}
49
50/// One table's name and what was concluded about it.
51pub struct TableHealth {
52    /// The declared table name.
53    pub name: String,
54    /// The verdict.
55    pub health: Health,
56    /// The counters worth showing whatever the verdict — the exclusion
57    /// causes, which are legitimate states rather than problems.
58    pub reported: String,
59}
60
61/// Pull `[field, value, …]` pairs out of a flat reply array.
62pub(crate) fn fields(items: &[Reply]) -> Vec<(String, String)> {
63    let bulks: Vec<String> = items
64        .iter()
65        .map(|r| match r {
66            Reply::Bulk(b) => String::from_utf8_lossy(b).into_owned(),
67            Reply::Int(i) => i.to_string(),
68            _ => String::new(),
69        })
70        .collect();
71    bulks.chunks(2).filter(|c| c.len() == 2).map(|c| (c[0].clone(), c[1].clone())).collect()
72}
73
74/// Every declared table's name, in declaration order.
75pub fn table_names(client: &mut RespClient) -> io::Result<Vec<String>> {
76    let Reply::Array(tables) = client.request_borrowed(&[b"TABLE.LIST"])? else {
77        return Ok(Vec::new());
78    };
79    Ok(tables
80        .iter()
81        .filter_map(|t| {
82            let Reply::Array(items) = t else { return None };
83            fields(items).into_iter().find(|(k, _)| k == "name").map(|(_, v)| v)
84        })
85        .collect())
86}
87
88/// Verify one table and read its counters against lesson 8's mapping.
89pub fn check_table(client: &mut RespClient, name: &str) -> io::Result<TableHealth> {
90    let reply = client.request_borrowed(&[b"TABLE.VERIFY", name.as_bytes()])?;
91    if let Reply::Error(e) = &reply {
92        let msg = String::from_utf8_lossy(e);
93        let health =
94            if msg.starts_with("INDEXBUILDING") { Health::Building } else { Health::Drift { detail: msg.into_owned() } };
95        return Ok(TableHealth { name: name.to_string(), health, reported: String::new() });
96    }
97    // The reply is per-index groups plus a spot-check group; summing the
98    // counters across groups is the table-level answer.
99    let Reply::Array(groups) = reply else {
100        return Ok(TableHealth {
101            name: name.to_string(),
102            health: Health::Drift { detail: "unreadable VERIFY reply".into() },
103            reported: String::new(),
104        });
105    };
106    let mut sums: std::collections::BTreeMap<String, u64> = Default::default();
107    for g in &groups {
108        if let Reply::Array(items) = g {
109            for (k, v) in fields(items) {
110                if let Ok(n) = v.parse::<u64>() {
111                    *sums.entry(k).or_insert(0) += n;
112                }
113            }
114        }
115    }
116    let get = |k: &str| sums.get(k).copied().unwrap_or(0);
117    let reported = format!(
118        "rows {} · entries {} · absent {} · excluded {} · coerce_failures {}",
119        get("rows"),
120        get("entries"),
121        get("absent"),
122        get("excluded"),
123        get("coerce_failures")
124    );
125    Ok(TableHealth { name: name.to_string(), health: classify(&groups), reported })
126}
127
128/// Lesson 8's mapping, as one function so a test can state it without a
129/// server: zero-forever counters fail, duplicates warn, exclusion
130/// causes are reported and never fail.
131fn classify(groups: &[Reply]) -> Health {
132    let mut sums: std::collections::BTreeMap<String, u64> = Default::default();
133    for g in groups {
134        if let Reply::Array(items) = g {
135            for (k, v) in fields(items) {
136                if let Ok(n) = v.parse::<u64>() {
137                    *sums.entry(k).or_insert(0) += n;
138                }
139            }
140        }
141    }
142    let get = |k: &str| sums.get(k).copied().unwrap_or(0);
143    let (drift, missing, dups) = (get("drift"), get("missing"), get("duplicates"));
144    if drift > 0 || missing > 0 {
145        Health::Drift { detail: format!("drift {drift}, missing {missing}") }
146    } else if dups > 0 {
147        Health::NeedsTieBreak { duplicates: dups }
148    } else {
149        Health::Ok
150    }
151}
152
153/// Check every table and print one line each. Exit non-zero only on
154/// drift — a warning is information, and a cron that fails on
155/// information stops being read.
156pub fn run(client: &mut RespClient, warn_is_failure: bool) -> io::Result<ExitCode> {
157    let names = table_names(client)?;
158    if names.is_empty() {
159        println!("doctor: no tables declared — nothing to verify");
160        return Ok(ExitCode::SUCCESS);
161    }
162    let (mut bad, mut warned, mut building) = (0u32, 0u32, 0u32);
163    for name in &names {
164        let h = check_table(client, name)?;
165        match &h.health {
166            Health::Ok => println!("  OK       {name}  ({})", h.reported),
167            Health::Building => {
168                building += 1;
169                println!("  BUILDING {name}  — an index is still backfilling, not a verdict");
170            }
171            Health::NeedsTieBreak { duplicates } => {
172                warned += 1;
173                println!(
174                    "  WARN     {name}  duplicates {duplicates} — paging this path needs a \
175                     bounded tie-break or pages repeat rows  ({})",
176                    h.reported
177                );
178            }
179            Health::Drift { detail } => {
180                bad += 1;
181                println!("  DRIFT    {name}  {detail}  ({})", h.reported);
182            }
183        }
184    }
185    println!(
186        "doctor: {} table(s) — {bad} drifted, {warned} warned, {building} still building",
187        names.len()
188    );
189    Ok(if bad > 0 || (warn_is_failure && warned > 0) {
190        ExitCode::FAILURE
191    } else {
192        ExitCode::SUCCESS
193    })
194}
195
196/// `doctor [-h host] [-p port] [--warn-is-failure]`
197pub fn run_doctor_cli(args: &[String]) -> ExitCode {
198    let (mut host, mut port) = (crate::DEFAULT_HOST.to_string(), crate::DEFAULT_PORT);
199    let mut strict = false;
200    let mut i = 0;
201    while i < args.len() {
202        match args[i].as_str() {
203            "-h" if i + 1 < args.len() => {
204                host = args[i + 1].clone();
205                i += 2;
206            }
207            "-p" if i + 1 < args.len() => {
208                port = args[i + 1].parse().unwrap_or(crate::DEFAULT_PORT);
209                i += 2;
210            }
211            "--warn-is-failure" => {
212                strict = true;
213                i += 1;
214            }
215            _ => i += 1,
216        }
217    }
218    let mut client = match RespClient::connect(&host, port) {
219        Ok(c) => c,
220        Err(e) => {
221            eprintln!("kevy-cli: could not connect to {host}:{port}: {e}");
222            return ExitCode::FAILURE;
223        }
224    };
225    match run(&mut client, strict) {
226        Ok(code) => code,
227        Err(e) => {
228            eprintln!("kevy-cli doctor: {e}");
229            ExitCode::FAILURE
230        }
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    fn arr(pairs: &[(&str, i64)]) -> Reply {
239        let mut items = Vec::new();
240        for (k, v) in pairs {
241            items.push(Reply::Bulk(k.as_bytes().to_vec()));
242            items.push(Reply::Bulk(v.to_string().into_bytes()));
243        }
244        Reply::Array(items)
245    }
246
247    /// The counters that must be zero forever are the ones that fail.
248    #[test]
249    fn drift_and_missing_are_the_failing_counters() {
250        for k in ["drift", "missing"] {
251            let sums = [("rows", 10), (k, 1)];
252            let groups = vec![arr(&sums)];
253            let health = classify(&groups);
254            assert!(matches!(health, Health::Drift { .. }), "{k} must fail");
255        }
256    }
257
258    /// Duplicates are a design signal, not corruption — the lesson says
259    /// it means pagination needs a bounded tie-break.
260    #[test]
261    fn duplicates_warn_rather_than_fail() {
262        let groups = vec![arr(&[("rows", 10), ("duplicates", 3), ("drift", 0)])];
263        assert!(matches!(classify(&groups), Health::NeedsTieBreak { duplicates: 3 }));
264    }
265
266    /// Every exclusion cause is a legitimate state. A doctor that failed
267    /// on them would be red on any table with a NULL column.
268    #[test]
269    fn exclusion_causes_never_fail() {
270        let groups =
271            vec![arr(&[("rows", 10), ("absent", 4), ("excluded", 2), ("coerce_failures", 1)])];
272        assert!(matches!(classify(&groups), Health::Ok));
273    }
274}