Skip to main content

orbit_metrics/
lib.rs

1//! Metrics snapshot families over `orbit-core` rings.
2//!
3//! Metrics are not cache entries. They are periodic, worker-local
4//! snapshots where readers want the latest value per node and can
5//! discard stale samples. This crate keeps that semantic layer out of
6//! `orbit-core` while staying independent of any application runtime.
7
8use std::collections::HashMap;
9use std::hash::Hash;
10use std::sync::Arc;
11
12use bytes::Bytes;
13use orbit_core::{Fleet, NetId64, NodeId};
14pub use orbit_core::{OrbitTyped, RingSpec, RingTopology};
15
16/// Default ring capacity for one current snapshot per fleet node.
17///
18/// This is a scan window, not a history-retention promise. At the default
19/// one-second publish cadence it leaves ample room for fleet-sized bursts
20/// while keeping scalar metric rings small.
21pub const METRIC_SNAPSHOT_RING_CAPACITY: usize =
22    orbit_core::compile::usize_from_env(
23        option_env!("ORBIT_METRIC_SNAPSHOT_RING_CAPACITY"),
24        1_024,
25    );
26
27/// Default ring capacity for ordinary keyed metric rows.
28///
29/// Keyed collectors may need to scan far enough back to find the latest row
30/// for every active key, so their default is larger than node snapshots.
31/// High-cardinality domains should still choose and document their own bound.
32pub const KEYED_METRIC_RING_CAPACITY: usize =
33    orbit_core::compile::usize_from_env(
34        option_env!("ORBIT_KEYED_METRIC_RING_CAPACITY"),
35        4_096,
36    );
37
38const _: () = assert!(METRIC_SNAPSHOT_RING_CAPACITY.is_power_of_two());
39const _: () = assert!(KEYED_METRIC_RING_CAPACITY.is_power_of_two());
40
41/// A periodic metrics snapshot carried by an Orbit ring.
42///
43/// Implement this on a compact snapshot type. Hot paths should update
44/// local atomics; a background task captures a snapshot and publishes it
45/// through [`OrbitMetricFamily`].
46pub trait OrbitMetricSnapshot: OrbitTyped + Sized {
47    /// Human-readable family name for diagnostics.
48    const FAMILY: &'static str;
49
50    /// Logical node this snapshot describes.
51    fn node_id(&self) -> u16;
52
53    /// Unix timestamp in seconds when the snapshot was captured.
54    fn captured_at_unix_secs(&self) -> u64;
55
56    /// Encode the snapshot into a ring payload.
57    fn encode(&self) -> Result<Vec<u8>, String>;
58
59    /// Decode the snapshot from a ring payload.
60    fn decode(bytes: &[u8]) -> Result<Self, String>;
61}
62
63/// Optional key projection for row-like metric families.
64///
65/// Worker-control metrics usually use `node_id`, but label-preserving
66/// families often need "latest row per logical key" where the key is not
67/// the producing process id.
68pub trait OrbitMetricKeyedSnapshot: OrbitMetricSnapshot {
69    type Key: Eq + Hash;
70
71    fn metric_key(&self) -> Self::Key;
72}
73
74/// One decoded metrics sample plus the Orbit id that carried it.
75#[derive(Clone, Debug)]
76pub struct OrbitMetricSample<T> {
77    pub id: NetId64,
78    pub snapshot: T,
79}
80
81impl<T: OrbitMetricSnapshot> OrbitMetricSample<T> {
82    pub fn node_id(&self) -> u16 {
83        self.snapshot.node_id()
84    }
85
86    pub fn captured_at_unix_secs(&self) -> u64 {
87        self.snapshot.captured_at_unix_secs()
88    }
89
90    pub fn age_secs(
91        &self,
92        now_unix_secs: u64,
93    ) -> u64 {
94        now_unix_secs.saturating_sub(self.captured_at_unix_secs())
95    }
96
97    pub fn is_fresh(
98        &self,
99        now_unix_secs: u64,
100        max_age_secs: u64,
101    ) -> bool {
102        self.age_secs(now_unix_secs) <= max_age_secs
103    }
104}
105
106/// Ring-backed metrics family.
107#[derive(Clone)]
108pub struct OrbitMetricFamily<T: OrbitMetricSnapshot> {
109    fleet: Arc<Fleet>,
110    _t: std::marker::PhantomData<T>,
111}
112
113impl<T: OrbitMetricSnapshot> OrbitMetricFamily<T> {
114    pub fn new(fleet: Arc<Fleet>) -> Self {
115        Self { fleet, _t: std::marker::PhantomData }
116    }
117
118    pub fn publisher(&self) -> OrbitMetricPublisher<T> {
119        OrbitMetricPublisher { family: self.clone() }
120    }
121
122    pub fn collector(&self) -> OrbitMetricCollector<T> {
123        OrbitMetricCollector { family: self.clone() }
124    }
125}
126
127/// Write-side handle for one metrics family. Usually lives in the
128/// worker/background publisher task.
129#[derive(Clone)]
130pub struct OrbitMetricPublisher<T: OrbitMetricSnapshot> {
131    family: OrbitMetricFamily<T>,
132}
133
134impl<T: OrbitMetricSnapshot> OrbitMetricPublisher<T> {
135    pub fn new(fleet: Arc<Fleet>) -> Self {
136        Self { family: OrbitMetricFamily::new(fleet) }
137    }
138
139    /// Publish one captured snapshot. The ring frame version mirrors
140    /// the snapshot timestamp so low-level tools can inspect freshness
141    /// without decoding.
142    pub fn publish(
143        &self,
144        snapshot: &T,
145    ) -> Result<NetId64, String> {
146        let payload = snapshot.encode()?;
147        if payload.len() > T::RING_SPEC.payload_capacity {
148            return Err(format!(
149                "orbit metrics payload too large for {}: {} > {}",
150                T::FAMILY,
151                payload.len(),
152                T::RING_SPEC.payload_capacity
153            ));
154        }
155        Ok(self.family.fleet.publish::<T>(
156            0,
157            snapshot.captured_at_unix_secs(),
158            Bytes::from(payload),
159        ))
160    }
161}
162
163/// Read-side handle for one metrics family. Usually lives in the
164/// master/aggregator path.
165#[derive(Clone)]
166pub struct OrbitMetricCollector<T: OrbitMetricSnapshot> {
167    family: OrbitMetricFamily<T>,
168}
169
170impl<T: OrbitMetricSnapshot> OrbitMetricCollector<T> {
171    pub fn new(fleet: Arc<Fleet>) -> Self {
172        Self { family: OrbitMetricFamily::new(fleet) }
173    }
174
175    /// Walk the ring backwards and return the newest decodable sample
176    /// for each node. Malformed frames are ignored; a newer valid frame
177    /// for a node wins because the walk starts at the ring head.
178    pub fn latest_by_node(&self) -> HashMap<u16, OrbitMetricSample<T>> {
179        if T::RING_SPEC.topology == RingTopology::PerNode {
180            return self.latest_by_node_lanes();
181        }
182        let head = self.family.fleet.head::<T>();
183        if head == 0 {
184            return HashMap::new();
185        }
186
187        let capacity = self.family.fleet.ring_capacity::<T>() as u64;
188        let walk_count = head.min(capacity);
189        let mut samples = HashMap::new();
190        let expected_nodes = usize::from(self.family.fleet.fleet_capacity());
191
192        for i in 0..walk_count {
193            let counter = head - 1 - i;
194            let Some(frame) = self.family.fleet.read_at::<T>(counter) else {
195                if counter == 0 {
196                    break;
197                }
198                continue;
199            };
200            let Ok(snapshot) = T::decode(&frame.payload) else {
201                if counter == 0 {
202                    break;
203                }
204                continue;
205            };
206            samples
207                .entry(snapshot.node_id())
208                .or_insert(OrbitMetricSample { id: frame.id, snapshot });
209            if expected_nodes > 0 && samples.len() >= expected_nodes {
210                break;
211            }
212            if counter == 0 {
213                break;
214            }
215        }
216
217        samples
218    }
219
220    /// Walk the ring backwards and return the newest decodable sample
221    /// for each logical metric key.
222    pub fn latest_by_key<K>(&self) -> HashMap<K, OrbitMetricSample<T>>
223    where
224        T: OrbitMetricKeyedSnapshot<Key = K>,
225        K: Eq + Hash,
226    {
227        if T::RING_SPEC.topology == RingTopology::PerNode {
228            return self.latest_by_key_lanes();
229        }
230        let head = self.family.fleet.head::<T>();
231        if head == 0 {
232            return HashMap::new();
233        }
234
235        let capacity = self.family.fleet.ring_capacity::<T>() as u64;
236        let walk_count = head.min(capacity);
237        let mut samples = HashMap::new();
238
239        for i in 0..walk_count {
240            let counter = head - 1 - i;
241            let Some(frame) = self.family.fleet.read_at::<T>(counter) else {
242                if counter == 0 {
243                    break;
244                }
245                continue;
246            };
247            let Ok(snapshot) = T::decode(&frame.payload) else {
248                if counter == 0 {
249                    break;
250                }
251                continue;
252            };
253            samples
254                .entry(snapshot.metric_key())
255                .or_insert(OrbitMetricSample { id: frame.id, snapshot });
256            if counter == 0 {
257                break;
258            }
259        }
260
261        samples
262    }
263
264    fn latest_by_node_lanes(&self) -> HashMap<u16, OrbitMetricSample<T>> {
265        let capacity = self.family.fleet.ring_capacity::<T>() as u64;
266        let mut samples = HashMap::new();
267
268        for node in 0..self.family.fleet.fleet_capacity() {
269            let node_id = NodeId::new(node);
270            let head = self.family.fleet.lane_head::<T>(node_id);
271            let walk_count = head.min(capacity);
272            for offset in 0..walk_count {
273                let counter = head - 1 - offset;
274                let Some(frame) =
275                    self.family.fleet.read_lane_at::<T>(node_id, counter)
276                else {
277                    continue;
278                };
279                let Ok(snapshot) = T::decode(&frame.payload) else {
280                    continue;
281                };
282                if snapshot.node_id() != node {
283                    continue;
284                }
285                samples.insert(
286                    snapshot.node_id(),
287                    OrbitMetricSample { id: frame.id, snapshot },
288                );
289                break;
290            }
291        }
292
293        samples
294    }
295
296    fn latest_by_key_lanes<K>(&self) -> HashMap<K, OrbitMetricSample<T>>
297    where
298        T: OrbitMetricKeyedSnapshot<Key = K>,
299        K: Eq + Hash,
300    {
301        let capacity = self.family.fleet.ring_capacity::<T>() as u64;
302        let mut samples = HashMap::new();
303
304        for node in 0..self.family.fleet.fleet_capacity() {
305            let node_id = NodeId::new(node);
306            let head = self.family.fleet.lane_head::<T>(node_id);
307            let walk_count = head.min(capacity);
308            for offset in 0..walk_count {
309                let counter = head - 1 - offset;
310                let Some(frame) =
311                    self.family.fleet.read_lane_at::<T>(node_id, counter)
312                else {
313                    continue;
314                };
315                let Ok(snapshot) = T::decode(&frame.payload) else {
316                    continue;
317                };
318                let key = snapshot.metric_key();
319                let candidate = OrbitMetricSample { id: frame.id, snapshot };
320                match samples.entry(key) {
321                    std::collections::hash_map::Entry::Vacant(entry) => {
322                        entry.insert(candidate);
323                    }
324                    std::collections::hash_map::Entry::Occupied(mut entry)
325                        if sample_is_newer(&candidate, entry.get()) =>
326                    {
327                        entry.insert(candidate);
328                    }
329                    std::collections::hash_map::Entry::Occupied(_) => {}
330                }
331            }
332        }
333
334        samples
335    }
336
337    /// Return latest keyed samples whose captured timestamp is within
338    /// `max_age_secs` of `now_unix_secs`.
339    pub fn fresh_by_key<K>(
340        &self,
341        now_unix_secs: u64,
342        max_age_secs: u64,
343    ) -> HashMap<K, OrbitMetricSample<T>>
344    where
345        T: OrbitMetricKeyedSnapshot<Key = K>,
346        K: Eq + Hash,
347    {
348        self.latest_by_key()
349            .into_iter()
350            .filter(|(_, sample)| sample.is_fresh(now_unix_secs, max_age_secs))
351            .collect()
352    }
353
354    /// Return latest samples whose captured timestamp is within
355    /// `max_age_secs` of `now_unix_secs`.
356    pub fn fresh_by_node(
357        &self,
358        now_unix_secs: u64,
359        max_age_secs: u64,
360    ) -> HashMap<u16, OrbitMetricSample<T>> {
361        self.latest_by_node()
362            .into_iter()
363            .filter(|(_, sample)| sample.is_fresh(now_unix_secs, max_age_secs))
364            .collect()
365    }
366}
367
368fn sample_is_newer<T: OrbitMetricSnapshot>(
369    candidate: &OrbitMetricSample<T>,
370    current: &OrbitMetricSample<T>,
371) -> bool {
372    (
373        candidate.captured_at_unix_secs(),
374        candidate.id.node(),
375        candidate.id.counter(),
376    ) > (
377        current.captured_at_unix_secs(),
378        current.id.node(),
379        current.id.counter(),
380    )
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    #[derive(Clone, Debug, PartialEq, Eq)]
388    struct TestSnapshot {
389        node: u16,
390        captured_at: u64,
391        value: u64,
392    }
393
394    impl OrbitTyped for TestSnapshot {
395        const KIND: u8 = 211;
396        const RING_SPEC: RingSpec =
397            RingSpec::new(METRIC_SNAPSHOT_RING_CAPACITY, 18);
398    }
399
400    impl OrbitMetricSnapshot for TestSnapshot {
401        const FAMILY: &'static str = "test";
402
403        fn node_id(&self) -> u16 {
404            self.node
405        }
406
407        fn captured_at_unix_secs(&self) -> u64 {
408            self.captured_at
409        }
410
411        fn encode(&self) -> Result<Vec<u8>, String> {
412            let mut out = Vec::with_capacity(18);
413            out.extend_from_slice(&self.node.to_le_bytes());
414            out.extend_from_slice(&self.captured_at.to_le_bytes());
415            out.extend_from_slice(&self.value.to_le_bytes());
416            Ok(out)
417        }
418
419        fn decode(bytes: &[u8]) -> Result<Self, String> {
420            if bytes.len() != 18 {
421                return Err(format!("bad len {}", bytes.len()));
422            }
423            let node =
424                u16::from_le_bytes(bytes[0..2].try_into().expect("node bytes"));
425            let captured_at = u64::from_le_bytes(
426                bytes[2..10].try_into().expect("time bytes"),
427            );
428            let value = u64::from_le_bytes(
429                bytes[10..18].try_into().expect("value bytes"),
430            );
431            Ok(Self { node, captured_at, value })
432        }
433    }
434
435    #[test]
436    fn latest_by_node_keeps_newest_sample_per_node() {
437        let fleet = Arc::new(Fleet::join("metrics-test", 2).unwrap());
438        let family = OrbitMetricFamily::<TestSnapshot>::new(fleet);
439        let publisher = family.publisher();
440        let collector = family.collector();
441
442        publisher
443            .publish(&TestSnapshot { node: 1, captured_at: 10, value: 100 })
444            .unwrap();
445        publisher
446            .publish(&TestSnapshot { node: 2, captured_at: 11, value: 200 })
447            .unwrap();
448        publisher
449            .publish(&TestSnapshot { node: 1, captured_at: 12, value: 101 })
450            .unwrap();
451
452        let latest = collector.latest_by_node();
453        assert_eq!(latest.len(), 2);
454        assert_eq!(latest[&1].snapshot.value, 101);
455        assert_eq!(latest[&2].snapshot.value, 200);
456    }
457
458    #[cfg(unix)]
459    #[test]
460    fn per_node_family_reads_each_writer_lane() {
461        #[derive(Clone, Debug, PartialEq, Eq)]
462        struct PerNodeSnapshot(TestSnapshot);
463
464        impl OrbitTyped for PerNodeSnapshot {
465            const KIND: u8 = 209;
466            const RING_SPEC: RingSpec = RingSpec::per_node(16, 18);
467        }
468
469        impl OrbitMetricSnapshot for PerNodeSnapshot {
470            const FAMILY: &'static str = "per-node-test";
471
472            fn node_id(&self) -> u16 {
473                self.0.node_id()
474            }
475
476            fn captured_at_unix_secs(&self) -> u64 {
477                self.0.captured_at_unix_secs()
478            }
479
480            fn encode(&self) -> Result<Vec<u8>, String> {
481                self.0.encode()
482            }
483
484            fn decode(bytes: &[u8]) -> Result<Self, String> {
485                TestSnapshot::decode(bytes).map(Self)
486            }
487        }
488
489        let name =
490            Box::leak(format!("m{:x}", std::process::id()).into_boxed_str());
491        let collector_fleet =
492            Arc::new(Fleet::join_shm_as(name, 3, NodeId::new(0)).unwrap());
493        collector_fleet.reset_ring::<PerNodeSnapshot>().unwrap();
494        let node_one = OrbitMetricPublisher::<PerNodeSnapshot>::new(Arc::new(
495            Fleet::join_shm_as(name, 3, NodeId::new(1)).unwrap(),
496        ));
497        let node_two = OrbitMetricPublisher::<PerNodeSnapshot>::new(Arc::new(
498            Fleet::join_shm_as(name, 3, NodeId::new(2)).unwrap(),
499        ));
500
501        node_one
502            .publish(&PerNodeSnapshot(TestSnapshot {
503                node: 1,
504                captured_at: 10,
505                value: 100,
506            }))
507            .unwrap();
508        node_two
509            .publish(&PerNodeSnapshot(TestSnapshot {
510                node: 2,
511                captured_at: 11,
512                value: 200,
513            }))
514            .unwrap();
515        node_one
516            .publish(&PerNodeSnapshot(TestSnapshot {
517                node: 1,
518                captured_at: 12,
519                value: 101,
520            }))
521            .unwrap();
522
523        let latest = OrbitMetricCollector::<PerNodeSnapshot>::new(
524            collector_fleet.clone(),
525        )
526        .latest_by_node();
527        assert_eq!(latest.len(), 2);
528        assert_eq!(latest[&1].snapshot.0.value, 101);
529        assert_eq!(latest[&2].snapshot.0.value, 200);
530
531        collector_fleet
532            .shm_ring::<PerNodeSnapshot>()
533            .unwrap()
534            .unlink()
535            .unwrap();
536    }
537
538    #[test]
539    fn fresh_by_node_drops_stale_samples() {
540        let fleet = Arc::new(Fleet::join("metrics-fresh-test", 2).unwrap());
541        let family = OrbitMetricFamily::<TestSnapshot>::new(fleet);
542        let publisher = family.publisher();
543        let collector = family.collector();
544
545        publisher
546            .publish(&TestSnapshot { node: 1, captured_at: 10, value: 100 })
547            .unwrap();
548        publisher
549            .publish(&TestSnapshot { node: 2, captured_at: 20, value: 200 })
550            .unwrap();
551
552        let fresh = collector.fresh_by_node(25, 10);
553        assert_eq!(fresh.len(), 1);
554        assert_eq!(fresh[&2].snapshot.value, 200);
555    }
556
557    #[derive(Clone, Debug, PartialEq, Eq)]
558    struct KeyedSnapshot {
559        node: u16,
560        key: &'static str,
561        captured_at: u64,
562        value: u64,
563    }
564
565    impl OrbitTyped for KeyedSnapshot {
566        const KIND: u8 = 212;
567        const RING_SPEC: RingSpec =
568            RingSpec::new(KEYED_METRIC_RING_CAPACITY, 64);
569    }
570
571    impl OrbitMetricSnapshot for KeyedSnapshot {
572        const FAMILY: &'static str = "keyed-test";
573
574        fn node_id(&self) -> u16 {
575            self.node
576        }
577
578        fn captured_at_unix_secs(&self) -> u64 {
579            self.captured_at
580        }
581
582        fn encode(&self) -> Result<Vec<u8>, String> {
583            let mut out = Vec::with_capacity(27);
584            out.extend_from_slice(&self.node.to_le_bytes());
585            out.extend_from_slice(&self.captured_at.to_le_bytes());
586            out.extend_from_slice(&self.value.to_le_bytes());
587            let key = self.key.as_bytes();
588            out.push(key.len() as u8);
589            out.extend_from_slice(key);
590            Ok(out)
591        }
592
593        fn decode(bytes: &[u8]) -> Result<Self, String> {
594            if bytes.len() < 19 {
595                return Err(format!("bad len {}", bytes.len()));
596            }
597            let node =
598                u16::from_le_bytes(bytes[0..2].try_into().expect("node bytes"));
599            let captured_at = u64::from_le_bytes(
600                bytes[2..10].try_into().expect("time bytes"),
601            );
602            let value = u64::from_le_bytes(
603                bytes[10..18].try_into().expect("value bytes"),
604            );
605            let key_len = usize::from(bytes[18]);
606            if bytes.len() != 19 + key_len {
607                return Err(format!("bad key len {}", bytes.len()));
608            }
609            let key =
610                std::str::from_utf8(&bytes[19..]).map_err(|e| e.to_string())?;
611            let key = match key {
612                "alpha" => "alpha",
613                "beta" => "beta",
614                _ => return Err(format!("unknown key {key}")),
615            };
616            Ok(Self { node, key, captured_at, value })
617        }
618    }
619
620    impl OrbitMetricKeyedSnapshot for KeyedSnapshot {
621        type Key = String;
622
623        fn metric_key(&self) -> Self::Key {
624            self.key.to_owned()
625        }
626    }
627
628    #[test]
629    fn latest_by_key_keeps_newest_sample_per_key() {
630        let fleet = Arc::new(Fleet::join("metrics-keyed-test", 2).unwrap());
631        let family = OrbitMetricFamily::<KeyedSnapshot>::new(fleet);
632        let publisher = family.publisher();
633        let collector = family.collector();
634
635        publisher
636            .publish(&KeyedSnapshot {
637                node: 0,
638                key: "alpha",
639                captured_at: 10,
640                value: 100,
641            })
642            .unwrap();
643        publisher
644            .publish(&KeyedSnapshot {
645                node: 0,
646                key: "beta",
647                captured_at: 11,
648                value: 200,
649            })
650            .unwrap();
651        publisher
652            .publish(&KeyedSnapshot {
653                node: 0,
654                key: "alpha",
655                captured_at: 12,
656                value: 101,
657            })
658            .unwrap();
659
660        let latest = collector.latest_by_key();
661        assert_eq!(latest.len(), 2);
662        assert_eq!(latest["alpha"].snapshot.value, 101);
663        assert_eq!(latest["beta"].snapshot.value, 200);
664    }
665}