Skip to main content

macrame/
metrics.rs

1//! What the write actor knows about its own latency (T1.4, D-079).
2//!
3//! # Why this exists
4//!
5//! [`crate::CHUNK_BUDGET`] is 3 ms and the crate has, until now, had exactly one
6//! way to find out whether that bound holds: run `benches/budgets.rs` on a
7//! synthetic fixture. That is a statement about a laptop, not about a database
8//! in use. D-059 already established that the bound does **not** hold on a large
9//! file, by a factor of 15, and it took a benchmark rewrite to notice — because
10//! nothing in the running system was counting.
11//!
12//! Tier 1's other three items are all "make the tail bounded". None of them can
13//! be validated in the field without something that measures the tail, which is
14//! why this is a precondition for them rather than a nice-to-have.
15//!
16//! # What is recorded, and what is deliberately not
17//!
18//! Four things, all of them per **actor turn** — one command, start to finish:
19//!
20//! - **queue depth** on both channels, sampled *before* the turn begins;
21//! - **hold duration**, bucketed, per command kind;
22//! - **holds over budget**, counted separately per kind;
23//! - **the longest hold since open**, with the kind that caused it.
24//!
25//! The hold is the whole turn, not the `execute` call's SQL. That is the
26//! quantity the budget is about: the SQLite write lock is not preemptible, so an
27//! interactive assertion arriving mid-turn waits for the turn, whatever the turn
28//! spent its time on.
29//!
30//! There is no per-command timestamp trail and no sampling of individual slow
31//! commands. That would be a tracing problem, and `tracing` is already a
32//! dependency — spans belong there. This module answers one question ("is the
33//! bound holding, and if not, which kind breaks it") in fixed memory, with no
34//! allocation on the actor's path.
35//!
36//! # The feature gate
37//!
38//! Behind `metrics`, off by default. With the feature off, [`ActorMetrics`] is a
39//! zero-sized type whose methods compile away and [`HoldTimer::start`] does not
40//! read the clock — so the actor loop has **one** shape either way. That
41//! matters more than the nanoseconds: a `#[cfg]` in the loop body is how the
42//! instrumented and uninstrumented paths drift until only one of them is the one
43//! that runs.
44
45use std::time::Duration;
46
47/// The command kinds the actor can spend a turn on.
48///
49/// One flat enum across both channels rather than one per channel. The question
50/// this exists to answer is "which command broke the budget", and a reader
51/// looking at a 400 ms hold does not first want to know which queue it came off.
52/// Priority is a property of scheduling; kind is a property of cost.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
54#[repr(u8)]
55pub enum CommandKind {
56    AssertEdge,
57    RetireEdge,
58    UpsertConcept,
59    WriteBulkAtomic,
60    RebuildCurrent,
61    RegisterModel,
62    Shutdown,
63    BulkImportChunk,
64    WriteConceptsChunk,
65    WriteAnalyticsChunk,
66    UpsertEmbeddingChunk,
67    Archive,
68    RebuildFts,
69    /// One step of a chunked shadow rebuild (T1.2). Its own kind rather than
70    /// folded into `RebuildCurrent`, because the two have opposite latency
71    /// profiles and the whole point of the chunked path is that its turns are
72    /// short — averaging them together would hide exactly the improvement.
73    ShadowRebuild,
74}
75
76impl CommandKind {
77    /// Every kind, in declaration order. Indexing into the per-kind arrays is by
78    /// position in this slice, so the two must not drift — which is why the
79    /// arrays are sized from `ALL.len()` rather than from a hand-written count.
80    pub const ALL: &'static [CommandKind] = &[
81        CommandKind::AssertEdge,
82        CommandKind::RetireEdge,
83        CommandKind::UpsertConcept,
84        CommandKind::WriteBulkAtomic,
85        CommandKind::RebuildCurrent,
86        CommandKind::RegisterModel,
87        CommandKind::Shutdown,
88        CommandKind::BulkImportChunk,
89        CommandKind::WriteConceptsChunk,
90        CommandKind::WriteAnalyticsChunk,
91        CommandKind::UpsertEmbeddingChunk,
92        CommandKind::Archive,
93        CommandKind::RebuildFts,
94        CommandKind::ShadowRebuild,
95    ];
96
97    pub const COUNT: usize = CommandKind::ALL.len();
98
99    pub const fn index(self) -> usize {
100        self as usize
101    }
102
103    pub const fn as_str(self) -> &'static str {
104        match self {
105            CommandKind::AssertEdge => "assert_edge",
106            CommandKind::RetireEdge => "retire_edge",
107            CommandKind::UpsertConcept => "upsert_concept",
108            CommandKind::WriteBulkAtomic => "write_bulk_atomic",
109            CommandKind::RebuildCurrent => "rebuild_current",
110            CommandKind::RegisterModel => "register_model",
111            CommandKind::Shutdown => "shutdown",
112            CommandKind::BulkImportChunk => "bulk_import_chunk",
113            CommandKind::WriteConceptsChunk => "write_concepts_chunk",
114            CommandKind::WriteAnalyticsChunk => "write_analytics_chunk",
115            CommandKind::UpsertEmbeddingChunk => "upsert_embedding_chunk",
116            CommandKind::Archive => "archive",
117            CommandKind::RebuildFts => "rebuild_fts",
118            CommandKind::ShadowRebuild => "shadow_rebuild",
119        }
120    }
121
122    /// Whether this kind is exempt from [`crate::CHUNK_BUDGET`] by contract.
123    ///
124    /// The three exemptions are the table in `CHUNK_BUDGET`'s own rustdoc, and
125    /// they are carried here so a dashboard can separate "the budget is being
126    /// broken" from "the budget does not apply and never claimed to". Counting
127    /// an `archive` as a budget violation would make the violation count useless
128    /// on any database that archives.
129    ///
130    /// [`CommandKind::ShadowRebuild`] is deliberately **not** exempt. Its fill
131    /// chunks are meant to fit the budget and its swap turn is not going to —
132    /// the swap rebuilds three indexes under the lock, which is the residual
133    /// cost T1.2 could not remove. Both facts are worth seeing, and exempting
134    /// the kind would hide the first to excuse the second.
135    pub const fn exempt_from_budget(self) -> bool {
136        matches!(
137            self,
138            CommandKind::WriteBulkAtomic | CommandKind::Archive | CommandKind::RebuildCurrent
139        )
140    }
141}
142
143impl std::fmt::Display for CommandKind {
144    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145        f.write_str(self.as_str())
146    }
147}
148
149/// Upper bounds of the hold-duration histogram, in microseconds.
150///
151/// `3_000` is [`crate::CHUNK_BUDGET`] exactly, so the bucket boundary and the
152/// bound are the same number and a reader does not have to interpolate to answer
153/// "what fraction of turns fit". The tail runs to 1 s because D-059's measured
154/// worst case was 45 ms and `rebuild_current` at 40K rows is 318 ms (D-077) —
155/// a range this has to cover without saturating.
156///
157/// Anything above the last bound lands in the overflow bucket, which is why
158/// [`KindSnapshot::buckets`] is one longer than this slice.
159pub const BUCKET_BOUNDS_MICROS: &[u64] = &[
160    100, 300, 1_000, 3_000, 10_000, 30_000, 100_000, 300_000, 1_000_000,
161];
162
163/// Number of histogram buckets, including the overflow bucket.
164pub const BUCKET_COUNT: usize = BUCKET_BOUNDS_MICROS.len() + 1;
165
166#[allow(dead_code)] // used by `imp` under `metrics`, and by the tests always
167fn bucket_of(micros: u64) -> usize {
168    // Linear scan over nine bounds. A binary search here would be slower in
169    // practice and this runs once per actor turn, against a turn measured in
170    // microseconds at best.
171    BUCKET_BOUNDS_MICROS
172        .iter()
173        .position(|&bound| micros <= bound)
174        .unwrap_or(BUCKET_BOUNDS_MICROS.len())
175}
176
177/// Times one actor turn.
178///
179/// With the `metrics` feature off this reads no clock and holds nothing: the
180/// call site is identical, the cost is not paid. That is the whole reason it is
181/// a type rather than a bare `Instant::now()` in the loop.
182pub struct HoldTimer {
183    #[cfg(feature = "metrics")]
184    start: std::time::Instant,
185}
186
187impl HoldTimer {
188    #[inline]
189    pub fn start() -> Self {
190        Self {
191            #[cfg(feature = "metrics")]
192            start: std::time::Instant::now(),
193        }
194    }
195
196    #[inline]
197    pub fn elapsed(&self) -> Duration {
198        #[cfg(feature = "metrics")]
199        {
200            self.start.elapsed()
201        }
202        #[cfg(not(feature = "metrics"))]
203        {
204            Duration::ZERO
205        }
206    }
207}
208
209// ---------------------------------------------------------------------------
210// Instrumented implementation
211// ---------------------------------------------------------------------------
212
213#[cfg(feature = "metrics")]
214mod imp {
215    use super::{bucket_of, CommandKind, BUCKET_COUNT};
216    use std::sync::atomic::{AtomicU64, Ordering};
217    use std::time::Duration;
218
219    /// One kind's counters. All `Relaxed`: these are statistics, and ordering
220    /// them against each other would buy a consistency no reader needs and cost
221    /// fences on the write path.
222    #[derive(Debug, Default)]
223    struct Kind {
224        turns: AtomicU64,
225        total_micros: AtomicU64,
226        over_budget: AtomicU64,
227        /// This kind's own high-water mark, in µs.
228        ///
229        /// Not redundant with the global `longest`. That one names a single
230        /// command, so on any real database it names whichever kind is slowest
231        /// overall — and the question "did windowing shrink the archive's worst
232        /// hold" cannot be answered by a counter that a bulk import wins. No
233        /// packing needed here: the kind is the array index.
234        longest_micros: AtomicU64,
235        buckets: [AtomicU64; BUCKET_COUNT],
236    }
237
238    /// Live counters, shared between the actor and the handle.
239    ///
240    /// Fixed size, no allocation, no lock. The actor updates; anyone may read.
241    #[derive(Debug, Default)]
242    pub struct ActorMetrics {
243        kinds: [Kind; CommandKind::COUNT],
244        /// Packed `micros << 8 | kind`, so the longest hold and the kind that
245        /// caused it are read and written as **one** value. Two atomics would
246        /// let a reader see a duration from one turn beside a kind from
247        /// another — a rare wrong answer to exactly the question this field
248        /// exists to answer. 2^56 µs is over two thousand years.
249        ///
250        /// **The duration must occupy the high bits.** The update is a
251        /// `fetch_max` on the packed word, so whichever field is packed high is
252        /// the one being compared. The first version of this had the kind up
253        /// there, which made the "longest hold" the hold with the largest
254        /// *enum index* — a 3 ms `write_concepts_chunk` beat a 10 ms
255        /// `rebuild_current` because its variant is declared later. It was
256        /// `actor_metrics_tests` that caught it, not the unit tests, because
257        /// nothing in the arithmetic is wrong: the packing is only incorrect in
258        /// the presence of the atomic operation it exists to serve.
259        longest: AtomicU64,
260        /// Loop iterations, which is **not** the number of turns taken.
261        ///
262        /// The depth sample happens at the top of the loop, before `select!`
263        /// blocks — so an idle actor has already counted the iteration for a
264        /// command that has not arrived. That is right for depth (the sample is
265        /// "what was queued when I went looking") and wrong for turns, which is
266        /// why [`MetricsSnapshot::turns`] is the sum of the per-kind counters
267        /// instead. Conflating the two made `turns` permanently one too high and
268        /// disagree with its own breakdown.
269        depth_samples: AtomicU64,
270        high_depth_sum: AtomicU64,
271        high_depth_max: AtomicU64,
272        low_depth_sum: AtomicU64,
273        low_depth_max: AtomicU64,
274    }
275
276    const MICROS_SHIFT: u32 = 8;
277    const KIND_MASK: u64 = (1 << MICROS_SHIFT) - 1;
278
279    impl ActorMetrics {
280        pub fn new() -> Self {
281            Self::default()
282        }
283
284        /// Sample both queue depths. Called before the turn, not after: after
285        /// the turn the queue reflects what arrived *during* it, which is a
286        /// different and much less useful quantity.
287        #[inline]
288        pub fn record_turn(&self, high_depth: usize, low_depth: usize) {
289            self.depth_samples.fetch_add(1, Ordering::Relaxed);
290            for (sum, max, depth) in [
291                (
292                    &self.high_depth_sum,
293                    &self.high_depth_max,
294                    high_depth as u64,
295                ),
296                (&self.low_depth_sum, &self.low_depth_max, low_depth as u64),
297            ] {
298                sum.fetch_add(depth, Ordering::Relaxed);
299                max.fetch_max(depth, Ordering::Relaxed);
300            }
301        }
302
303        #[inline]
304        pub fn record_hold(&self, kind: CommandKind, held: Duration) {
305            let micros = held.as_micros().min(super::MICROS_CEILING as u128) as u64;
306            let k = &self.kinds[kind.index()];
307            k.turns.fetch_add(1, Ordering::Relaxed);
308            k.total_micros.fetch_add(micros, Ordering::Relaxed);
309            k.buckets[bucket_of(micros)].fetch_add(1, Ordering::Relaxed);
310            k.longest_micros.fetch_max(micros, Ordering::Relaxed);
311            if !kind.exempt_from_budget() && held > crate::CHUNK_BUDGET {
312                k.over_budget.fetch_add(1, Ordering::Relaxed);
313            }
314            self.longest.fetch_max(
315                (micros << MICROS_SHIFT) | kind.index() as u64,
316                Ordering::Relaxed,
317            );
318        }
319
320        /// A consistent-enough picture for a dashboard.
321        ///
322        /// Not a torn-read-free snapshot, and it does not pretend to be: the
323        /// actor keeps running while this walks the array, so two kinds may be
324        /// read one turn apart. Locking the actor to produce a report would make
325        /// the observer a source of the latency it is measuring.
326        pub fn snapshot(&self) -> super::MetricsSnapshot {
327            let samples = self.depth_samples.load(Ordering::Relaxed);
328            let mean = |sum: &AtomicU64| {
329                if samples == 0 {
330                    0.0
331                } else {
332                    sum.load(Ordering::Relaxed) as f64 / samples as f64
333                }
334            };
335
336            let packed = self.longest.load(Ordering::Relaxed);
337            let longest_micros = packed >> MICROS_SHIFT;
338            let longest = (longest_micros > 0)
339                .then(|| {
340                    let idx = (packed & KIND_MASK) as usize;
341                    CommandKind::ALL
342                        .get(idx)
343                        .map(|&kind| (kind, Duration::from_micros(longest_micros)))
344                })
345                .flatten();
346
347            let kinds: Vec<_> = CommandKind::ALL
348                .iter()
349                .map(|&kind| {
350                    let k = &self.kinds[kind.index()];
351                    let turns = k.turns.load(Ordering::Relaxed);
352                    let total = k.total_micros.load(Ordering::Relaxed);
353                    super::KindSnapshot {
354                        kind,
355                        turns,
356                        over_budget: k.over_budget.load(Ordering::Relaxed),
357                        mean: total
358                            .checked_div(turns)
359                            .map_or(Duration::ZERO, Duration::from_micros),
360                        longest: Duration::from_micros(k.longest_micros.load(Ordering::Relaxed)),
361                        buckets: std::array::from_fn(|i| k.buckets[i].load(Ordering::Relaxed)),
362                    }
363                })
364                .collect();
365
366            super::MetricsSnapshot {
367                // Summed, not counted separately — see `depth_samples`.
368                turns: kinds.iter().map(|k| k.turns).sum(),
369                depth_samples: samples,
370                high_depth_mean: mean(&self.high_depth_sum),
371                high_depth_max: self.high_depth_max.load(Ordering::Relaxed),
372                low_depth_mean: mean(&self.low_depth_sum),
373                low_depth_max: self.low_depth_max.load(Ordering::Relaxed),
374                longest,
375                kinds,
376            }
377        }
378    }
379}
380
381// ---------------------------------------------------------------------------
382// No-op implementation
383// ---------------------------------------------------------------------------
384
385#[cfg(not(feature = "metrics"))]
386mod imp {
387    use super::CommandKind;
388    use std::time::Duration;
389
390    /// The `metrics`-off shape: zero-sized, and every method is nothing.
391    #[derive(Debug, Default)]
392    pub struct ActorMetrics;
393
394    impl ActorMetrics {
395        pub fn new() -> Self {
396            Self
397        }
398        #[inline]
399        pub fn record_turn(&self, _high_depth: usize, _low_depth: usize) {}
400        #[inline]
401        pub fn record_hold(&self, _kind: CommandKind, _held: Duration) {}
402    }
403}
404
405pub use imp::ActorMetrics;
406
407/// Saturation point for a recorded hold, in microseconds (~2,000 years).
408///
409/// Exists so the packed `longest` field cannot have a pathological duration
410/// overflow into the kind bits. A hold this long is not a measurement, it is a
411/// hang — and the counter should stay readable rather than start reporting the
412/// wrong command.
413///
414/// Kept out of the `metrics` cfg so the invariant test below runs in the default
415/// build too: the packing is a property of the layout, and a build that does not
416/// record is exactly the build where nobody would notice it break.
417#[allow(dead_code)]
418const MICROS_CEILING: u64 = (1u64 << 56) - 1;
419
420/// One command kind's holds, as of the moment [`ActorMetrics::snapshot`] read it.
421#[cfg(feature = "metrics")]
422#[derive(Debug, Clone, PartialEq, Eq)]
423pub struct KindSnapshot {
424    pub kind: CommandKind,
425    /// Turns spent on this kind.
426    pub turns: u64,
427    /// Turns that exceeded [`crate::CHUNK_BUDGET`]. Always 0 for the three
428    /// kinds [`CommandKind::exempt_from_budget`] names — see there for why.
429    pub over_budget: u64,
430    pub mean: Duration,
431    /// This kind's longest hold. Distinct from [`MetricsSnapshot::longest`],
432    /// which names one command across all kinds and so tends to be permanently
433    /// whichever kind is slowest overall.
434    pub longest: Duration,
435    /// Counts per [`BUCKET_BOUNDS_MICROS`], plus a final overflow bucket.
436    pub buckets: [u64; BUCKET_COUNT],
437}
438
439/// What the actor has done since the database was opened.
440#[cfg(feature = "metrics")]
441#[derive(Debug, Clone, PartialEq)]
442pub struct MetricsSnapshot {
443    /// Commands executed, i.e. the sum of [`KindSnapshot::turns`]. The two agree
444    /// by construction rather than by coincidence.
445    pub turns: u64,
446    /// Loop iterations that took a queue-depth reading. Always at least
447    /// `turns + 1` on a live actor, because the reading is taken on the way in
448    /// to a `select!` that has not resolved yet. This is the denominator of the
449    /// two means below, and it is exposed so the difference is visible rather
450    /// than looking like drift.
451    pub depth_samples: u64,
452    pub high_depth_mean: f64,
453    pub high_depth_max: u64,
454    pub low_depth_mean: f64,
455    pub low_depth_max: u64,
456    /// The longest hold since open and what caused it. `None` before the first
457    /// turn, and — honestly — also when every turn so far took under a
458    /// microsecond, which on this path does not happen.
459    pub longest: Option<(CommandKind, Duration)>,
460    pub kinds: Vec<KindSnapshot>,
461}
462
463#[cfg(feature = "metrics")]
464impl MetricsSnapshot {
465    /// Kinds that broke the budget, worst first. The one-line answer to "is the
466    /// 3 ms bound holding?".
467    pub fn budget_violations(&self) -> Vec<&KindSnapshot> {
468        let mut v: Vec<_> = self.kinds.iter().filter(|k| k.over_budget > 0).collect();
469        v.sort_by_key(|k| std::cmp::Reverse(k.over_budget));
470        v
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477
478    #[test]
479    fn every_kind_indexes_to_its_own_slot() {
480        for (i, &kind) in CommandKind::ALL.iter().enumerate() {
481            assert_eq!(kind.index(), i, "{kind} is out of order in ALL");
482        }
483        assert_eq!(CommandKind::COUNT, CommandKind::ALL.len());
484    }
485
486    /// The budget is a bucket boundary, not a value inside one — so "fits in the
487    /// budget" is a prefix sum and needs no interpolation.
488    #[test]
489    fn the_chunk_budget_is_exactly_a_bucket_boundary() {
490        let budget = crate::CHUNK_BUDGET.as_micros() as u64;
491        assert!(
492            BUCKET_BOUNDS_MICROS.contains(&budget),
493            "CHUNK_BUDGET is {budget} µs, which is not a bucket bound: \
494             {BUCKET_BOUNDS_MICROS:?}"
495        );
496        assert_eq!(bucket_of(budget), bucket_of(budget - 1));
497        assert_eq!(bucket_of(budget + 1), bucket_of(budget) + 1);
498    }
499
500    #[test]
501    fn the_overflow_bucket_catches_everything_past_the_last_bound() {
502        let last = *BUCKET_BOUNDS_MICROS.last().unwrap();
503        assert_eq!(bucket_of(last), BUCKET_BOUNDS_MICROS.len() - 1);
504        assert_eq!(bucket_of(last + 1), BUCKET_COUNT - 1);
505        assert_eq!(bucket_of(u64::MAX), BUCKET_COUNT - 1);
506    }
507
508    /// The packing is the reason `longest` is one atomic: duration high, kind
509    /// low, so a `fetch_max` on the word compares the duration.
510    #[test]
511    fn the_packing_leaves_room_for_both_fields() {
512        assert!(
513            (CommandKind::COUNT as u64) <= 0xFF,
514            "the kind index must fit in the low 8 bits"
515        );
516        // The ceiling must survive being shifted up by the kind's width.
517        assert_eq!(MICROS_CEILING.checked_shl(8), Some(MICROS_CEILING << 8));
518        assert_eq!((MICROS_CEILING << 8) >> 8, MICROS_CEILING);
519    }
520
521    #[cfg(feature = "metrics")]
522    #[test]
523    fn the_longest_hold_names_the_command_that_caused_it() {
524        let m = ActorMetrics::new();
525        m.record_hold(CommandKind::AssertEdge, Duration::from_micros(500));
526        m.record_hold(CommandKind::Archive, Duration::from_millis(40));
527        m.record_hold(CommandKind::UpsertConcept, Duration::from_micros(900));
528
529        let snap = m.snapshot();
530        assert_eq!(
531            snap.longest,
532            Some((CommandKind::Archive, Duration::from_millis(40)))
533        );
534    }
535
536    /// The regression the packing bug produced: a *short* hold of a
537    /// later-declared kind must not outrank a long hold of an earlier one.
538    ///
539    /// The test above does not catch it, because `Archive` happens to be both
540    /// the longest hold and a high enum index — which is exactly why the first
541    /// version of the packing shipped past it. Here the two orderings disagree.
542    #[cfg(feature = "metrics")]
543    #[test]
544    fn a_later_declared_kind_does_not_outrank_a_longer_hold() {
545        let long = CommandKind::AssertEdge; // index 0
546        let short = CommandKind::RebuildFts; // last index
547        assert!(short.index() > long.index(), "the fixture needs the gap");
548
549        let m = ActorMetrics::new();
550        m.record_hold(long, Duration::from_millis(40));
551        m.record_hold(short, Duration::from_micros(1));
552
553        assert_eq!(
554            m.snapshot().longest,
555            Some((long, Duration::from_millis(40))),
556            "the max is being taken over the kind index, not the duration"
557        );
558    }
559
560    /// The three contractual exemptions must not show up as violations, or the
561    /// violation count is noise on any database that archives.
562    #[cfg(feature = "metrics")]
563    #[test]
564    fn an_exempt_kind_over_budget_is_not_a_violation() {
565        let m = ActorMetrics::new();
566        m.record_hold(CommandKind::Archive, Duration::from_millis(40));
567        m.record_hold(CommandKind::AssertEdge, Duration::from_millis(40));
568
569        let snap = m.snapshot();
570        let violations = snap.budget_violations();
571        assert_eq!(violations.len(), 1);
572        assert_eq!(violations[0].kind, CommandKind::AssertEdge);
573        assert_eq!(violations[0].over_budget, 1);
574
575        // But the hold is still *recorded* — exempt means "not a violation",
576        // not "not measured". A 40 ms archive is exactly what T1.1 exists to
577        // shrink, and it cannot be shrunk if it is not counted.
578        let archive = snap
579            .kinds
580            .iter()
581            .find(|k| k.kind == CommandKind::Archive)
582            .unwrap();
583        assert_eq!(archive.turns, 1);
584        assert_eq!(archive.mean, Duration::from_millis(40));
585    }
586
587    #[cfg(feature = "metrics")]
588    #[test]
589    fn queue_depth_is_a_mean_and_a_high_water_mark() {
590        let m = ActorMetrics::new();
591        m.record_turn(0, 4);
592        m.record_turn(10, 0);
593
594        let snap = m.snapshot();
595        // No command ran, so `turns` is 0 while `depth_samples` is 2. The two
596        // counters are different facts and this is the case that shows it.
597        assert_eq!(snap.turns, 0);
598        assert_eq!(snap.depth_samples, 2);
599        assert_eq!(snap.high_depth_mean, 5.0);
600        assert_eq!(snap.high_depth_max, 10);
601        assert_eq!(snap.low_depth_mean, 2.0);
602        assert_eq!(snap.low_depth_max, 4);
603    }
604}