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}