1use std::io;
26use std::process::ExitCode;
27
28use kevy_resp_client::{Reply, RespClient};
29
30pub enum Health {
32 Ok,
34 Drift {
37 detail: String,
39 },
40 NeedsTieBreak {
43 duplicates: u64,
45 },
46 Building,
48}
49
50pub struct TableHealth {
52 pub name: String,
54 pub health: Health,
56 pub reported: String,
59}
60
61pub(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
74pub 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
88pub 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 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
131fn 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
156pub 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
199pub 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 #[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 #[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 #[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}