Skip to main content

fast_telemetry/metric/dynamic/
distribution.rs

1//! Runtime-labeled distribution for dynamic dimensions.
2//!
3//! Uses base-2 exponential histogram buckets per label set, matching the
4//! `Distribution` implementation.  Each label set × thread gets its own
5//! fixed-size bucket array.
6
7use super::cache::{CacheValue, LabelCache, SERIES_CACHE_SIZE};
8#[cfg(feature = "eviction")]
9use super::current_cycle;
10use super::{DISTRIBUTION_IDS, DynamicIndexMap, DynamicLabelSet, dynamic_index_map};
11use crate::exp_buckets::{ExpBuckets, ExpBucketsSnapshot};
12use crossbeam_utils::CachePadded;
13use parking_lot::{Mutex, RwLock};
14use std::cell::RefCell;
15use std::sync::Arc;
16use std::sync::Weak;
17#[cfg(feature = "eviction")]
18use std::sync::atomic::AtomicU32;
19use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
20
21const DEFAULT_MAX_SERIES: usize = 2000;
22const OVERFLOW_LABEL_KEY: &str = "__ft_overflow";
23const OVERFLOW_LABEL_VALUE: &str = "true";
24type DistributionIndexShard = CachePadded<RwLock<DynamicIndexMap<Arc<DistributionSeries>>>>;
25type DistributionSnapshotEntry = (DynamicLabelSet, u64, u64, ExpBucketsSnapshot);
26static SERIES_IDS: AtomicUsize = AtomicUsize::new(1);
27
28struct DistributionSeries {
29    id: usize,
30    registry: Mutex<Vec<Arc<ExpBuckets>>>,
31    /// Tombstone flag set by exporter before removing from map.
32    evicted: AtomicBool,
33    /// Last export cycle when this series was accessed.
34    #[cfg(feature = "eviction")]
35    last_accessed_cycle: AtomicU32,
36}
37
38impl DistributionSeries {
39    #[cfg(feature = "eviction")]
40    fn new(cycle: u32) -> Self {
41        Self {
42            id: SERIES_IDS.fetch_add(1, Ordering::Relaxed),
43            registry: Mutex::new(Vec::new()),
44            evicted: AtomicBool::new(false),
45            last_accessed_cycle: AtomicU32::new(cycle),
46        }
47    }
48
49    #[cfg(not(feature = "eviction"))]
50    fn new() -> Self {
51        Self {
52            id: SERIES_IDS.fetch_add(1, Ordering::Relaxed),
53            registry: Mutex::new(Vec::new()),
54            evicted: AtomicBool::new(false),
55        }
56    }
57
58    /// Touch the series timestamp. Called on slow path only.
59    #[cfg(feature = "eviction")]
60    #[inline]
61    fn touch(&self, cycle: u32) {
62        self.last_accessed_cycle.store(cycle, Ordering::Relaxed);
63    }
64
65    #[inline]
66    fn is_evicted(&self) -> bool {
67        self.evicted.load(Ordering::Relaxed)
68    }
69
70    #[cfg(feature = "eviction")]
71    fn mark_evicted(&self) {
72        self.evicted.store(true, Ordering::Relaxed);
73    }
74
75    fn get_or_create_buf(&self) -> Arc<ExpBuckets> {
76        let buf = Arc::new(ExpBuckets::new());
77        self.registry.lock().push(Arc::clone(&buf));
78        buf
79    }
80
81    fn count(&self) -> u64 {
82        self.registry.lock().iter().map(|buf| buf.get_count()).sum()
83    }
84
85    fn sum(&self) -> u64 {
86        self.registry.lock().iter().map(|buf| buf.get_sum()).sum()
87    }
88
89    fn buckets_snapshot(&self) -> ExpBucketsSnapshot {
90        let mut positive = [0u64; 64];
91        let mut zero_count = 0u64;
92        let mut sum = 0u64;
93        let mut count = 0u64;
94
95        let registry = self.registry.lock();
96        for buf in registry.iter() {
97            let thread_buckets = buf.get_positive_buckets();
98            for (i, &c) in thread_buckets.iter().enumerate() {
99                positive[i] += c;
100            }
101            zero_count += buf.get_zero_count();
102            sum += buf.get_sum();
103            count += buf.get_count();
104        }
105
106        ExpBucketsSnapshot {
107            positive,
108            zero_count,
109            sum,
110            count,
111        }
112    }
113}
114
115struct DistributionCacheValue {
116    series: Weak<DistributionSeries>,
117    buf: Arc<ExpBuckets>,
118}
119
120impl CacheValue for DistributionCacheValue {
121    type Strong = (Arc<DistributionSeries>, Arc<ExpBuckets>);
122
123    fn upgrade(&self) -> Option<Self::Strong> {
124        Some((self.series.upgrade()?, Arc::clone(&self.buf)))
125    }
126
127    fn is_valid(strong: &Self::Strong) -> bool {
128        !strong.0.is_evicted()
129    }
130}
131
132/// A reusable handle to a dynamic-label distribution series.
133///
134/// Use this for hot paths to avoid per-update label canonicalization and map
135/// lookups. Resolve once with `DynamicDistribution::series(...)`, then call
136/// `record()` on the handle.
137#[derive(Clone)]
138pub struct DynamicDistributionSeries {
139    series: Arc<DistributionSeries>,
140    buf: Arc<ExpBuckets>,
141}
142
143impl DynamicDistributionSeries {
144    /// Record a value.
145    #[inline]
146    pub fn record(&self, value: u64) {
147        self.buf.record(value);
148    }
149
150    /// Get the count across all threads for this series.
151    pub fn count(&self) -> u64 {
152        self.series.count()
153    }
154
155    /// Get the sum across all threads for this series.
156    pub fn sum(&self) -> u64 {
157        self.series.sum()
158    }
159
160    /// Check if this series handle has been evicted.
161    #[inline]
162    pub fn is_evicted(&self) -> bool {
163        self.series.is_evicted()
164    }
165}
166
167thread_local! {
168    static SERIES_CACHE: RefCell<LabelCache<DistributionCacheValue, SERIES_CACHE_SIZE>> =
169        RefCell::new(LabelCache::new());
170    static SERIES_BUF_CACHE: RefCell<Vec<(usize, usize, Weak<ExpBuckets>)>> = const { RefCell::new(Vec::new()) };
171}
172
173/// Distribution keyed by runtime label sets.
174///
175/// Each label set gets its own set of thread-local exponential histogram buckets.
176pub struct DynamicDistribution {
177    id: usize,
178    max_series: usize,
179    shard_mask: usize,
180    index_shards: Vec<DistributionIndexShard>,
181    /// Approximate number of live series (incremented on insert, decremented on evict).
182    series_count: AtomicUsize,
183    /// Count of records routed to overflow bucket due to cardinality cap.
184    overflow_count: AtomicU64,
185}
186
187impl DynamicDistribution {
188    /// Creates a new runtime-labeled distribution with default cardinality cap.
189    pub fn new(shard_count: usize) -> Self {
190        Self::with_max_series(shard_count, DEFAULT_MAX_SERIES)
191    }
192
193    /// Creates a new runtime-labeled distribution with a custom cardinality cap.
194    pub fn with_max_series(shard_count: usize, max_series: usize) -> Self {
195        let shard_count = shard_count.next_power_of_two();
196        let id = DISTRIBUTION_IDS.fetch_add(1, Ordering::Relaxed);
197        Self {
198            id,
199            max_series,
200            shard_mask: shard_count - 1,
201            index_shards: (0..shard_count)
202                .map(|_| CachePadded::new(RwLock::new(dynamic_index_map())))
203                .collect(),
204            series_count: AtomicUsize::new(0),
205            overflow_count: AtomicU64::new(0),
206        }
207    }
208
209    /// Resolve a reusable series handle for `labels`.
210    ///
211    /// Preferred for hot paths when labels come from a finite active set.
212    pub fn series(&self, labels: &[(&str, &str)]) -> DynamicDistributionSeries {
213        if let Some((series, buf)) = self.cached_series(labels) {
214            return DynamicDistributionSeries { series, buf };
215        }
216        let series = self.lookup_or_create(labels);
217        let buf = self.get_or_create_thread_buf(&series);
218        self.update_cache(labels, &series, Arc::clone(&buf));
219        DynamicDistributionSeries { series, buf }
220    }
221
222    /// Record a value for the series identified by `labels`.
223    #[inline]
224    pub fn record(&self, labels: &[(&str, &str)], value: u64) {
225        if let Some((_series, buf)) = self.cached_series(labels) {
226            buf.record(value);
227            return;
228        }
229
230        let series = self.lookup_or_create(labels);
231        let buf = self.get_or_create_thread_buf(&series);
232        self.update_cache(labels, &series, Arc::clone(&buf));
233        buf.record(value);
234    }
235
236    /// Get count for the series identified by `labels`.
237    pub fn count(&self, labels: &[(&str, &str)]) -> u64 {
238        let key = DynamicLabelSet::from_pairs(labels);
239        let index_shard = self.index_shard_for(&key);
240        self.index_shards[index_shard]
241            .read()
242            .get(&key)
243            .map(|series| series.count())
244            .unwrap_or(0)
245    }
246
247    /// Get sum for the series identified by `labels`.
248    pub fn sum(&self, labels: &[(&str, &str)]) -> u64 {
249        let key = DynamicLabelSet::from_pairs(labels);
250        let index_shard = self.index_shard_for(&key);
251        self.index_shards[index_shard]
252            .read()
253            .get(&key)
254            .map(|series| series.sum())
255            .unwrap_or(0)
256    }
257
258    /// Returns a snapshot of all label-sets with their stats.
259    pub fn snapshot(&self) -> Vec<DistributionSnapshotEntry> {
260        let mut out = Vec::new();
261        for shard in &self.index_shards {
262            let guard = shard.read();
263            for (labels, series) in guard.iter() {
264                let snap = series.buckets_snapshot();
265                out.push((labels.clone(), snap.count, snap.sum, snap));
266            }
267        }
268        out
269    }
270
271    /// Returns the current number of distinct label sets.
272    pub fn cardinality(&self) -> usize {
273        self.index_shards
274            .iter()
275            .map(|shard| shard.read().len())
276            .sum()
277    }
278
279    /// Returns the number of records routed to the overflow bucket.
280    ///
281    /// A non-zero value indicates the cardinality cap was hit and label
282    /// fidelity is being lost. Use this to alert on cardinality pressure.
283    pub fn overflow_count(&self) -> u64 {
284        self.overflow_count.load(Ordering::Relaxed)
285    }
286
287    /// Iterate all series without cloning label sets.
288    ///
289    /// Calls `f` with borrowed label pairs, count, sum, and bucket snapshot
290    /// for each series. Used by exporters/macros to avoid `snapshot()` cloning.
291    /// The callback runs while a shard read lock is held; it must be fast and
292    /// must not call back into this metric.
293    #[doc(hidden)]
294    pub fn visit_series(
295        &self,
296        mut f: impl FnMut(&[(String, String)], u64, u64, ExpBucketsSnapshot),
297    ) {
298        for shard in &self.index_shards {
299            let guard = shard.read();
300            for (labels, series) in guard.iter() {
301                let snap = series.buckets_snapshot();
302                f(labels.pairs(), snap.count, snap.sum, snap);
303            }
304        }
305    }
306
307    /// Evict series that haven't been accessed for `max_staleness` cycles.
308    ///
309    /// Call this after `advance_cycle()` in your sweeper task.
310    /// Series are marked as evicted (so cached handles see the tombstone),
311    /// then removed from the index.
312    ///
313    /// Protected series (Arc::strong_count > 1) are never evicted — someone
314    /// holds a DynamicDistributionSeries handle to them.
315    ///
316    /// Returns the number of series evicted.
317    #[cfg(feature = "eviction")]
318    pub fn evict_stale(&self, max_staleness: u32) -> usize {
319        let cycle = current_cycle();
320        let mut removed = 0;
321
322        for shard in &self.index_shards {
323            let mut guard = shard.write();
324            guard.retain(|_labels, series| {
325                // Protected if someone holds a handle (strong_count > 1 means
326                // both the map and at least one DynamicDistributionSeries hold refs)
327                if Arc::strong_count(series) > 1 {
328                    return true;
329                }
330                // Otherwise check timestamp staleness
331                let last = series.last_accessed_cycle.load(Ordering::Relaxed);
332                let stale = cycle.saturating_sub(last) > max_staleness;
333                if stale {
334                    series.mark_evicted();
335                    removed += 1;
336                    self.series_count.fetch_sub(1, Ordering::Relaxed);
337                }
338                !stale
339            });
340        }
341
342        removed
343    }
344
345    fn lookup_or_create(&self, labels: &[(&str, &str)]) -> Arc<DistributionSeries> {
346        let requested_key = DynamicLabelSet::from_pairs(labels);
347        let requested_shard = self.index_shard_for(&requested_key);
348        #[cfg(feature = "eviction")]
349        let cycle = current_cycle();
350
351        // Fast path: read lock only.
352        if let Some(series) = self.index_shards[requested_shard]
353            .read()
354            .get(&requested_key)
355        {
356            #[cfg(feature = "eviction")]
357            series.touch(cycle);
358            return Arc::clone(series);
359        }
360
361        // Check cardinality cap BEFORE taking any write lock (lock-free).
362        let key = if self.max_series > 0
363            && self.series_count.load(Ordering::Relaxed) >= self.max_series
364        {
365            self.overflow_count.fetch_add(1, Ordering::Relaxed);
366            DynamicLabelSet::from_pairs(&[(OVERFLOW_LABEL_KEY, OVERFLOW_LABEL_VALUE)])
367        } else {
368            requested_key
369        };
370        let shard = self.index_shard_for(&key);
371
372        if let Some(series) = self.index_shards[shard].read().get(&key) {
373            #[cfg(feature = "eviction")]
374            series.touch(cycle);
375            return Arc::clone(series);
376        }
377
378        let mut guard = self.index_shards[shard].write();
379        if let Some(series) = guard.get(&key) {
380            #[cfg(feature = "eviction")]
381            series.touch(cycle);
382            return Arc::clone(series);
383        }
384        #[cfg(feature = "eviction")]
385        let series = Arc::new(DistributionSeries::new(cycle));
386        #[cfg(not(feature = "eviction"))]
387        let series = Arc::new(DistributionSeries::new());
388        guard.insert(key, Arc::clone(&series));
389        self.series_count.fetch_add(1, Ordering::Relaxed);
390        series
391    }
392
393    fn index_shard_for(&self, key: &DynamicLabelSet) -> usize {
394        key.shard_index(self.shard_mask)
395    }
396
397    fn cached_series(
398        &self,
399        labels: &[(&str, &str)],
400    ) -> Option<(Arc<DistributionSeries>, Arc<ExpBuckets>)> {
401        SERIES_CACHE.with(|cache| {
402            let (series, buf) = cache.borrow_mut().get(self.id, labels)?;
403            #[cfg(feature = "eviction")]
404            series.touch(current_cycle());
405            Some((series, buf))
406        })
407    }
408
409    fn update_cache(
410        &self,
411        labels: &[(&str, &str)],
412        series: &Arc<DistributionSeries>,
413        buf: Arc<ExpBuckets>,
414    ) {
415        SERIES_CACHE.with(|cache| {
416            cache.borrow_mut().insert(
417                self.id,
418                labels,
419                DistributionCacheValue {
420                    series: Arc::downgrade(series),
421                    buf,
422                },
423            );
424        });
425    }
426
427    fn get_or_create_thread_buf(&self, series: &Arc<DistributionSeries>) -> Arc<ExpBuckets> {
428        let dist_id = self.id;
429        let series_id = series.id;
430
431        SERIES_BUF_CACHE.with(|cache| {
432            let mut entries = cache.borrow_mut();
433            entries.retain(|(_id, _ptr, weak)| weak.strong_count() > 0);
434
435            for (id, ptr, weak) in entries.iter() {
436                if *id == dist_id
437                    && *ptr == series_id
438                    && let Some(buf) = weak.upgrade()
439                {
440                    return buf;
441                }
442            }
443
444            let buf = series.get_or_create_buf();
445            entries.push((dist_id, series_id, Arc::downgrade(&buf)));
446            buf
447        })
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    #[test]
456    fn test_basic_recording() {
457        let dist = DynamicDistribution::new(4);
458        let labels = &[("org_id", "42")];
459
460        dist.record(labels, 100);
461        dist.record(labels, 200);
462        dist.record(labels, 300);
463
464        assert_eq!(dist.count(labels), 3);
465        assert_eq!(dist.sum(labels), 600);
466    }
467
468    #[test]
469    fn test_label_order_is_canonicalized() {
470        let dist = DynamicDistribution::new(4);
471
472        dist.record(&[("org_id", "42"), ("endpoint", "abc")], 100);
473
474        assert_eq!(dist.count(&[("endpoint", "abc"), ("org_id", "42")]), 1);
475    }
476
477    #[test]
478    fn test_series_handle() {
479        let dist = DynamicDistribution::new(4);
480        let series = dist.series(&[("org_id", "42")]);
481
482        series.record(100);
483        series.record(200);
484
485        assert_eq!(series.count(), 2);
486        assert_eq!(series.sum(), 300);
487        assert_eq!(dist.count(&[("org_id", "42")]), 2);
488    }
489
490    #[test]
491    fn test_multiple_label_sets() {
492        let dist = DynamicDistribution::new(4);
493
494        dist.record(&[("org_id", "1")], 100);
495        dist.record(&[("org_id", "2")], 200);
496
497        assert_eq!(dist.count(&[("org_id", "1")]), 1);
498        assert_eq!(dist.count(&[("org_id", "2")]), 1);
499
500        let snap = dist.snapshot();
501        assert_eq!(snap.len(), 2);
502    }
503
504    #[test]
505    fn test_overflow_bucket_routes_new_series_at_capacity() {
506        let dist = DynamicDistribution::with_max_series(4, 2);
507
508        dist.record(&[("org_id", "1")], 100);
509        dist.record(&[("org_id", "2")], 200);
510        // Third label set should overflow
511        dist.record(&[("org_id", "3")], 300);
512
513        assert_eq!(dist.cardinality(), 3); // 2 real + 1 overflow
514        assert!(dist.overflow_count() > 0);
515        assert_eq!(dist.count(&[(OVERFLOW_LABEL_KEY, OVERFLOW_LABEL_VALUE)]), 1);
516        assert_eq!(dist.sum(&[(OVERFLOW_LABEL_KEY, OVERFLOW_LABEL_VALUE)]), 300);
517    }
518
519    #[test]
520    fn test_snapshot_includes_buckets() {
521        let dist = DynamicDistribution::new(4);
522        dist.record(&[("org_id", "1")], 100);
523        dist.record(&[("org_id", "1")], 200);
524
525        let snap = dist.snapshot();
526        assert_eq!(snap.len(), 1);
527        let (_, count, sum, bucket_snap) = &snap[0];
528        assert_eq!(*count, 2);
529        assert_eq!(*sum, 300);
530        // Both 100 and 200 land in bucket 6 and 7 respectively
531        assert!(bucket_snap.positive[6] > 0 || bucket_snap.positive[7] > 0);
532    }
533}