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>> = items
65 .iter()
66 .filter_map(|r| if let Reply::Bulk(b) = r { Some(b) } else { None })
67 .collect();
68 bulks
69 .chunks(2)
70 .map(|c| (c[0].clone(), c.get(1).map(|v| (*v).clone()).unwrap_or_default()))
71 .collect()
72}
73
74pub struct Divergence {
76 pub at: usize,
78 pub old: Option<(Vec<u8>, Vec<u8>)>,
80 pub new: Option<(Vec<u8>, Vec<u8>)>,
82}
83
84pub struct Compared {
89 pub missing: Vec<Vec<u8>>,
91 pub extra: Vec<Vec<u8>>,
93 pub first: Option<Divergence>,
95}
96
97pub fn compare(old: &Rows, new: &Rows) -> Compared {
100 let old_set: std::collections::HashSet<&[u8]> = old.iter().map(|(k, _)| k.as_slice()).collect();
101 let new_set: std::collections::HashSet<&[u8]> = new.iter().map(|(k, _)| k.as_slice()).collect();
102 let missing =
103 old.iter().filter(|(k, _)| !new_set.contains(k.as_slice())).map(|(k, _)| k.clone()).collect();
104 let extra =
105 new.iter().filter(|(k, _)| !old_set.contains(k.as_slice())).map(|(k, _)| k.clone()).collect();
106 let mut first = None;
107 for i in 0..old.len().max(new.len()) {
108 if old.get(i).map(|(k, _)| k) != new.get(i).map(|(k, _)| k) {
109 first = Some(Divergence { at: i, old: old.get(i).cloned(), new: new.get(i).cloned() });
110 break;
111 }
112 }
113 Compared { missing, extra, first }
114}
115
116pub struct ShadowReport {
118 pub samples: u64,
120 pub diverged: u64,
122 pub first: Option<(u64, Compared)>,
124}
125
126pub fn run(
134 client: &mut RespClient,
135 old_cmd: &[Vec<u8>],
136 new_cmd: &[Vec<u8>],
137 old_shape: Shape,
138 new_shape: Shape,
139 samples: u64,
140) -> io::Result<ShadowReport> {
141 let mut report = ShadowReport { samples: 0, diverged: 0, first: None };
142 for i in 0..samples {
143 let old_ref: Vec<&[u8]> = old_cmd.iter().map(|a| a.as_slice()).collect();
144 let new_ref: Vec<&[u8]> = new_cmd.iter().map(|a| a.as_slice()).collect();
145 let old = rows_of(&client.request_borrowed(&old_ref)?, old_shape);
146 let new = rows_of(&client.request_borrowed(&new_ref)?, new_shape);
147 report.samples += 1;
148 let c = compare(&old, &new);
149 if !c.missing.is_empty() || !c.extra.is_empty() || c.first.is_some() {
150 report.diverged += 1;
151 if report.first.is_none() {
152 report.first = Some((i, c));
153 }
154 }
155 }
156 Ok(report)
157}
158
159pub fn print_report(r: &ShadowReport) {
163 let show = |b: &[u8]| String::from_utf8_lossy(b).into_owned();
164 match &r.first {
165 None => println!(
166 "shadow: {} samples, 0 divergences — the new path answers what the old one does",
167 r.samples
168 ),
169 Some((n, c)) => {
170 println!(
171 "shadow: {} samples, {} diverged (first at sample {})",
172 r.samples, r.diverged, n
173 );
174 if !c.missing.is_empty() {
175 println!(
176 " MISSING from the new path ({}): {}",
177 c.missing.len(),
178 c.missing.iter().take(5).map(|k| show(k)).collect::<Vec<_>>().join(", ")
179 );
180 println!(" a row the old path has and the new one does not is usually a writer");
181 println!(" that was never updated — the same class TABLE.VERIFY's `missing` finds");
182 }
183 if !c.extra.is_empty() {
184 println!(
185 " EXTRA in the new path ({}): {}",
186 c.extra.len(),
187 c.extra.iter().take(5).map(|k| show(k)).collect::<Vec<_>>().join(", ")
188 );
189 }
190 if let Some(d) = &c.first {
191 let side = |x: &Option<(Vec<u8>, Vec<u8>)>| match x {
192 Some((k, v)) if v.is_empty() => show(k),
193 Some((k, v)) => format!("{} (sort {})", show(k), show(v)),
194 None => "<past the end>".to_string(),
195 };
196 println!(" ORDER differs at position {}:", d.at);
197 println!(" old: {}", side(&d.old));
198 println!(" new: {}", side(&d.new));
199 println!(" identical sets in different orders is score drift, and a paged UI");
200 println!(" shows it to users as churn — compare the two sort values above");
201 }
202 }
203 }
204}
205
206struct ShadowArgs {
215 host: String,
216 port: u16,
217 old: Option<String>,
218 new: Option<String>,
219 old_shape: Shape,
220 new_shape: Shape,
221 samples: u64,
222}
223
224fn parse_shadow_flags(args: &[String]) -> ShadowArgs {
225 let mut a = ShadowArgs {
229 host: crate::DEFAULT_HOST.to_string(),
230 port: crate::DEFAULT_PORT,
231 old: None,
232 new: None,
233 old_shape: Shape::Flat,
234 new_shape: Shape::Paged,
235 samples: 1,
236 };
237 let mut i = 0;
238 while i < args.len() {
239 match args[i].as_str() {
240 "-h" if i + 1 < args.len() => {
241 a.host = args[i + 1].clone();
242 i += 2;
243 }
244 "-p" if i + 1 < args.len() => {
245 a.port = args[i + 1].parse().unwrap_or(crate::DEFAULT_PORT);
246 i += 2;
247 }
248 "--old" if i + 1 < args.len() => {
249 a.old = Some(args[i + 1].clone());
250 i += 2;
251 }
252 "--new" if i + 1 < args.len() => {
253 a.new = Some(args[i + 1].clone());
254 i += 2;
255 }
256 "--old-pairs" => {
257 a.old_shape = Shape::Pairs;
258 i += 1;
259 }
260 "--new-flat" => {
261 a.new_shape = Shape::Flat;
262 i += 1;
263 }
264 "--samples" if i + 1 < args.len() => {
265 a.samples = args[i + 1].parse().unwrap_or(1);
266 i += 2;
267 }
268 _ => i += 1,
269 }
270 }
271 a
272}
273
274pub fn run_shadow_cli(args: &[String]) -> ExitCode {
285 let ShadowArgs { host, port, old, new, old_shape, new_shape, samples } =
286 parse_shadow_flags(args);
287 let (Some(old), Some(new)) = (old, new) else {
288 eprintln!(
289 "usage: kevy-cli shadow [-h host] [-p port] --old \"<command>\" \
290 --new \"<command>\" [--old-pairs] [--new-flat] [--samples n]"
291 );
292 return ExitCode::FAILURE;
293 };
294 let split = |s: &str| -> Vec<Vec<u8>> {
295 s.split_whitespace().map(|t| t.as_bytes().to_vec()).collect()
296 };
297 let mut client = match RespClient::connect(&host, port) {
298 Ok(c) => c,
299 Err(e) => {
300 eprintln!("kevy-cli: could not connect to {host}:{port}: {e}");
301 return ExitCode::FAILURE;
302 }
303 };
304 match run(&mut client, &split(&old), &split(&new), old_shape, new_shape, samples) {
305 Ok(report) => {
306 print_report(&report);
307 if report.diverged > 0 { ExitCode::FAILURE } else { ExitCode::SUCCESS }
310 }
311 Err(e) => {
312 eprintln!("kevy-cli shadow: {e}");
313 ExitCode::FAILURE
314 }
315 }
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 fn bulk(s: &str) -> Reply {
323 Reply::Bulk(s.as_bytes().to_vec())
324 }
325
326 #[test]
329 fn a_paged_reply_is_read_without_being_declared() {
330 let reply = Reply::Array(vec![
331 bulk("0"),
332 Reply::Array(vec![bulk("u:1"), bulk("10"), bulk("u:2"), bulk("20")]),
333 ]);
334 let rows = rows_of(&reply, Shape::Flat); assert_eq!(rows.len(), 2);
336 assert_eq!(rows[0], (b"u:1".to_vec(), b"10".to_vec()));
337 }
338
339 #[test]
343 fn pairs_and_flat_are_told_apart_by_the_caller() {
344 let reply = Reply::Array(vec![bulk("u:1"), bulk("10"), bulk("u:2"), bulk("20")]);
345 assert_eq!(rows_of(&reply, Shape::Flat).len(), 4, "flat: four rows");
346 assert_eq!(rows_of(&reply, Shape::Pairs).len(), 2, "pairs: two rows with scores");
347 }
348
349 #[test]
352 fn a_row_only_the_old_path_has_is_reported_missing() {
353 let old = vec![(b"u:1".to_vec(), vec![]), (b"u:2".to_vec(), vec![])];
354 let new = vec![(b"u:1".to_vec(), vec![])];
355 let c = compare(&old, &new);
356 assert_eq!(c.missing, vec![b"u:2".to_vec()]);
357 assert!(c.extra.is_empty());
358 }
359
360 #[test]
363 fn identical_sets_in_different_orders_still_diverge() {
364 let old = vec![(b"u:2".to_vec(), b"5".to_vec()), (b"u:1".to_vec(), b"10".to_vec())];
365 let new = vec![(b"u:1".to_vec(), b"10".to_vec()), (b"u:2".to_vec(), b"20".to_vec())];
366 let c = compare(&old, &new);
367 assert!(c.missing.is_empty() && c.extra.is_empty(), "same membership");
368 let d = c.first.expect("order must still diverge");
369 assert_eq!(d.at, 0);
370 assert_eq!(d.old.unwrap().1, b"5".to_vec());
373 assert_eq!(d.new.unwrap().1, b"10".to_vec());
374 }
375
376 #[test]
377 fn agreement_reports_nothing() {
378 let rows = vec![(b"u:1".to_vec(), b"10".to_vec())];
379 let c = compare(&rows, &rows);
380 assert!(c.missing.is_empty() && c.extra.is_empty() && c.first.is_none());
381 }
382}