Skip to main content

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}