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}