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 = if msg.starts_with("INDEXBUILDING") {
94            Health::Building
95        } else {
96            Health::Drift { detail: msg.into_owned() }
97        };
98        return Ok(TableHealth { name: name.to_string(), health, reported: String::new() });
99    }
100    // The reply is per-index groups plus a spot-check group; summing the
101    // counters across groups is the table-level answer.
102    let Reply::Array(groups) = reply else {
103        return Ok(TableHealth {
104            name: name.to_string(),
105            health: Health::Drift { detail: "unreadable VERIFY reply".into() },
106            reported: String::new(),
107        });
108    };
109    let mut sums: std::collections::BTreeMap<String, u64> = Default::default();
110    for g in &groups {
111        if let Reply::Array(items) = g {
112            for (k, v) in fields(items) {
113                if let Ok(n) = v.parse::<u64>() {
114                    *sums.entry(k).or_insert(0) += n;
115                }
116            }
117        }
118    }
119    let get = |k: &str| sums.get(k).copied().unwrap_or(0);
120    let reported = format!(
121        "rows {} · entries {} · absent {} · excluded {} · coerce_failures {}",
122        get("rows"),
123        get("entries"),
124        get("absent"),
125        get("excluded"),
126        get("coerce_failures")
127    );
128    Ok(TableHealth { name: name.to_string(), health: classify(&groups), reported })
129}
130
131/// Lesson 8's mapping, as one function so a test can state it without a
132/// server: zero-forever counters fail, duplicates warn, exclusion
133/// causes are reported and never fail.
134fn classify(groups: &[Reply]) -> Health {
135    let mut sums: std::collections::BTreeMap<String, u64> = Default::default();
136    for g in groups {
137        if let Reply::Array(items) = g {
138            for (k, v) in fields(items) {
139                if let Ok(n) = v.parse::<u64>() {
140                    *sums.entry(k).or_insert(0) += n;
141                }
142            }
143        }
144    }
145    let get = |k: &str| sums.get(k).copied().unwrap_or(0);
146    let (drift, missing, dups) = (get("drift"), get("missing"), get("duplicates"));
147    if drift > 0 || missing > 0 {
148        Health::Drift { detail: format!("drift {drift}, missing {missing}") }
149    } else if dups > 0 {
150        Health::NeedsTieBreak { duplicates: dups }
151    } else {
152        Health::Ok
153    }
154}
155
156/// Check every table and print one line each. Exit non-zero only on
157/// drift — a warning is information, and a cron that fails on
158/// information stops being read.
159pub fn run(client: &mut RespClient, warn_is_failure: bool) -> io::Result<ExitCode> {
160    let names = table_names(client)?;
161    if names.is_empty() {
162        println!("doctor: no tables declared — nothing to verify");
163        return Ok(ExitCode::SUCCESS);
164    }
165    let (mut bad, mut warned, mut building) = (0u32, 0u32, 0u32);
166    for name in &names {
167        let h = check_table(client, name)?;
168        match &h.health {
169            Health::Ok => println!("  OK       {name}  ({})", h.reported),
170            Health::Building => {
171                building += 1;
172                println!("  BUILDING {name}  — an index is still backfilling, not a verdict");
173            }
174            Health::NeedsTieBreak { duplicates } => {
175                warned += 1;
176                println!(
177                    "  WARN     {name}  duplicates {duplicates} — paging this path needs a \
178                     bounded tie-break or pages repeat rows  ({})",
179                    h.reported
180                );
181            }
182            Health::Drift { detail } => {
183                bad += 1;
184                println!("  DRIFT    {name}  {detail}  ({})", h.reported);
185            }
186        }
187    }
188    println!(
189        "doctor: {} table(s) — {bad} drifted, {warned} warned, {building} still building",
190        names.len()
191    );
192    Ok(if bad > 0 || (warn_is_failure && warned > 0) {
193        ExitCode::FAILURE
194    } else {
195        ExitCode::SUCCESS
196    })
197}
198
199/// `doctor [-h host] [-p port] [--warn-is-failure]`
200pub fn run_doctor_cli(args: &[String]) -> ExitCode {
201    let (mut host, mut port) = (crate::DEFAULT_HOST.to_string(), crate::DEFAULT_PORT);
202    let mut strict = false;
203    let mut i = 0;
204    while i < args.len() {
205        match args[i].as_str() {
206            "-h" if i + 1 < args.len() => {
207                host = args[i + 1].clone();
208                i += 2;
209            }
210            "-p" if i + 1 < args.len() => {
211                port = args[i + 1].parse().unwrap_or(crate::DEFAULT_PORT);
212                i += 2;
213            }
214            "--warn-is-failure" => {
215                strict = true;
216                i += 1;
217            }
218            _ => i += 1,
219        }
220    }
221    let mut client = match RespClient::connect(&host, port) {
222        Ok(c) => c,
223        Err(e) => {
224            eprintln!("kevy-cli: could not connect to {host}:{port}: {e}");
225            return ExitCode::FAILURE;
226        }
227    };
228    match run(&mut client, strict) {
229        Ok(code) => code,
230        Err(e) => {
231            eprintln!("kevy-cli doctor: {e}");
232            ExitCode::FAILURE
233        }
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    fn arr(pairs: &[(&str, i64)]) -> Reply {
242        let mut items = Vec::new();
243        for (k, v) in pairs {
244            items.push(Reply::Bulk(k.as_bytes().to_vec()));
245            items.push(Reply::Bulk(v.to_string().into_bytes()));
246        }
247        Reply::Array(items)
248    }
249
250    /// The counters that must be zero forever are the ones that fail.
251    #[test]
252    fn drift_and_missing_are_the_failing_counters() {
253        for k in ["drift", "missing"] {
254            let sums = [("rows", 10), (k, 1)];
255            let groups = vec![arr(&sums)];
256            let health = classify(&groups);
257            assert!(matches!(health, Health::Drift { .. }), "{k} must fail");
258        }
259    }
260
261    /// Duplicates are a design signal, not corruption — the lesson says
262    /// it means pagination needs a bounded tie-break.
263    #[test]
264    fn duplicates_warn_rather_than_fail() {
265        let groups = vec![arr(&[("rows", 10), ("duplicates", 3), ("drift", 0)])];
266        assert!(matches!(classify(&groups), Health::NeedsTieBreak { duplicates: 3 }));
267    }
268
269    /// Every exclusion cause is a legitimate state. A doctor that failed
270    /// on them would be red on any table with a NULL column.
271    #[test]
272    fn exclusion_causes_never_fail() {
273        let groups =
274            vec![arr(&[("rows", 10), ("absent", 4), ("excluded", 2), ("coerce_failures", 1)])];
275        assert!(matches!(classify(&groups), Health::Ok));
276    }
277}