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 (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}