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//! # Hot-path instrumentation
24//!
25//! In addition to the original drop/ack counters, this module now tracks
26//! the **relay hot path** — the sequence every message traverses from
27//! WebSocket receive to WebSocket send. These counters let us identify
28//! the load-bearing component when throughput is bottlenecked:
29//!
30//! 1. `messages_parsed` — JSON parse entry (`Message::try_from`)
31//! 2. `messages_dropped_dup` — dedup gate hit (`Dup::check` returned true)
32//! 3. `messages_relayed` — successful relay fan-out (`handle_put_relay`)
33//! 4. `subscriber_fanout_total` — total subscriber deliveries across all relays
34//! 5. `serialization_calls` — wire-format serialization (`Message::to_string`)
35//! 6. `ws_messages_received` — inbound WebSocket frames
36//! 7. `ws_messages_sent` — outbound WebSocket frames
37//!
38//! # Design principles
39//!
40//! - **Lock-free**: all counters are `AtomicU64` with `Relaxed` ordering.
41//!   They are advisory observation, not synchronization primitives.
42//!   Snapshot reads may be slightly stale but are guaranteed monotonic.
43//! - **Cumulative**: counters only increase for the lifetime of the
44//!   `Metrics` instance. There is no "reset" — if you need per-window
45//!   counters, create a new `Metrics`.
46//! - **Composition-Root IoC**: `Metrics` is passed into actors that need
47//!   it, not accessed from a global registry. This makes the dependency
48//!   graph explicit and testable.
49//! - **No behavior change**: recording a metric is a side effect that
50//!   does not affect control flow. Existing fire-and-forget semantics
51//!   are preserved.
52//! - **Negligible cost**: `Relaxed` atomic increments are a single
53//!   `LOCK` instruction on x86 (~1-2 ns). At 100k TPS the total overhead
54//!   is ~0.2 ms/s — well within the noise floor of any benchmark.
55//!
56//! # Usage
57//!
58//! ```no_run
59//! use beam::metrics::Metrics;
60//! use std::sync::Arc;
61//!
62//! let metrics = Arc::new(Metrics::new());
63//!
64//! // Record a drop
65//! metrics.record_dropped_send();
66//!
67//! // Snapshot for telemetry
68//! let snap = metrics.snapshot();
69//! println!("dropped_sends = {}", snap.dropped_sends);
70//! println!("messages_parsed = {}", snap.messages_parsed);
71//! ```
72
73use std::sync::atomic::{AtomicU64, Ordering};
74
75/// Lock-free counters for BEAM actor sends, drops, and hot-path throughput.
76///
77/// Cheap to share via `Arc<Metrics>`. Designed to be passed via
78/// `ActorContext` (Composition-Root IoC) so any actor can record
79/// metrics without coupling to a global registry.
80///
81/// All counters are cumulative for the lifetime of the `Metrics`
82/// instance. They never reset — start a new `Metrics` if you need
83/// per-window observation.
84///
85/// # Counter semantics
86///
87/// ## Drop & quorum counters (original)
88///
89/// - `dropped_sends`: incremented when `Addr::send` returns `Err(())`
90///   in a fire-and-forget context. The primary "silent drop is no longer
91///   invisible" counter.
92/// - `reaped_quorums`: incremented when the quorum reaper evicts an
93///   expired entry. Indicates that quorum timeouts are happening.
94/// - `put_acks_seen`: incremented when a Node receives any Put ack.
95/// - `put_acks_quorum`: incremented when a Put ack completes a quorum
96///   (the `__quorum_met__` sentinel fired).
97///
98/// ## Hot-path counters (v0.11.0)
99///
100/// These trace the relay hot path: WebSocket → parse → router → serialize → WebSocket.
101/// Under load, the ratio between these counters reveals the bottleneck:
102///
103/// - If `messages_parsed` >> `messages_relayed` → dedup is dropping most messages
104/// - If `messages_relayed` >> `ws_messages_sent` → serialization or I/O is the bottleneck
105/// - If `ws_messages_received` ≈ `messages_parsed` → parse is keeping up
106/// - `subscriber_fanout_total / messages_relayed` → average fanout ratio
107#[derive(Debug, Default)]
108pub struct Metrics {
109    // ── Drop & quorum counters (original) ───────────────────────────
110    /// Times a fire-and-forget send was silently dropped because the
111    /// receiver's mailbox was full or closed.
112    dropped_sends: AtomicU64,
113    /// Times the quorum reaper evicted an expired entry.
114    reaped_quorums: AtomicU64,
115    /// Put acks received by any Node (from storage or peer).
116    put_acks_seen: AtomicU64,
117    /// Put acks that completed a quorum (triggered `__quorum_met__`).
118    put_acks_quorum: AtomicU64,
119
120    // ── Hot-path counters (v0.11.0) ─────────────────────────────────
121    /// Times a wire message was parsed from JSON into a `Message` struct.
122    ///
123    /// Incremented in `Message::try_from` — the entry point of every
124    /// inbound message. Under steady state, this should track
125    /// `ws_messages_received` closely.
126    messages_parsed: AtomicU64,
127
128    /// Times a Put was relayed to peers/subscribers (successful fan-out).
129    ///
130    /// Incremented in `Router::handle_put_relay` after the relay
131    /// completes. The delta between `messages_parsed` and
132    /// `messages_relayed` includes dedup drops, quorum acks, and
133    /// Get-response routing.
134    messages_relayed: AtomicU64,
135
136    /// Times a message was dropped because the dedup gate hit.
137    ///
138    /// Incremented in `Router::handle_put` when `Dup::check` returns
139    /// true. A high ratio of `messages_dropped_dup / messages_parsed`
140    /// means peers are redundantly relaying the same messages.
141    messages_dropped_dup: AtomicU64,
142
143    /// Times `Message::to_string` / `Put::to_string` was called for
144    /// wire-format serialization.
145    ///
146    /// Incremented on every outbound serialization. Under steady
147    /// state, this should track `ws_messages_sent` closely. A
148    /// discrepancy means serializations are being called for internal
149    /// purposes (logging, debugging) without reaching the wire.
150    serialization_calls: AtomicU64,
151
152    /// Total subscriber deliveries across all relay fan-outs.
153    ///
154    /// Incremented once per subscriber in `handle_put_relay`. The
155    /// ratio `subscriber_fanout_total / messages_relayed` gives the
156    /// average fanout ratio — how many subscribers receive each
157    /// relayed message.
158    subscriber_fanout_total: AtomicU64,
159
160    /// Inbound WebSocket message frames received by all WsConn actors.
161    ///
162    /// Incremented in `WsConn::handle` on each incoming Text/Binary
163    /// frame. Under steady state, this is the raw inbound rate before
164    /// any processing.
165    ws_messages_received: AtomicU64,
166
167    /// Outbound WebSocket message frames sent by all WsConn actors.
168    ///
169    /// Incremented on each successful `WsConn` send. Under steady
170    /// state with no dedup, this should be approximately
171    /// `messages_relayed * subscriber_fanout_ratio`.
172    ws_messages_sent: AtomicU64,
173
174    // ── HAM pre-filter counter (v0.12.0) ───────────────────────────
175    /// Times a Put was dropped by the HAM stale-data pre-filter.
176    ///
177    /// Incremented in `Router::handle_put` when `ham_filter` returns
178    /// `false` — all (soul, key) pairs in the Put had timestamps
179    /// older than or equal to what the router has already seen. A
180    /// high ratio of `messages_dropped_ham / messages_parsed` means
181    /// peers are redundantly relaying stale data that the router
182    /// already knows about.
183    ///
184    /// This counter measures work *avoided* — each increment
185    /// represents a Put that was not forwarded to storage or
186    /// relayed to network peers, saving mailbox sends, allocations,
187    /// and serialization.
188    messages_dropped_ham: AtomicU64,
189}
190
191/// Plain-old-data snapshot of `Metrics` for safe export across threads.
192///
193/// This is `Copy` because it holds plain `u64` values — no atomics,
194/// no references. Safe to log, serialize, or send over a channel.
195///
196/// Note: snapshot is non-atomic across counters — values may be
197/// slightly inconsistent (one counter advanced, another not yet).
198/// This is acceptable for telemetry; do not use for control flow.
199#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
200pub struct MetricsSnapshot {
201    // Drop & quorum counters (original)
202    pub dropped_sends: u64,
203    pub reaped_quorums: u64,
204    pub put_acks_seen: u64,
205    pub put_acks_quorum: u64,
206    // Hot-path counters (v0.11.0)
207    pub messages_parsed: u64,
208    pub messages_relayed: u64,
209    pub messages_dropped_dup: u64,
210    pub serialization_calls: u64,
211    pub subscriber_fanout_total: u64,
212    pub ws_messages_received: u64,
213    pub ws_messages_sent: u64,
214    // HAM pre-filter counter (v0.12.0)
215    pub messages_dropped_ham: u64,
216}
217
218impl Metrics {
219    /// Create a new `Metrics` with all counters at zero.
220    pub fn new() -> Self {
221        Self::default()
222    }
223
224    // ── Drop & quorum recording methods (original) ──────────────────
225
226    /// Record that a fire-and-forget send was dropped.
227    ///
228    /// Call this when `Addr::send(msg)` returns `Err(())` in a
229    /// context where you accept the loss but want to know it happened.
230    #[inline]
231    pub fn record_dropped_send(&self) {
232        self.dropped_sends.fetch_add(1, Ordering::Relaxed);
233    }
234
235    /// Record that the quorum reaper evicted an expired entry.
236    #[inline]
237    pub fn record_reaped_quorum(&self) {
238        self.reaped_quorums.fetch_add(1, Ordering::Relaxed);
239    }
240
241    /// Record that a Put ack was received.
242    #[inline]
243    pub fn record_put_ack(&self) {
244        self.put_acks_seen.fetch_add(1, Ordering::Relaxed);
245    }
246
247    /// Record that a Put ack completed a quorum.
248    #[inline]
249    pub fn record_quorum_ack(&self) {
250        self.put_acks_quorum.fetch_add(1, Ordering::Relaxed);
251    }
252
253    // ── Hot-path recording methods (v0.11.0) ────────────────────────
254
255    /// Record that a wire message was parsed from JSON into a `Message` struct.
256    ///
257    /// Called at the entry point of every inbound message —
258    /// `Message::try_from`. This is the first counter in the hot path.
259    #[inline]
260    pub fn record_parsed(&self) {
261        self.messages_parsed.fetch_add(1, Ordering::Relaxed);
262    }
263
264    /// Record that a Put was successfully relayed to peers/subscribers.
265    ///
266    /// Called in `Router::handle_put_relay` after fan-out completes.
267    #[inline]
268    pub fn record_relayed(&self) {
269        self.messages_relayed.fetch_add(1, Ordering::Relaxed);
270    }
271
272    /// Record that a message was dropped by the dedup gate.
273    ///
274    /// Called in `Router::handle_put` when `Dup::check` returns true.
275    #[inline]
276    pub fn record_dropped_dup(&self) {
277        self.messages_dropped_dup.fetch_add(1, Ordering::Relaxed);
278    }
279
280    /// Record a wire-format serialization call.
281    ///
282    /// Called in `Message::to_string` / `Put::to_string`.
283    #[inline]
284    pub fn record_serialization(&self) {
285        self.serialization_calls.fetch_add(1, Ordering::Relaxed);
286    }
287
288    /// Record subscriber deliveries from a relay fan-out.
289    ///
290    /// Called in `Router::handle_put_relay` with the number of
291    /// subscribers that received this message. Pass 0 if no
292    /// subscribers were present (the relay still happened, just
293    /// nobody was listening).
294    #[inline]
295    pub fn record_subscriber_fanout(&self, count: u64) {
296        self.subscriber_fanout_total
297            .fetch_add(count, Ordering::Relaxed);
298    }
299
300    /// Record an inbound WebSocket message frame.
301    ///
302    /// Called in `WsConn::handle` on each incoming Text or Binary frame.
303    #[inline]
304    pub fn record_ws_received(&self) {
305        self.ws_messages_received.fetch_add(1, Ordering::Relaxed);
306    }
307
308    /// Record an outbound WebSocket message frame.
309    ///
310    /// Called on each successful `WsConn` send.
311    #[inline]
312    pub fn record_ws_sent(&self) {
313        self.ws_messages_sent.fetch_add(1, Ordering::Relaxed);
314    }
315
316    /// Record that a Put was dropped by the HAM stale-data pre-filter.
317    ///
318    /// Called in `Router::handle_put` when `ham_filter` returns `false`.
319    /// Each increment represents a Put that was not forwarded to storage
320    /// or relayed — work avoided, not just work made cheaper.
321    #[inline]
322    pub fn record_dropped_ham(&self) {
323        self.messages_dropped_ham.fetch_add(1, Ordering::Relaxed);
324    }
325
326    // ── Snapshot ────────────────────────────────────────────────────
327
328    /// Read all counters as a plain struct.
329    ///
330    /// Non-atomic across counters — values may be slightly inconsistent.
331    /// Acceptable for telemetry; do not use for control flow.
332    pub fn snapshot(&self) -> MetricsSnapshot {
333        MetricsSnapshot {
334            dropped_sends: self.dropped_sends.load(Ordering::Relaxed),
335            reaped_quorums: self.reaped_quorums.load(Ordering::Relaxed),
336            put_acks_seen: self.put_acks_seen.load(Ordering::Relaxed),
337            put_acks_quorum: self.put_acks_quorum.load(Ordering::Relaxed),
338            messages_parsed: self.messages_parsed.load(Ordering::Relaxed),
339            messages_relayed: self.messages_relayed.load(Ordering::Relaxed),
340            messages_dropped_dup: self.messages_dropped_dup.load(Ordering::Relaxed),
341            serialization_calls: self.serialization_calls.load(Ordering::Relaxed),
342            subscriber_fanout_total: self.subscriber_fanout_total.load(Ordering::Relaxed),
343            ws_messages_received: self.ws_messages_received.load(Ordering::Relaxed),
344            ws_messages_sent: self.ws_messages_sent.load(Ordering::Relaxed),
345            messages_dropped_ham: self.messages_dropped_ham.load(Ordering::Relaxed),
346        }
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use std::sync::Arc;
354
355    #[test]
356    fn default_is_all_zero() {
357        let snap = Metrics::new().snapshot();
358        assert_eq!(snap.dropped_sends, 0);
359        assert_eq!(snap.reaped_quorums, 0);
360        assert_eq!(snap.put_acks_seen, 0);
361        assert_eq!(snap.put_acks_quorum, 0);
362        // Hot-path counters
363        assert_eq!(snap.messages_parsed, 0);
364        assert_eq!(snap.messages_relayed, 0);
365        assert_eq!(snap.messages_dropped_dup, 0);
366        assert_eq!(snap.serialization_calls, 0);
367        assert_eq!(snap.subscriber_fanout_total, 0);
368        assert_eq!(snap.ws_messages_received, 0);
369        assert_eq!(snap.ws_messages_sent, 0);
370        assert_eq!(snap.messages_dropped_ham, 0);
371    }
372
373    // ── Original counter tests ──────────────────────────────────────
374
375    #[test]
376    fn record_dropped_send_increments_counter() {
377        let m = Metrics::new();
378        m.record_dropped_send();
379        m.record_dropped_send();
380        m.record_dropped_send();
381        assert_eq!(m.snapshot().dropped_sends, 3);
382    }
383
384    #[test]
385    fn record_reaped_quorum_increments_counter() {
386        let m = Metrics::new();
387        m.record_reaped_quorum();
388        assert_eq!(m.snapshot().reaped_quorums, 1);
389    }
390
391    #[test]
392    fn record_put_ack_increments_counter() {
393        let m = Metrics::new();
394        m.record_put_ack();
395        m.record_put_ack();
396        assert_eq!(m.snapshot().put_acks_seen, 2);
397    }
398
399    #[test]
400    fn record_quorum_ack_increments_counter() {
401        let m = Metrics::new();
402        m.record_quorum_ack();
403        assert_eq!(m.snapshot().put_acks_quorum, 1);
404    }
405
406    // ── Hot-path counter tests ──────────────────────────────────────
407
408    #[test]
409    fn record_parsed_increments_counter() {
410        let m = Metrics::new();
411        m.record_parsed();
412        m.record_parsed();
413        assert_eq!(m.snapshot().messages_parsed, 2);
414    }
415
416    #[test]
417    fn record_relayed_increments_counter() {
418        let m = Metrics::new();
419        m.record_relayed();
420        assert_eq!(m.snapshot().messages_relayed, 1);
421    }
422
423    #[test]
424    fn record_dropped_dup_increments_counter() {
425        let m = Metrics::new();
426        m.record_dropped_dup();
427        m.record_dropped_dup();
428        m.record_dropped_dup();
429        assert_eq!(m.snapshot().messages_dropped_dup, 3);
430    }
431
432    #[test]
433    fn record_dropped_ham_increments_counter() {
434        let m = Metrics::new();
435        m.record_dropped_ham();
436        m.record_dropped_ham();
437        assert_eq!(m.snapshot().messages_dropped_ham, 2);
438    }
439
440    #[test]
441    fn record_serialization_increments_counter() {
442        let m = Metrics::new();
443        m.record_serialization();
444        assert_eq!(m.snapshot().serialization_calls, 1);
445    }
446
447    #[test]
448    fn record_subscriber_fanout_accumulates() {
449        let m = Metrics::new();
450        m.record_subscriber_fanout(5);
451        m.record_subscriber_fanout(3);
452        m.record_subscriber_fanout(0); // no subscribers — still relayed
453        assert_eq!(m.snapshot().subscriber_fanout_total, 8);
454    }
455
456    #[test]
457    fn record_ws_received_increments_counter() {
458        let m = Metrics::new();
459        for _ in 0..500 {
460            m.record_ws_received();
461        }
462        assert_eq!(m.snapshot().ws_messages_received, 500);
463    }
464
465    #[test]
466    fn record_ws_sent_increments_counter() {
467        let m = Metrics::new();
468        m.record_ws_sent();
469        m.record_ws_sent();
470        assert_eq!(m.snapshot().ws_messages_sent, 2);
471    }
472
473    // ── Cross-counter & invariant tests ─────────────────────────────
474
475    #[test]
476    fn snapshot_reflects_independent_increments() {
477        let m = Metrics::new();
478        m.record_dropped_send();
479        m.record_put_ack();
480        m.record_quorum_ack();
481        m.record_parsed();
482        m.record_relayed();
483        m.record_serialization();
484        m.record_dropped_ham();
485        let snap = m.snapshot();
486        assert_eq!(snap.dropped_sends, 1);
487        assert_eq!(snap.put_acks_seen, 1);
488        assert_eq!(snap.put_acks_quorum, 1);
489        assert_eq!(snap.reaped_quorums, 0);
490        assert_eq!(snap.messages_parsed, 1);
491        assert_eq!(snap.messages_relayed, 1);
492        assert_eq!(snap.messages_dropped_dup, 0);
493        assert_eq!(snap.serialization_calls, 1);
494        assert_eq!(snap.messages_dropped_ham, 1);
495    }
496
497    #[test]
498    fn counters_are_monotonic() {
499        // Relaxed atomics guarantee that increments on a single
500        // counter are not lost, but concurrent increments may be
501        // reordered relative to each other. For a single-threaded
502        // sequence, the counter must be strictly monotonic.
503        let m = Metrics::new();
504        for i in 1..=1000 {
505            m.record_dropped_send();
506            assert_eq!(m.snapshot().dropped_sends, i);
507        }
508    }
509
510    #[test]
511    fn shared_metrics_via_arc() {
512        // Verify Arc<Metrics> is the idiomatic shared handle and
513        // updates are visible across clones.
514        let m: Arc<Metrics> = Arc::new(Metrics::new());
515        let m2 = Arc::clone(&m);
516        m.record_dropped_send();
517        m.record_parsed();
518        assert_eq!(m2.snapshot().dropped_sends, 1);
519        assert_eq!(m2.snapshot().messages_parsed, 1);
520    }
521
522    #[test]
523    fn concurrent_increments_are_not_lost() {
524        // Sanity check: 100 threads × 1000 increments = 100_000 total.
525        // Relaxed ordering may interleave but no increment is lost.
526        use std::thread;
527        let m = Arc::new(Metrics::new());
528        let mut handles = Vec::new();
529        for _ in 0..100 {
530            let m = Arc::clone(&m);
531            handles.push(thread::spawn(move || {
532                for _ in 0..1000 {
533                    m.record_dropped_send();
534                }
535            }));
536        }
537        for h in handles {
538            h.join().unwrap();
539        }
540        assert_eq!(m.snapshot().dropped_sends, 100_000);
541    }
542
543    #[test]
544    fn concurrent_hot_path_increments_are_not_lost() {
545        // Same concurrent test for a hot-path counter.
546        use std::thread;
547        let m = Arc::new(Metrics::new());
548        let mut handles = Vec::new();
549        for _ in 0..50 {
550            let m = Arc::clone(&m);
551            handles.push(thread::spawn(move || {
552                for _ in 0..2000 {
553                    m.record_parsed();
554                }
555            }));
556        }
557        for h in handles {
558            h.join().unwrap();
559        }
560        assert_eq!(m.snapshot().messages_parsed, 100_000);
561    }
562
563    #[test]
564    fn snapshot_is_copy() {
565        // Compile-time check: MetricsSnapshot is Copy.
566        let s = Metrics::new().snapshot();
567        let s2 = s; // Copy, not move
568        assert_eq!(s, s2);
569    }
570
571    #[test]
572    fn snapshot_serializes_to_json() {
573        // Verify MetricsSnapshot can be serialized to JSON (for the
574        // /metrics HTTP endpoint).
575        let snap = Metrics::new().snapshot();
576        let json = serde_json::to_string(&snap).unwrap();
577        assert!(json.contains("dropped_sends"));
578        assert!(json.contains("messages_parsed"));
579        assert!(json.contains("ws_messages_sent"));
580    }
581}