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}