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