Skip to main content

spate_core/metrics/
sink.rs

1//! Sink-shard handles (`spate_sink_*`), one struct per shard worker, plus the
2//! end-to-end latency histogram observed at the terminal stage.
3
4use super::labels::{ComponentLabels, OwnedGauge};
5use super::names;
6use super::ownership::{SeriesClaim, series_key};
7use super::{E2eBasis, MetricsError};
8use crate::error::ErrorClass;
9use metrics::{Counter, Histogram, SharedString};
10use std::collections::HashMap;
11use std::sync::Mutex;
12use std::time::Duration;
13
14/// Why a sink batch was sealed and flushed.
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub enum FlushReason {
17    /// `max_rows` reached.
18    Rows,
19    /// `max_bytes` reached.
20    Bytes,
21    /// Linger deadline expired.
22    Linger,
23    /// Drain (shutdown or revocation) forced the seal.
24    Drain,
25}
26
27impl FlushReason {
28    fn label(self) -> &'static str {
29        match self {
30            FlushReason::Rows => "rows",
31            FlushReason::Bytes => "bytes",
32            FlushReason::Linger => "linger",
33            FlushReason::Drain => "drain",
34        }
35    }
36}
37
38/// Outcome of one sink write attempt (the `outcome` label on
39/// `spate_sink_write_duration_seconds`).
40///
41/// One family with a label rather than two names, which is the opposite of
42/// the call made for the two coordination latencies — the distinction is
43/// that those measure different things on different clocks with different
44/// denominators, so a shared family would assert a composition that does not
45/// exist. These two are the same measurement (time inside `write_batch`)
46/// over the same population (attempts), so the aggregate is well-defined; it
47/// is merely the wrong *diagnostic*, which a label documents and a split name
48/// would over-state. It also matches `outcome` on the three counter families
49/// that already use it.
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51#[non_exhaustive]
52pub(crate) enum AttemptOutcome {
53    /// The write was accepted.
54    Ok,
55    /// The write failed; its taxonomy class goes to
56    /// [`SinkShardMetrics::errors`].
57    Error,
58}
59
60/// Per-replica handles inside one shard.
61#[derive(Debug)]
62struct ReplicaMetrics {
63    healthy: OwnedGauge,
64    breaker_opens: Counter,
65    errors: Counter,
66}
67
68/// Sink-shard handles (`spate_sink_*`), one struct per shard worker.
69#[derive(Debug)]
70pub struct SinkShardMetrics {
71    records: Counter,
72    bytes: Counter,
73    batch_rows: Histogram,
74    batch_bytes: Histogram,
75    flush_rows: Counter,
76    flush_bytes: Counter,
77    flush_linger: Counter,
78    flush_drain: Counter,
79    flush_duration: Histogram,
80    write_ok: Histogram,
81    write_err: Histogram,
82    permit_wait: Histogram,
83    retries: Counter,
84    retry_backoff: OwnedGauge,
85    /// Current backoff step, in seconds, of every batch of this shard that is
86    /// sleeping between write attempts, keyed by batch sequence number. The
87    /// gauge publishes the max; the map is bounded by `inflight.max_per_shard`
88    /// and empties back to nothing whenever the shard stops backing off.
89    backoff_steps: Mutex<HashMap<u64, f64>>,
90    err_retryable: Counter,
91    err_record: Counter,
92    err_fatal: Counter,
93    inflight: OwnedGauge,
94    abandoned: Counter,
95    drain_overrun: Counter,
96    shard_healthy: OwnedGauge,
97    e2e: Histogram,
98    e2e_basis: E2eBasis,
99    replicas: Vec<ReplicaMetrics>,
100    _claim: Option<SeriesClaim>,
101}
102
103impl SinkShardMetrics {
104    /// Resolve all handles for one shard. `replicas` are display names used
105    /// as the `replica` label (bounded by cluster topology). `e2e_basis`
106    /// selects the time base for `spate_e2e_latency_seconds` (see
107    /// `docs/METRICS.md`).
108    ///
109    /// Call **after** [`install`](crate::metrics::install): handles bind to
110    /// the recorder present at construction, and a handle built before the
111    /// exporter exists silently records into the void.
112    ///
113    /// Claims this shard's series — the labels plus `shard` — so that only one
114    /// live handle set publishes them. The gauges here are edge-triggered
115    /// (health flips on a breaker transition, backoff on a retry), so a second
116    /// writer's reading would stand until the owner's next transition, which
117    /// for a quarantined shard may be never. A collision therefore logs and
118    /// leaves this instance a shadow: its counters still record, its gauges do
119    /// not (see "Series ownership" in `docs/METRICS.md`). Assembly through
120    /// [`Pipeline`](crate::pipeline::Pipeline) refuses to build instead.
121    pub fn new(
122        labels: &ComponentLabels,
123        shard: u32,
124        replicas: &[String],
125        e2e_basis: E2eBasis,
126    ) -> Self {
127        let claim = SeriesClaim::claim_or_shadow(Self::key(labels, shard));
128        Self::build(labels, shard, replicas, e2e_basis, claim)
129    }
130
131    /// Resolve all handles for one shard, failing when another live handle set
132    /// already owns the shard's series. The pipeline builder's path.
133    ///
134    /// # Errors
135    ///
136    /// [`MetricsError::DuplicateSeries`] on a collision.
137    pub fn try_new(
138        labels: &ComponentLabels,
139        shard: u32,
140        replicas: &[String],
141        e2e_basis: E2eBasis,
142    ) -> Result<Self, MetricsError> {
143        let claim = SeriesClaim::try_claim(Self::key(labels, shard))?;
144        Ok(Self::build(labels, shard, replicas, e2e_basis, Some(claim)))
145    }
146
147    fn key(labels: &ComponentLabels, shard: u32) -> String {
148        series_key("sink", labels, &format!("shard={shard}"))
149    }
150
151    fn build(
152        labels: &ComponentLabels,
153        shard: u32,
154        replicas: &[String],
155        e2e_basis: E2eBasis,
156        claim: Option<SeriesClaim>,
157    ) -> Self {
158        // Resolved before any handle is written: the initial publishes below
159        // are exactly the writes that would clobber a live owner's reading.
160        let owned = claim.is_some();
161        let shard: SharedString = shard.to_string().into();
162        let replicas = replicas
163            .iter()
164            .map(|replica| {
165                let m = ReplicaMetrics {
166                    healthy: OwnedGauge::new(
167                        labels.gauge2(
168                            names::SINK_REPLICA_HEALTHY,
169                            names::L_SHARD,
170                            shard.clone(),
171                            names::L_REPLICA,
172                            replica.clone(),
173                        ),
174                        owned,
175                    ),
176                    breaker_opens: labels.counter2(
177                        names::SINK_BREAKER_OPENS_TOTAL,
178                        names::L_SHARD,
179                        shard.clone(),
180                        names::L_REPLICA,
181                        replica.clone(),
182                    ),
183                    errors: labels.counter2(
184                        names::SINK_REPLICA_ERRORS_TOTAL,
185                        names::L_SHARD,
186                        shard.clone(),
187                        names::L_REPLICA,
188                        replica.clone(),
189                    ),
190                };
191                m.healthy.set(1.0);
192                m
193            })
194            .collect();
195        let shard_healthy = OwnedGauge::new(
196            labels.gauge1(names::SINK_SHARD_HEALTHY, names::L_SHARD, shard.clone()),
197            owned,
198        );
199        shard_healthy.set(1.0);
200        // Published as `0` from construction rather than left absent until the
201        // first retry: "this shard is not backing off" is true of a shard that
202        // has never written, so there is no measurement to wait for. (Contrast
203        // `spate_source_lag_records`, where absence carries information — see the
204        // "Absent, zero, and stale" section of `docs/METRICS.md`.)
205        let retry_backoff = OwnedGauge::new(
206            labels.gauge1(
207                names::SINK_RETRY_BACKOFF_SECONDS,
208                names::L_SHARD,
209                shard.clone(),
210            ),
211            owned,
212        );
213        retry_backoff.set(0.0);
214        SinkShardMetrics {
215            records: labels.counter1(names::SINK_RECORDS_TOTAL, names::L_SHARD, shard.clone()),
216            bytes: labels.counter1(names::SINK_BYTES_TOTAL, names::L_SHARD, shard.clone()),
217            batch_rows: labels.histogram(names::SINK_BATCH_ROWS),
218            batch_bytes: labels.histogram(names::SINK_BATCH_BYTES),
219            flush_rows: labels.counter2(
220                names::SINK_FLUSHES_TOTAL,
221                names::L_SHARD,
222                shard.clone(),
223                names::L_REASON,
224                FlushReason::Rows.label(),
225            ),
226            flush_bytes: labels.counter2(
227                names::SINK_FLUSHES_TOTAL,
228                names::L_SHARD,
229                shard.clone(),
230                names::L_REASON,
231                FlushReason::Bytes.label(),
232            ),
233            flush_linger: labels.counter2(
234                names::SINK_FLUSHES_TOTAL,
235                names::L_SHARD,
236                shard.clone(),
237                names::L_REASON,
238                FlushReason::Linger.label(),
239            ),
240            flush_drain: labels.counter2(
241                names::SINK_FLUSHES_TOTAL,
242                names::L_SHARD,
243                shard.clone(),
244                names::L_REASON,
245                FlushReason::Drain.label(),
246            ),
247            flush_duration: labels.histogram1(
248                names::SINK_FLUSH_DURATION_SECONDS,
249                names::L_SHARD,
250                shard.clone(),
251            ),
252            write_ok: labels.histogram2(
253                names::SINK_WRITE_DURATION_SECONDS,
254                names::L_SHARD,
255                shard.clone(),
256                names::L_OUTCOME,
257                "ok",
258            ),
259            write_err: labels.histogram2(
260                names::SINK_WRITE_DURATION_SECONDS,
261                names::L_SHARD,
262                shard.clone(),
263                names::L_OUTCOME,
264                "error",
265            ),
266            permit_wait: labels.histogram1(
267                names::SINK_PERMIT_WAIT_DURATION_SECONDS,
268                names::L_SHARD,
269                shard.clone(),
270            ),
271            retries: labels.counter1(names::SINK_RETRIES_TOTAL, names::L_SHARD, shard.clone()),
272            retry_backoff,
273            backoff_steps: Mutex::new(HashMap::new()),
274            err_retryable: labels.counter2(
275                names::SINK_ERRORS_TOTAL,
276                names::L_SHARD,
277                shard.clone(),
278                names::L_ERROR_TYPE,
279                ErrorClass::Retryable.label(),
280            ),
281            err_record: labels.counter2(
282                names::SINK_ERRORS_TOTAL,
283                names::L_SHARD,
284                shard.clone(),
285                names::L_ERROR_TYPE,
286                ErrorClass::RecordLevel.label(),
287            ),
288            err_fatal: labels.counter2(
289                names::SINK_ERRORS_TOTAL,
290                names::L_SHARD,
291                shard.clone(),
292                names::L_ERROR_TYPE,
293                ErrorClass::Fatal.label(),
294            ),
295            inflight: OwnedGauge::new(
296                labels.gauge1(names::SINK_INFLIGHT_BATCHES, names::L_SHARD, shard.clone()),
297                owned,
298            ),
299            abandoned: labels.counter1(
300                names::SINK_ABANDONED_BATCHES_TOTAL,
301                names::L_SHARD,
302                shard.clone(),
303            ),
304            drain_overrun: labels.counter1(names::SINK_DRAIN_OVERRUN_TOTAL, names::L_SHARD, shard),
305            shard_healthy,
306            e2e: labels.histogram(names::E2E_LATENCY_SECONDS),
307            e2e_basis,
308            replicas,
309            _claim: claim,
310        }
311    }
312
313    /// Observe end-to-end latency for one durably written batch, from its
314    /// oldest record. `ingest_age` is time since that record entered the
315    /// terminal stage; `oldest_event_ms` is its source event time. The
316    /// configured basis picks which one lands in the histogram (event
317    /// basis falls back to ingest when no event time was available).
318    #[inline]
319    pub fn e2e_observed(&self, ingest_age: Duration, oldest_event_ms: i64) {
320        let latency = match self.e2e_basis {
321            E2eBasis::Event if oldest_event_ms != i64::MAX => {
322                let now_ms = std::time::SystemTime::now()
323                    .duration_since(std::time::UNIX_EPOCH)
324                    .map(|d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
325                    .unwrap_or(0);
326                Duration::from_millis(u64::try_from(now_ms - oldest_event_ms).unwrap_or(0))
327            }
328            _ => ingest_age,
329        };
330        self.e2e.record(latency.as_secs_f64());
331    }
332
333    /// Record one durably acknowledged flush.
334    ///
335    /// `d` is the batch's **seal-to-settle** time, and contains everything
336    /// that stood between the two: the wait for an `inflight.max_per_shard`
337    /// permit, every failed attempt, every retry-backoff sleep and
338    /// all-replicas-quarantined probe wait, and the write that finally
339    /// succeeded. It is the right input for a commit-lag budget and the wrong
340    /// one for "how fast is the sink" — [`write_attempt`](Self::write_attempt)
341    /// answers that, and [`permit_waited`](Self::permit_waited) the queueing
342    /// share.
343    ///
344    /// Only settled batches are observed. An abandoned one never reaches
345    /// here — whether it was aborted at the drain deadline, rejected with a
346    /// fatal class, exhausted `retry.max_attempts`, or died with a panicking
347    /// write task. All four are counted by [`abandoned`](Self::abandoned),
348    /// and the last three happen in steady state with no drain in sight.
349    #[inline]
350    pub fn flushed(&self, reason: FlushReason, rows: u64, bytes: u64, d: Duration) {
351        self.records.increment(rows);
352        self.bytes.increment(bytes);
353        self.batch_rows.record(rows as f64);
354        self.batch_bytes.record(bytes as f64);
355        self.flush_duration.record(d.as_secs_f64());
356        match reason {
357            FlushReason::Rows => self.flush_rows.increment(1),
358            FlushReason::Bytes => self.flush_bytes.increment(1),
359            FlushReason::Linger => self.flush_linger.increment(1),
360            FlushReason::Drain => self.flush_drain.increment(1),
361        }
362    }
363
364    /// Observe one write attempt: the time inside
365    /// [`ShardWriter::write_batch`](crate::sink::ShardWriter::write_batch) and
366    /// nothing else *of the framework's own*. Every attempt is observed,
367    /// retries included, so this is the sink system's round-trip distribution
368    /// — the signal `spate_sink_flush_duration_seconds` cannot give, because it
369    /// also carries the permit wait and the sleeps between attempts.
370    ///
371    /// "Nothing else" is bounded by the writer's own implementation: a
372    /// connector that sleeps *inside* `write_batch` puts that sleep in here.
373    /// The Kafka sink does exactly this when the producer queue is full, and
374    /// the wall-clock also charges whatever the sink's I/O runtime was busy
375    /// with at each await point. What is excluded is the framework's
376    /// scheduling around the call — the permit wait, the retry backoff, and
377    /// the all-replicas-quarantined probe wait.
378    ///
379    /// `outcome` splits the family: a batch rejected fatally in a millisecond
380    /// and one that times out after thirty seconds are both attempts, and
381    /// mixing them moves the distribution in opposite directions. The error's
382    /// taxonomy class stays on [`errors`](Self::errors).
383    ///
384    /// An attempt aborted at the drain deadline is never observed — the write
385    /// task is dropped mid-call, and a histogram observation is a point event
386    /// with nothing to strand (contrast
387    /// [`backing_off`](Self::backing_off), whose guard exists precisely to
388    /// survive that abort). Attempts that *completed* before the abort are
389    /// observed as usual, so an abandoned batch can leave `error`
390    /// observations here with no matching flush.
391    #[inline]
392    pub(crate) fn write_attempt(&self, outcome: AttemptOutcome, d: Duration) {
393        let h = match outcome {
394            AttemptOutcome::Ok => &self.write_ok,
395            AttemptOutcome::Error => &self.write_err,
396        };
397        h.record(d.as_secs_f64());
398    }
399
400    /// Observe how long a sealed batch waited for one of its shard's
401    /// `inflight.max_per_shard` slots before its first write attempt — the
402    /// queueing share of a flush, and the reading that tells a healthy-but-slow
403    /// dashboard apart from a saturated one.
404    ///
405    /// Observed for every sealed batch that starts a write, including the
406    /// healthy case where the permit is free and the observation is ~0: a
407    /// family that appeared only under contention would read as absent
408    /// precisely when an operator wants to confirm there is none. A batch the
409    /// drain deadline drops before it ever gets a permit is not observed
410    /// (there is no wait that ended), and is counted by
411    /// [`abandoned`](Self::abandoned).
412    #[inline]
413    pub(crate) fn permit_waited(&self, d: Duration) {
414        self.permit_wait.record(d.as_secs_f64());
415    }
416
417    /// Count flush attempts beyond the first.
418    #[inline]
419    pub fn retries(&self, n: u64) {
420        self.retries.increment(n);
421    }
422
423    /// Publish `delay` as `batch`'s current retry backoff step for as long as
424    /// the returned guard lives.
425    ///
426    /// `spate_sink_retry_backoff_seconds` reads the **max** across the shard's
427    /// backing-off batches (a shard writes up to `inflight.max_per_shard` of
428    /// them at once, each with its own backoff), and `0` once none is — so it
429    /// answers "how long is this shard currently sleeping between attempts",
430    /// which no combination of the other sink series can.
431    ///
432    /// The value is the step being served, not the time left in it: it does
433    /// not count down while the sleep runs.
434    ///
435    /// Scope: the sleep between attempts *on an available replica*. A shard
436    /// whose every replica is quarantined also sleeps — waiting for the
437    /// earliest probe window — and reads `0` throughout, because no attempt
438    /// is being backed off. That state has its own signal, and the two are
439    /// exactly coincident: the write loop waits for a probe precisely when
440    /// no replica is circuit-closed, which is the definition of
441    /// `spate_sink_shard_healthy == 0`.
442    ///
443    /// Clearing is tied to the guard's `Drop` rather than to a settle/abandon
444    /// call because the sleeping task can be *aborted* — the sink's drain
445    /// deadline cancels in-flight writes wherever they are parked. Dropping
446    /// the task future drops the guard, so an abandoned batch cannot strand
447    /// the gauge at a value the shard is no longer sleeping.
448    ///
449    /// # Panics
450    ///
451    /// Debug builds only: `batch` must be unique among this shard's *live*
452    /// guards. Two live guards sharing a key collapse to one entry, and the
453    /// first `Drop` withdraws both contributions — the gauge would then read
454    /// `0` while the other sleep is still running. In-tree the key is the
455    /// batch sequence number, which is monotonic per shard.
456    #[must_use]
457    pub fn backing_off(&self, batch: u64, delay: Duration) -> BackoffGuard<'_> {
458        self.publish_backoff(|steps| {
459            let previous = steps.insert(batch, delay.as_secs_f64());
460            debug_assert!(
461                previous.is_none(),
462                "a live BackoffGuard already exists for batch {batch}"
463            );
464        });
465        BackoffGuard {
466            metrics: self,
467            batch,
468        }
469    }
470
471    /// Mutate the backing-off set and republish the max (`0` when empty).
472    /// Called only from the retry path, never per record.
473    fn publish_backoff(&self, mutate: impl FnOnce(&mut HashMap<u64, f64>)) {
474        // Poison-tolerant because this also runs from `BackoffGuard::drop`:
475        // a panicking `expect` there, reached while already unwinding, aborts
476        // the process. The critical section only inserts, removes and folds,
477        // so a poisoned map is not a corrupt one — recovering it publishes a
478        // stale reading at worst.
479        let mut steps = self
480            .backoff_steps
481            .lock()
482            .unwrap_or_else(std::sync::PoisonError::into_inner);
483        mutate(&mut steps);
484        let max = steps.values().copied().fold(0.0_f64, f64::max);
485        // Published *under* the lock, deliberately. Releasing it first lets
486        // two publishers' `set` calls land in the opposite order from the
487        // snapshots they computed, stranding the gauge at a value no batch is
488        // serving — until the next mutation, which is `retry.max` away under
489        // a patient policy and never once the shard recovers. Two write tasks
490        // per shard is the default (`inflight.max_per_shard: 2`) on a
491        // multi-threaded I/O runtime, so this is the ordinary case, not a
492        // corner one. `Gauge::set` is an atomic store that cannot re-enter
493        // this function, so holding the lock across it cannot deadlock.
494        self.retry_backoff.set(max);
495    }
496
497    /// Count write errors of one taxonomy class.
498    #[inline]
499    pub fn errors(&self, class: ErrorClass, n: u64) {
500        match class {
501            ErrorClass::Retryable => self.err_retryable.increment(n),
502            ErrorClass::RecordLevel => self.err_record.increment(n),
503            ErrorClass::Fatal => self.err_fatal.increment(n),
504        }
505    }
506
507    /// Set the number of sealed batches currently in flight.
508    #[inline]
509    pub fn set_inflight(&self, batches: usize) {
510        self.inflight.set(batches as f64);
511    }
512
513    /// Mark one replica healthy (circuit closed) or quarantined (open).
514    pub fn set_replica_healthy(&self, replica: usize, healthy: bool) {
515        if let Some(r) = self.replicas.get(replica) {
516            r.healthy.set(if healthy { 1.0 } else { 0.0 });
517        }
518    }
519
520    /// Count a circuit-breaker open transition on one replica.
521    pub fn breaker_opened(&self, replica: usize) {
522        if let Some(r) = self.replicas.get(replica) {
523            r.breaker_opens.increment(1);
524        }
525    }
526
527    /// Count one failed write attempt attributed to a replica.
528    pub fn replica_error(&self, replica: usize) {
529        if let Some(r) = self.replicas.get(replica) {
530            r.errors.increment(1);
531        }
532    }
533
534    /// Record whether the shard has at least one circuit-closed replica.
535    /// Level-set and idempotent — the shard's breaker set republishes it on
536    /// every write outcome, not only on a transition, so a reading that has
537    /// gone stale corrects itself within one probe cycle.
538    pub fn set_shard_healthy(&self, up: bool) {
539        self.shard_healthy.set(if up { 1.0 } else { 0.0 });
540    }
541
542    /// Count batches abandoned at the drain deadline.
543    pub fn abandoned(&self, n: u64) {
544        self.abandoned.increment(n);
545    }
546
547    /// Record that this shard's worker had to be force-aborted because it did
548    /// not return by the drain deadline. A framework bug, not an operating
549    /// condition — see `SinkPool::drain`.
550    pub fn drain_overrun(&self) {
551        self.drain_overrun.increment(1);
552    }
553}
554
555/// One batch's contribution to `spate_sink_retry_backoff_seconds`, held for the
556/// duration of a backoff sleep. Returned by
557/// [`SinkShardMetrics::backing_off`]; dropping it — including by the write
558/// task being aborted mid-sleep — withdraws this batch's step and republishes
559/// the shard's max, `0` when it was the last one sleeping.
560#[derive(Debug)]
561pub struct BackoffGuard<'a> {
562    metrics: &'a SinkShardMetrics,
563    batch: u64,
564}
565
566impl Drop for BackoffGuard<'_> {
567    fn drop(&mut self) {
568        let batch = self.batch;
569        self.metrics.publish_backoff(|steps| {
570            steps.remove(&batch);
571        });
572    }
573}