spate_core/metrics/source.rs
1//! Source-stage handles (`spate_source_*`).
2//!
3//! Every handle the record loop touches is resolved once at build time, and
4//! its methods take per-batch aggregates, as [the metrics reference]
5//! specifies. The per-partition lag series is resolved lazily instead, on the
6//! control plane, so a partition whose lag has never been measured is absent
7//! rather than a `0` that reads as "caught up".
8//!
9//! [the metrics reference]: https://spate.kainth.dev/docs/METRICS
10
11use super::MetricsError;
12use super::labels::{ComponentLabels, OwnedGauge, PartitionGauges};
13use super::names;
14use super::ownership::{SeriesClaim, series_key};
15use crate::record::PartitionId;
16use metrics::{Counter, Histogram};
17use std::collections::HashMap;
18use std::sync::Mutex;
19use std::time::Duration;
20
21/// Source-stage handles (`spate_source_*`).
22#[derive(Debug)]
23pub struct SourceMetrics {
24 records: Counter,
25 bytes: Counter,
26 poll_duration: Histogram,
27 rebalance_assign: Counter,
28 rebalance_revoke: Counter,
29 lanes_active: OwnedGauge,
30 partition_lag: PartitionGauges,
31 _claim: Option<SeriesClaim>,
32}
33
34impl SourceMetrics {
35 /// Resolve all source handles, claiming the `spate_source_*` series for
36 /// these labels.
37 ///
38 /// A pipeline builds several of these on identical labels (one per
39 /// pipeline thread plus the controller's) because every thread counts
40 /// records it polled. Only *one* of them may publish the source gauges,
41 /// and it must be the controller's. The controller holds the assignment
42 /// and hands its clone to the source, which is the only thing that can
43 /// measure lag. The per-thread instances are therefore built with
44 /// [`shadow`](Self::shadow), not this constructor. A collision here logs
45 /// and shadows rather than panicking.
46 ///
47 /// Consumer lag is not gated by `per_partition_detail`. The per-partition
48 /// series is the *only* representation of a golden signal, so a
49 /// cardinality knob must not be able to delete it. The lag handles are
50 /// also not resolved here; `PartitionGauges` registers a partition's
51 /// series on its first known value, so a partition whose lag has never
52 /// been measured is absent rather than reporting a `0` that reads as
53 /// "caught up".
54 pub fn new(labels: &ComponentLabels) -> Self {
55 Self::build(labels, SeriesClaim::claim_or_shadow(Self::key(labels)))
56 }
57
58 /// Resolve all source handles, failing when another live handle set
59 /// already owns the series. The pipeline runtime's path for the
60 /// controller's instance (the one that owns lag and lanes).
61 ///
62 /// # Errors
63 ///
64 /// [`MetricsError::DuplicateSeries`] on a collision.
65 pub fn try_new(labels: &ComponentLabels) -> Result<Self, MetricsError> {
66 let claim = SeriesClaim::try_claim(Self::key(labels))?;
67 Ok(Self::build(labels, Some(claim)))
68 }
69
70 /// Resolve source handles that **do not** own their series. Counters and
71 /// the poll histogram record as normal (they aggregate across instances);
72 /// gauge writes are dropped.
73 ///
74 /// A pipeline thread counts its own polls this way without competing for
75 /// `spate_source_lag_records` and `spate_source_lanes_active`,
76 /// which only the controller can populate correctly. Use it when a second
77 /// instance on the same labels is intended; anything else should use
78 /// [`new`](Self::new) or [`try_new`](Self::try_new) and hear about the
79 /// collision.
80 #[must_use]
81 pub fn shadow(labels: &ComponentLabels) -> Self {
82 Self::build(labels, None)
83 }
84
85 fn key(labels: &ComponentLabels) -> String {
86 series_key("source", labels, "")
87 }
88
89 fn build(labels: &ComponentLabels, claim: Option<SeriesClaim>) -> Self {
90 let owned = claim.is_some();
91 SourceMetrics {
92 records: labels.counter(names::SOURCE_RECORDS_TOTAL),
93 bytes: labels.counter(names::SOURCE_BYTES_TOTAL),
94 poll_duration: labels.histogram(names::SOURCE_POLL_DURATION_SECONDS),
95 rebalance_assign: labels.counter1(
96 names::SOURCE_REBALANCES_TOTAL,
97 names::L_EVENT,
98 "assign",
99 ),
100 rebalance_revoke: labels.counter1(
101 names::SOURCE_REBALANCES_TOTAL,
102 names::L_EVENT,
103 "revoke",
104 ),
105 lanes_active: OwnedGauge::new(labels.gauge(names::SOURCE_LANES_ACTIVE), owned),
106 partition_lag: PartitionGauges {
107 name: names::SOURCE_LAG_RECORDS,
108 labels: labels.clone(),
109 gauges: Mutex::new(HashMap::new()),
110 owned,
111 },
112 _claim: claim,
113 }
114 }
115
116 /// Record one polled batch.
117 #[inline]
118 pub fn batch(&self, records: u64, bytes: u64) {
119 self.records.increment(records);
120 self.bytes.increment(bytes);
121 }
122
123 /// Observe one `poll` call's duration.
124 #[inline]
125 pub fn poll_duration(&self, d: Duration) {
126 self.poll_duration.record(d.as_secs_f64());
127 }
128
129 /// Publish one partition's consumer lag.
130 ///
131 /// Only call this with a lag the client measured. The series is registered
132 /// on the first such call, so never publishing is how "lag unknown" is
133 /// expressed. A `0` would be indistinguishable from a consumer that has
134 /// caught up.
135 pub fn set_partition_lag(&self, partition: PartitionId, lag: u64) {
136 self.partition_lag.set(partition, lag as f64);
137 }
138
139 /// Zero and drop the lag series for partitions this member no longer
140 /// owns.
141 ///
142 /// The exporter has no deletion, so a partition that moved to another
143 /// member would keep rendering this member's last lag forever and every
144 /// reader that sums across partitions would count it twice. Zeroing first
145 /// makes the sum correct. The member that now owns the partition publishes
146 /// the real figure, and this one contributes the `0` it holds.
147 ///
148 /// Call this once the *new* assignment is known, not while partitions are
149 /// still draining. A member that is about to be handed a partition back
150 /// should never publish a zero for it.
151 pub fn retain_partitions(&self, keep: &[PartitionId]) {
152 self.partition_lag.retain(keep);
153 }
154
155 /// Count a rebalance assignment event.
156 pub fn rebalance_assigned(&self) {
157 self.rebalance_assign.increment(1);
158 }
159
160 /// Count a rebalance revocation event.
161 pub fn rebalance_revoked(&self) {
162 self.rebalance_revoke.increment(1);
163 }
164
165 /// Set the number of currently assigned lanes.
166 pub fn set_lanes_active(&self, lanes: usize) {
167 self.lanes_active.set(lanes as f64);
168 }
169}