zenkey_fleet/judge/
cutover.rs1use 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
30pub 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
43pub 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 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 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 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 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
127pub 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 #[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 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}