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>> = 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
74/// What one comparison found.
75pub struct Divergence {
76    /// Position of the first place the two orders differ.
77    pub at: usize,
78    /// The old side's row and the value it was ordered by.
79    pub old: Option<(Vec<u8>, Vec<u8>)>,
80    /// The new side's, at the same position.
81    pub new: Option<(Vec<u8>, Vec<u8>)>,
82}
83
84/// Rows the new side lacks, rows it invents, and the first ordering
85/// difference. Membership and order are reported separately because
86/// they fail for different reasons: a missing row is a writer nobody
87/// updated, a reordering is score drift.
88pub struct Compared {
89    /// Rows the old path returns and the new one does not.
90    pub missing: Vec<Vec<u8>>,
91    /// Rows the new path returns and the old one does not.
92    pub extra: Vec<Vec<u8>>,
93    /// The first position where the two orders differ, if any.
94    pub first: Option<Divergence>,
95}
96
97/// Compare two readings: what is missing, what is extra, and where the
98/// orders first part company.
99pub 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
116/// Outcome of a shadow run — the paste-able conclusion.
117pub struct ShadowReport {
118    /// How many times both sides were asked.
119    pub samples: u64,
120    /// How many of those disagreed in membership or order.
121    pub diverged: u64,
122    /// The first sample that disagreed, and how.
123    pub first: Option<(u64, Compared)>,
124}
125
126/// Run both commands `samples` times and compare each pair.
127///
128/// Both sides are issued on the same connection, back to back, so the
129/// window between them is as small as this can make it. A row written
130/// between the two reads shows up as a divergence, which is why a
131/// single disagreement is a lead rather than a verdict — the report
132/// carries the count so a rate can be read off it.
133pub 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
159/// Print the report the way lesson 4 asks for: the first divergence
160/// with **both** sort keys, because that one line names the drifting
161/// writer.
162pub 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
206/// `shadow [-h host] [-p port] --old "<cmd>" --new "<cmd>"
207/// [--old-pairs] [--new-flat] [--samples n]`
208///
209/// Both sides are whole commands, quoted, because the old path is
210/// whatever the application already runs — a ZRANGE, an LRANGE, a
211/// SMEMBERS — and the new one is an IDX.QUERY. Nothing here knows
212/// which; it compares the two orders of row keys they produce.
213/// Everything `shadow` takes from the command line.
214struct 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    // A kevy paged reply is recognised from its shape. The ambiguity
226    // that needs declaring is member/score pairs versus a plain list,
227    // and only on the old side in practice.
228    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
274/// `shadow [-h host] [-p port] --old "<cmd>" --new "<cmd>"
275/// [--old-pairs] [--new-flat] [--samples n]`
276///
277/// Both sides are whole commands, quoted, because the old path is
278/// whatever the application already runs — a ZRANGE, an LRANGE, a
279/// SMEMBERS — and the new one is an `IDX.QUERY`. Nothing here knows
280/// which; it compares the two orders of row keys they produce.
281///
282/// Exits non-zero on any divergence, so a cutover script can gate on
283/// it without parsing the text.
284pub 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            // A divergence is a finding, not a crash: exit non-zero so a
308            // cutover script can gate on it without parsing the text.
309            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    /// kevy's paged reply is recognised from its shape, so a caller who
327    /// never thought about shapes still gets rows rather than nonsense.
328    #[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); // deliberately the "wrong" shape
335        assert_eq!(rows.len(), 2);
336        assert_eq!(rows[0], (b"u:1".to_vec(), b"10".to_vec()));
337    }
338
339    /// The ambiguity that cannot be detected: member/score pairs look
340    /// exactly like a plain list. Reading WITHSCORES as flat would make
341    /// every score a row key and report a divergence on every sample.
342    #[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    /// Lesson 2's consequence: a row the old path has and the new one
350    /// does not is a writer nobody updated.
351    #[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    /// Lesson 4's whole point: identical membership, different order.
361    /// Set comparison alone calls this a match.
362    #[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        // Both sort keys travel with it — that pair is what names the
371        // drifting writer.
372        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}