use std::collections::BTreeMap;
use std::time::Duration;
use crate::{Error, Result};
use crate::judge::common::{FINDING_CAP, new_prefix};
use crate::model::examples::Examples;
use crate::report::{CutoverReport, CutoverVerdict};
pub fn scope_note(old_root: &str, new_prefix: &str, window: Duration) -> String {
let window = window.as_secs_f64();
format!(
"cutover check: {window}s window — asserting {old_root} silent while \
{new_prefix}** carries traffic (RFC 09 §6). `**` cannot cross \
`@`-chunks: verbatim planes and the admin space are outside this \
check by construction (O5)."
)
}
pub async fn run_cutover(
fleet: &crate::Fleet<'_>,
old_root: &str,
window: Duration,
) -> Result<CutoverReport> {
let old_expr = zenoh::key_expr::KeyExpr::try_from(old_root.to_string())
.map_err(|e| Error::unaskable_from(format!("--old-root {old_root:?}"), e))?;
let new_prefix = new_prefix(fleet.base());
let monitor = crate::Monitor::start(fleet.session(), crate::MonitorSpec::default()).await?;
let mut events = monitor.events();
let monitor = monitor.watching(["**"]).await?;
let mut old_keys: BTreeMap<String, u64> = BTreeMap::new();
let mut leaked: BTreeMap<String, u64> = BTreeMap::new();
let (mut old_samples, mut new_samples, mut leak_samples, mut dropped) =
(0u64, 0u64, 0u64, 0u64);
let deadline = tokio::time::Instant::now() + window;
let window_over = tokio::time::sleep_until(deadline);
tokio::pin!(window_over);
loop {
let item = tokio::select! {
item = events.recv() => item,
() = &mut window_over => break,
};
match item {
Some(crate::StreamItem::Event(crate::FleetEvent::Sample(s))) => {
if zenoh::key_expr::KeyExpr::try_from(s.key.as_str())
.map(|k| old_expr.includes(&k))
.unwrap_or(false)
{
old_samples += 1;
*old_keys.entry(s.key.clone()).or_default() += 1;
} else if s.key.starts_with(&new_prefix) {
new_samples += 1;
} else {
leak_samples += 1;
*leaked.entry(s.key.clone()).or_default() += 1;
}
}
Some(crate::StreamItem::Dropped(n)) => dropped += n,
Some(_) => continue,
None => break,
}
}
monitor.shutdown().await?;
let cap = |m: &BTreeMap<String, u64>| {
let mut ex = Examples::new(FINDING_CAP);
for (k, n) in m {
ex.push_with(|| format!("{k} ({n})"));
}
ex.into_vec()
};
Ok(CutoverReport {
old_root: old_root.to_string(),
new_prefix,
window_s: window.as_secs_f64(),
old_samples,
old_keys_seen: old_keys.len(),
old_examples: cap(&old_keys),
new_samples,
leak_samples,
leaked_keys_seen: leaked.len(),
leak_examples: cap(&leaked),
dropped,
verdict: verdict(old_samples, new_samples),
})
}
pub fn verdict(old_samples: u64, new_samples: u64) -> CutoverVerdict {
if old_samples > 0 {
CutoverVerdict::OldStillSpeaks
} else if new_samples == 0 {
CutoverVerdict::Unproven
} else {
CutoverVerdict::Pass
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn silence_on_both_planes_is_unproven_not_a_pass() {
assert_eq!(verdict(0, 12), CutoverVerdict::Pass);
assert_eq!(verdict(3, 12), CutoverVerdict::OldStillSpeaks);
assert_eq!(verdict(0, 0), CutoverVerdict::Unproven);
assert_eq!(verdict(1, 10_000), CutoverVerdict::OldStillSpeaks);
}
#[test]
fn the_scope_note_states_what_it_cannot_see() {
let note = scope_note("old/**", "acme/v1/", Duration::from_secs(30));
assert!(note.contains("30s window"));
assert!(
note.contains("cannot cross"),
"a wildcard scope must not be presented as total coverage (O5): {note}"
);
}
}