spate_core/metrics/checkpoint.rs
1//! Checkpointer handles (`spate_checkpoint_*`).
2
3use super::MetricsError;
4use super::labels::{ComponentLabels, OwnedGauge, PartitionGauges};
5use super::names;
6use super::ownership::{SeriesClaim, series_key};
7use crate::record::PartitionId;
8use metrics::{Counter, Histogram};
9use std::collections::HashMap;
10use std::sync::Mutex;
11use std::time::Duration;
12
13/// Checkpointer handles (`spate_checkpoint_*`).
14#[derive(Debug)]
15pub struct CheckpointMetrics {
16 pending_max: OwnedGauge,
17 commits_ok: Counter,
18 commits_err: Counter,
19 commit_duration: Histogram,
20 watermark_age: OwnedGauge,
21 partition_pending: Option<PartitionGauges>,
22 _claim: Option<SeriesClaim>,
23}
24
25impl CheckpointMetrics {
26 /// Resolve all checkpointer handles.
27 ///
28 /// Claims the `spate_checkpoint_*` series for these labels; a second live
29 /// handle set logs and becomes a shadow, counting commits but publishing
30 /// no gauge.
31 pub fn new(labels: &ComponentLabels, per_partition_detail: bool) -> Self {
32 let claim = SeriesClaim::claim_or_shadow(Self::key(labels));
33 Self::build(labels, per_partition_detail, claim)
34 }
35
36 /// Resolve all checkpointer handles, failing when another live handle set
37 /// already owns the series. The pipeline runtime's path.
38 ///
39 /// # Errors
40 ///
41 /// [`MetricsError::DuplicateSeries`] on a collision.
42 pub fn try_new(
43 labels: &ComponentLabels,
44 per_partition_detail: bool,
45 ) -> Result<Self, MetricsError> {
46 let claim = SeriesClaim::try_claim(Self::key(labels))?;
47 Ok(Self::build(labels, per_partition_detail, Some(claim)))
48 }
49
50 fn key(labels: &ComponentLabels) -> String {
51 series_key("checkpoint", labels, "")
52 }
53
54 fn build(
55 labels: &ComponentLabels,
56 per_partition_detail: bool,
57 claim: Option<SeriesClaim>,
58 ) -> Self {
59 let owned = claim.is_some();
60 CheckpointMetrics {
61 pending_max: OwnedGauge::new(labels.gauge(names::CHECKPOINT_PENDING_BATCHES), owned),
62 commits_ok: labels.counter1(names::CHECKPOINT_COMMITS_TOTAL, names::L_OUTCOME, "ok"),
63 commits_err: labels.counter1(
64 names::CHECKPOINT_COMMITS_TOTAL,
65 names::L_OUTCOME,
66 "error",
67 ),
68 commit_duration: labels.histogram(names::CHECKPOINT_COMMIT_DURATION_SECONDS),
69 watermark_age: OwnedGauge::new(
70 labels.gauge(names::CHECKPOINT_WATERMARK_AGE_SECONDS),
71 owned,
72 ),
73 partition_pending: per_partition_detail.then(|| PartitionGauges {
74 name: names::CHECKPOINT_PENDING_BATCHES,
75 labels: labels.clone(),
76 gauges: Mutex::new(HashMap::new()),
77 owned,
78 }),
79 _claim: claim,
80 }
81 }
82
83 /// Set the max pending-batch count across partitions.
84 pub fn set_pending_max(&self, pending: usize) {
85 self.pending_max.set(pending as f64);
86 }
87
88 /// Whether this handle set publishes the per-partition series.
89 ///
90 /// False when `per_partition_detail` was not set, and false for a shadow,
91 /// which publishes no gauge.
92 /// [`set_partition_pending`](Self::set_partition_pending) and
93 /// [`retain_partitions`](Self::retain_partitions) no-op in both cases. A
94 /// caller reads this to skip gathering the counts at all.
95 #[must_use]
96 pub fn publishes_partition_detail(&self) -> bool {
97 self.partition_pending.as_ref().is_some_and(|pg| pg.owned)
98 }
99
100 /// Set one partition's pending count. No-op unless
101 /// `per_partition_detail`.
102 pub fn set_partition_pending(&self, partition: PartitionId, pending: usize) {
103 if let Some(pg) = &self.partition_pending {
104 pg.set(partition, pending as f64);
105 }
106 }
107
108 /// Drop per-partition series for revoked partitions.
109 ///
110 /// Two things differ from
111 /// [`SourceMetrics::retain_partitions`](super::SourceMetrics::retain_partitions),
112 /// and both follow from what this family counts.
113 ///
114 /// This takes the live set on every commit cycle rather than once a new
115 /// assignment is known. Zeroing a partition the runtime is about to be
116 /// handed back costs nothing, because the unlabeled series reads the same
117 /// `0` from the same empty tracker map in the same cycle. The lag family
118 /// has no aggregate to agree with, so it waits instead.
119 ///
120 /// This also zeroes a partition no other member will publish, where a lag
121 /// gauge holds its last value. `0` here is the count of what the
122 /// checkpointer tracks, and it tracks nothing for a partition it has
123 /// dropped, so the reading stays true. A lag of `0` instead claims the
124 /// partition is caught up, which is a claim about the world rather than
125 /// about the handle, and false for a partition still being consumed
126 /// somewhere.
127 pub fn retain_partitions(&self, keep: &[PartitionId]) {
128 if let Some(pg) = &self.partition_pending {
129 pg.retain(keep);
130 }
131 }
132
133 /// Record one source commit call.
134 pub fn commit(&self, ok: bool, d: Duration) {
135 if ok {
136 self.commits_ok.increment(1);
137 } else {
138 self.commits_err.increment(1);
139 }
140 self.commit_duration.record(d.as_secs_f64());
141 }
142
143 /// Set the age of the oldest unacknowledged batch.
144 pub fn set_watermark_age(&self, age: Duration) {
145 self.watermark_age.set(age.as_secs_f64());
146 }
147}