Rindle docs and package mapSkip to main content

rindle/
metrics.rs

1//! Build-gated process metrics (the `metrics` feature) — the *scrape-path* sibling
2//! of the `observe` shim.
3//!
4//! `observe` routes seams to `tracing` spans/events for **sampled diagnostics**.
5//! Counters do not belong there: a Prometheus counter is just a monotonic number
6//! read at scrape time, so routing each increment through an event (callsite check,
7//! field capture, subscriber dispatch) is all cost and no benefit — and sampling a
8//! counter only trades that cost for an undercount you then have to scale back up.
9//! These macros instead fold each seam into a single relaxed atomic add on a
10//! process-global registry the metrics endpoint reads directly.
11//!
12//! Like `observe`, this is **off by default**: with the feature off every macro
13//! expands to an argument-consuming no-op and **no global is linked** (the wasm
14//! client and any embedder that doesn't want the counters pay nothing — verify with
15//! `cargo tree` / a symbol check). The daemon (`rindle-server`) opts in through its
16//! own `metrics` feature, which enables `rindle/metrics`.
17//!
18//! # Taxonomy (WS03 metric contract; counters unless noted)
19//!
20//! | Name                              | Labels | Seam |
21//! |-----------------------------------|--------|------|
22//! | `rindle.changes.processed`        | `kind` = add/remove/edit | `Graph::try_source_push` |
23//! | `rindle.build.ok`                 | —      | `builder::build_pipeline` (Ok) |
24//! | `rindle.build.errors`             | `kind` | `builder::build_pipeline` (Err) |
25//! | `rindle.push.visited`             | —      | `source_common::gen_push` (index candidates) |
26//! | `rindle.push.skipped`             | —      | `source_common::gen_push` (index-pruned slots) |
27//! | `rindle.join.probe.hit`           | —      | `Graph::push_child_change` (the parent fetch ran) |
28//! | `rindle.join.probe.miss`          | —      | `Graph::push_child_change` (the parent fetch was skipped) |
29//! | `rindle.join.precheck.disabled`   | `reason` = ineligible/observation/maintenance | the join pre-check gate (design 311 §2.5) |
30//!
31//! The `push.skipped / (push.visited + push.skipped)` ratio is the guarded-fan-out
32//! win (`designs/205-GUARDED-PUSH-FANOUT-DESIGN.md` §Observability); a `visited` that
33//! tracks total connections flags guard extraction silently failing for a workload.
34//! Likewise `join.probe.miss / (hit + miss)` is the join membership pre-check's win
35//! (`designs/311-JOIN-MEMBERSHIP-PRECHECK-DESIGN.md` §8): every miss is one reentrant
36//! parent fetch that did not run; a rising `precheck.disabled` says which gate is
37//! tripping for the workload.
38//!
39//! Labels are `&'static str` only, so the series count is bounded (no per-row/-query
40//! cardinality) — the discipline that keeps a cardinality-billed backend cheap.
41
42#[cfg(feature = "metrics")]
43mod imp {
44    use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
45
46    /// Process-global engine counters. All monotonic; read via [`snapshot`].
47    pub struct EngineMetrics {
48        pub changes_add: AtomicU64,
49        pub changes_remove: AtomicU64,
50        pub changes_edit: AtomicU64,
51        pub build_ok: AtomicU64,
52        pub build_err_unknown_column: AtomicU64,
53        pub build_err_unsupported: AtomicU64,
54        pub build_err_invalid: AtomicU64,
55        pub build_err_unknown_table: AtomicU64,
56        pub build_err_unknown_relationship: AtomicU64,
57        /// Slots the push index selected to visit (candidates), summed per push.
58        pub push_visited: AtomicU64,
59        /// Slots the push index pruned (total slots − candidates), summed per push.
60        pub push_skipped: AtomicU64,
61        /// Per-mutation panics contained by [`Graph::source_push_isolated`](crate::graph)'s
62        /// `catch_unwind` (the `release-server` `panic=unwind` net). A rising value is the
63        /// single best "a bad mutation is loose" signal (208.5).
64        pub mutation_panics: AtomicU64,
65        /// Join membership pre-check probes that HIT (the per-child-change parent fetch ran).
66        pub join_probe_hit: AtomicU64,
67        /// Join membership pre-check probes that MISSED (the parent fetch was skipped).
68        pub join_probe_miss: AtomicU64,
69        /// Joins whose pre-check was disabled by the eligibility walk (not on a root chain).
70        pub join_precheck_disabled_ineligible: AtomicU64,
71        /// Joins whose pre-check tripped a bound while observing a fetch.
72        pub join_precheck_disabled_observation: AtomicU64,
73        /// Joins whose pre-check tripped a bound (or an inconsistent count) on a parent push.
74        pub join_precheck_disabled_maintenance: AtomicU64,
75        /// Parameterized-query-family partitions bound (design 310 §4.4).
76        pub family_binds: AtomicU64,
77        /// Family partitions unbound (the synthetic drain, design 310 §4.4 / D5).
78        pub family_unbinds: AtomicU64,
79    }
80
81    impl EngineMetrics {
82        const fn new() -> Self {
83            EngineMetrics {
84                changes_add: AtomicU64::new(0),
85                changes_remove: AtomicU64::new(0),
86                changes_edit: AtomicU64::new(0),
87                build_ok: AtomicU64::new(0),
88                build_err_unknown_column: AtomicU64::new(0),
89                build_err_unsupported: AtomicU64::new(0),
90                build_err_invalid: AtomicU64::new(0),
91                build_err_unknown_table: AtomicU64::new(0),
92                build_err_unknown_relationship: AtomicU64::new(0),
93                push_visited: AtomicU64::new(0),
94                push_skipped: AtomicU64::new(0),
95                mutation_panics: AtomicU64::new(0),
96                join_probe_hit: AtomicU64::new(0),
97                join_probe_miss: AtomicU64::new(0),
98                join_precheck_disabled_ineligible: AtomicU64::new(0),
99                join_precheck_disabled_observation: AtomicU64::new(0),
100                join_precheck_disabled_maintenance: AtomicU64::new(0),
101                family_binds: AtomicU64::new(0),
102                family_unbinds: AtomicU64::new(0),
103            }
104        }
105    }
106
107    static ENGINE: EngineMetrics = EngineMetrics::new();
108
109    /// The process-global registry the instrumentation macros bump.
110    #[inline]
111    pub fn engine() -> &'static EngineMetrics {
112        &ENGINE
113    }
114
115    /// A plain-data copy of the registry for the metrics endpoint — keeps atomics
116    /// out of the public surface and reads every counter once (relaxed).
117    #[derive(Clone, Copy, Debug, Default)]
118    pub struct EngineSnapshot {
119        pub changes_add: u64,
120        pub changes_remove: u64,
121        pub changes_edit: u64,
122        pub build_ok: u64,
123        pub build_err_unknown_column: u64,
124        pub build_err_unsupported: u64,
125        pub build_err_invalid: u64,
126        pub build_err_unknown_table: u64,
127        pub build_err_unknown_relationship: u64,
128        pub push_visited: u64,
129        pub push_skipped: u64,
130        pub mutation_panics: u64,
131        pub join_probe_hit: u64,
132        pub join_probe_miss: u64,
133        pub join_precheck_disabled_ineligible: u64,
134        pub join_precheck_disabled_observation: u64,
135        pub join_precheck_disabled_maintenance: u64,
136        pub family_binds: u64,
137        pub family_unbinds: u64,
138    }
139
140    /// Snapshot every engine counter for rendering.
141    pub fn snapshot() -> EngineSnapshot {
142        EngineSnapshot {
143            changes_add: ENGINE.changes_add.load(Relaxed),
144            changes_remove: ENGINE.changes_remove.load(Relaxed),
145            changes_edit: ENGINE.changes_edit.load(Relaxed),
146            build_ok: ENGINE.build_ok.load(Relaxed),
147            build_err_unknown_column: ENGINE.build_err_unknown_column.load(Relaxed),
148            build_err_unsupported: ENGINE.build_err_unsupported.load(Relaxed),
149            build_err_invalid: ENGINE.build_err_invalid.load(Relaxed),
150            build_err_unknown_table: ENGINE.build_err_unknown_table.load(Relaxed),
151            build_err_unknown_relationship: ENGINE.build_err_unknown_relationship.load(Relaxed),
152            push_visited: ENGINE.push_visited.load(Relaxed),
153            push_skipped: ENGINE.push_skipped.load(Relaxed),
154            mutation_panics: ENGINE.mutation_panics.load(Relaxed),
155            join_probe_hit: ENGINE.join_probe_hit.load(Relaxed),
156            join_probe_miss: ENGINE.join_probe_miss.load(Relaxed),
157            join_precheck_disabled_ineligible: ENGINE
158                .join_precheck_disabled_ineligible
159                .load(Relaxed),
160            join_precheck_disabled_observation: ENGINE
161                .join_precheck_disabled_observation
162                .load(Relaxed),
163            join_precheck_disabled_maintenance: ENGINE
164                .join_precheck_disabled_maintenance
165                .load(Relaxed),
166            family_binds: ENGINE.family_binds.load(Relaxed),
167            family_unbinds: ENGINE.family_unbinds.load(Relaxed),
168        }
169    }
170
171    // -----------------------------------------------------------------------
172    // Histograms (208.2): a std-only fixed-bucket latency histogram, hand-rolled
173    // to keep this crate dep-free (no `prometheus`/`metrics` crate — wasm-clean,
174    // no C toolchain). Durations are captured as `u64` MICROSECONDS so the running
175    // sum stays integer (no float atomics); `_sum` is rendered `micros / 1e6` at
176    // scrape time only.
177    // -----------------------------------------------------------------------
178    use std::time::Instant;
179
180    /// One shared bucket table for every latency histogram, so the renderer stays
181    /// uniform. **These boundaries are a metric contract — choose once.** Upper bounds
182    /// for the first `N_BUCKETS - 1` buckets, in µs; the final bucket is `+Inf` (catches
183    /// any over-range sample).
184    pub const BUCKET_BOUNDS_MICROS: [u64; N_BUCKETS - 1] = [
185        50, 100, 250, 500, 1_000, 2_500, 5_000, 10_000, 25_000, 50_000, 100_000, 250_000, 500_000,
186        1_000_000, 2_500_000, 5_000_000,
187    ];
188
189    /// Total bucket count (16 finite bounds + `+Inf`).
190    pub const N_BUCKETS: usize = 17;
191
192    /// The `le` label for each bucket, in **seconds** (histograms are exposed in seconds
193    /// per Prometheus convention, though stored in µs). Kept next to `BUCKET_BOUNDS_MICROS`
194    /// so the two can't drift; the server renderer reads it directly.
195    pub const BUCKET_LE_SECONDS: [&str; N_BUCKETS] = [
196        "0.00005", "0.0001", "0.00025", "0.0005", "0.001", "0.0025", "0.005", "0.01", "0.025",
197        "0.05", "0.1", "0.25", "0.5", "1", "2.5", "5", "+Inf",
198    ];
199
200    /// Bounds for histograms that count **rows**, not time — today just the apply-batch
201    /// size. A separate table because the µs bounds above start at 50µs, which would put
202    /// every realistic batch in one bucket.
203    ///
204    /// Deliberately dense at the bottom: the interesting question for an app is "are my
205    /// normal writes 1 row or 20?", and the answer lives between 1 and 64.
206    ///
207    /// The top bound is **1024 on purpose** — it is `rindle-replica`'s `PUSH_CHUNK_ROWS`,
208    /// the size at which the coordinator cuts a transaction into apply batches, so 1024 is
209    /// the largest value this histogram can observe on the production path. Bounds beyond
210    /// it would be dead buckets that read like headroom. `+Inf` is therefore not slack but
211    /// a **signal**: anything landing there means an unchunked caller appeared and the cap
212    /// assumption behind this table no longer holds. See [`ApplyBatch`].
213    pub const ROW_BUCKET_BOUNDS: [u64; N_BUCKETS - 1] = [
214        1, 2, 3, 4, 6, 8, 12, 16, 24, 32, 48, 64, 128, 256, 512, 1024,
215    ];
216
217    /// The `le` label for each [`ROW_BUCKET_BOUNDS`] entry. Kept beside it so the two
218    /// cannot drift, exactly like [`BUCKET_LE_SECONDS`].
219    pub const ROW_BUCKET_LE: [&str; N_BUCKETS] = [
220        "1", "2", "3", "4", "6", "8", "12", "16", "24", "32", "48", "64", "128", "256", "512",
221        "1024", "+Inf",
222    ];
223
224    /// A Prometheus-shaped histogram over `BUCKET_BOUNDS_MICROS`, in microseconds.
225    /// All lock-free: `observe` is one linear scan (≤ `N_BUCKETS` compares) + three
226    /// relaxed adds. Per-bucket counts are **not** cumulative — the renderer sums them
227    /// cumulatively at scrape time.
228    pub struct Histogram {
229        buckets: [AtomicU64; N_BUCKETS],
230        /// Σ observed values → `_sum`, in whatever unit was observed: µs for a latency
231        /// histogram (the renderer divides by 1e6), raw rows for a count histogram.
232        /// Integer either way, so no float atomic.
233        sum: AtomicU64,
234        /// Total observations → `_count`.
235        count: AtomicU64,
236    }
237
238    impl Histogram {
239        pub const fn new() -> Self {
240            Histogram {
241                buckets: [const { AtomicU64::new(0) }; N_BUCKETS],
242                sum: AtomicU64::new(0),
243                count: AtomicU64::new(0),
244            }
245        }
246
247        /// Record one observation of `micros` µs. Linear scan for the first bucket whose
248        /// upper bound is `>= micros` (Prometheus `le` is inclusive); over-range falls to
249        /// the `+Inf` slot.
250        #[inline]
251        pub fn observe(&self, micros: u64) {
252            self.observe_in(micros, &BUCKET_BOUNDS_MICROS);
253        }
254
255        /// Record one observation against an explicit bound table — the same linear scan,
256        /// for histograms whose unit is not µs (see [`ROW_BUCKET_BOUNDS`]). The caller
257        /// must pass the SAME table the renderer labels with, or `le` would lie.
258        #[inline]
259        pub fn observe_in(&self, value: u64, bounds: &[u64; N_BUCKETS - 1]) {
260            let idx = bounds
261                .iter()
262                .position(|&b| value <= b)
263                .unwrap_or(N_BUCKETS - 1);
264            self.buckets[idx].fetch_add(1, Relaxed);
265            self.sum.fetch_add(value, Relaxed);
266            self.count.fetch_add(1, Relaxed);
267        }
268
269        /// A plain-data copy for rendering — reads every atomic once (relaxed).
270        pub fn snapshot(&self) -> HistogramSnapshot {
271            let mut buckets = [0u64; N_BUCKETS];
272            for (dst, src) in buckets.iter_mut().zip(self.buckets.iter()) {
273                *dst = src.load(Relaxed);
274            }
275            HistogramSnapshot {
276                buckets,
277                sum: self.sum.load(Relaxed),
278                count: self.count.load(Relaxed),
279            }
280        }
281    }
282
283    impl Default for Histogram {
284        fn default() -> Self {
285            Self::new()
286        }
287    }
288
289    /// A rendering-ready copy of a [`Histogram`]: per-bucket (non-cumulative) counts,
290    /// the summed observations (µs for latency, rows for a count histogram), and the
291    /// total count.
292    #[derive(Clone, Copy, Debug)]
293    pub struct HistogramSnapshot {
294        pub buckets: [u64; N_BUCKETS],
295        pub sum: u64,
296        pub count: u64,
297    }
298
299    /// RAII latency timer (208): mirrors the codebase's `CursorGuard`/`PooledStmt` habit
300    /// so early returns and `?` still record — it observes elapsed µs into its histogram
301    /// on drop, on every return path. `Instant::now()` (~tens of ns) is compiled only
302    /// under `metrics`.
303    pub struct Timed {
304        hist: &'static Histogram,
305        start: Instant,
306    }
307
308    impl Timed {
309        #[inline]
310        pub fn new(hist: &'static Histogram) -> Self {
311            Timed {
312                hist,
313                start: Instant::now(),
314            }
315        }
316    }
317
318    impl Drop for Timed {
319        #[inline]
320        fn drop(&mut self) {
321            self.hist.observe(self.start.elapsed().as_micros() as u64);
322        }
323    }
324
325    /// Process-global Tier-1 latency histograms (208.2), read via [`engine_hist`].
326    pub struct EngineHistograms {
327        /// One caller-declared BATCH of source pushes — on the replica path, one worker's
328        /// apply of one bounded chunk (fan-out + sink drain). See [`ApplyBatch`] for why
329        /// this is neither per-push nor per-transaction.
330        pub apply_batch: Histogram,
331        /// Rows in that batch, over [`ROW_BUCKET_BOUNDS`]. The companion that keeps the
332        /// latency reading interpretable: it separates "slow because it was 1000 rows"
333        /// from "slow because something is wrong".
334        pub apply_batch_rows: Histogram,
335        /// `builder::build_pipeline` — query compile latency.
336        pub query_build: Histogram,
337        /// `Graph::try_hydrate` — subscription cold-start latency.
338        pub hydrate: Histogram,
339        /// One family partition's constrained hydrate (`Graph::bind_family_partition`,
340        /// design 310 §4.4).
341        pub family_partition_hydrate: Histogram,
342    }
343
344    impl EngineHistograms {
345        const fn new() -> Self {
346            EngineHistograms {
347                apply_batch: Histogram::new(),
348                apply_batch_rows: Histogram::new(),
349                query_build: Histogram::new(),
350                hydrate: Histogram::new(),
351                family_partition_hydrate: Histogram::new(),
352            }
353        }
354    }
355
356    static ENGINE_HIST: EngineHistograms = EngineHistograms::new();
357
358    /// The process-global histogram registry the `metric_timer` macro observes into.
359    #[inline]
360    pub fn engine_hist() -> &'static EngineHistograms {
361        &ENGINE_HIST
362    }
363
364    /// RAII scope for ONE batch of source pushes: times the whole batch and records how
365    /// many rows it carried, both on drop.
366    ///
367    /// **Why this is not a per-push timer.** It used to be — `try_source_push` opened a
368    /// [`Timed`] — and that was wrong twice over. The apply path pushes a batch's rows ONE
369    /// AT A TIME (`rindle-replica`'s `push_and_drain` loops over the captured slice), so a
370    /// 100k-row write paid 100k `Instant::now()` pairs: measured at ~15ns per push, ~13%
371    /// of a minimal in-memory push. And it bought nothing, because `BUCKET_BOUNDS_MICROS`
372    /// starts at 50µs while a push takes ~150ns — every sample landed in bucket 0, so the
373    /// 17 buckets described no distribution at all.
374    ///
375    /// **Why it is not per-TRANSACTION either.** Naming matters here, because the obvious
376    /// reading of "batch" is "transaction" and it is wrong on the replica path in two
377    /// independent ways:
378    ///
379    /// 1. **Chunking.** `rindle-replica`'s coordinator cuts a transaction into `TxPush`
380    ///    chunks of at most `PUSH_CHUNK_ROWS` (1024) rows, so a transaction bigger than
381    ///    that is several batches. A 50k-row backfill is 49 observations, never one.
382    /// 2. **Broadcast.** Each chunk goes to EVERY worker (one shared `Arc`), and each
383    ///    worker opens its own scope over it. With `n_workers = W` a single chunk yields W
384    ///    observations of the same row count — so `_count` and `_sum` are ×W. Quantiles
385    ///    are unaffected (identical duplicates do not move a distribution), which is why
386    ///    the dashboard reads quantiles and not rates.
387    ///
388    /// So one observation is **one worker's apply of one bounded chunk** — real, useful
389    /// latency, but not a transaction. Do not label it as one. (An earlier revision of this
390    /// metric did, under the name `push_batch`; see the write-latency revision note in
391    /// `designs-implemented/208-METRICS-EXPANSION-DESIGN.md`.)
392    ///
393    /// Per-ROW cost stays recoverable, because `rindle_changes_processed_total` still
394    /// counts every change (one relaxed add, no clock) and scales with W the same way:
395    ///
396    /// ```text
397    /// rate(rindle_apply_batch_seconds_sum) / rate(rindle_changes_processed_total)
398    ///     = mean seconds per row
399    /// ```
400    ///
401    /// A caller that declares no batch is simply not timed — its rows are still counted.
402    /// That is deliberate: only a caller that knows its own batch boundary can draw one,
403    /// and inventing a batch-of-1 would reintroduce the per-push clock.
404    pub struct ApplyBatch {
405        rows: u64,
406        start: Instant,
407    }
408
409    impl Drop for ApplyBatch {
410        #[inline]
411        fn drop(&mut self) {
412            let h = engine_hist();
413            h.apply_batch
414                .observe(self.start.elapsed().as_micros() as u64);
415            h.apply_batch_rows.observe_in(self.rows, &ROW_BUCKET_BOUNDS);
416        }
417    }
418
419    /// Open an [`ApplyBatch`] scope for a batch of `rows` changes. Bind it (`let _batch =
420    /// …`) so it closes on every return path, `?` included.
421    #[inline]
422    pub fn apply_batch(rows: u64) -> ApplyBatch {
423        ApplyBatch {
424            rows,
425            start: Instant::now(),
426        }
427    }
428}
429
430// `ROW_BUCKET_BOUNDS` ships alongside `ROW_BUCKET_LE` deliberately: `Histogram::observe_in`
431// is public and its contract is "pass the SAME table the renderer labels with", which an
432// out-of-crate caller cannot honour if only the label half is reachable.
433#[cfg(feature = "metrics")]
434pub use imp::{
435    apply_batch, engine, engine_hist, snapshot, ApplyBatch, EngineHistograms, EngineMetrics,
436    EngineSnapshot, Histogram, HistogramSnapshot, Timed, BUCKET_LE_SECONDS, N_BUCKETS,
437    ROW_BUCKET_BOUNDS, ROW_BUCKET_LE,
438};
439
440// A no-op RAII timer TYPE for the feature-off build, so a `metric_timer!` site binds a
441// (zero-sized) value rather than unit — `let _t = ()` would trip `clippy::let_unit_value`
442// under `-D warnings`. No `Instant`, no Drop work: pays nothing.
443#[cfg(not(feature = "metrics"))]
444mod imp_off {
445    pub struct Timed;
446    impl Timed {
447        #[inline]
448        pub fn noop() -> Self {
449            Timed
450        }
451    }
452
453    /// Feature-off twin of the batch scope: a ZST with no `Drop`, no `Instant`, no
454    /// global. `apply_batch` is a plain `fn` (not a macro) so a caller in ANOTHER crate —
455    /// `rindle-replica`'s apply loop is the one that matters — writes one unconditional
456    /// line with no `#[cfg]` of its own and pays nothing here.
457    pub struct ApplyBatch;
458
459    #[inline]
460    pub fn apply_batch(_rows: u64) -> ApplyBatch {
461        ApplyBatch
462    }
463}
464
465#[cfg(not(feature = "metrics"))]
466pub use imp_off::{apply_batch, ApplyBatch, Timed};
467
468// ---------------------------------------------------------------------------
469// metrics ON: fold each seam into a relaxed atomic add on the global registry.
470// ---------------------------------------------------------------------------
471
472/// Bump a named [`EngineMetrics`] counter by one.
473#[cfg(feature = "metrics")]
474macro_rules! metric_inc {
475    ($field:ident) => {
476        $crate::metrics::engine()
477            .$field
478            .fetch_add(1, ::std::sync::atomic::Ordering::Relaxed)
479    };
480}
481
482/// Add `n` to a named [`EngineMetrics`] counter (for per-batch seams like the push
483/// fan-out's visited/skipped slot counts). `n` is evaluated once.
484#[cfg(feature = "metrics")]
485macro_rules! metric_add {
486    ($field:ident, $n:expr) => {
487        $crate::metrics::engine()
488            .$field
489            .fetch_add($n, ::std::sync::atomic::Ordering::Relaxed)
490    };
491}
492
493/// Classify a `SourceChange` into a small kind token carried across the move into the
494/// push (then consumed by [`metric_changes_inc!`]). Unconditional — always a `u8`, so the
495/// caller's binding is never unit (which would trip `clippy::let_unit_value` in the OFF
496/// build); when the feature is off the token feeds a no-op and the match is DCE'd. Pass
497/// `&change` so the original value is still movable into the push afterwards.
498macro_rules! metric_change_kind {
499    ($change:expr) => {
500        match $change {
501            $crate::change::SourceChange::Add(_) => 0u8,
502            $crate::change::SourceChange::Remove(_) => 1u8,
503            $crate::change::SourceChange::Edit { .. } => 2u8,
504        }
505    };
506}
507
508/// Bump `rindle.changes.processed` for the kind token from [`metric_change_kind!`].
509#[cfg(feature = "metrics")]
510macro_rules! metric_changes_inc {
511    ($kind:expr) => {{
512        use ::std::sync::atomic::Ordering::Relaxed;
513        let m = $crate::metrics::engine();
514        match $kind {
515            0u8 => &m.changes_add,
516            1u8 => &m.changes_remove,
517            _ => &m.changes_edit,
518        }
519        .fetch_add(1, Relaxed);
520    }};
521}
522
523/// Bump the per-`kind` `rindle.build.errors` counter for a `&BuildError`.
524#[cfg(feature = "metrics")]
525macro_rules! metric_build_err {
526    ($err:expr) => {{
527        use ::std::sync::atomic::Ordering::Relaxed;
528        let m = $crate::metrics::engine();
529        match $err {
530            $crate::builder::BuildError::UnknownColumn(_) => &m.build_err_unknown_column,
531            // The 226 §8 int64 gate is an unsupported-shape rejection; no separate
532            // counter until the gate is load-bearing enough to want one.
533            $crate::builder::BuildError::Unsupported(_)
534            | $crate::builder::BuildError::Int64ColumnUnsupported { .. } => {
535                &m.build_err_unsupported
536            }
537            $crate::builder::BuildError::Invalid(_) => &m.build_err_invalid,
538            $crate::builder::BuildError::UnknownTable(_) => &m.build_err_unknown_table,
539            $crate::builder::BuildError::UnknownRelationship(_) => {
540                &m.build_err_unknown_relationship
541            }
542        }
543        .fetch_add(1, Relaxed);
544    }};
545}
546
547/// Start an RAII latency timer for a named [`EngineHistograms`](imp::EngineHistograms)
548/// field. Bind it (`let _t = metric_timer!(hydrate);`) so it observes elapsed µs into
549/// the histogram when it drops — on every return path, `?` included. One timer per
550/// call, never per row — and "never per row" has to hold for the CALL GRAPH, not just
551/// this function body: check whether the caller loops before deciding a seam is coarse
552/// (that is exactly how the retired `source_push` timer became a per-row clock). A seam
553/// whose caller owns the batch wants [`ApplyBatch`](imp::ApplyBatch), not this.
554#[cfg(feature = "metrics")]
555macro_rules! metric_timer {
556    ($field:ident) => {
557        $crate::metrics::Timed::new(&$crate::metrics::engine_hist().$field)
558    };
559}
560
561// ---------------------------------------------------------------------------
562// metrics OFF: consume the args, emit nothing, link no global.
563// ---------------------------------------------------------------------------
564
565#[cfg(not(feature = "metrics"))]
566macro_rules! metric_inc {
567    ($field:ident) => {{}};
568}
569
570#[cfg(not(feature = "metrics"))]
571macro_rules! metric_add {
572    ($field:ident, $n:expr) => {{
573        let _ = $n;
574    }};
575}
576
577#[cfg(not(feature = "metrics"))]
578macro_rules! metric_changes_inc {
579    ($kind:expr) => {{
580        let _ = $kind;
581    }};
582}
583
584#[cfg(not(feature = "metrics"))]
585macro_rules! metric_build_err {
586    ($err:expr) => {{
587        let _ = $err;
588    }};
589}
590
591// A no-op timer guard (ZST) so the binding is never unit (`clippy::let_unit_value`).
592#[cfg(not(feature = "metrics"))]
593macro_rules! metric_timer {
594    ($field:ident) => {
595        $crate::metrics::Timed::noop()
596    };
597}
598
599#[allow(unused_imports)]
600pub(crate) use {
601    metric_add, metric_build_err, metric_change_kind, metric_changes_inc, metric_inc, metric_timer,
602};