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 =
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 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
128fn 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
153pub 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
196pub 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 #[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 #[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 #[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}