1use std::io;
17
18use std::process::ExitCode;
19
20use kevy_resp_client::{Reply, RespClient};
21
22type Rows = Vec<(Vec<u8>, Vec<u8>)>;
26
27#[derive(Clone, Copy, PartialEq)]
32pub enum Shape {
33 Paged,
37 Flat,
39 Pairs,
41}
42
43pub fn rows_of(reply: &Reply, shape: Shape) -> Rows {
47 let Reply::Array(items) = reply else { return Vec::new() };
48 if let [Reply::Bulk(_), Reply::Array(inner)] = items.as_slice() {
49 return pairs(inner);
50 }
51 match shape {
52 Shape::Paged | Shape::Pairs => pairs(items),
53 Shape::Flat => items
54 .iter()
55 .filter_map(|r| match r {
56 Reply::Bulk(b) => Some((b.clone(), Vec::new())),
57 _ => None,
58 })
59 .collect(),
60 }
61}
62
63fn pairs(items: &[Reply]) -> Rows {
64 let bulks: Vec<&Vec<u8>> =
65 items.iter().filter_map(|r| if let Reply::Bulk(b) = r { Some(b) } else { None }).collect();
66 bulks
67 .chunks(2)
68 .map(|c| (c[0].clone(), c.get(1).map(|v| (*v).clone()).unwrap_or_default()))
69 .collect()
70}
71
72pub struct Divergence {
74 pub at: usize,
76 pub old: Option<(Vec<u8>, Vec<u8>)>,
78 pub new: Option<(Vec<u8>, Vec<u8>)>,
80}
81
82pub struct Compared {
87 pub missing: Vec<Vec<u8>>,
89 pub extra: Vec<Vec<u8>>,
91 pub first: Option<Divergence>,
93}
94
95pub fn compare(old: &Rows, new: &Rows) -> Compared {
98 let old_set: std::collections::HashSet<&[u8]> = old.iter().map(|(k, _)| k.as_slice()).collect();
99 let new_set: std::collections::HashSet<&[u8]> = new.iter().map(|(k, _)| k.as_slice()).collect();
100 let missing = old
101 .iter()
102 .filter(|(k, _)| !new_set.contains(k.as_slice()))
103 .map(|(k, _)| k.clone())
104 .collect();
105 let extra = new
106 .iter()
107 .filter(|(k, _)| !old_set.contains(k.as_slice()))
108 .map(|(k, _)| k.clone())
109 .collect();
110 let mut first = None;
111 for i in 0..old.len().max(new.len()) {
112 if old.get(i).map(|(k, _)| k) != new.get(i).map(|(k, _)| k) {
113 first = Some(Divergence { at: i, old: old.get(i).cloned(), new: new.get(i).cloned() });
114 break;
115 }
116 }
117 Compared { missing, extra, first }
118}
119
120pub struct ShadowReport {
122 pub samples: u64,
124 pub diverged: u64,
126 pub first: Option<(u64, Compared)>,
128}
129
130pub fn run(
138 client: &mut RespClient,
139 old_cmd: &[Vec<u8>],
140 new_cmd: &[Vec<u8>],
141 old_shape: Shape,
142 new_shape: Shape,
143 samples: u64,
144) -> io::Result<ShadowReport> {
145 let mut report = ShadowReport { samples: 0, diverged: 0, first: None };
146 for i in 0..samples {
147 let old_ref: Vec<&[u8]> = old_cmd.iter().map(|a| a.as_slice()).collect();
148 let new_ref: Vec<&[u8]> = new_cmd.iter().map(|a| a.as_slice()).collect();
149 let old = rows_of(&client.request_borrowed(&old_ref)?, old_shape);
150 let new = rows_of(&client.request_borrowed(&new_ref)?, new_shape);
151 report.samples += 1;
152 let c = compare(&old, &new);
153 if !c.missing.is_empty() || !c.extra.is_empty() || c.first.is_some() {
154 report.diverged += 1;
155 if report.first.is_none() {
156 report.first = Some((i, c));
157 }
158 }
159 }
160 Ok(report)
161}
162
163pub fn print_report(r: &ShadowReport) {
167 let show = |b: &[u8]| String::from_utf8_lossy(b).into_owned();
168 match &r.first {
169 None => println!(
170 "shadow: {} samples, 0 divergences — the new path answers what the old one does",
171 r.samples
172 ),
173 Some((n, c)) => {
174 println!(
175 "shadow: {} samples, {} diverged (first at sample {})",
176 r.samples, r.diverged, n
177 );
178 if !c.missing.is_empty() {
179 println!(
180 " MISSING from the new path ({}): {}",
181 c.missing.len(),
182 c.missing.iter().take(5).map(|k| show(k)).collect::<Vec<_>>().join(", ")
183 );
184 println!(" a row the old path has and the new one does not is usually a writer");
185 println!(
186 " that was never updated — the same class TABLE.VERIFY's `missing` finds"
187 );
188 }
189 if !c.extra.is_empty() {
190 println!(
191 " EXTRA in the new path ({}): {}",
192 c.extra.len(),
193 c.extra.iter().take(5).map(|k| show(k)).collect::<Vec<_>>().join(", ")
194 );
195 }
196 if let Some(d) = &c.first {
197 let side = |x: &Option<(Vec<u8>, Vec<u8>)>| match x {
198 Some((k, v)) if v.is_empty() => show(k),
199 Some((k, v)) => format!("{} (sort {})", show(k), show(v)),
200 None => "<past the end>".to_string(),
201 };
202 println!(" ORDER differs at position {}:", d.at);
203 println!(" old: {}", side(&d.old));
204 println!(" new: {}", side(&d.new));
205 println!(" identical sets in different orders is score drift, and a paged UI");
206 println!(" shows it to users as churn — compare the two sort values above");
207 }
208 }
209 }
210}
211
212struct ShadowArgs {
221 host: String,
222 port: u16,
223 old: Option<String>,
224 new: Option<String>,
225 old_shape: Shape,
226 new_shape: Shape,
227 samples: u64,
228}
229
230fn parse_shadow_flags(args: &[String]) -> ShadowArgs {
231 let mut a = ShadowArgs {
235 host: crate::DEFAULT_HOST.to_string(),
236 port: crate::DEFAULT_PORT,
237 old: None,
238 new: None,
239 old_shape: Shape::Flat,
240 new_shape: Shape::Paged,
241 samples: 1,
242 };
243 let mut i = 0;
244 while i < args.len() {
245 match args[i].as_str() {
246 "-h" if i + 1 < args.len() => {
247 a.host = args[i + 1].clone();
248 i += 2;
249 }
250 "-p" if i + 1 < args.len() => {
251 a.port = args[i + 1].parse().unwrap_or(crate::DEFAULT_PORT);
252 i += 2;
253 }
254 "--old" if i + 1 < args.len() => {
255 a.old = Some(args[i + 1].clone());
256 i += 2;
257 }
258 "--new" if i + 1 < args.len() => {
259 a.new = Some(args[i + 1].clone());
260 i += 2;
261 }
262 "--old-pairs" => {
263 a.old_shape = Shape::Pairs;
264 i += 1;
265 }
266 "--new-flat" => {
267 a.new_shape = Shape::Flat;
268 i += 1;
269 }
270 "--samples" if i + 1 < args.len() => {
271 a.samples = args[i + 1].parse().unwrap_or(1);
272 i += 2;
273 }
274 _ => i += 1,
275 }
276 }
277 a
278}
279
280pub fn run_shadow_cli(args: &[String]) -> ExitCode {
291 let ShadowArgs { host, port, old, new, old_shape, new_shape, samples } =
292 parse_shadow_flags(args);
293 let (Some(old), Some(new)) = (old, new) else {
294 eprintln!(
295 "usage: kevy-cli shadow [-h host] [-p port] --old \"<command>\" \
296 --new \"<command>\" [--old-pairs] [--new-flat] [--samples n]"
297 );
298 return ExitCode::FAILURE;
299 };
300 let split =
301 |s: &str| -> Vec<Vec<u8>> { s.split_whitespace().map(|t| t.as_bytes().to_vec()).collect() };
302 let mut client = match RespClient::connect(&host, port) {
303 Ok(c) => c,
304 Err(e) => {
305 eprintln!("kevy-cli: could not connect to {host}:{port}: {e}");
306 return ExitCode::FAILURE;
307 }
308 };
309 match run(&mut client, &split(&old), &split(&new), old_shape, new_shape, samples) {
310 Ok(report) => {
311 print_report(&report);
312 if report.diverged > 0 { ExitCode::FAILURE } else { ExitCode::SUCCESS }
315 }
316 Err(e) => {
317 eprintln!("kevy-cli shadow: {e}");
318 ExitCode::FAILURE
319 }
320 }
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
327 fn bulk(s: &str) -> Reply {
328 Reply::Bulk(s.as_bytes().to_vec())
329 }
330
331 #[test]
334 fn a_paged_reply_is_read_without_being_declared() {
335 let reply = Reply::Array(vec![
336 bulk("0"),
337 Reply::Array(vec![bulk("u:1"), bulk("10"), bulk("u:2"), bulk("20")]),
338 ]);
339 let rows = rows_of(&reply, Shape::Flat); assert_eq!(rows.len(), 2);
341 assert_eq!(rows[0], (b"u:1".to_vec(), b"10".to_vec()));
342 }
343
344 #[test]
348 fn pairs_and_flat_are_told_apart_by_the_caller() {
349 let reply = Reply::Array(vec![bulk("u:1"), bulk("10"), bulk("u:2"), bulk("20")]);
350 assert_eq!(rows_of(&reply, Shape::Flat).len(), 4, "flat: four rows");
351 assert_eq!(rows_of(&reply, Shape::Pairs).len(), 2, "pairs: two rows with scores");
352 }
353
354 #[test]
357 fn a_row_only_the_old_path_has_is_reported_missing() {
358 let old = vec![(b"u:1".to_vec(), vec![]), (b"u:2".to_vec(), vec![])];
359 let new = vec![(b"u:1".to_vec(), vec![])];
360 let c = compare(&old, &new);
361 assert_eq!(c.missing, vec![b"u:2".to_vec()]);
362 assert!(c.extra.is_empty());
363 }
364
365 #[test]
368 fn identical_sets_in_different_orders_still_diverge() {
369 let old = vec![(b"u:2".to_vec(), b"5".to_vec()), (b"u:1".to_vec(), b"10".to_vec())];
370 let new = vec![(b"u:1".to_vec(), b"10".to_vec()), (b"u:2".to_vec(), b"20".to_vec())];
371 let c = compare(&old, &new);
372 assert!(c.missing.is_empty() && c.extra.is_empty(), "same membership");
373 let d = c.first.expect("order must still diverge");
374 assert_eq!(d.at, 0);
375 assert_eq!(d.old.unwrap().1, b"5".to_vec());
378 assert_eq!(d.new.unwrap().1, b"10".to_vec());
379 }
380
381 #[test]
382 fn agreement_reports_nothing() {
383 let rows = vec![(b"u:1".to_vec(), b"10".to_vec())];
384 let c = compare(&rows, &rows);
385 assert!(c.missing.is_empty() && c.extra.is_empty() && c.first.is_none());
386 }
387}