Skip to main content

coven_foundation/
stage_timing.rs

1//! Wall-clock timing for the named stages a run is made of.
2//!
3//! A sync loop iteration, a sync cycle, a Store pull, and a database open are
4//! each a sequence of named stages — probe the provider, drain uploads,
5//! discover streams, verify commits, migrate, install a snapshot image,
6//! materialize. When one of them takes twenty seconds the only useful question
7//! is which of those stages it spent them in, and the logs have to answer it
8//! without a second round of instrumentation.
9//!
10//! This lives in the foundation rather than beside the sync loop because the
11//! slow stages are not all in one crate: a device join spends most of its time
12//! inside a database open, and timing that from the caller only ever reports
13//! one opaque total.
14//!
15//! Each run holds one [`StageTimings`], times every stage through it, and
16//! reports one line naming each stage's total. Stages repeat — a pull discovers
17//! once per device stream and applies once per commit — so a stage's entry
18//! accumulates across the run instead of being replaced, and only stages that
19//! actually ran appear (a stage skipped because a key rotation is pending is
20//! absent, not zero). Nested runs report their own line, so the cycle's `pull`
21//! stage and the pull's own line describe the same span at two levels of detail.
22//!
23//! The reported total is the run's whole wall time, so time spent outside every
24//! named stage stays visible as the difference rather than disappearing.
25//! Timing reads a [`Stopwatch`], not the injected
26//! [`Clock`](crate::clock::Clock): this measures how long real work
27//! took, not what the store stamps its commits with.
28//!
29//! A time alone does not say what a stage waited on, so a run whose storage can
30//! count what it asks of the provider is started with
31//! [`StageTimings::counting`] and reports each stage's operation count beside
32//! its time. That is the form the round-trip budget is written in: a join is
33//! one snapshot download and a handful of small operations, and a stage that
34//! exceeds that says so in its own line.
35
36use std::fmt;
37use std::future::Future;
38use std::sync::Arc;
39use std::time::Duration;
40
41use crate::clock::Stopwatch;
42use tracing::info;
43
44/// The running total of provider operations the storage behind a run has
45/// issued.
46///
47/// A stage's wall time says how long it waited, not what it waited on, and the
48/// two shapes want opposite fixes: one slow transfer is a size problem, two
49/// hundred fast ones are a round-trip problem no faster network will help.
50/// Counting them apart is what lets a stage convict itself instead of inviting
51/// another round of instrumentation.
52///
53/// The foundation cannot see the cloud layer, so a run that wants counts is
54/// handed something that can read the total. What counts as one operation is
55/// the storage's to define — a call that pages internally is one operation here
56/// and several round trips underneath, so a stage whose count is small while
57/// its time is large is a paging or streaming call, which is worth telling
58/// apart rather than hiding in a total.
59pub trait ProviderRequests: Send + Sync {
60    fn issued(&self) -> u64;
61}
62
63/// One named stage's accumulated cost.
64///
65/// `requests` is meaningful only for a run started with
66/// [`StageTimings::counting`]. An uncounted run leaves it zero and prints no
67/// counts at all, because zero operations and "nobody was counting" must not
68/// read the same way.
69struct Stage {
70    name: &'static str,
71    elapsed: Duration,
72    requests: u64,
73}
74
75pub struct StageTimings {
76    run: &'static str,
77    started: Stopwatch,
78    stages: Vec<Stage>,
79    /// `None` for a run nobody handed a counter, which reports times alone.
80    requests: Option<Arc<dyn ProviderRequests>>,
81    /// The counter's reading when this run began. The total is the difference,
82    /// so a run sharing a home with runs before it starts from its own zero.
83    started_requests: u64,
84    reported: bool,
85}
86
87impl StageTimings {
88    /// Begins timing a run. `run` names it in the reported line.
89    pub fn start(run: &'static str) -> Self {
90        Self {
91            run,
92            started: Stopwatch::start(),
93            stages: Vec::new(),
94            requests: None,
95            started_requests: 0,
96            reported: false,
97        }
98    }
99
100    /// Begins timing a run that asks its storage to count the provider
101    /// operations each stage issues.
102    ///
103    /// `requests` is what the storage answered: `None` from storage nobody
104    /// wrapped for counting, and such a run reports the line
105    /// [`start`](Self::start) would have. Every run with storage in reach comes
106    /// through here rather than choosing between the two constructors itself,
107    /// because whether counting is on is the storage's answer, not the run's.
108    ///
109    /// The reported line carries the run's own total beside its wall time, so
110    /// operations made outside every named stage stay visible as the difference
111    /// — the same way unnamed time already does.
112    ///
113    /// The counter belongs to the provider, not to this run, so a second run
114    /// working the same home at the same time is counted into whichever stage
115    /// is open — exactly the way it is already counted into that stage's wall
116    /// time. Both numbers describe what the home did while the stage was open,
117    /// which is the honest reading of a shared home.
118    pub fn counting(run: &'static str, requests: Option<Arc<dyn ProviderRequests>>) -> Self {
119        let started_requests = requests.as_ref().map_or(0, |counter| counter.issued());
120        Self {
121            run,
122            started: Stopwatch::start(),
123            stages: Vec::new(),
124            requests,
125            started_requests,
126            reported: false,
127        }
128    }
129
130    fn issued(&self) -> u64 {
131        self.requests.as_ref().map_or(0, |counter| counter.issued())
132    }
133
134    /// Awaits `work` as the named stage, adding its elapsed time and the
135    /// operations it issued to that stage's totals for this run.
136    pub async fn stage<T>(&mut self, stage: &'static str, work: impl Future<Output = T>) -> T {
137        let started = Stopwatch::start();
138        let before = self.issued();
139        let outcome = work.await;
140        let issued = self.issued().saturating_sub(before);
141        self.add(stage, started.elapsed(), issued);
142        outcome
143    }
144
145    /// Time one blocking step. [`stage`](Self::stage) covers work that awaits;
146    /// plenty of what dominates a run — parsing and signature checks over a
147    /// carried history — never awaits, and is invisible without this.
148    pub fn mark<T>(&mut self, stage: &'static str, work: impl FnOnce() -> T) -> T {
149        let started = Stopwatch::start();
150        let before = self.issued();
151        let outcome = work();
152        let issued = self.issued().saturating_sub(before);
153        self.add(stage, started.elapsed(), issued);
154        outcome
155    }
156
157    /// Adds time and operations a caller measured itself, for work whose split
158    /// is only visible from inside it — a stream walk knows how much of its wait
159    /// was head slots and how much was the commits behind them, and how many
160    /// reads each took; the pull only sees the total.
161    pub fn record(&mut self, stage: &'static str, elapsed: Duration, requests: u64) {
162        self.add(stage, elapsed, requests);
163    }
164
165    fn add(&mut self, stage: &'static str, elapsed: Duration, requests: u64) {
166        match self.stages.iter_mut().find(|held| held.name == stage) {
167            Some(held) => {
168                held.elapsed = held.elapsed.saturating_add(elapsed);
169                held.requests = held.requests.saturating_add(requests);
170            }
171            None => self.stages.push(Stage {
172                name: stage,
173                elapsed,
174                requests,
175            }),
176        }
177    }
178
179    /// Each stage this run has accumulated and the operations charged to it, in
180    /// first-seen order — the same content the reported line renders, for a test
181    /// that has to see where a choreography's operations landed rather than
182    /// read them back off a log.
183    #[cfg(any(test, feature = "test-utils"))]
184    pub fn counted_stages(&self) -> impl Iterator<Item = (&'static str, u64)> + '_ {
185        self.stages.iter().map(|stage| (stage.name, stage.requests))
186    }
187
188    /// Reports the breakdown. Called on every exit path, including failures —
189    /// a cycle that died halfway is exactly the one whose stage timings matter.
190    pub fn report(mut self) {
191        self.emit(false);
192    }
193
194    /// Reports once. Returns whether this call was the one that reported, so a
195    /// test can hold the "exactly once, whichever path gets there first" rule.
196    fn emit(&mut self, cancelled: bool) -> bool {
197        if std::mem::replace(&mut self.reported, true) {
198            return false;
199        }
200        let counted = self.requests.is_some();
201        info!(
202            run = self.run,
203            total_ms = self.started.elapsed().as_millis() as u64,
204            total_requests = counted.then(|| self.issued().saturating_sub(self.started_requests)),
205            stages = %StageBreakdown(&self.stages, counted),
206            cancelled,
207            "Stage timings"
208        );
209        true
210    }
211}
212
213/// A run that is dropped without reporting was cancelled — its future was
214/// abandoned partway, which no `?` and no explicit call at the end of a function
215/// can catch. A device join whose pairing code expires mid-install is exactly
216/// that, and it used to leave no trace at all: the stages it had reached died
217/// with the future. Reporting from the drop makes the abandoned run say how far
218/// it got.
219impl Drop for StageTimings {
220    fn drop(&mut self) {
221        self.emit(true);
222    }
223}
224
225/// Renders the stages of one run. `counted` decides whether the counts print at
226/// all: an uncounted run's zeroes would claim a measurement nobody took.
227struct StageBreakdown<'stages>(&'stages [Stage], bool);
228
229impl fmt::Display for StageBreakdown<'_> {
230    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
231        let StageBreakdown(stages, counted) = self;
232        if stages.is_empty() {
233            return formatter.write_str("none");
234        }
235        for (index, stage) in stages.iter().enumerate() {
236            if index > 0 {
237                formatter.write_str(", ")?;
238            }
239            write!(formatter, "{} {}ms", stage.name, stage.elapsed.as_millis())?;
240            if *counted {
241                write!(formatter, "/{}req", stage.requests)?;
242            }
243        }
244        Ok(())
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use std::sync::atomic::{AtomicU64, Ordering};
252
253    /// A counter a test drives by hand, standing in for the storage's.
254    #[derive(Default)]
255    struct FakeRequests(AtomicU64);
256
257    impl FakeRequests {
258        fn issue(&self, operations: u64) {
259            self.0.fetch_add(operations, Ordering::Relaxed);
260        }
261    }
262
263    impl ProviderRequests for FakeRequests {
264        fn issued(&self) -> u64 {
265            self.0.load(Ordering::Relaxed)
266        }
267    }
268
269    #[test]
270    fn repeated_stages_accumulate_in_first_seen_order() {
271        let mut timings = StageTimings::start("test run");
272        timings.add("discover streams", Duration::from_millis(30), 3);
273        timings.add("materialize", Duration::from_millis(5), 0);
274        timings.add("discover streams", Duration::from_millis(12), 1);
275
276        assert_eq!(
277            StageBreakdown(&timings.stages, true).to_string(),
278            "discover streams 42ms/4req, materialize 5ms/0req"
279        );
280    }
281
282    /// A run nobody handed a counter reports the line it always did. Printing
283    /// `0req` would claim every stage was measured and found free.
284    #[test]
285    fn an_uncounted_run_reports_times_alone() {
286        let mut timings = StageTimings::start("test run");
287        timings.add("discover streams", Duration::from_millis(30), 0);
288        timings.add("materialize", Duration::from_millis(5), 0);
289
290        assert_eq!(
291            StageBreakdown(&timings.stages, false).to_string(),
292            "discover streams 30ms, materialize 5ms"
293        );
294    }
295
296    #[test]
297    fn a_run_with_no_stages_reports_none() {
298        let timings = StageTimings::start("test run");
299
300        assert_eq!(StageBreakdown(&timings.stages, true).to_string(), "none");
301    }
302
303    #[tokio::test]
304    async fn a_timed_stage_is_recorded_once_it_completes() {
305        let mut timings = StageTimings::start("test run");
306
307        let outcome = timings.stage("verify commits", async { 7_u32 }).await;
308
309        assert_eq!(outcome, 7);
310        assert_eq!(
311            timings
312                .stages
313                .iter()
314                .map(|stage| stage.name)
315                .collect::<Vec<_>>(),
316            vec!["verify commits"]
317        );
318    }
319
320    /// The point of the whole mechanism: a known choreography's operations land
321    /// on the stage that issued them, and no other. The stage boundaries are the
322    /// only thing dividing one shared running total, so a stage that issues
323    /// nothing must not inherit its neighbour's count.
324    #[tokio::test]
325    async fn each_stage_reports_the_operations_it_issued() {
326        let counter = Arc::new(FakeRequests::default());
327        counter.issue(9); // A run before this one, on the same home.
328        let mut timings = StageTimings::counting("device join", Some(counter.clone()));
329
330        timings
331            .stage("discover streams", async { counter.issue(4) })
332            .await;
333        timings.mark("verify commits", || {});
334        timings
335            .stage("download the snapshot", async { counter.issue(1) })
336            .await;
337        timings
338            .stage("discover streams", async { counter.issue(2) })
339            .await;
340
341        assert_eq!(
342            StageBreakdown(&timings.stages, true).to_string(),
343            "discover streams 0ms/6req, verify commits 0ms/0req, \
344             download the snapshot 0ms/1req"
345        );
346    }
347
348    /// The run's own total counts from where it began, not from the home's whole
349    /// lifetime, and it exceeds the sum of the stages by whatever was issued
350    /// between them — the same way unnamed time already exceeds the stage times.
351    #[tokio::test]
352    async fn a_counted_run_totals_only_its_own_operations() {
353        let counter = Arc::new(FakeRequests::default());
354        counter.issue(9);
355        let mut timings = StageTimings::counting("device join", Some(counter.clone()));
356
357        timings
358            .stage("probe the provider", async { counter.issue(1) })
359            .await;
360        counter.issue(3); // Issued between stages, named by no stage.
361
362        assert_eq!(timings.issued() - timings.started_requests, 4);
363    }
364
365    /// Storage nobody wrapped for counting answers `None`, and the run it was
366    /// asked for reports the line it always did rather than a column of zeroes
367    /// standing in for a measurement nobody took.
368    #[tokio::test]
369    async fn a_run_over_uncounted_storage_reports_times_alone() {
370        let mut timings = StageTimings::counting("device join", None);
371
372        timings.stage("download the snapshot", async {}).await;
373
374        assert_eq!(
375            StageBreakdown(&timings.stages, timings.requests.is_some()).to_string(),
376            "download the snapshot 0ms"
377        );
378    }
379
380    /// Times recorded by a caller that counted its own reads carry those counts
381    /// through, for splits only the callee can see.
382    #[test]
383    fn recorded_stages_carry_the_counts_their_caller_measured() {
384        let mut timings =
385            StageTimings::counting("Store pull", Some(Arc::new(FakeRequests::default())));
386        timings.record("fetch heads", Duration::from_millis(120), 6);
387        timings.record("fetch commits", Duration::from_millis(80), 14);
388
389        assert_eq!(
390            StageBreakdown(&timings.stages, true).to_string(),
391            "fetch heads 120ms/6req, fetch commits 80ms/14req"
392        );
393    }
394}
395
396#[cfg(test)]
397mod cancellation_tests {
398    use super::*;
399
400    /// A run whose future is abandoned partway never reaches its `report()` —
401    /// the live case is a device join whose pairing code expires mid-install.
402    /// Its drop reports instead, so the stages it did reach still say so.
403    #[test]
404    fn an_abandoned_run_reports_from_its_drop() {
405        let mut timings = StageTimings::start("abandoned run");
406        timings.add("first", Duration::from_millis(3), 0);
407
408        assert!(
409            timings.emit(true),
410            "a run that never reported reports when it is dropped",
411        );
412    }
413
414    /// And a run that did report stays quiet when it drops, so an ordinary
415    /// completion still logs one line.
416    #[test]
417    fn a_reported_run_does_not_report_again_when_it_drops() {
418        let mut timings = StageTimings::start("reported run");
419        timings.add("only", Duration::from_millis(1), 0);
420
421        assert!(timings.emit(false), "the first report is the one that logs");
422        assert!(
423            !timings.emit(true),
424            "its drop finds the run already reported",
425        );
426    }
427}