Skip to main content

zenkey_fleet/
bench.rs

1//! `bench rpc` (issue #52) — how fast does the fleet answer, and which
2//! origin is slow.
3//!
4//! Two design decisions worth stating, because both are refusals:
5//!
6//! **Latency is per reply, not per call.** A fan-out GET finishes when the
7//! *slowest* origin answers, so attributing the call's duration to every
8//! responder would report the fastest node's latency as the worst one's. The
9//! measurement therefore rides
10//! [`RepeatingQuery::fetch_timed`](crate::query::RepeatingQuery::fetch_timed),
11//! which stamps each reply where it is drained — inside the RFC 05 §2.1
12//! chokepoint, not around it.
13//!
14//! **Benching writes is refused by default.** The registry declares
15//! `idempotent`, and a benchmark is by definition N repetitions: repeating a
16//! non-idempotent write into a live fleet is a different act from measuring
17//! it. The refusal is registry-driven, so it is only as good as the
18//! declaration — which is why a producer that declares *nothing* is also
19//! refused rather than assumed safe (O4: "not declared" is not "declared
20//! idempotent").
21
22use std::collections::BTreeMap;
23use std::time::{Duration, Instant};
24
25use anyhow::{Result, anyhow, bail};
26use zenoh::Session;
27
28use crate::query::{Answer, RepeatingQuery, declare_repeating};
29use crate::registry::SliceSet;
30use crate::report::{BenchReport, OriginLatency};
31use crate::write::CallTarget;
32
33/// What to measure.
34pub struct BenchSpec<'a> {
35    pub target: &'a CallTarget,
36    pub producer: &'a str,
37    pub procedure: &'a str,
38    /// Total calls to issue.
39    pub count: usize,
40    /// How many may be in flight at once. 1 = strictly sequential.
41    pub concurrency: usize,
42    pub timeout: Duration,
43    /// Proceed even when the registry does not declare the procedure
44    /// idempotent. The caller must have meant it.
45    pub force: bool,
46}
47
48/// The procedures **this convention** defines, rather than an application:
49/// `introspect` (RFC 08 §6) and `describe` (RFC 08 §7). Both are reads that
50/// return a document, both are MUST/SHOULD for every producer, and neither is
51/// an application's to declare differently — so their idempotence is a fact
52/// about the convention, not something to look up in a registry that may not
53/// bother listing them.
54const FRAMEWORK_READS: [&str; 2] = ["introspect", "describe"];
55
56/// Refuse a benchmark that would repeat a non-idempotent call.
57///
58/// With no slices loaded the registry layer cannot judge — and unlike the
59/// fan-out guard, which has builder and ACL layers behind it, there is nothing
60/// behind this one. So it refuses rather than proceeding, and says how to
61/// override.
62fn check_idempotent(slices: Option<&SliceSet>, producer: &str, procedure: &str) -> Result<()> {
63    if FRAMEWORK_READS.contains(&procedure) {
64        return Ok(());
65    }
66    let Some(slices) = slices else {
67        bail!(
68            "no registry loaded, so {producer}/{procedure}'s idempotence is unknown — a \
69             benchmark repeats a call N times, and \"not asked\" is not \"safe to repeat\" \
70             (RFC 09 §5.1 O4). Load a registry, or pass --i-know."
71        );
72    };
73    let decl = slices
74        .get(producer)
75        .and_then(|s| s.procedures.iter().find(|p| p.path == procedure));
76    match decl {
77        Some(d) if d.idempotent == Some(true) => Ok(()),
78        Some(d) => bail!(
79            "{producer}/{procedure} declares kind = {:?}, idempotent = {} — repeating it is a \
80             write into a live fleet, not a measurement. Pass --i-know to mean it.",
81            d.kind,
82            match d.idempotent {
83                Some(false) => "false",
84                _ => "(undeclared)",
85            }
86        ),
87        None => bail!(
88            "the loaded registry does not declare {producer}/{procedure}, so nothing says it \
89             is safe to repeat. Pass --i-know to bench it anyway."
90        ),
91    }
92}
93
94/// Percentile by nearest-rank over a sorted slice. Reported in milliseconds.
95fn percentile(sorted: &[Duration], p: f64) -> f64 {
96    if sorted.is_empty() {
97        return 0.0;
98    }
99    let rank = ((p / 100.0) * sorted.len() as f64).ceil() as usize;
100    let idx = rank.saturating_sub(1).min(sorted.len() - 1);
101    sorted[idx].as_secs_f64() * 1000.0
102}
103
104/// Run the benchmark.
105pub async fn bench_rpc(
106    session: &Session,
107    base: &str,
108    spec: BenchSpec<'_>,
109    slices: Option<&SliceSet>,
110) -> Result<BenchReport> {
111    if !spec.force {
112        check_idempotent(slices, spec.producer, spec.procedure)?;
113    }
114    if spec.count == 0 {
115        bail!("--count 0 measures nothing");
116    }
117
118    let segments: Vec<&str> = spec.procedure.split('/').collect();
119    let relative = match spec.target {
120        CallTarget::Host(id) => {
121            let origin = zenkey::origin::RemoteOrigin::from_host(id.clone());
122            zenkey::selector::rpc_at(&origin, spec.producer, &segments).to_string()
123        }
124        CallTarget::Fleet => zenkey::selector::fleet_rpc(spec.producer, &segments).to_string(),
125        CallTarget::Service(origin) => zenkey::selector::service_rpc(origin, &segments).to_string(),
126    };
127    let key = zenkey::grammar::with_base(base, relative);
128
129    // One declared querier for the whole run (#37): re-declaring per call
130    // would measure zenoh's declaration path rather than the fleet's answers.
131    let querier = std::sync::Arc::new(
132        declare_repeating(session, base, &key, spec.timeout)
133            .await
134            .map_err(|e| anyhow!("declare querier {key}: {e}"))?,
135    );
136
137    let concurrency = spec.concurrency.max(1).min(spec.count);
138    let started = Instant::now();
139    let mut per_origin: BTreeMap<String, Vec<Duration>> = BTreeMap::new();
140    let mut errors = 0usize;
141    let mut silent = 0usize;
142    let mut completed = 0usize;
143
144    let mut issued = 0usize;
145    while issued < spec.count {
146        let batch = concurrency.min(spec.count - issued);
147        let mut set = Vec::with_capacity(batch);
148        for _ in 0..batch {
149            let q: std::sync::Arc<RepeatingQuery> = querier.clone();
150            set.push(tokio::spawn(async move { q.fetch_timed().await }));
151        }
152        issued += batch;
153        for handle in set {
154            let Ok(result) = handle.await else { continue };
155            let answers = match result {
156                Ok(a) => a,
157                Err(_) => {
158                    errors += 1;
159                    continue;
160                }
161            };
162            completed += 1;
163            if answers.is_empty() {
164                // RFC 05 §3.1: zero replies is its own outcome, counted apart
165                // from an error so a benchmark cannot average silence away.
166                silent += 1;
167                continue;
168            }
169            for (answer, at) in answers {
170                match answer.answer {
171                    Answer::Value(_) => per_origin.entry(answer.origin).or_default().push(at),
172                    Answer::Error { .. } => errors += 1,
173                }
174            }
175        }
176    }
177    let elapsed = started.elapsed();
178    std::sync::Arc::try_unwrap(querier)
179        .map_err(|_| anyhow!("bench tasks outlived the run"))?
180        .undeclare()
181        .await?;
182
183    let origins = per_origin
184        .into_iter()
185        .map(|(origin, mut samples)| {
186            samples.sort_unstable();
187            OriginLatency {
188                origin,
189                replies: samples.len(),
190                min_ms: samples[0].as_secs_f64() * 1000.0,
191                p50_ms: percentile(&samples, 50.0),
192                p95_ms: percentile(&samples, 95.0),
193                p99_ms: percentile(&samples, 99.0),
194                max_ms: samples[samples.len() - 1].as_secs_f64() * 1000.0,
195            }
196        })
197        .collect();
198
199    Ok(BenchReport {
200        key,
201        requested: spec.count,
202        completed,
203        concurrency,
204        errors,
205        silent,
206        elapsed_s: elapsed.as_secs_f64(),
207        calls_per_s: if elapsed.as_secs_f64() > 0.0 {
208            completed as f64 / elapsed.as_secs_f64()
209        } else {
210            0.0
211        },
212        origins,
213    })
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use zenkey::slice::{ProcedureDecl, RegistrySlice};
220
221    fn slices(kind: &str, idempotent: Option<bool>) -> SliceSet {
222        SliceSet::from_slices(vec![RegistrySlice {
223            version: "1.0".into(),
224            app: "t".into(),
225            convention: 1,
226            name: "netring".into(),
227            service_origin: None,
228            description: None,
229            subjects: vec![],
230            procedures: vec![ProcedureDecl {
231                path: "capture/trigger".into(),
232                kind: kind.into(),
233                reply: Some("Ack".into()),
234                request: None,
235                encoding: None,
236                fanout: None,
237                idempotent,
238                since: None,
239                description: None,
240            }],
241            blob: vec![],
242            media: vec![],
243            deprecated: vec![],
244        }])
245    }
246
247    /// The guard: only an explicit `idempotent = true` passes. "Undeclared"
248    /// and "not in the registry at all" both refuse — a benchmark repeats,
249    /// and O4 forbids reading an unasked question as a yes.
250    #[test]
251    fn only_a_declared_idempotent_procedure_benches_by_default() {
252        let ok = slices("read", Some(true));
253        assert!(check_idempotent(Some(&ok), "netring", "capture/trigger").is_ok());
254
255        for (kind, idem) in [("write", Some(false)), ("read", None)] {
256            let s = slices(kind, idem);
257            let err = check_idempotent(Some(&s), "netring", "capture/trigger")
258                .unwrap_err()
259                .to_string();
260            assert!(err.contains("--i-know"), "{err}");
261        }
262
263        // Unknown procedure, and no registry at all.
264        let s = slices("read", Some(true));
265        assert!(check_idempotent(Some(&s), "netring", "other").is_err());
266        let err = check_idempotent(None, "netring", "capture/trigger")
267            .unwrap_err()
268            .to_string();
269        assert!(err.contains("O4"), "{err}");
270    }
271
272    /// The convention's own reads bench without a registry entry: RFC 08 §6
273    /// makes `introspect` a MUST for every producer and §7 makes `describe` a
274    /// SHOULD, so their idempotence is not an application's to declare — and
275    /// requiring a slice to restate it would refuse the one call the tool
276    /// already fans out on by design.
277    #[test]
278    fn the_conventions_own_reads_need_no_registry_permission() {
279        for p in ["introspect", "describe"] {
280            assert!(check_idempotent(None, "anything", p).is_ok(), "{p}");
281        }
282        // …and nothing else gets the exemption by resembling them.
283        assert!(check_idempotent(None, "anything", "introspect/all").is_err());
284    }
285
286    #[test]
287    fn percentiles_are_nearest_rank_and_survive_one_sample() {
288        let d = |ms: u64| Duration::from_millis(ms);
289        let one = [d(7)];
290        assert_eq!(percentile(&one, 50.0), 7.0);
291        assert_eq!(percentile(&one, 99.0), 7.0);
292
293        let ten: Vec<Duration> = (1..=10).map(d).collect();
294        assert_eq!(percentile(&ten, 50.0), 5.0);
295        assert_eq!(percentile(&ten, 95.0), 10.0);
296        assert_eq!(percentile(&ten, 100.0), 10.0);
297        // Empty is 0, not a panic — a bench with no replies still reports.
298        assert_eq!(percentile(&[], 50.0), 0.0);
299    }
300}