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