Skip to main content

beam/
metrics.rs

1//! Lock-free metrics for BEAM actor send/fanout observability.
2//!
3//! # Why this exists
4//!
5//! Before this module, the Router (and other actors) used `let _ = addr.send(msg)`
6//! everywhere to dispatch messages. When an actor's mailbox was full or closed,
7//! `Addr::send` returned `Err(())` and the codebase silently dropped it.
8//!
9//! Silent drops are dangerous because:
10//!
11//! 1. **Invisible failures**: a "successful" `Node::put` might never reach
12//!    the storage adapter if its mailbox is full. The caller has no signal.
13//! 2. **No debugging trail**: when data doesn't replicate, there's no
14//!    counter showing "1000 Puts were silently dropped" — the operator
15//!    sees a working system that silently lost data.
16//! 3. **No backpressure visibility**: a slow consumer looks identical to
17//!    a fast one until messages start disappearing into the void.
18//!
19//! This module fixes the observability gap without changing behavior.
20//! `Metrics` is a tiny, lock-free counter struct that any actor can hold
21//! (via `ActorContext` or directly) to record events of interest.
22//!
23//! # Design principles
24//!
25//! - **Lock-free**: all counters are `AtomicU64` with `Relaxed` ordering.
26//!   They are advisory observation, not synchronization primitives.
27//!   Snapshot reads may be slightly stale but are guaranteed monotonic.
28//! - **Cumulative**: counters only increase for the lifetime of the
29//!   `Metrics` instance. There is no "reset" — if you need per-window
30//!   counters, create a new `Metrics`.
31//! - **Composition-Root IoC**: `Metrics` is passed into actors that need
32//!   it, not accessed from a global registry. This makes the dependency
33//!   graph explicit and testable.
34//! - **No behavior change**: recording a metric is a side effect that
35//!   does not affect control flow. Existing fire-and-forget semantics
36//!   are preserved.
37//!
38//! # Usage
39//!
40//! ```no_run
41//! use beam::metrics::Metrics;
42//! use std::sync::Arc;
43//!
44//! let metrics = Arc::new(Metrics::new());
45//!
46//! // Record a drop
47//! metrics.record_dropped_send();
48//!
49//! // Snapshot for telemetry
50//! let snap = metrics.snapshot();
51//! println!("dropped_sends = {}", snap.dropped_sends);
52//! ```
53
54use std::sync::atomic::{AtomicU64, Ordering};
55
56/// Lock-free counters for BEAM actor sends and drops.
57///
58/// Cheap to clone via `Arc<Metrics>`. Designed to be passed via
59/// `ActorContext` (Composition-Root IoC) so any actor can record
60/// metrics without coupling to a global registry.
61///
62/// All counters are cumulative for the lifetime of the `Metrics`
63/// instance. They never reset — start a new `Metrics` if you need
64/// per-window observation.
65///
66/// # Counter semantics
67///
68/// - `dropped_sends`: incremented when `Addr::send` returns `Err(())`
69///   in a fire-and-forget context. This is the primary "silent drop
70///   is no longer invisible" counter.
71/// - `reaped_quorums`: incremented when the quorum reaper evicts an
72///   expired entry. Indicates that quorum timeouts are happening.
73/// - `put_acks_seen`: incremented when a Node receives any Put ack.
74/// - `put_acks_quorum`: incremented when a Put ack completes a quorum
75///   (the `__quorum_met__` sentinel fired).
76#[derive(Debug, Default)]
77pub struct Metrics {
78    /// Times a fire-and-forget send was silently dropped because the
79    /// receiver's mailbox was full or closed.
80    dropped_sends: AtomicU64,
81    /// Times the quorum reaper evicted an expired entry.
82    reaped_quorums: AtomicU64,
83    /// Put acks received by any Node (from storage or peer).
84    put_acks_seen: AtomicU64,
85    /// Put acks that completed a quorum (triggered `__quorum_met__`).
86    put_acks_quorum: AtomicU64,
87}
88
89/// Plain-old-data snapshot of `Metrics` for safe export across threads.
90///
91/// This is `Copy` because it holds plain `u64` values — no atomics,
92/// no references. Safe to log, serialize, or send over a channel.
93///
94/// Note: snapshot is non-atomic across counters — values may be
95/// slightly inconsistent (one counter advanced, another not yet).
96/// This is acceptable for telemetry; do not use for control flow.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub struct MetricsSnapshot {
99    pub dropped_sends: u64,
100    pub reaped_quorums: u64,
101    pub put_acks_seen: u64,
102    pub put_acks_quorum: u64,
103}
104
105impl Metrics {
106    /// Create a new `Metrics` with all counters at zero.
107    pub fn new() -> Self {
108        Self::default()
109    }
110
111    /// Record that a fire-and-forget send was dropped.
112    ///
113    /// Call this when `Addr::send(msg)` returns `Err(())` in a
114    /// context where you accept the loss but want to know it happened.
115    #[inline]
116    pub fn record_dropped_send(&self) {
117        self.dropped_sends.fetch_add(1, Ordering::Relaxed);
118    }
119
120    /// Record that the quorum reaper evicted an expired entry.
121    #[inline]
122    pub fn record_reaped_quorum(&self) {
123        self.reaped_quorums.fetch_add(1, Ordering::Relaxed);
124    }
125
126    /// Record that a Put ack was received.
127    #[inline]
128    pub fn record_put_ack(&self) {
129        self.put_acks_seen.fetch_add(1, Ordering::Relaxed);
130    }
131
132    /// Record that a Put ack completed a quorum.
133    #[inline]
134    pub fn record_quorum_ack(&self) {
135        self.put_acks_quorum.fetch_add(1, Ordering::Relaxed);
136    }
137
138    /// Read all counters as a plain struct.
139    ///
140    /// Non-atomic across counters — values may be slightly inconsistent.
141    /// Acceptable for telemetry; do not use for control flow.
142    pub fn snapshot(&self) -> MetricsSnapshot {
143        MetricsSnapshot {
144            dropped_sends: self.dropped_sends.load(Ordering::Relaxed),
145            reaped_quorums: self.reaped_quorums.load(Ordering::Relaxed),
146            put_acks_seen: self.put_acks_seen.load(Ordering::Relaxed),
147            put_acks_quorum: self.put_acks_quorum.load(Ordering::Relaxed),
148        }
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use std::sync::Arc;
156
157    #[test]
158    fn default_is_all_zero() {
159        let snap = Metrics::new().snapshot();
160        assert_eq!(snap.dropped_sends, 0);
161        assert_eq!(snap.reaped_quorums, 0);
162        assert_eq!(snap.put_acks_seen, 0);
163        assert_eq!(snap.put_acks_quorum, 0);
164    }
165
166    #[test]
167    fn record_dropped_send_increments_counter() {
168        let m = Metrics::new();
169        m.record_dropped_send();
170        m.record_dropped_send();
171        m.record_dropped_send();
172        assert_eq!(m.snapshot().dropped_sends, 3);
173    }
174
175    #[test]
176    fn record_reaped_quorum_increments_counter() {
177        let m = Metrics::new();
178        m.record_reaped_quorum();
179        assert_eq!(m.snapshot().reaped_quorums, 1);
180    }
181
182    #[test]
183    fn record_put_ack_increments_counter() {
184        let m = Metrics::new();
185        m.record_put_ack();
186        m.record_put_ack();
187        assert_eq!(m.snapshot().put_acks_seen, 2);
188    }
189
190    #[test]
191    fn record_quorum_ack_increments_counter() {
192        let m = Metrics::new();
193        m.record_quorum_ack();
194        assert_eq!(m.snapshot().put_acks_quorum, 1);
195    }
196
197    #[test]
198    fn snapshot_reflects_independent_increments() {
199        let m = Metrics::new();
200        m.record_dropped_send();
201        m.record_put_ack();
202        m.record_quorum_ack();
203        let snap = m.snapshot();
204        assert_eq!(snap.dropped_sends, 1);
205        assert_eq!(snap.put_acks_seen, 1);
206        assert_eq!(snap.put_acks_quorum, 1);
207        assert_eq!(snap.reaped_quorums, 0);
208    }
209
210    #[test]
211    fn counters_are_monotonic() {
212        // Relaxed atomics guarantee that increments on a single
213        // counter are not lost, but concurrent increments may be
214        // reordered relative to each other. For a single-threaded
215        // sequence, the counter must be strictly monotonic.
216        let m = Metrics::new();
217        for i in 1..=1000 {
218            m.record_dropped_send();
219            assert_eq!(m.snapshot().dropped_sends, i);
220        }
221    }
222
223    #[test]
224    fn shared_metrics_via_arc() {
225        // Verify Arc<Metrics> is the idiomatic shared handle and
226        // updates are visible across clones.
227        let m: Arc<Metrics> = Arc::new(Metrics::new());
228        let m2 = Arc::clone(&m);
229        m.record_dropped_send();
230        assert_eq!(m2.snapshot().dropped_sends, 1);
231    }
232
233    #[test]
234    fn concurrent_increments_are_not_lost() {
235        // Sanity check: 100 threads × 1000 increments = 100_000 total.
236        // Relaxed ordering may interleave but no increment is lost.
237        use std::thread;
238        let m = Arc::new(Metrics::new());
239        let mut handles = Vec::new();
240        for _ in 0..100 {
241            let m = Arc::clone(&m);
242            handles.push(thread::spawn(move || {
243                for _ in 0..1000 {
244                    m.record_dropped_send();
245                }
246            }));
247        }
248        for h in handles {
249            h.join().unwrap();
250        }
251        assert_eq!(m.snapshot().dropped_sends, 100_000);
252    }
253
254    #[test]
255    fn snapshot_is_copy() {
256        // Compile-time check: MetricsSnapshot is Copy.
257        let s = Metrics::new().snapshot();
258        let s2 = s; // Copy, not move
259        assert_eq!(s, s2);
260    }
261}