Skip to main content

kevy_cli/
shadow.rs

1//! `shadow` — run the old query and the new one side by side and say
2//! where they disagree.
3//!
4//! Lesson 4 of the migration playbook, which is the one that decides
5//! whether anyone dares cut over: *serve reads from the old path while
6//! computing the new answer beside it, and compare the **order** too,
7//! not just the membership.* Score drift produces identical sets in
8//! different orders, and a paginated UI turns that into user-visible
9//! churn.
10//!
11//! It also carries lesson 2 without being asked to. A writer nobody
12//! remembered to update shows up here as rows the new path is missing —
13//! which is the same signal `TABLE.VERIFY` reports after the fact, seen
14//! before the cutover instead of after.
15
16use std::io;
17
18use std::process::ExitCode;
19
20use kevy_resp_client::{Reply, RespClient};
21
22/// One side's reading of a reply: the row keys in order, each with the
23/// sort value it was ordered by (empty when the shape does not carry
24/// one).
25type Rows = Vec<(Vec<u8>, Vec<u8>)>;
26
27/// How to read a reply into rows. Guessing is not free here — reading
28/// `ZRANGE … WITHSCORES` as a plain list silently treats every score as
29/// a row key and reports a divergence on every sample — so the two
30/// ambiguous shapes are told apart by the caller, not by a heuristic.
31#[derive(Clone, Copy, PartialEq)]
32pub enum Shape {
33    /// `[cursor, [key, sortval, key, sortval, …]]` — kevy's paged
34    /// index reply. Detected, not declared: a two-element array whose
35    /// second element is an array cannot be anything else here.
36    Paged,
37    /// `[a, b, c, …]` — every element is a row key.
38    Flat,
39    /// `[member, score, member, score, …]` — `WITHSCORES` and friends.
40    Pairs,
41}
42
43/// Read a reply into ordered rows under `shape`. `Paged` is recognised
44/// from the reply itself, so passing `Flat` for a kevy index reply
45/// still does the right thing rather than reporting nonsense.
46pub 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
72/// What one comparison found.
73pub struct Divergence {
74    /// Position of the first place the two orders differ.
75    pub at: usize,
76    /// The old side's row and the value it was ordered by.
77    pub old: Option<(Vec<u8>, Vec<u8>)>,
78    /// The new side's, at the same position.
79    pub new: Option<(Vec<u8>, Vec<u8>)>,
80}
81
82/// Rows the new side lacks, rows it invents, and the first ordering
83/// difference. Membership and order are reported separately because
84/// they fail for different reasons: a missing row is a writer nobody
85/// updated, a reordering is score drift.
86pub struct Compared {
87    /// Rows the old path returns and the new one does not.
88    pub missing: Vec<Vec<u8>>,
89    /// Rows the new path returns and the old one does not.
90    pub extra: Vec<Vec<u8>>,
91    /// The first position where the two orders differ, if any.
92    pub first: Option<Divergence>,
93}
94
95/// Compare two readings: what is missing, what is extra, and where the
96/// orders first part company.
97pub 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
120/// Outcome of a shadow run — the paste-able conclusion.
121pub struct ShadowReport {
122    /// How many times both sides were asked.
123    pub samples: u64,
124    /// How many of those disagreed in membership or order.
125    pub diverged: u64,
126    /// The first sample that disagreed, and how.
127    pub first: Option<(u64, Compared)>,
128}
129
130/// Run both commands `samples` times and compare each pair.
131///
132/// Both sides are issued on the same connection, back to back, so the
133/// window between them is as small as this can make it. A row written
134/// between the two reads shows up as a divergence, which is why a
135/// single disagreement is a lead rather than a verdict — the report
136/// carries the count so a rate can be read off it.
137pub 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
163/// Print the report the way lesson 4 asks for: the first divergence
164/// with **both** sort keys, because that one line names the drifting
165/// writer.
166pub 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
212/// `shadow [-h host] [-p port] --old "<cmd>" --new "<cmd>"
213/// [--old-pairs] [--new-flat] [--samples n]`
214///
215/// Both sides are whole commands, quoted, because the old path is
216/// whatever the application already runs — a ZRANGE, an LRANGE, a
217/// SMEMBERS — and the new one is an IDX.QUERY. Nothing here knows
218/// which; it compares the two orders of row keys they produce.
219/// Everything `shadow` takes from the command line.
220struct 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    // A kevy paged reply is recognised from its shape. The ambiguity
232    // that needs declaring is member/score pairs versus a plain list,
233    // and only on the old side in practice.
234    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
280/// `shadow [-h host] [-p port] --old "<cmd>" --new "<cmd>"
281/// [--old-pairs] [--new-flat] [--samples n]`
282///
283/// Both sides are whole commands, quoted, because the old path is
284/// whatever the application already runs — a ZRANGE, an LRANGE, a
285/// SMEMBERS — and the new one is an `IDX.QUERY`. Nothing here knows
286/// which; it compares the two orders of row keys they produce.
287///
288/// Exits non-zero on any divergence, so a cutover script can gate on
289/// it without parsing the text.
290pub 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            // A divergence is a finding, not a crash: exit non-zero so a
313            // cutover script can gate on it without parsing the text.
314            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    /// kevy's paged reply is recognised from its shape, so a caller who
332    /// never thought about shapes still gets rows rather than nonsense.
333    #[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); // deliberately the "wrong" shape
340        assert_eq!(rows.len(), 2);
341        assert_eq!(rows[0], (b"u:1".to_vec(), b"10".to_vec()));
342    }
343
344    /// The ambiguity that cannot be detected: member/score pairs look
345    /// exactly like a plain list. Reading WITHSCORES as flat would make
346    /// every score a row key and report a divergence on every sample.
347    #[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    /// Lesson 2's consequence: a row the old path has and the new one
355    /// does not is a writer nobody updated.
356    #[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    /// Lesson 4's whole point: identical membership, different order.
366    /// Set comparison alone calls this a match.
367    #[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        // Both sort keys travel with it — that pair is what names the
376        // drifting writer.
377        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}