Skip to main content

zenkey_fleet/judge/
cutover.rs

1//! RFC 09 §6 cutover acceptance, half one (issue #59): prove the retired key
2//! family **silent** while the new planes carry traffic.
3//!
4//! One without the other is not evidence — a quiet old root on a dead fleet
5//! proves only that the fleet is dead, which is why the verdict has three
6//! states and not two.
7//!
8//! The leak check states its meaning explicitly — *anything outside
9//! `<base>/v1/`* — rather than riding on key algebra, because the version
10//! chunk is plain (`v1`, not `@v1`) and `<base>/**` reaches the new keys too
11//! (RFC 09 §6's own note). And the scope is honest per RFC 09 §5.1 O5: a `**`
12//! subscription cannot cross `@`-chunks, so the verbatim planes and the admin
13//! space are outside this check by construction.
14//!
15//! Lives here rather than in a frontend (issue #206) because every line of it
16//! is judgement over bus traffic: the sample-bucketing rule, the tie-break and
17//! the verdict ladder are the check, and a second explorer must not have to
18//! re-derive them. The frontend keeps the session, the rendering and the exit
19//! code.
20
21use std::collections::BTreeMap;
22use std::time::Duration;
23
24use crate::{Error, Result};
25
26use crate::judge::common::{FINDING_CAP, new_prefix};
27use crate::model::examples::Examples;
28use crate::report::{CutoverReport, CutoverVerdict};
29
30/// The scope sentence this check operates under — rendered by the caller
31/// before the window opens, because a user watching a 30-second silence
32/// deserves to know what was and was not being watched (O5).
33pub fn scope_note(old_root: &str, new_prefix: &str, window: Duration) -> String {
34    let window = window.as_secs_f64();
35    format!(
36        "cutover check: {window}s window — asserting {old_root} silent while \
37         {new_prefix}** carries traffic (RFC 09 §6). `**` cannot cross \
38         `@`-chunks: verbatim planes and the admin space are outside this \
39         check by construction (O5)."
40    )
41}
42
43/// Watch the whole bus for `window` seconds and judge the cutover.
44pub async fn run_cutover(
45    fleet: &crate::Fleet<'_>,
46    old_root: &str,
47    window: Duration,
48) -> Result<CutoverReport> {
49    let old_expr = zenoh::key_expr::KeyExpr::try_from(old_root.to_string())
50        .map_err(|e| Error::unaskable_from(format!("--old-root {old_root:?}"), e))?;
51    let new_prefix = new_prefix(fleet.base());
52
53    let monitor = crate::Monitor::start(fleet.session(), crate::MonitorSpec::default()).await?;
54    let mut events = monitor.events();
55    // `**` is the whole bus: leaving this one to `Drop` on an error path is
56    // the loudest version of the leak (#336).
57    let monitor = monitor.watching(["**"]).await?;
58
59    let mut old_keys: BTreeMap<String, u64> = BTreeMap::new();
60    let mut leaked: BTreeMap<String, u64> = BTreeMap::new();
61    let (mut old_samples, mut new_samples, mut leak_samples, mut dropped) =
62        (0u64, 0u64, 0u64, 0u64);
63    let deadline = tokio::time::Instant::now() + window;
64    // One timer for the whole window, not one per iteration (#346).
65    // `sleep_until` builds a future and registers a timer each time it
66    // is evaluated, and a `select!` in a loop evaluates it on every
67    // pass — at 100k samples/s that is 100k registrations a second for
68    // a deadline that never moves.
69    let window_over = tokio::time::sleep_until(deadline);
70    tokio::pin!(window_over);
71    loop {
72        let item = tokio::select! {
73            item = events.recv() => item,
74            () = &mut window_over => break,
75        };
76        match item {
77            Some(crate::StreamItem::Event(crate::FleetEvent::Sample(s))) => {
78                // Old-root membership is key-expression inclusion (the root
79                // may be a wildcard family); the new plane is a stated
80                // prefix. Old wins ties: a root inside <base>/v1/ is being
81                // *retired*, and its traffic is the failure.
82                if zenoh::key_expr::KeyExpr::try_from(s.key.as_str())
83                    .map(|k| old_expr.includes(&k))
84                    .unwrap_or(false)
85                {
86                    old_samples += 1;
87                    *old_keys.entry(s.key.clone()).or_default() += 1;
88                } else if s.key.starts_with(&new_prefix) {
89                    new_samples += 1;
90                } else {
91                    leak_samples += 1;
92                    *leaked.entry(s.key.clone()).or_default() += 1;
93                }
94            }
95            Some(crate::StreamItem::Dropped(n)) => dropped += n,
96            Some(_) => continue,
97            None => break,
98        }
99    }
100    monitor.shutdown().await?;
101
102    // The keys-seen counts beside these are the exact totals, so the buckets
103    // name examples and leave the arithmetic to the counter.
104    let cap = |m: &BTreeMap<String, u64>| {
105        let mut ex = Examples::new(FINDING_CAP);
106        for (k, n) in m {
107            ex.push_with(|| format!("{k} ({n})"));
108        }
109        ex.into_vec()
110    };
111    Ok(CutoverReport {
112        old_root: old_root.to_string(),
113        new_prefix,
114        window_s: window.as_secs_f64(),
115        old_samples,
116        old_keys_seen: old_keys.len(),
117        old_examples: cap(&old_keys),
118        new_samples,
119        leak_samples,
120        leaked_keys_seen: leaked.len(),
121        leak_examples: cap(&leaked),
122        dropped,
123        verdict: verdict(old_samples, new_samples),
124    })
125}
126
127/// The three-state ladder, pure so it can be exercised without a bus.
128///
129/// Order matters: the old root speaking is a failure whatever else is true,
130/// and a silent old root on a silent bus is *unproven* rather than a pass —
131/// a dead fleet passes the silence half for free (RFC 05 §3.1).
132pub fn verdict(old_samples: u64, new_samples: u64) -> CutoverVerdict {
133    if old_samples > 0 {
134        CutoverVerdict::OldStillSpeaks
135    } else if new_samples == 0 {
136        CutoverVerdict::Unproven
137    } else {
138        CutoverVerdict::Pass
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    /// The ladder's three states, including the one nothing exercised: a
147    /// quiet old root on a quiet bus is not evidence of a finished migration.
148    #[test]
149    fn silence_on_both_planes_is_unproven_not_a_pass() {
150        assert_eq!(verdict(0, 12), CutoverVerdict::Pass);
151        assert_eq!(verdict(3, 12), CutoverVerdict::OldStillSpeaks);
152        assert_eq!(verdict(0, 0), CutoverVerdict::Unproven);
153        // Old wins ties: traffic on the retired family is the failure even
154        // when the new plane is busy.
155        assert_eq!(verdict(1, 10_000), CutoverVerdict::OldStillSpeaks);
156    }
157    #[test]
158    fn the_scope_note_states_what_it_cannot_see() {
159        let note = scope_note("old/**", "acme/v1/", Duration::from_secs(30));
160        assert!(note.contains("30s window"));
161        assert!(
162            note.contains("cannot cross"),
163            "a wildcard scope must not be presented as total coverage (O5): {note}"
164        );
165    }
166}