Skip to main content

zenkey_fleet/tape/
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::bus::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 crate::{Error, Result};
26
27use crate::bus::query::{Answer, RepeatingQuery, declare_repeating};
28use crate::bus::write::CallTarget;
29use crate::model::registry::SliceSet;
30use crate::report::{BenchReport, OriginLatency};
31
32/// What to measure.
33pub struct BenchSpec<'a> {
34    pub target: &'a CallTarget,
35    pub producer: &'a str,
36    pub procedure: &'a str,
37    /// Total calls to issue.
38    pub count: usize,
39    /// How many may be in flight at once. 1 = strictly sequential.
40    pub concurrency: usize,
41    pub timeout: Duration,
42    /// Proceed even when the registry does not declare the procedure
43    /// idempotent. The caller must have meant it.
44    pub force: bool,
45}
46
47/// The procedures **this convention** defines, rather than an application:
48/// `introspect` (RFC 08 §6) and `describe` (RFC 08 §7). Both are reads that
49/// return a document, both are MUST/SHOULD for every producer, and neither is
50/// an application's to declare differently — so their idempotence is a fact
51/// about the convention, not something to look up in a registry that may not
52/// bother listing them.
53const FRAMEWORK_READS: [&str; 2] = ["introspect", "describe"];
54
55/// Refuse a benchmark that would repeat a non-idempotent call.
56///
57/// With no slices loaded the registry layer cannot judge — and unlike the
58/// fan-out guard, which has builder and ACL layers behind it, there is nothing
59/// behind this one. So it refuses rather than proceeding, and says how to
60/// override.
61fn check_idempotent(slices: Option<&SliceSet>, producer: &str, procedure: &str) -> Result<()> {
62    if FRAMEWORK_READS.contains(&procedure) {
63        return Ok(());
64    }
65    let Some(slices) = slices else {
66        return Err(Error::unaskable(
67            format!("{producer}/{procedure}"),
68            "no registry is loaded, so its idempotence is unknown — a benchmark \
69             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) => Err(Error::unaskable(
79            format!("{producer}/{procedure}"),
80            format!(
81                "declares kind = {:?}, idempotent = {} — repeating it is a write \
82                 into a live fleet, not a measurement. Pass --i-know to mean it.",
83                d.kind,
84                match d.idempotent {
85                    Some(false) => "false",
86                    _ => "(undeclared)",
87                }
88            ),
89        )),
90        None => Err(Error::unaskable(
91            format!("{producer}/{procedure}"),
92            "the loaded registry does not declare it, so nothing says it is safe \
93             to repeat. Pass --i-know to bench it anyway.",
94        )),
95    }
96}
97
98/// The four populations a benchmark keeps apart, and the one place a joined
99/// call is sorted into them.
100///
101/// Keeping them apart is the whole honesty claim of this report (RFC 13 §3 O6,
102/// RFC 05 §3.1): an error reply is the fleet refusing, silence is the fleet not
103/// answering, and a **panicked** call is this tool falling over — three
104/// different facts that a single "failed" counter would flatten into a lie.
105/// The fold lives here rather than inline so the fourth one can be tested
106/// against a real `JoinError` (#329), which is what the loop above cannot
107/// manufacture.
108#[derive(Debug, Default, PartialEq, Eq)]
109struct Tally {
110    completed: usize,
111    errors: usize,
112    silent: usize,
113    panicked: usize,
114}
115
116impl Tally {
117    /// Fold one joined call in, routing its per-reply latencies to their
118    /// origins.
119    fn record(
120        &mut self,
121        joined: std::result::Result<
122            Result<Vec<(crate::bus::query::FleetAnswer, Duration)>>,
123            tokio::task::JoinError,
124        >,
125        per_origin: &mut BTreeMap<String, Vec<Duration>>,
126    ) {
127        // A panicked call reached no ledger at all before #329: the
128        // `let Ok(result) = handle.await else { continue }` that stood here
129        // skipped `completed`, `errors` and `silent` in one line.
130        let Ok(result) = joined else {
131            self.panicked += 1;
132            return;
133        };
134        let Ok(answers) = result else {
135            self.errors += 1;
136            return;
137        };
138        self.completed += 1;
139        if answers.is_empty() {
140            // RFC 05 §3.1: zero replies is its own outcome, counted apart
141            // from an error so a benchmark cannot average silence away.
142            self.silent += 1;
143            return;
144        }
145        for (answer, at) in answers {
146            match answer.answer {
147                Answer::Value(_) => per_origin.entry(answer.origin).or_default().push(at),
148                Answer::Error { .. } => self.errors += 1,
149            }
150        }
151    }
152}
153
154/// Percentile by nearest-rank over a sorted slice. Reported in milliseconds.
155fn percentile(sorted: &[Duration], p: f64) -> f64 {
156    if sorted.is_empty() {
157        return 0.0;
158    }
159    let rank = ((p / 100.0) * sorted.len() as f64).ceil() as usize;
160    let idx = rank.saturating_sub(1).min(sorted.len() - 1);
161    sorted[idx].as_secs_f64() * 1000.0
162}
163
164/// Run the benchmark.
165pub async fn run_bench(
166    fleet: &crate::Fleet<'_>,
167    spec: BenchSpec<'_>,
168    slices: Option<&SliceSet>,
169) -> Result<BenchReport> {
170    if !spec.force {
171        check_idempotent(slices, spec.producer, spec.procedure)?;
172    }
173    if spec.count == 0 {
174        return Err(Error::unaskable("--calls 0", "measures nothing"));
175    }
176
177    let segments: Vec<&str> = spec.procedure.split('/').collect();
178    let relative = match spec.target {
179        CallTarget::Host(id) => {
180            let origin = zenkey::origin::RemoteOrigin::from_host(id.clone());
181            zenkey::selector::rpc_at(&origin, spec.producer, &segments).to_string()
182        }
183        CallTarget::Fleet => zenkey::selector::fleet_rpc(spec.producer, &segments).to_string(),
184        CallTarget::Service(origin) => zenkey::selector::service_rpc(origin, &segments).to_string(),
185    };
186    let key = fleet.wire(relative);
187
188    // One declared querier for the whole run (#37): re-declaring per call
189    // would measure zenoh's declaration path rather than the fleet's answers.
190    let querier = std::sync::Arc::new(
191        declare_repeating(fleet, &key, spec.timeout)
192            .await
193            .map_err(|e| Error::bus("declare querier", key.clone(), e))?,
194    );
195
196    let concurrency = spec.concurrency.max(1).min(spec.count);
197    let started = Instant::now();
198    let mut per_origin: BTreeMap<String, Vec<Duration>> = BTreeMap::new();
199    let mut tally = Tally::default();
200
201    let mut issued = 0usize;
202    while issued < spec.count {
203        let batch = concurrency.min(spec.count - issued);
204        let mut set = Vec::with_capacity(batch);
205        for _ in 0..batch {
206            let q: std::sync::Arc<RepeatingQuery> = querier.clone();
207            set.push(tokio::spawn(async move { q.fetch_timed().await }));
208        }
209        issued += batch;
210        for handle in set {
211            tally.record(handle.await, &mut per_origin);
212        }
213    }
214    let Tally {
215        completed,
216        errors,
217        silent,
218        panicked,
219    } = tally;
220    let elapsed = started.elapsed();
221    std::sync::Arc::try_unwrap(querier)
222        .map_err(|_| Error::Internal("bench tasks outlived the run".into()))?
223        .undeclare()
224        .await?;
225
226    let origins = per_origin
227        .into_iter()
228        .map(|(origin, mut samples)| {
229            samples.sort_unstable();
230            OriginLatency {
231                origin,
232                replies: samples.len(),
233                min_ms: samples[0].as_secs_f64() * 1000.0,
234                p50_ms: percentile(&samples, 50.0),
235                p95_ms: percentile(&samples, 95.0),
236                p99_ms: percentile(&samples, 99.0),
237                max_ms: samples[samples.len() - 1].as_secs_f64() * 1000.0,
238            }
239        })
240        .collect();
241
242    Ok(BenchReport {
243        key,
244        requested: spec.count,
245        completed,
246        concurrency,
247        errors,
248        silent,
249        panicked,
250        elapsed_s: elapsed.as_secs_f64(),
251        calls_per_s: if elapsed.as_secs_f64() > 0.0 {
252            completed as f64 / elapsed.as_secs_f64()
253        } else {
254            0.0
255        },
256        origins,
257    })
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use zenkey::slice::{ProcedureDecl, RegistrySlice};
264
265    fn slices(kind: &str, idempotent: Option<bool>) -> SliceSet {
266        let mut trigger = ProcedureDecl::new("capture/trigger");
267        trigger.kind = Some(zenkey::Declared::parse(kind));
268        trigger.reply = Some("Ack".into());
269        trigger.idempotent = idempotent;
270        let mut slice = RegistrySlice::new("1.0", "t", "netring");
271        slice.procedures = vec![trigger];
272        SliceSet::from_slices(vec![slice])
273    }
274
275    /// The guard: only an explicit `idempotent = true` passes. "Undeclared"
276    /// and "not in the registry at all" both refuse — a benchmark repeats,
277    /// and O4 forbids reading an unasked question as a yes.
278    #[test]
279    fn only_a_declared_idempotent_procedure_benches_by_default() {
280        let ok = slices("read", Some(true));
281        assert!(check_idempotent(Some(&ok), "netring", "capture/trigger").is_ok());
282
283        for (kind, idem) in [("write", Some(false)), ("read", None)] {
284            let s = slices(kind, idem);
285            let err = check_idempotent(Some(&s), "netring", "capture/trigger")
286                .unwrap_err()
287                .to_string();
288            assert!(err.contains("--i-know"), "{err}");
289        }
290
291        // Unknown procedure, and no registry at all.
292        let s = slices("read", Some(true));
293        assert!(check_idempotent(Some(&s), "netring", "other").is_err());
294        let err = check_idempotent(None, "netring", "capture/trigger")
295            .unwrap_err()
296            .to_string();
297        assert!(err.contains("O4"), "{err}");
298    }
299
300    /// The convention's own reads bench without a registry entry: RFC 08 §6
301    /// makes `introspect` a MUST for every producer and §7 makes `describe` a
302    /// SHOULD, so their idempotence is not an application's to declare — and
303    /// requiring a slice to restate it would refuse the one call the tool
304    /// already fans out on by design.
305    #[test]
306    fn the_conventions_own_reads_need_no_registry_permission() {
307        for p in ["introspect", "describe"] {
308            assert!(check_idempotent(None, "anything", p).is_ok(), "{p}");
309        }
310        // …and nothing else gets the exemption by resembling them.
311        assert!(check_idempotent(None, "anything", "introspect/all").is_err());
312    }
313
314    /// The four populations, each landing in exactly one ledger — and a
315    /// panicked task landing in the fourth rather than in none (#329). The
316    /// `JoinError` is a real one: nothing else produces the value the loop
317    /// used to throw away.
318    #[tokio::test]
319    async fn a_panicked_call_is_its_own_population_and_reaches_a_ledger() {
320        let mut per_origin: BTreeMap<String, Vec<Duration>> = BTreeMap::new();
321        let mut tally = Tally::default();
322
323        let join_error = tokio::spawn(async { panic!("a call fell over") })
324            .await
325            .expect_err("the task panicked");
326        tally.record(Err(join_error), &mut per_origin);
327        assert_eq!(
328            tally,
329            Tally {
330                completed: 0,
331                errors: 0,
332                silent: 0,
333                panicked: 1,
334            },
335            "the panic reaches its own ledger and no other"
336        );
337
338        // The three it must not be confused with.
339        tally.record(
340            Ok(Err(Error::bus("get", "", "the GET failed"))),
341            &mut per_origin,
342        );
343        tally.record(Ok(Ok(vec![])), &mut per_origin);
344        tally.record(
345            Ok(Ok(vec![(
346                crate::bus::query::FleetAnswer {
347                    origin: "h-3fa9c2d41b7e".into(),
348                    key: "v1/h-3fa9c2d41b7e/@rpc/netring/capture/trigger".into(),
349                    encoding: None,
350                    attachment: None,
351                    answer: Answer::Value(zenoh::bytes::ZBytes::from(b"{}".to_vec())),
352                },
353                Duration::from_millis(3),
354            )])),
355            &mut per_origin,
356        );
357        assert_eq!(
358            tally,
359            Tally {
360                completed: 2,
361                errors: 1,
362                silent: 1,
363                panicked: 1,
364            }
365        );
366        assert_eq!(per_origin["h-3fa9c2d41b7e"], vec![Duration::from_millis(3)]);
367    }
368
369    #[test]
370    fn percentiles_are_nearest_rank_and_survive_one_sample() {
371        let d = |ms: u64| Duration::from_millis(ms);
372        let one = [d(7)];
373        assert_eq!(percentile(&one, 50.0), 7.0);
374        assert_eq!(percentile(&one, 99.0), 7.0);
375
376        let ten: Vec<Duration> = (1..=10).map(d).collect();
377        assert_eq!(percentile(&ten, 50.0), 5.0);
378        assert_eq!(percentile(&ten, 95.0), 10.0);
379        assert_eq!(percentile(&ten, 100.0), 10.0);
380        // Empty is 0, not a panic — a bench with no replies still reports.
381        assert_eq!(percentile(&[], 50.0), 0.0);
382    }
383}