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}