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/// # This clock is no longer optional (0.12.0, W1)
180///
181/// Until 0.11.0 the field was `#[cfg(feature = "metrics")]` and `elapsed()`
182/// returned `Duration::ZERO` in a default build: the reading existed only to
183/// feed [`ActorMetrics::record_hold`]'s histogram, so a build that did not keep
184/// the histogram had no reason to read a clock.
185///
186/// The chunk loop changed what the reading is *for*. A chunk's measured hold is
187/// now the input to the next chunk's size (`connection::next_chunk_size`, named
188/// in prose because it is private — D-144), which means it is a control signal
189/// in every build and not an observation in some of them. Left gated, `bulk_import` would have sized its chunks off
190/// `Duration::ZERO` — a value that reads as "comfortably under budget" — and
191/// grown every chunk to the ceiling, in exactly the builds nobody was measuring.
192///
193/// So the clock is unconditional and **only the histogram is still gated**:
194/// `record_hold` remains a no-op without the feature. What that costs is one
195/// `Instant::now()` pair per actor turn — tens of nanoseconds against a turn
196/// measured in microseconds at best, and the same reasoning §5.1.5 uses to
197/// decide that a channel hop is free beside a chunk.
198///
199/// It stays a type rather than a bare `Instant::now()` in the loop because the
200/// ordering guarantee in [`crate::connection`]'s `Turn` is attached to it.
201pub struct HoldTimer {
202    start: std::time::Instant,
203}
204
205impl HoldTimer {
206    #[inline]
207    pub fn start() -> Self {
208        Self {
209            start: std::time::Instant::now(),
210        }
211    }
212
213    #[inline]
214    pub fn elapsed(&self) -> Duration {
215        self.start.elapsed()
216    }
217}
218
219// ---------------------------------------------------------------------------
220// Instrumented implementation
221// ---------------------------------------------------------------------------
222
223#[cfg(feature = "metrics")]
224mod imp {
225    use super::{bucket_of, CommandKind, BUCKET_COUNT};
226    use std::sync::atomic::{AtomicU64, Ordering};
227    use std::time::Duration;
228
229    /// One kind's counters. All `Relaxed`: these are statistics, and ordering
230    /// them against each other would buy a consistency no reader needs and cost
231    /// fences on the write path.
232    #[derive(Debug, Default)]
233    struct Kind {
234        turns: AtomicU64,
235        total_micros: AtomicU64,
236        over_budget: AtomicU64,
237        /// This kind's own high-water mark, in µs.
238        ///
239        /// Not redundant with the global `longest`. That one names a single
240        /// command, so on any real database it names whichever kind is slowest
241        /// overall — and the question "did windowing shrink the archive's worst
242        /// hold" cannot be answered by a counter that a bulk import wins. No
243        /// packing needed here: the kind is the array index.
244        longest_micros: AtomicU64,
245        buckets: [AtomicU64; BUCKET_COUNT],
246    }
247
248    /// Live counters, shared between the actor and the handle.
249    ///
250    /// Fixed size, no allocation, no lock. The actor updates; anyone may read.
251    #[derive(Debug, Default)]
252    pub struct ActorMetrics {
253        kinds: [Kind; CommandKind::COUNT],
254        /// Packed `micros << 8 | kind`, so the longest hold and the kind that
255        /// caused it are read and written as **one** value. Two atomics would
256        /// let a reader see a duration from one turn beside a kind from
257        /// another — a rare wrong answer to exactly the question this field
258        /// exists to answer. 2^56 µs is over two thousand years.
259        ///
260        /// **The duration must occupy the high bits.** The update is a
261        /// `fetch_max` on the packed word, so whichever field is packed high is
262        /// the one being compared. The first version of this had the kind up
263        /// there, which made the "longest hold" the hold with the largest
264        /// *enum index* — a 3 ms `write_concepts_chunk` beat a 10 ms
265        /// `rebuild_current` because its variant is declared later. It was
266        /// `actor_metrics_tests` that caught it, not the unit tests, because
267        /// nothing in the arithmetic is wrong: the packing is only incorrect in
268        /// the presence of the atomic operation it exists to serve.
269        longest: AtomicU64,
270        /// Loop iterations, which is **not** the number of turns taken.
271        ///
272        /// The depth sample happens at the top of the loop, before `select!`
273        /// blocks — so an idle actor has already counted the iteration for a
274        /// command that has not arrived. That is right for depth (the sample is
275        /// "what was queued when I went looking") and wrong for turns, which is
276        /// why [`MetricsSnapshot::turns`] is the sum of the per-kind counters
277        /// instead. Conflating the two made `turns` permanently one too high and
278        /// disagree with its own breakdown.
279        depth_samples: AtomicU64,
280        high_depth_sum: AtomicU64,
281        high_depth_max: AtomicU64,
282        low_depth_sum: AtomicU64,
283        low_depth_max: AtomicU64,
284    }
285
286    const MICROS_SHIFT: u32 = 8;
287    const KIND_MASK: u64 = (1 << MICROS_SHIFT) - 1;
288
289    impl ActorMetrics {
290        pub fn new() -> Self {
291            Self::default()
292        }
293
294        /// Sample both queue depths. Called before the turn, not after: after
295        /// the turn the queue reflects what arrived *during* it, which is a
296        /// different and much less useful quantity.
297        #[inline]
298        pub fn record_turn(&self, high_depth: usize, low_depth: usize) {
299            self.depth_samples.fetch_add(1, Ordering::Relaxed);
300            for (sum, max, depth) in [
301                (
302                    &self.high_depth_sum,
303                    &self.high_depth_max,
304                    high_depth as u64,
305                ),
306                (&self.low_depth_sum, &self.low_depth_max, low_depth as u64),
307            ] {
308                sum.fetch_add(depth, Ordering::Relaxed);
309                max.fetch_max(depth, Ordering::Relaxed);
310            }
311        }
312
313        #[inline]
314        pub fn record_hold(&self, kind: CommandKind, held: Duration) {
315            let micros = held.as_micros().min(super::MICROS_CEILING as u128) as u64;
316            let k = &self.kinds[kind.index()];
317            k.turns.fetch_add(1, Ordering::Relaxed);
318            k.total_micros.fetch_add(micros, Ordering::Relaxed);
319            k.buckets[bucket_of(micros)].fetch_add(1, Ordering::Relaxed);
320            k.longest_micros.fetch_max(micros, Ordering::Relaxed);
321            if !kind.exempt_from_budget() && held > crate::CHUNK_BUDGET {
322                k.over_budget.fetch_add(1, Ordering::Relaxed);
323            }
324            self.longest.fetch_max(
325                (micros << MICROS_SHIFT) | kind.index() as u64,
326                Ordering::Relaxed,
327            );
328        }
329
330        /// A consistent-enough picture for a dashboard.
331        ///
332        /// Not a torn-read-free snapshot, and it does not pretend to be: the
333        /// actor keeps running while this walks the array, so two kinds may be
334        /// read one turn apart. Locking the actor to produce a report would make
335        /// the observer a source of the latency it is measuring.
336        pub fn snapshot(&self) -> super::MetricsSnapshot {
337            let samples = self.depth_samples.load(Ordering::Relaxed);
338            let mean = |sum: &AtomicU64| {
339                if samples == 0 {
340                    0.0
341                } else {
342                    sum.load(Ordering::Relaxed) as f64 / samples as f64
343                }
344            };
345
346            let packed = self.longest.load(Ordering::Relaxed);
347            let longest_micros = packed >> MICROS_SHIFT;
348            let longest = (longest_micros > 0)
349                .then(|| {
350                    let idx = (packed & KIND_MASK) as usize;
351                    CommandKind::ALL
352                        .get(idx)
353                        .map(|&kind| (kind, Duration::from_micros(longest_micros)))
354                })
355                .flatten();
356
357            let kinds: Vec<_> = CommandKind::ALL
358                .iter()
359                .map(|&kind| {
360                    let k = &self.kinds[kind.index()];
361                    let turns = k.turns.load(Ordering::Relaxed);
362                    let total = k.total_micros.load(Ordering::Relaxed);
363                    super::KindSnapshot {
364                        kind,
365                        turns,
366                        over_budget: k.over_budget.load(Ordering::Relaxed),
367                        mean: total
368                            .checked_div(turns)
369                            .map_or(Duration::ZERO, Duration::from_micros),
370                        longest: Duration::from_micros(k.longest_micros.load(Ordering::Relaxed)),
371                        buckets: std::array::from_fn(|i| k.buckets[i].load(Ordering::Relaxed)),
372                    }
373                })
374                .collect();
375
376            super::MetricsSnapshot {
377                // Summed, not counted separately — see `depth_samples`.
378                turns: kinds.iter().map(|k| k.turns).sum(),
379                depth_samples: samples,
380                high_depth_mean: mean(&self.high_depth_sum),
381                high_depth_max: self.high_depth_max.load(Ordering::Relaxed),
382                low_depth_mean: mean(&self.low_depth_sum),
383                low_depth_max: self.low_depth_max.load(Ordering::Relaxed),
384                longest,
385                kinds,
386            }
387        }
388    }
389}
390
391// ---------------------------------------------------------------------------
392// No-op implementation
393// ---------------------------------------------------------------------------
394
395#[cfg(not(feature = "metrics"))]
396mod imp {
397    use super::CommandKind;
398    use std::time::Duration;
399
400    /// The `metrics`-off shape: zero-sized, and every method is nothing.
401    #[derive(Debug, Default)]
402    pub struct ActorMetrics;
403
404    impl ActorMetrics {
405        pub fn new() -> Self {
406            Self
407        }
408        #[inline]
409        pub fn record_turn(&self, _high_depth: usize, _low_depth: usize) {}
410        #[inline]
411        pub fn record_hold(&self, _kind: CommandKind, _held: Duration) {}
412    }
413}
414
415pub use imp::ActorMetrics;
416
417/// Saturation point for a recorded hold, in microseconds (~2,000 years).
418///
419/// Exists so the packed `longest` field cannot have a pathological duration
420/// overflow into the kind bits. A hold this long is not a measurement, it is a
421/// hang — and the counter should stay readable rather than start reporting the
422/// wrong command.
423///
424/// Kept out of the `metrics` cfg so the invariant test below runs in the default
425/// build too: the packing is a property of the layout, and a build that does not
426/// record is exactly the build where nobody would notice it break.
427#[allow(dead_code)]
428const MICROS_CEILING: u64 = (1u64 << 56) - 1;
429
430/// One command kind's holds, as of the moment [`ActorMetrics::snapshot`] read it.
431#[cfg(feature = "metrics")]
432#[derive(Debug, Clone, PartialEq, Eq)]
433pub struct KindSnapshot {
434    pub kind: CommandKind,
435    /// Turns spent on this kind.
436    pub turns: u64,
437    /// Turns that exceeded [`crate::CHUNK_BUDGET`]. Always 0 for the three
438    /// kinds [`CommandKind::exempt_from_budget`] names — see there for why.
439    pub over_budget: u64,
440    pub mean: Duration,
441    /// This kind's longest hold. Distinct from [`MetricsSnapshot::longest`],
442    /// which names one command across all kinds and so tends to be permanently
443    /// whichever kind is slowest overall.
444    pub longest: Duration,
445    /// Counts per [`BUCKET_BOUNDS_MICROS`], plus a final overflow bucket.
446    pub buckets: [u64; BUCKET_COUNT],
447}
448
449/// What the actor has done since the database was opened.
450#[cfg(feature = "metrics")]
451#[derive(Debug, Clone, PartialEq)]
452pub struct MetricsSnapshot {
453    /// Commands executed, i.e. the sum of [`KindSnapshot::turns`]. The two agree
454    /// by construction rather than by coincidence.
455    pub turns: u64,
456    /// Loop iterations that took a queue-depth reading. Always at least
457    /// `turns + 1` on a live actor, because the reading is taken on the way in
458    /// to a `select!` that has not resolved yet. This is the denominator of the
459    /// two means below, and it is exposed so the difference is visible rather
460    /// than looking like drift.
461    pub depth_samples: u64,
462    pub high_depth_mean: f64,
463    pub high_depth_max: u64,
464    pub low_depth_mean: f64,
465    pub low_depth_max: u64,
466    /// The longest hold since open and what caused it. `None` before the first
467    /// turn, and — honestly — also when every turn so far took under a
468    /// microsecond, which on this path does not happen.
469    pub longest: Option<(CommandKind, Duration)>,
470    pub kinds: Vec<KindSnapshot>,
471}
472
473#[cfg(feature = "metrics")]
474impl MetricsSnapshot {
475    /// Kinds that broke the budget, worst first. The one-line answer to "is the
476    /// 3 ms bound holding?".
477    pub fn budget_violations(&self) -> Vec<&KindSnapshot> {
478        let mut v: Vec<_> = self.kinds.iter().filter(|k| k.over_budget > 0).collect();
479        v.sort_by_key(|k| std::cmp::Reverse(k.over_budget));
480        v
481    }
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487
488    #[test]
489    fn every_kind_indexes_to_its_own_slot() {
490        for (i, &kind) in CommandKind::ALL.iter().enumerate() {
491            assert_eq!(kind.index(), i, "{kind} is out of order in ALL");
492        }
493        assert_eq!(CommandKind::COUNT, CommandKind::ALL.len());
494    }
495
496    /// The budget is a bucket boundary, not a value inside one — so "fits in the
497    /// budget" is a prefix sum and needs no interpolation.
498    #[test]
499    fn the_chunk_budget_is_exactly_a_bucket_boundary() {
500        let budget = crate::CHUNK_BUDGET.as_micros() as u64;
501        assert!(
502            BUCKET_BOUNDS_MICROS.contains(&budget),
503            "CHUNK_BUDGET is {budget} µs, which is not a bucket bound: \
504             {BUCKET_BOUNDS_MICROS:?}"
505        );
506        assert_eq!(bucket_of(budget), bucket_of(budget - 1));
507        assert_eq!(bucket_of(budget + 1), bucket_of(budget) + 1);
508    }
509
510    #[test]
511    fn the_overflow_bucket_catches_everything_past_the_last_bound() {
512        let last = *BUCKET_BOUNDS_MICROS.last().unwrap();
513        assert_eq!(bucket_of(last), BUCKET_BOUNDS_MICROS.len() - 1);
514        assert_eq!(bucket_of(last + 1), BUCKET_COUNT - 1);
515        assert_eq!(bucket_of(u64::MAX), BUCKET_COUNT - 1);
516    }
517
518    /// The packing is the reason `longest` is one atomic: duration high, kind
519    /// low, so a `fetch_max` on the word compares the duration.
520    #[test]
521    fn the_packing_leaves_room_for_both_fields() {
522        assert!(
523            (CommandKind::COUNT as u64) <= 0xFF,
524            "the kind index must fit in the low 8 bits"
525        );
526        // The ceiling must survive being shifted up by the kind's width.
527        assert_eq!(MICROS_CEILING.checked_shl(8), Some(MICROS_CEILING << 8));
528        assert_eq!((MICROS_CEILING << 8) >> 8, MICROS_CEILING);
529    }
530
531    #[cfg(feature = "metrics")]
532    #[test]
533    fn the_longest_hold_names_the_command_that_caused_it() {
534        let m = ActorMetrics::new();
535        m.record_hold(CommandKind::AssertEdge, Duration::from_micros(500));
536        m.record_hold(CommandKind::Archive, Duration::from_millis(40));
537        m.record_hold(CommandKind::UpsertConcept, Duration::from_micros(900));
538
539        let snap = m.snapshot();
540        assert_eq!(
541            snap.longest,
542            Some((CommandKind::Archive, Duration::from_millis(40)))
543        );
544    }
545
546    /// The regression the packing bug produced: a *short* hold of a
547    /// later-declared kind must not outrank a long hold of an earlier one.
548    ///
549    /// The test above does not catch it, because `Archive` happens to be both
550    /// the longest hold and a high enum index — which is exactly why the first
551    /// version of the packing shipped past it. Here the two orderings disagree.
552    #[cfg(feature = "metrics")]
553    #[test]
554    fn a_later_declared_kind_does_not_outrank_a_longer_hold() {
555        let long = CommandKind::AssertEdge; // index 0
556        let short = CommandKind::RebuildFts; // last index
557        assert!(short.index() > long.index(), "the fixture needs the gap");
558
559        let m = ActorMetrics::new();
560        m.record_hold(long, Duration::from_millis(40));
561        m.record_hold(short, Duration::from_micros(1));
562
563        assert_eq!(
564            m.snapshot().longest,
565            Some((long, Duration::from_millis(40))),
566            "the max is being taken over the kind index, not the duration"
567        );
568    }
569
570    /// The three contractual exemptions must not show up as violations, or the
571    /// violation count is noise on any database that archives.
572    #[cfg(feature = "metrics")]
573    #[test]
574    fn an_exempt_kind_over_budget_is_not_a_violation() {
575        let m = ActorMetrics::new();
576        m.record_hold(CommandKind::Archive, Duration::from_millis(40));
577        m.record_hold(CommandKind::AssertEdge, Duration::from_millis(40));
578
579        let snap = m.snapshot();
580        let violations = snap.budget_violations();
581        assert_eq!(violations.len(), 1);
582        assert_eq!(violations[0].kind, CommandKind::AssertEdge);
583        assert_eq!(violations[0].over_budget, 1);
584
585        // But the hold is still *recorded* — exempt means "not a violation",
586        // not "not measured". A 40 ms archive is exactly what T1.1 exists to
587        // shrink, and it cannot be shrunk if it is not counted.
588        let archive = snap
589            .kinds
590            .iter()
591            .find(|k| k.kind == CommandKind::Archive)
592            .unwrap();
593        assert_eq!(archive.turns, 1);
594        assert_eq!(archive.mean, Duration::from_millis(40));
595    }
596
597    #[cfg(feature = "metrics")]
598    #[test]
599    fn queue_depth_is_a_mean_and_a_high_water_mark() {
600        let m = ActorMetrics::new();
601        m.record_turn(0, 4);
602        m.record_turn(10, 0);
603
604        let snap = m.snapshot();
605        // No command ran, so `turns` is 0 while `depth_samples` is 2. The two
606        // counters are different facts and this is the case that shows it.
607        assert_eq!(snap.turns, 0);
608        assert_eq!(snap.depth_samples, 2);
609        assert_eq!(snap.high_depth_mean, 5.0);
610        assert_eq!(snap.high_depth_max, 10);
611        assert_eq!(snap.low_depth_mean, 2.0);
612        assert_eq!(snap.low_depth_max, 4);
613    }
614}