use std::time::Duration;
use zenkey_fleet::{SeedItem, SeedPolicy, seed_subscribe};
use zenoh_ext::AdvancedPublisherBuilderExt;
mod util;
use util::timestamping_pair;
async fn drain_seed(
sub: &mut zenkey_fleet::SeededSubscriber,
) -> (Vec<String>, zenkey_fleet::SeedCoverage) {
let mut values = Vec::new();
loop {
match tokio::time::timeout(util::SETTLE, sub.recv())
.await
.expect("seed boundary within 5s")
.expect("stream alive")
{
SeedItem::Sample(v) => {
values.push(String::from_utf8_lossy(&v.payload.to_bytes()).to_string())
}
SeedItem::Dropped(n) => {
panic!("these fixtures never outrun the bounded channel ({n} dropped)")
}
SeedItem::SeedComplete(c) => return (values, c),
}
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn history_seed_lands_before_the_boundary() {
let (a, b) = timestamping_pair().await;
let publisher = a
.declare_publisher("seedtest/state/health")
.cache(zenoh_ext::CacheConfig::default().max_samples(1))
.await
.expect("advanced publisher");
publisher.put("v1").await.expect("cached put");
tokio::time::sleep(Duration::from_millis(300)).await;
let mut sub = seed_subscribe(
&b,
"seedtest/state/**",
SeedPolicy {
timeout: Duration::from_millis(800),
..SeedPolicy::default()
},
)
.await
.expect("seed subscribe");
let (seen, coverage) = drain_seed(&mut sub).await;
assert_eq!(seen, ["v1"], "the cached value seeds — before the boundary");
assert_eq!(coverage.history_replies, Some(1), "the cache answered once");
assert_eq!(
coverage.storage_replies,
Some(0),
"no storage on this bus — ran and found nothing, an observation"
);
publisher.put("v2").await.expect("live put");
let item = tokio::time::timeout(util::SETTLE, sub.recv())
.await
.expect("live within 5s")
.expect("stream alive");
match item {
SeedItem::Sample(v) => assert_eq!(v.payload.to_bytes().as_ref(), b"v2"),
other => panic!("expected the live sample, got {other:?}"),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_stale_storage_seed_cannot_regress_a_key() {
let (a, b) = timestamping_pair().await;
let publisher = a
.declare_publisher("staletest/state/doc")
.cache(zenoh_ext::CacheConfig::default().max_samples(1))
.await
.expect("advanced publisher");
publisher.put("current").await.expect("put");
let _storage = a
.declare_queryable("staletest/state/doc")
.callback(move |query| {
let q = query.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(300)).await;
q.reply("staletest/state/doc", "stale-from-storage")
.await
.ok();
});
})
.await
.expect("queryable");
tokio::time::sleep(Duration::from_millis(300)).await;
let mut sub = seed_subscribe(
&b,
"staletest/state/**",
SeedPolicy {
timeout: Duration::from_millis(800),
..SeedPolicy::default()
},
)
.await
.expect("seed subscribe");
let (values, coverage) = drain_seed(&mut sub).await;
assert_eq!(
values,
["current"],
"the stamped cache value seeds once; the late unstamped echo never surfaces"
);
assert_eq!(coverage.storage_replies, Some(1), "the storage DID answer");
assert!(
coverage.superseded >= 1,
"…and its suppression is counted, not silent (O6)"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_transition_in_the_seed_window_lands_exactly_once() {
let (a, b) = timestamping_pair().await;
let publisher = a
.declare_publisher("gaptest/state/flag")
.await
.expect("publisher");
let matching = publisher
.matching_listener()
.await
.expect("matching listener");
let (got_query_tx, mut got_query) = tokio::sync::mpsc::unbounded_channel::<()>();
let _slow_storage = a
.declare_queryable("gaptest/state/**")
.callback(move |query| {
let _ = got_query_tx.send(());
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(2000)).await;
drop(query);
});
})
.await
.expect("queryable");
let _ready = a
.declare_queryable("gaptest/ready")
.callback(|query| {
let q = query.clone();
tokio::spawn(async move {
q.reply("gaptest/ready", "ok").await.ok();
});
})
.await
.expect("ready queryable");
let probe_deadline = tokio::time::Instant::now() + Duration::from_secs(10);
loop {
let replies = b
.get("gaptest/ready")
.timeout(Duration::from_millis(300))
.await
.expect("probe get");
if replies.recv_async().await.is_ok() {
break;
}
assert!(
tokio::time::Instant::now() < probe_deadline,
"routing never converged"
);
}
let mut sub = seed_subscribe(
&b,
"gaptest/state/**",
SeedPolicy {
history: false, timeout: Duration::from_millis(3000),
..SeedPolicy::default()
},
)
.await
.expect("seed subscribe");
let ev = tokio::time::timeout(util::SETTLE, matching.recv_async())
.await
.expect("matching event within 5s")
.expect("listener alive");
assert!(ev.matching(), "the seed subscriber is a real subscriber");
tokio::time::timeout(util::SETTLE, got_query.recv())
.await
.expect("the storage GET reaches the queryable within 5s")
.expect("channel alive");
publisher.put("flank").await.expect("put");
let mut before_boundary = 0;
let mut after_boundary = 0;
let mut done = false;
let mut deadline = tokio::time::Instant::now() + Duration::from_secs(8);
loop {
match tokio::time::timeout_at(deadline, sub.recv()).await {
Ok(Some(SeedItem::Sample(v))) => {
assert_eq!(v.payload.to_bytes().as_ref(), b"flank");
if done {
after_boundary += 1;
} else {
before_boundary += 1;
}
}
Ok(Some(SeedItem::Dropped(n))) => {
panic!("this fixture never outruns the bounded channel ({n} dropped)")
}
Ok(Some(SeedItem::SeedComplete(c))) => {
assert_eq!(c.history_replies, None, "history was opted out");
done = true;
deadline = tokio::time::Instant::now() + Duration::from_millis(700);
}
Ok(None) | Err(_) => break,
}
}
assert!(done, "the seed boundary must arrive");
assert_eq!(
(before_boundary, after_boundary),
(1, 0),
"the in-window transition lands exactly once, inside the seed phase"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_seeded_watch_shows_pre_existing_state() {
let (a, b) = timestamping_pair().await;
let publisher = a
.declare_publisher("wseed/state/health")
.cache(zenoh_ext::CacheConfig::default().max_samples(1))
.await
.expect("advanced publisher");
publisher.put("cached-before-watch").await.expect("put");
tokio::time::sleep(Duration::from_millis(300)).await;
let monitor = zenkey_fleet::Monitor::start(&b, zenkey_fleet::MonitorSpec::default())
.await
.expect("monitor");
let mut events = monitor.events();
let id = monitor
.watch_seeded(
"wseed/state/**",
SeedPolicy {
timeout: Duration::from_millis(800),
..SeedPolicy::default()
},
)
.await
.expect("seeded watch");
let mut seen = Vec::new();
let coverage = loop {
let item = tokio::time::timeout(util::SETTLE, events.recv())
.await
.expect("boundary within 5s")
.expect("stream alive");
match item {
zenkey_fleet::StreamItem::Event(zenkey_fleet::FleetEvent::Sample(s)) => {
seen.push(String::from_utf8_lossy(&s.payload.to_bytes()).to_string());
}
zenkey_fleet::StreamItem::Event(zenkey_fleet::FleetEvent::WatchSeeded {
id: seeded,
coverage,
}) => {
assert_eq!(seeded, id, "the boundary names the watch it closes");
break coverage;
}
_ => {}
}
};
assert_eq!(
seen,
["cached-before-watch"],
"pre-existing state arrives without waiting for a refresh"
);
assert_eq!(coverage.history_replies, Some(1));
assert_eq!(coverage.storage_replies, Some(0));
assert_eq!(monitor.tree().keys, 1);
publisher.put("live-after").await.expect("live put");
loop {
let item = tokio::time::timeout(util::SETTLE, events.recv())
.await
.expect("live within 5s")
.expect("stream alive");
if let zenkey_fleet::StreamItem::Event(zenkey_fleet::FleetEvent::Sample(s)) = item {
assert_eq!(s.payload.to_bytes().as_ref(), b"live-after");
break;
}
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn dropping_a_monitor_aborts_its_seed_tasks() {
let (a, b) = timestamping_pair().await;
let _blocker = a
.declare_queryable("wdrop/state/**")
.callback(move |query| {
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(30)).await;
drop(query);
});
})
.await
.expect("blocking queryable");
let _ready = a
.declare_queryable("wdrop/ready")
.callback(|query| {
let q = query.clone();
tokio::spawn(async move {
q.reply("wdrop/ready", "ok").await.ok();
});
})
.await
.expect("ready queryable");
let probe_deadline = tokio::time::Instant::now() + Duration::from_secs(10);
loop {
let replies = b
.get("wdrop/ready")
.timeout(Duration::from_millis(300))
.await
.expect("probe get");
if replies.recv_async().await.is_ok() {
break;
}
assert!(
tokio::time::Instant::now() < probe_deadline,
"routing never converged"
);
}
let monitor = zenkey_fleet::Monitor::start(&b, zenkey_fleet::MonitorSpec::default())
.await
.expect("monitor");
let mut events = monitor.events();
const SEED_TIMEOUT: Duration = Duration::from_secs(2);
monitor
.watch_seeded(
"wdrop/state/**",
SeedPolicy {
timeout: SEED_TIMEOUT,
..SeedPolicy::default()
},
)
.await
.expect("seeded watch");
while let Ok(Some(item)) = tokio::time::timeout(Duration::from_millis(50), events.recv()).await
{
assert!(
!matches!(
item,
zenkey_fleet::StreamItem::Event(zenkey_fleet::FleetEvent::WatchSeeded { .. })
),
"the seed announced before the monitor was dropped — the premise is void, \
not the code (raise SEED_TIMEOUT)"
);
}
drop(monitor);
let listen = tokio::time::Instant::now() + SEED_TIMEOUT * 2;
while let Ok(Some(item)) = tokio::time::timeout_at(listen, events.recv()).await {
assert!(
!matches!(
item,
zenkey_fleet::StreamItem::Event(zenkey_fleet::FleetEvent::WatchSeeded { .. })
),
"the seed task outlived the monitor that owned it"
);
}
}