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}