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};