Skip to main content

fast_telemetry/metric/dynamic/
counter.rs

1//! Runtime-labeled counter for dynamic dimensions.
2
3use super::cache::{CacheableSeries, LabelCache, SERIES_CACHE_SIZE};
4#[cfg(feature = "eviction")]
5use super::current_cycle;
6use super::{COUNTER_IDS, DynamicIndexMap, DynamicLabelSet, dynamic_index_map, thread_id};
7use crossbeam_utils::CachePadded;
8use parking_lot::RwLock;
9use std::cell::RefCell;
10#[cfg(feature = "eviction")]
11use std::sync::atomic::AtomicU32;
12use std::sync::atomic::{AtomicBool, AtomicIsize, AtomicU64, AtomicUsize, Ordering};
13use std::sync::{Arc, Weak};
14
15const DEFAULT_MAX_SERIES: usize = 2000;
16const OVERFLOW_LABEL_KEY: &str = "__ft_overflow";
17const OVERFLOW_LABEL_VALUE: &str = "true";
18
19struct CounterSeries {
20    cells: Vec<CachePadded<AtomicIsize>>,
21    /// Tombstone flag set by exporter before removing from map.
22    /// Checked in cached_series() to invalidate stale cache entries.
23    evicted: AtomicBool,
24    /// Last export cycle when this series was accessed.
25    /// Used for staleness-based eviction.
26    #[cfg(feature = "eviction")]
27    last_accessed_cycle: AtomicU32,
28}
29
30type CounterIndexShard = CachePadded<RwLock<DynamicIndexMap<Arc<CounterSeries>>>>;
31
32impl CounterSeries {
33    #[cfg(feature = "eviction")]
34    fn new(shard_count: usize, current_cycle: u32) -> Self {
35        Self {
36            cells: (0..shard_count)
37                .map(|_| CachePadded::new(AtomicIsize::new(0)))
38                .collect(),
39            evicted: AtomicBool::new(false),
40            last_accessed_cycle: AtomicU32::new(current_cycle),
41        }
42    }
43
44    #[cfg(not(feature = "eviction"))]
45    fn new(shard_count: usize) -> Self {
46        Self {
47            cells: (0..shard_count)
48                .map(|_| CachePadded::new(AtomicIsize::new(0)))
49                .collect(),
50            evicted: AtomicBool::new(false),
51        }
52    }
53
54    #[inline]
55    fn add_at(&self, shard_idx: usize, value: isize) {
56        self.cells[shard_idx].fetch_add(value, Ordering::Relaxed);
57        // Note: timestamp updated on slow path (lookup/cache miss) to avoid
58        // global atomic read on every increment.
59    }
60
61    /// Touch the series timestamp. Called on slow path only.
62    #[cfg(feature = "eviction")]
63    #[inline]
64    fn touch(&self, cycle: u32) {
65        self.last_accessed_cycle.store(cycle, Ordering::Relaxed);
66    }
67
68    #[inline]
69    fn sum(&self) -> isize {
70        self.cells
71            .iter()
72            .map(|cell| cell.load(Ordering::Relaxed))
73            .sum()
74    }
75
76    #[inline]
77    fn is_evicted(&self) -> bool {
78        self.evicted.load(Ordering::Relaxed)
79    }
80
81    #[cfg(feature = "eviction")]
82    fn mark_evicted(&self) {
83        self.evicted.store(true, Ordering::Relaxed);
84    }
85}
86
87impl CacheableSeries for CounterSeries {
88    fn is_evicted(&self) -> bool {
89        self.is_evicted()
90    }
91}
92
93/// A reusable handle to a dynamic-label counter series.
94///
95/// Use this for hot paths to avoid per-update label canonicalization and map
96/// lookups. Resolve once with `DynamicCounter::series(...)`, then call `inc()`
97/// / `add()` on the handle.
98#[derive(Clone)]
99pub struct DynamicCounterSeries {
100    series: Arc<CounterSeries>,
101    shard_mask: usize,
102}
103
104impl DynamicCounterSeries {
105    /// Increment this series by 1.
106    #[inline]
107    pub fn inc(&self) {
108        self.add(1);
109    }
110
111    /// Add `value` to this series.
112    #[inline]
113    pub fn add(&self, value: isize) {
114        let shard_idx = thread_id() & self.shard_mask;
115        self.series.add_at(shard_idx, value);
116    }
117
118    /// Get this series total across shards.
119    #[inline]
120    pub fn get(&self) -> isize {
121        self.series.sum()
122    }
123
124    /// Check if this series handle has been evicted.
125    ///
126    /// If true, writes go to a detached series that is no longer exported.
127    /// Callers holding long-lived handles can check this and re-resolve
128    /// via `DynamicCounter::series()` if needed.
129    #[inline]
130    pub fn is_evicted(&self) -> bool {
131        self.series.is_evicted()
132    }
133}
134
135thread_local! {
136    static SERIES_CACHE: RefCell<LabelCache<Weak<CounterSeries>, SERIES_CACHE_SIZE>> =
137        RefCell::new(LabelCache::new());
138}
139
140/// Counter keyed by runtime label sets.
141///
142/// Uses a sharded index for key->series lookup and per-series sharded atomics
143/// for fast updates.
144pub struct DynamicCounter {
145    id: usize,
146    shard_count: usize,
147    max_series: usize,
148    shard_mask: usize,
149    index_shards: Vec<CounterIndexShard>,
150    /// Approximate total series count across all shards for fast cap checks.
151    series_count: AtomicUsize,
152    /// Count of records routed to overflow bucket due to cardinality cap.
153    overflow_count: AtomicU64,
154}
155
156impl DynamicCounter {
157    /// Creates a new runtime-labeled counter.
158    pub fn new(shard_count: usize) -> Self {
159        Self::with_max_series(shard_count, DEFAULT_MAX_SERIES)
160    }
161
162    /// Creates a new runtime-labeled counter with a series cardinality cap.
163    ///
164    /// When the number of unique label sets approximately reaches `max_series`,
165    /// new label sets are redirected into a single overflow series
166    /// (`__ft_overflow=true`). The cap is checked via a lock-free atomic counter,
167    /// so concurrent inserts may briefly overshoot by the number of in-flight
168    /// writers before the overflow kicks in.
169    pub fn with_max_series(shard_count: usize, max_series: usize) -> Self {
170        let shard_count = shard_count.next_power_of_two();
171        let id = COUNTER_IDS.fetch_add(1, Ordering::Relaxed);
172        Self {
173            id,
174            shard_count,
175            max_series,
176            shard_mask: shard_count - 1,
177            index_shards: (0..shard_count)
178                .map(|_| CachePadded::new(RwLock::new(dynamic_index_map())))
179                .collect(),
180            series_count: AtomicUsize::new(0),
181            overflow_count: AtomicU64::new(0),
182        }
183    }
184
185    /// Resolve a reusable series handle for `labels`.
186    ///
187    /// Preferred for hot paths when labels come from a finite active set.
188    pub fn series(&self, labels: &[(&str, &str)]) -> DynamicCounterSeries {
189        if let Some(series) = self.cached_series(labels) {
190            return DynamicCounterSeries {
191                series,
192                shard_mask: self.shard_mask,
193            };
194        }
195        let series = self.lookup_or_create(labels);
196        self.update_cache(labels, &series);
197        DynamicCounterSeries {
198            series,
199            shard_mask: self.shard_mask,
200        }
201    }
202
203    /// Increments the series identified by `labels` by 1.
204    #[inline]
205    pub fn inc(&self, labels: &[(&str, &str)]) {
206        self.add(labels, 1);
207    }
208
209    /// Adds `value` to the series identified by `labels`.
210    #[inline]
211    pub fn add(&self, labels: &[(&str, &str)], value: isize) {
212        if let Some(series) = self.cached_series(labels) {
213            let shard_idx = thread_id() & self.shard_mask;
214            series.add_at(shard_idx, value);
215            return;
216        }
217
218        let series = self.lookup_or_create(labels);
219        self.update_cache(labels, &series);
220        let shard_idx = thread_id() & self.shard_mask;
221        series.add_at(shard_idx, value);
222    }
223
224    /// Gets the current value for the series identified by `labels`.
225    pub fn get(&self, labels: &[(&str, &str)]) -> isize {
226        let key = DynamicLabelSet::from_pairs(labels);
227        let index_shard = self.index_shard_for(&key);
228        self.index_shards[index_shard]
229            .read()
230            .get(&key)
231            .map(|series| series.sum())
232            .unwrap_or(0)
233    }
234
235    /// Sums all series.
236    pub fn sum_all(&self) -> isize {
237        self.index_shards
238            .iter()
239            .map(|shard| {
240                let guard = shard.read();
241                guard.values().map(|series| series.sum()).sum::<isize>()
242            })
243            .sum()
244    }
245
246    /// Returns a snapshot of all label-set/count pairs.
247    pub fn snapshot(&self) -> Vec<(DynamicLabelSet, isize)> {
248        let mut out = Vec::new();
249        for shard in &self.index_shards {
250            let guard = shard.read();
251            for (labels, series) in guard.iter() {
252                out.push((labels.clone(), series.sum()));
253            }
254        }
255        out
256    }
257
258    /// Returns the current number of distinct label sets.
259    pub fn cardinality(&self) -> usize {
260        self.index_shards
261            .iter()
262            .map(|shard| shard.read().len())
263            .sum()
264    }
265
266    /// Returns the number of records routed to the overflow bucket.
267    ///
268    /// A non-zero value indicates the cardinality cap was hit and label
269    /// fidelity is being lost. Use this to alert on cardinality pressure.
270    pub fn overflow_count(&self) -> u64 {
271        self.overflow_count.load(Ordering::Relaxed)
272    }
273
274    /// Iterate all series without cloning label sets.
275    ///
276    /// Calls `f` with borrowed label pairs and the current sum for each series.
277    /// Used by exporters/macros to avoid the intermediate `snapshot()` allocation.
278    /// The callback runs while a shard read lock is held; it must be fast and
279    /// must not call back into this metric.
280    #[doc(hidden)]
281    pub fn visit_series(&self, mut f: impl FnMut(&[(String, String)], isize)) {
282        for shard in &self.index_shards {
283            let guard = shard.read();
284            for (labels, series) in guard.iter() {
285                f(labels.pairs(), series.sum());
286            }
287        }
288    }
289
290    /// Evict series that haven't been accessed for `max_staleness` cycles.
291    ///
292    /// Call this after `advance_cycle()` in your exporter task.
293    /// Series are marked as evicted (so cached handles see the tombstone),
294    /// then removed from the index.
295    ///
296    /// Protected series (Arc::strong_count > 1) are never evicted - someone
297    /// holds a DynamicCounterSeries handle to them.
298    ///
299    /// Returns the number of series evicted.
300    #[cfg(feature = "eviction")]
301    pub fn evict_stale(&self, max_staleness: u32) -> usize {
302        let cycle = current_cycle();
303        let mut removed = 0;
304
305        for shard in &self.index_shards {
306            let mut guard = shard.write();
307            guard.retain(|_labels, series| {
308                // Protected if someone holds a handle (strong_count > 1 means
309                // both the map and at least one DynamicCounterSeries hold refs)
310                if Arc::strong_count(series) > 1 {
311                    return true;
312                }
313                // Otherwise check timestamp staleness
314                let last = series.last_accessed_cycle.load(Ordering::Relaxed);
315                let stale = cycle.saturating_sub(last) > max_staleness;
316                if stale {
317                    series.mark_evicted();
318                    removed += 1;
319                    self.series_count.fetch_sub(1, Ordering::Relaxed);
320                }
321                !stale
322            });
323        }
324
325        removed
326    }
327
328    fn lookup_or_create(&self, labels: &[(&str, &str)]) -> Arc<CounterSeries> {
329        let requested_key = DynamicLabelSet::from_pairs(labels);
330        let requested_shard = self.index_shard_for(&requested_key);
331        #[cfg(feature = "eviction")]
332        let cycle = current_cycle();
333
334        // Fast path: read lock only.
335        if let Some(series) = self.index_shards[requested_shard]
336            .read()
337            .get(&requested_key)
338        {
339            #[cfg(feature = "eviction")]
340            series.touch(cycle);
341            return Arc::clone(series);
342        }
343
344        // Check cardinality cap BEFORE taking any write lock (lock-free).
345        // series_count is approximate — concurrent inserts may briefly exceed the
346        // cap by the number of in-flight writers, but it cannot deadlock and the
347        // overshoot is bounded by thread count, not by workload cardinality.
348        let key = if self.max_series > 0
349            && self.series_count.load(Ordering::Relaxed) >= self.max_series
350        {
351            self.overflow_count.fetch_add(1, Ordering::Relaxed);
352            DynamicLabelSet::from_pairs(&[(OVERFLOW_LABEL_KEY, OVERFLOW_LABEL_VALUE)])
353        } else {
354            requested_key
355        };
356        let shard = self.index_shard_for(&key);
357
358        // Check read lock on the (possibly redirected) shard.
359        if let Some(series) = self.index_shards[shard].read().get(&key) {
360            #[cfg(feature = "eviction")]
361            series.touch(cycle);
362            return Arc::clone(series);
363        }
364
365        let mut guard = self.index_shards[shard].write();
366        if let Some(series) = guard.get(&key) {
367            #[cfg(feature = "eviction")]
368            series.touch(cycle);
369            return Arc::clone(series);
370        }
371        #[cfg(feature = "eviction")]
372        let series = Arc::new(CounterSeries::new(self.shard_count, cycle));
373        #[cfg(not(feature = "eviction"))]
374        let series = Arc::new(CounterSeries::new(self.shard_count));
375        guard.insert(key, Arc::clone(&series));
376        self.series_count.fetch_add(1, Ordering::Relaxed);
377        series
378    }
379
380    fn index_shard_for(&self, key: &DynamicLabelSet) -> usize {
381        key.shard_index(self.shard_mask)
382    }
383
384    fn cached_series(&self, labels: &[(&str, &str)]) -> Option<Arc<CounterSeries>> {
385        SERIES_CACHE.with(|cache| {
386            let series = cache.borrow_mut().get(self.id, labels)?;
387            #[cfg(feature = "eviction")]
388            series.touch(current_cycle());
389            Some(series)
390        })
391    }
392
393    fn update_cache(&self, labels: &[(&str, &str)], series: &Arc<CounterSeries>) {
394        SERIES_CACHE.with(|cache| {
395            cache
396                .borrow_mut()
397                .insert(self.id, labels, Arc::downgrade(series));
398        });
399    }
400}
401
402#[cfg(test)]
403mod tests {
404    #[cfg(feature = "eviction")]
405    use super::super::{advance_cycle, lock_eviction_cycle_for_test};
406    use super::*;
407
408    #[test]
409    fn test_basic_operations() {
410        let counter = DynamicCounter::new(4);
411        counter.inc(&[("org_id", "42"), ("endpoint_uuid", "abc")]);
412        counter.add(&[("org_id", "42"), ("endpoint_uuid", "abc")], 2);
413
414        assert_eq!(
415            counter.get(&[("org_id", "42"), ("endpoint_uuid", "abc")]),
416            3
417        );
418        assert_eq!(counter.sum_all(), 3);
419    }
420
421    #[test]
422    fn test_label_order_is_canonicalized() {
423        let counter = DynamicCounter::new(4);
424        counter.inc(&[("org_id", "42"), ("endpoint_uuid", "abc")]);
425
426        assert_eq!(
427            counter.get(&[("endpoint_uuid", "abc"), ("org_id", "42")]),
428            1
429        );
430    }
431
432    #[test]
433    fn test_series_handle() {
434        let counter = DynamicCounter::new(4);
435        let series = counter.series(&[("org_id", "42"), ("endpoint_uuid", "abc")]);
436        series.inc();
437        series.add(9);
438
439        assert_eq!(series.get(), 10);
440        assert_eq!(
441            counter.get(&[("org_id", "42"), ("endpoint_uuid", "abc")]),
442            10
443        );
444    }
445
446    #[test]
447    fn test_concurrent_adds() {
448        let counter = DynamicCounter::new(8);
449        let series = counter.series(&[("org_id", "42"), ("endpoint_uuid", "abc")]);
450
451        std::thread::scope(|s| {
452            for _ in 0..8 {
453                let series = series.clone();
454                s.spawn(move || {
455                    for _ in 0..10_000 {
456                        series.inc();
457                    }
458                });
459            }
460        });
461
462        assert_eq!(
463            counter.get(&[("org_id", "42"), ("endpoint_uuid", "abc")]),
464            80_000
465        );
466    }
467
468    #[cfg(feature = "eviction")]
469    #[test]
470    fn test_evict_stale() {
471        let _cycle_guard = lock_eviction_cycle_for_test();
472        let counter = DynamicCounter::new(4);
473        let labels = &[("org_id", "42")];
474
475        // Create series and increment
476        counter.inc(labels);
477        assert_eq!(counter.cardinality(), 1);
478        assert_eq!(counter.get(labels), 1);
479
480        // Advance cycle past staleness threshold
481        advance_cycle();
482        advance_cycle();
483
484        // Flush thread-local cache by accessing a different label set
485        counter.inc(&[("flush", "cache")]);
486
487        // Evict series not accessed in last 1 cycle
488        let removed = counter.evict_stale(1);
489        assert_eq!(removed, 1); // Only the original label set, not the flush one
490        assert_eq!(counter.cardinality(), 1); // flush series remains
491
492        // Series is gone - get returns 0
493        assert_eq!(counter.get(labels), 0);
494
495        // New inc creates fresh series
496        counter.inc(labels);
497        assert_eq!(counter.cardinality(), 2);
498        assert_eq!(counter.get(labels), 1);
499    }
500
501    #[cfg(feature = "eviction")]
502    #[test]
503    fn test_evict_stale_keeps_active() {
504        let _cycle_guard = lock_eviction_cycle_for_test();
505        let counter = DynamicCounter::new(4);
506        let active = &[("status", "active")];
507        let stale = &[("status", "stale")];
508
509        // Create both series
510        counter.inc(active);
511        counter.inc(stale);
512        assert_eq!(counter.cardinality(), 2);
513
514        // Advance cycle
515        advance_cycle();
516
517        // Touch only the active series
518        counter.inc(active);
519
520        // Advance again
521        advance_cycle();
522
523        // Evict with staleness of 1 - should only evict 'stale'
524        let removed = counter.evict_stale(1);
525        assert_eq!(removed, 1);
526        assert_eq!(counter.cardinality(), 1);
527        assert_eq!(counter.get(active), 2);
528        assert_eq!(counter.get(stale), 0);
529    }
530
531    #[cfg(feature = "eviction")]
532    #[test]
533    fn test_eviction_tombstone_invalidates_cache() {
534        let _cycle_guard = lock_eviction_cycle_for_test();
535        let counter = DynamicCounter::new(4);
536        let labels = &[("org_id", "evict_test")];
537
538        // Populate the thread-local cache
539        counter.inc(labels);
540        counter.inc(labels); // Second call uses cached series
541        assert_eq!(counter.get(labels), 2);
542
543        // Force eviction by advancing cycles
544        advance_cycle();
545        advance_cycle();
546
547        // Flush thread-local cache by accessing a different label set
548        counter.inc(&[("flush", "cache")]);
549
550        counter.evict_stale(1);
551
552        // Next inc should create fresh series (tombstone invalidates cache)
553        counter.inc(labels);
554        assert_eq!(counter.get(labels), 1); // Fresh series starts at 1, not 3
555    }
556
557    #[cfg(feature = "eviction")]
558    #[test]
559    fn test_series_handle_protects_from_eviction() {
560        let _cycle_guard = lock_eviction_cycle_for_test();
561        let counter = DynamicCounter::new(4);
562        let labels = &[("org_id", "handle_test")];
563
564        // Get a long-lived handle
565        let series = counter.series(labels);
566        series.inc();
567        assert!(!series.is_evicted());
568
569        // Try to evict - but handle protects the series
570        advance_cycle();
571        advance_cycle();
572        let removed = counter.evict_stale(1);
573
574        // Handle protects series from eviction (Arc::strong_count > 1)
575        assert_eq!(removed, 0);
576        assert!(!series.is_evicted());
577        assert_eq!(counter.cardinality(), 1);
578        assert_eq!(counter.get(labels), 1);
579
580        // Writes still work
581        series.inc();
582        assert_eq!(counter.get(labels), 2);
583    }
584
585    #[cfg(feature = "eviction")]
586    #[test]
587    fn test_series_evicted_after_handle_dropped() {
588        let _cycle_guard = lock_eviction_cycle_for_test();
589        let counter = DynamicCounter::new(4);
590        let labels = &[("org_id", "handle_drop_test")];
591
592        // Create series via handle, then drop it
593        {
594            let series = counter.series(labels);
595            series.inc();
596        }
597        // Handle dropped, but thread-local cache still holds reference
598
599        assert_eq!(counter.cardinality(), 1);
600        assert_eq!(counter.get(labels), 1);
601
602        // Advance cycles
603        advance_cycle();
604        advance_cycle();
605
606        // Flush thread-local cache by accessing a different label set
607        counter.inc(&[("flush", "cache")]);
608
609        // Now eviction should work
610        let removed = counter.evict_stale(1);
611        assert_eq!(removed, 1);
612        assert_eq!(counter.get(labels), 0);
613    }
614
615    #[test]
616    fn test_overflow_bucket_routes_new_series_at_capacity() {
617        let counter = DynamicCounter::with_max_series(4, 2);
618
619        counter.inc(&[("org_id", "1")]);
620        counter.inc(&[("org_id", "2")]);
621        counter.inc(&[("org_id", "3")]);
622
623        assert_eq!(counter.cardinality(), 3);
624        assert_eq!(
625            counter.get(&[(OVERFLOW_LABEL_KEY, OVERFLOW_LABEL_VALUE)]),
626            1
627        );
628    }
629
630    #[test]
631    fn test_concurrent_cap_bounded_overshoot() {
632        use std::sync::{Arc, Barrier};
633        use std::thread;
634
635        let cap = 10;
636        let threads = 16;
637        let counter = Arc::new(DynamicCounter::with_max_series(4, cap));
638        let barrier = Arc::new(Barrier::new(threads));
639
640        let handles: Vec<_> = (0..threads)
641            .map(|t| {
642                let counter = Arc::clone(&counter);
643                let barrier = Arc::clone(&barrier);
644                thread::spawn(move || {
645                    barrier.wait();
646                    // Each thread creates a unique label set
647                    for i in 0..5 {
648                        let label = format!("t{t}_s{i}");
649                        counter.inc(&[("key", &label)]);
650                    }
651                })
652            })
653            .collect();
654
655        for h in handles {
656            h.join().unwrap();
657        }
658
659        let card = counter.cardinality();
660        // Cap is approximate: may overshoot by at most thread count, but must
661        // not grow unboundedly (80 distinct labels were attempted).
662        assert!(
663            card <= cap + threads + 1, // +1 for the overflow bucket
664            "cardinality {card} exceeded bounded overshoot (cap={cap}, threads={threads})"
665        );
666        // Must have hit overflow at least once
667        assert!(
668            counter.overflow_count() > 0,
669            "overflow should have triggered"
670        );
671    }
672
673    #[cfg(feature = "eviction")]
674    #[test]
675    fn test_eviction_and_reinsertion_bookkeeping() {
676        let _cycle_guard = lock_eviction_cycle_for_test();
677        let counter = DynamicCounter::with_max_series(4, 3);
678
679        counter.inc(&[("k", "a")]);
680        counter.inc(&[("k", "b")]);
681        counter.inc(&[("k", "c")]);
682        assert_eq!(counter.cardinality(), 3);
683
684        counter.inc(&[("k", "d")]);
685        assert!(counter.overflow_count() > 0);
686        let card_after_overflow = counter.cardinality();
687        assert!(card_after_overflow <= 4);
688
689        advance_cycle();
690        advance_cycle();
691        advance_cycle();
692        counter.inc(&[("flush", "cache")]);
693        let evicted = counter.evict_stale(1);
694        assert!(evicted > 0);
695
696        let card_after_evict = counter.cardinality();
697        assert!(
698            card_after_evict < card_after_overflow,
699            "cardinality should decrease after eviction: before={card_after_overflow} after={card_after_evict}"
700        );
701
702        let overflow_before = counter.overflow_count();
703        counter.inc(&[("k", "new1")]);
704        counter.inc(&[("k", "new2")]);
705
706        assert!(counter.cardinality() <= 5);
707
708        let overflow_after = counter.overflow_count();
709        assert!(
710            overflow_after - overflow_before <= 1,
711            "unexpected overflow after eviction freed space"
712        );
713    }
714}