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}