Skip to main content

commonware_runtime/telemetry/metrics/
batched.rs

1//! Metric handles that publish one writer's updates to a shared metric in batches.
2
3use super::{GaugeValue, Registered, raw};
4use std::num::NonZeroU32;
5
6mod sealed {
7    /// The operations a [`super::Batched`] handle needs from its metric.
8    pub trait Sealed: Clone {
9        /// The metric's value type. It also represents the change between two values.
10        type Value: Copy + Default + PartialEq;
11
12        /// The change from `from` to `to`, wrapping on overflow.
13        fn change(from: Self::Value, to: Self::Value) -> Self::Value;
14
15        /// Add `change` to the metric.
16        fn apply(&self, change: Self::Value);
17    }
18}
19
20/// A metric that a [`Batched`] handle can publish to. It is sealed and implemented for
21/// [`raw::Counter`] and [`raw::Gauge`].
22pub trait Batchable: sealed::Sealed {}
23
24impl Batchable for raw::Counter {}
25
26impl Batchable for raw::Gauge {}
27
28impl sealed::Sealed for raw::Counter {
29    type Value = u64;
30
31    fn change(from: u64, to: u64) -> u64 {
32        to.wrapping_sub(from)
33    }
34
35    fn apply(&self, change: u64) {
36        self.inc_by(change);
37    }
38}
39
40impl sealed::Sealed for raw::Gauge {
41    type Value = GaugeValue;
42
43    fn change(from: GaugeValue, to: GaugeValue) -> GaugeValue {
44        to.wrapping_sub(from)
45    }
46
47    fn apply(&self, change: GaugeValue) {
48        self.inc_by(change);
49    }
50}
51
52/// A metric handle that publishes one writer's updates to a shared metric in batches.
53///
54/// The handle keeps the writer's total and adds the total's change since the previous publication
55/// to the shared metric after every `size` updates and when the handle is dropped. The shared
56/// metric includes each update made before the writer's latest publication exactly once, and none
57/// made after it.
58///
59/// Writers that update one shared metric directly contend on its cache line with every update.
60/// Giving each writer its own handle limits that to one update of the shared metric per batch.
61pub struct Batched<M: Batchable> {
62    /// A clone of the shared metric that shares its value but not its registration.
63    shared: M,
64
65    /// The writer's total.
66    value: M::Value,
67
68    /// The writer's total at creation or at the latest publication. `shared` already includes
69    /// every update up to it.
70    published: M::Value,
71
72    /// Updates made since the latest publication.
73    pending: u32,
74
75    /// Updates per batch.
76    size: NonZeroU32,
77}
78
79impl<M: Batchable> Batched<M> {
80    /// Create a handle that publishes to `shared`'s metric after every `size` updates. The
81    /// writer's total starts at zero, and every update counts toward a batch, including one that
82    /// leaves the total unchanged.
83    ///
84    /// The handle does not hold `shared`'s registration, so the metric leaves the registry when
85    /// the last [`Registered`] handle to it is dropped. This handle does not publish to a fresh
86    /// metric registered under the same name afterwards.
87    pub fn new(shared: &Registered<M>, size: NonZeroU32) -> Self {
88        Self {
89            shared: shared.metric().clone(),
90            value: M::Value::default(),
91            published: M::Value::default(),
92            pending: 0,
93            size,
94        }
95    }
96
97    /// The writer's total, including updates not yet published.
98    pub const fn get(&self) -> M::Value {
99        self.value
100    }
101
102    /// Add the writer's change since the previous publication to the shared metric, and start a
103    /// new batch.
104    fn publish(&mut self) {
105        if self.value != self.published {
106            self.shared.apply(M::change(self.published, self.value));
107            self.published = self.value;
108        }
109        self.pending = 0;
110    }
111
112    /// Record the writer's new total and publish once this update completes a batch.
113    fn update(&mut self, value: M::Value) {
114        self.value = value;
115        self.pending += 1;
116        if self.pending == self.size.get() {
117            self.publish();
118        }
119    }
120}
121
122impl<M: Batchable> Drop for Batched<M> {
123    fn drop(&mut self) {
124        self.publish();
125    }
126}
127
128impl Batched<raw::Counter> {
129    /// Increase the writer's total by one.
130    pub fn inc(&mut self) {
131        self.update(self.value.wrapping_add(1));
132    }
133
134    /// Increase the writer's total by `value`.
135    pub fn inc_by(&mut self, value: u64) {
136        self.update(self.value.wrapping_add(value));
137    }
138}
139
140impl Batched<raw::Gauge> {
141    /// Increase the writer's total by one.
142    pub fn inc(&mut self) {
143        self.update(self.value.wrapping_add(1));
144    }
145
146    /// Decrease the writer's total by one.
147    pub fn dec(&mut self) {
148        self.update(self.value.wrapping_sub(1));
149    }
150
151    /// Increase the writer's total by `value`.
152    pub fn inc_by(&mut self, value: GaugeValue) {
153        self.update(self.value.wrapping_add(value));
154    }
155
156    /// Decrease the writer's total by `value`.
157    pub fn dec_by(&mut self, value: GaugeValue) {
158        self.update(self.value.wrapping_sub(value));
159    }
160
161    /// Set the writer's total to `value`.
162    pub fn set(&mut self, value: GaugeValue) {
163        self.update(value);
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crate::{
171        Metrics as _, Runner as _, deterministic,
172        telemetry::metrics::{MetricsExt as _, has_metric_value, metric_samples},
173    };
174    use commonware_utils::NZU32;
175
176    #[test]
177    fn test_counter_publishes_full_batches() {
178        // Updates reach the shared metric only as each batch of three fills.
179        let shared = Registered::detached(raw::Counter::default());
180        let mut batched = Batched::new(&shared, NZU32!(3));
181        batched.inc();
182        batched.inc();
183        assert_eq!(shared.get(), 0);
184        assert_eq!(batched.get(), 2);
185        batched.inc();
186        assert_eq!(shared.get(), 3);
187        batched.inc();
188        batched.inc();
189        assert_eq!(shared.get(), 3);
190        batched.inc();
191        assert_eq!(shared.get(), 6);
192
193        // Dropping the handle publishes a partial batch.
194        batched.inc_by(5);
195        assert_eq!(shared.get(), 6);
196        assert_eq!(batched.get(), 11);
197        drop(batched);
198        assert_eq!(shared.get(), 11);
199    }
200
201    #[test]
202    fn test_gauge_publishes_net_change() {
203        // Updates that cancel out publish nothing when their batch fills.
204        let shared = Registered::detached(raw::Gauge::default());
205        let mut batched = Batched::new(&shared, NZU32!(2));
206        batched.inc();
207        batched.dec();
208        assert_eq!(shared.get(), 0);
209
210        // A net decrease publishes a negative change.
211        batched.dec_by(3);
212        batched.inc();
213        assert_eq!(shared.get(), -2);
214
215        // The second `set(5)` leaves the total unchanged but still completes the batch, so the
216        // change since the previous publication reaches the shared metric.
217        batched.set(5);
218        batched.set(5);
219        assert_eq!(shared.get(), 5);
220    }
221
222    #[test]
223    fn test_handles_publish_independently() {
224        // The first handle publishes its updates.
225        let shared = Registered::detached(raw::Gauge::default());
226        let mut first = Batched::new(&shared, NZU32!(1));
227        first.inc_by(3);
228        assert_eq!(shared.get(), 3);
229
230        // A handle created after another writer has published adds only its own updates.
231        let mut second = Batched::new(&shared, NZU32!(1));
232        second.inc_by(4);
233        assert_eq!(second.get(), 4);
234        assert_eq!(shared.get(), 7);
235
236        // Dropping a handle publishes the updates it has not yet published.
237        let mut third = Batched::new(&shared, NZU32!(2));
238        third.inc();
239        assert_eq!(shared.get(), 7);
240        drop(third);
241        assert_eq!(shared.get(), 8);
242    }
243
244    #[test]
245    fn test_registration_stays_with_owner() {
246        deterministic::Runner::default().start(|context| async move {
247            // Publications reach the registered metric while its owner holds the registration.
248            let shared = context.gauge("keys", "Number of keys");
249            let mut batched = Batched::new(&shared, NZU32!(1));
250            batched.inc_by(5);
251            assert!(has_metric_value(&context.encode(), "keys", 5));
252
253            // Dropping the owner's handle unregisters the metric even though the batched handle
254            // still publishes to it. Registering the name again creates a new metric that later
255            // publications do not reach.
256            drop(shared);
257            assert!(metric_samples(&context.encode(), "keys").next().is_none());
258            let fresh = context.gauge("keys", "Number of keys");
259            batched.inc();
260            assert_eq!(fresh.get(), 0);
261        });
262    }
263}