Skip to main content

fast_telemetry/metric/dynamic/
gauge_i64.rs

1//! Runtime-labeled signed integer gauge for dynamic dimensions.
2//!
3//! Use this for metrics like "active connections" or "in-flight requests"
4//! that need atomic add/sub semantics but should export as absolute gauge values
5//! (not counter deltas).
6
7use super::cache::{CacheableSeries, LabelCache, SERIES_CACHE_SIZE};
8#[cfg(feature = "eviction")]
9use super::current_cycle;
10use super::{DynamicIndexMap, DynamicLabelSet, dynamic_index_map, thread_id};
11use crossbeam_utils::CachePadded;
12use parking_lot::RwLock;
13use std::cell::RefCell;
14#[cfg(feature = "eviction")]
15use std::sync::atomic::AtomicU32;
16use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, AtomicUsize, Ordering};
17use std::sync::{Arc, Weak};
18
19static GAUGE_I64_IDS: AtomicUsize = AtomicUsize::new(1);
20const DEFAULT_MAX_SERIES: usize = 2000;
21const OVERFLOW_LABEL_KEY: &str = "__ft_overflow";
22const OVERFLOW_LABEL_VALUE: &str = "true";
23type GaugeI64IndexShard = CachePadded<RwLock<DynamicIndexMap<Arc<GaugeI64Series>>>>;
24
25struct GaugeI64Series {
26    cells: Vec<CachePadded<AtomicI64>>,
27    /// Tombstone flag set by exporter before removing from map.
28    evicted: AtomicBool,
29    /// Last export cycle when this series was accessed.
30    #[cfg(feature = "eviction")]
31    last_accessed_cycle: AtomicU32,
32}
33
34impl GaugeI64Series {
35    #[cfg(feature = "eviction")]
36    fn new(shard_count: usize, cycle: u32) -> Self {
37        Self {
38            cells: (0..shard_count)
39                .map(|_| CachePadded::new(AtomicI64::new(0)))
40                .collect(),
41            evicted: AtomicBool::new(false),
42            last_accessed_cycle: AtomicU32::new(cycle),
43        }
44    }
45
46    #[cfg(not(feature = "eviction"))]
47    fn new(shard_count: usize) -> Self {
48        Self {
49            cells: (0..shard_count)
50                .map(|_| CachePadded::new(AtomicI64::new(0)))
51                .collect(),
52            evicted: AtomicBool::new(false),
53        }
54    }
55
56    #[inline]
57    fn add_at(&self, shard_idx: usize, value: i64) {
58        self.cells[shard_idx].fetch_add(value, Ordering::Relaxed);
59        // Note: timestamp updated on slow path (lookup/cache miss) to avoid
60        // global atomic read on every add.
61    }
62
63    #[inline]
64    fn set_at(&self, shard_idx: usize, value: i64) {
65        // For set, we need to clear other shards and set the target shard
66        // This is inherently racy but acceptable for gauge semantics
67        for (i, cell) in self.cells.iter().enumerate() {
68            if i == shard_idx {
69                cell.store(value, Ordering::Relaxed);
70            } else {
71                cell.store(0, Ordering::Relaxed);
72            }
73        }
74        // Note: timestamp updated on slow path (lookup/cache miss) to avoid
75        // global atomic read on every set.
76    }
77
78    /// Touch the series timestamp. Called on slow path only.
79    #[cfg(feature = "eviction")]
80    #[inline]
81    fn touch(&self, cycle: u32) {
82        self.last_accessed_cycle.store(cycle, Ordering::Relaxed);
83    }
84
85    #[inline]
86    fn sum(&self) -> i64 {
87        self.cells
88            .iter()
89            .map(|cell| cell.load(Ordering::Relaxed))
90            .sum()
91    }
92
93    #[inline]
94    fn is_evicted(&self) -> bool {
95        self.evicted.load(Ordering::Relaxed)
96    }
97
98    #[cfg(feature = "eviction")]
99    fn mark_evicted(&self) {
100        self.evicted.store(true, Ordering::Relaxed);
101    }
102}
103
104impl CacheableSeries for GaugeI64Series {
105    fn is_evicted(&self) -> bool {
106        self.is_evicted()
107    }
108}
109
110/// A reusable handle to a dynamic-label i64 gauge series.
111///
112/// Use this for hot paths to avoid per-update label canonicalization and map
113/// lookups. Resolve once with `DynamicGaugeI64::series(...)`, then call `add()`
114/// / `set()` on the handle.
115#[derive(Clone)]
116pub struct DynamicGaugeI64Series {
117    series: Arc<GaugeI64Series>,
118    shard_mask: usize,
119}
120
121impl DynamicGaugeI64Series {
122    /// Increment this gauge by 1.
123    #[inline]
124    pub fn inc(&self) {
125        self.add(1);
126    }
127
128    /// Decrement this gauge by 1.
129    #[inline]
130    pub fn dec(&self) {
131        self.add(-1);
132    }
133
134    /// Add `value` to this gauge (can be negative).
135    #[inline]
136    pub fn add(&self, value: i64) {
137        let shard_idx = thread_id() & self.shard_mask;
138        self.series.add_at(shard_idx, value);
139    }
140
141    /// Set this gauge to an absolute value.
142    #[inline]
143    pub fn set(&self, value: i64) {
144        let shard_idx = thread_id() & self.shard_mask;
145        self.series.set_at(shard_idx, value);
146    }
147
148    /// Get this gauge's total across shards.
149    #[inline]
150    pub fn get(&self) -> i64 {
151        self.series.sum()
152    }
153
154    /// Check if this series handle has been evicted.
155    #[inline]
156    pub fn is_evicted(&self) -> bool {
157        self.series.is_evicted()
158    }
159}
160
161thread_local! {
162    static SERIES_CACHE: RefCell<LabelCache<Weak<GaugeI64Series>, SERIES_CACHE_SIZE>> =
163        RefCell::new(LabelCache::new());
164}
165
166/// Signed integer gauge keyed by runtime label sets.
167///
168/// Unlike `DynamicCounter`, this exports as a gauge (absolute value) rather than
169/// a counter (delta). Use for metrics like "active connections" that go up and down.
170///
171/// Uses sharded atomics internally for fast concurrent updates.
172pub struct DynamicGaugeI64 {
173    id: usize,
174    shard_count: usize,
175    max_series: usize,
176    shard_mask: usize,
177    index_shards: Vec<GaugeI64IndexShard>,
178    /// Approximate number of live series (incremented on insert, decremented on evict).
179    series_count: AtomicUsize,
180    /// Count of records routed to overflow bucket due to cardinality cap.
181    overflow_count: AtomicU64,
182}
183
184impl DynamicGaugeI64 {
185    /// Creates a new runtime-labeled i64 gauge.
186    pub fn new(shard_count: usize) -> Self {
187        Self::with_max_series(shard_count, DEFAULT_MAX_SERIES)
188    }
189
190    /// Creates a new runtime-labeled i64 gauge with a series cardinality cap.
191    ///
192    /// When the number of unique label sets approximately reaches `max_series`,
193    /// new label sets are redirected into a single overflow series
194    /// (`__ft_overflow=true`). The cap is checked via a lock-free atomic counter,
195    /// so concurrent inserts may briefly overshoot by the number of in-flight
196    /// writers before the overflow kicks in.
197    pub fn with_max_series(shard_count: usize, max_series: usize) -> Self {
198        let shard_count = shard_count.next_power_of_two();
199        let id = GAUGE_I64_IDS.fetch_add(1, Ordering::Relaxed);
200        Self {
201            id,
202            shard_count,
203            max_series,
204            shard_mask: shard_count - 1,
205            index_shards: (0..shard_count)
206                .map(|_| CachePadded::new(RwLock::new(dynamic_index_map())))
207                .collect(),
208            series_count: AtomicUsize::new(0),
209            overflow_count: AtomicU64::new(0),
210        }
211    }
212
213    /// Resolve a reusable series handle for `labels`.
214    pub fn series(&self, labels: &[(&str, &str)]) -> DynamicGaugeI64Series {
215        if let Some(series) = self.cached_series(labels) {
216            return DynamicGaugeI64Series {
217                series,
218                shard_mask: self.shard_mask,
219            };
220        }
221        let series = self.lookup_or_create(labels);
222        self.update_cache(labels, &series);
223        DynamicGaugeI64Series {
224            series,
225            shard_mask: self.shard_mask,
226        }
227    }
228
229    /// Increments the gauge identified by `labels` by 1.
230    #[inline]
231    pub fn inc(&self, labels: &[(&str, &str)]) {
232        self.add(labels, 1);
233    }
234
235    /// Decrements the gauge identified by `labels` by 1.
236    #[inline]
237    pub fn dec(&self, labels: &[(&str, &str)]) {
238        self.add(labels, -1);
239    }
240
241    /// Adds `value` to the gauge identified by `labels` (can be negative).
242    #[inline]
243    pub fn add(&self, labels: &[(&str, &str)], value: i64) {
244        if let Some(series) = self.cached_series(labels) {
245            let shard_idx = thread_id() & self.shard_mask;
246            series.add_at(shard_idx, value);
247            return;
248        }
249
250        let series = self.lookup_or_create(labels);
251        self.update_cache(labels, &series);
252        let shard_idx = thread_id() & self.shard_mask;
253        series.add_at(shard_idx, value);
254    }
255
256    /// Sets the gauge identified by `labels` to an absolute value.
257    #[inline]
258    pub fn set(&self, labels: &[(&str, &str)], value: i64) {
259        if let Some(series) = self.cached_series(labels) {
260            let shard_idx = thread_id() & self.shard_mask;
261            series.set_at(shard_idx, value);
262            return;
263        }
264
265        let series = self.lookup_or_create(labels);
266        self.update_cache(labels, &series);
267        let shard_idx = thread_id() & self.shard_mask;
268        series.set_at(shard_idx, value);
269    }
270
271    /// Gets the current value for the gauge identified by `labels`.
272    pub fn get(&self, labels: &[(&str, &str)]) -> i64 {
273        let key = DynamicLabelSet::from_pairs(labels);
274        let index_shard = self.index_shard_for(&key);
275        self.index_shards[index_shard]
276            .read()
277            .get(&key)
278            .map(|series| series.sum())
279            .unwrap_or(0)
280    }
281
282    /// Sums all series.
283    pub fn sum_all(&self) -> i64 {
284        self.index_shards
285            .iter()
286            .map(|shard| {
287                let guard = shard.read();
288                guard.values().map(|series| series.sum()).sum::<i64>()
289            })
290            .sum()
291    }
292
293    /// Returns a snapshot of all label-set/value pairs.
294    pub fn snapshot(&self) -> Vec<(DynamicLabelSet, i64)> {
295        let mut out = Vec::new();
296        for shard in &self.index_shards {
297            let guard = shard.read();
298            for (labels, series) in guard.iter() {
299                out.push((labels.clone(), series.sum()));
300            }
301        }
302        out
303    }
304
305    /// Returns the current number of distinct label sets.
306    pub fn cardinality(&self) -> usize {
307        self.index_shards
308            .iter()
309            .map(|shard| shard.read().len())
310            .sum()
311    }
312
313    /// Returns the number of records routed to the overflow bucket.
314    ///
315    /// A non-zero value indicates the cardinality cap was hit and label
316    /// fidelity is being lost. Use this to alert on cardinality pressure.
317    pub fn overflow_count(&self) -> u64 {
318        self.overflow_count.load(Ordering::Relaxed)
319    }
320
321    /// Iterate all series without cloning label sets.
322    ///
323    /// Calls `f` with borrowed label pairs and the current value for each series.
324    /// Used by exporters to avoid the intermediate `snapshot()` allocation.
325    /// The callback runs while a shard read lock is held; it must be fast and
326    /// must not call back into this metric.
327    #[doc(hidden)]
328    pub fn visit_series(&self, mut f: impl FnMut(&[(String, String)], i64)) {
329        for shard in &self.index_shards {
330            let guard = shard.read();
331            for (labels, series) in guard.iter() {
332                f(labels.pairs(), series.sum());
333            }
334        }
335    }
336
337    /// Evict series that haven't been accessed for `max_staleness` cycles.
338    ///
339    /// Call this after `advance_cycle()` in your exporter task.
340    /// Series are marked as evicted (so cached handles see the tombstone),
341    /// then removed from the index.
342    ///
343    /// Protected series (Arc::strong_count > 1) are never evicted - someone
344    /// holds a DynamicGaugeI64Series handle to them.
345    ///
346    /// Returns the number of series evicted.
347    #[cfg(feature = "eviction")]
348    pub fn evict_stale(&self, max_staleness: u32) -> usize {
349        let cycle = current_cycle();
350        let mut removed = 0;
351
352        for shard in &self.index_shards {
353            let mut guard = shard.write();
354            guard.retain(|_labels, series| {
355                // Protected if someone holds a handle (strong_count > 1 means
356                // both the map and at least one DynamicGaugeI64Series hold refs)
357                if Arc::strong_count(series) > 1 {
358                    return true;
359                }
360                // Otherwise check timestamp staleness
361                let last = series.last_accessed_cycle.load(Ordering::Relaxed);
362                let stale = cycle.saturating_sub(last) > max_staleness;
363                if stale {
364                    series.mark_evicted();
365                    removed += 1;
366                    self.series_count.fetch_sub(1, Ordering::Relaxed);
367                }
368                !stale
369            });
370        }
371
372        removed
373    }
374
375    fn lookup_or_create(&self, labels: &[(&str, &str)]) -> Arc<GaugeI64Series> {
376        let requested_key = DynamicLabelSet::from_pairs(labels);
377        let requested_shard = self.index_shard_for(&requested_key);
378        #[cfg(feature = "eviction")]
379        let cycle = current_cycle();
380
381        // Fast path: read lock only.
382        if let Some(series) = self.index_shards[requested_shard]
383            .read()
384            .get(&requested_key)
385        {
386            #[cfg(feature = "eviction")]
387            series.touch(cycle);
388            return Arc::clone(series);
389        }
390
391        // Check cardinality cap BEFORE taking any write lock (lock-free).
392        let key = if self.max_series > 0
393            && self.series_count.load(Ordering::Relaxed) >= self.max_series
394        {
395            self.overflow_count.fetch_add(1, Ordering::Relaxed);
396            DynamicLabelSet::from_pairs(&[(OVERFLOW_LABEL_KEY, OVERFLOW_LABEL_VALUE)])
397        } else {
398            requested_key
399        };
400        let shard = self.index_shard_for(&key);
401
402        if let Some(series) = self.index_shards[shard].read().get(&key) {
403            #[cfg(feature = "eviction")]
404            series.touch(cycle);
405            return Arc::clone(series);
406        }
407
408        let mut guard = self.index_shards[shard].write();
409        if let Some(series) = guard.get(&key) {
410            #[cfg(feature = "eviction")]
411            series.touch(cycle);
412            return Arc::clone(series);
413        }
414        #[cfg(feature = "eviction")]
415        let series = Arc::new(GaugeI64Series::new(self.shard_count, cycle));
416        #[cfg(not(feature = "eviction"))]
417        let series = Arc::new(GaugeI64Series::new(self.shard_count));
418        guard.insert(key, Arc::clone(&series));
419        self.series_count.fetch_add(1, Ordering::Relaxed);
420        series
421    }
422
423    fn index_shard_for(&self, key: &DynamicLabelSet) -> usize {
424        key.shard_index(self.shard_mask)
425    }
426
427    fn cached_series(&self, labels: &[(&str, &str)]) -> Option<Arc<GaugeI64Series>> {
428        SERIES_CACHE.with(|cache| {
429            let series = cache.borrow_mut().get(self.id, labels)?;
430            #[cfg(feature = "eviction")]
431            series.touch(current_cycle());
432            Some(series)
433        })
434    }
435
436    fn update_cache(&self, labels: &[(&str, &str)], series: &Arc<GaugeI64Series>) {
437        SERIES_CACHE.with(|cache| {
438            cache
439                .borrow_mut()
440                .insert(self.id, labels, Arc::downgrade(series));
441        });
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    #[cfg(feature = "eviction")]
448    use super::super::{advance_cycle, lock_eviction_cycle_for_test};
449    use super::*;
450
451    #[test]
452    fn test_basic_operations() {
453        let gauge = DynamicGaugeI64::new(4);
454        gauge.inc(&[("endpoint_id", "ep1")]);
455        gauge.add(&[("endpoint_id", "ep1")], 2);
456
457        assert_eq!(gauge.get(&[("endpoint_id", "ep1")]), 3);
458
459        gauge.dec(&[("endpoint_id", "ep1")]);
460        assert_eq!(gauge.get(&[("endpoint_id", "ep1")]), 2);
461
462        gauge.add(&[("endpoint_id", "ep1")], -2);
463        assert_eq!(gauge.get(&[("endpoint_id", "ep1")]), 0);
464    }
465
466    #[test]
467    fn test_series_handle() {
468        let gauge = DynamicGaugeI64::new(4);
469        let series = gauge.series(&[("endpoint_id", "ep1")]);
470        series.inc();
471        series.inc();
472        series.dec();
473
474        assert_eq!(series.get(), 1);
475        assert_eq!(gauge.get(&[("endpoint_id", "ep1")]), 1);
476    }
477
478    #[test]
479    fn test_snapshot() {
480        let gauge = DynamicGaugeI64::new(4);
481        gauge.add(&[("endpoint_id", "ep1")], 10);
482        gauge.add(&[("endpoint_id", "ep2")], 20);
483
484        let snap = gauge.snapshot();
485        assert_eq!(snap.len(), 2);
486
487        let total: i64 = snap.iter().map(|(_, v)| v).sum();
488        assert_eq!(total, 30);
489    }
490
491    #[cfg(feature = "eviction")]
492    #[test]
493    fn test_evict_stale() {
494        let _cycle_guard = lock_eviction_cycle_for_test();
495        let gauge = DynamicGaugeI64::new(4);
496        let labels = &[("endpoint_id", "evict_i64")];
497
498        gauge.add(labels, 5);
499        assert_eq!(gauge.cardinality(), 1);
500
501        // Advance cycles past staleness threshold
502        advance_cycle();
503        advance_cycle();
504
505        // Flush thread-local cache by accessing a different label set
506        gauge.add(&[("flush", "cache")], 1);
507
508        let removed = gauge.evict_stale(1);
509        assert_eq!(removed, 1);
510        assert_eq!(gauge.cardinality(), 1); // flush series remains
511        assert_eq!(gauge.get(labels), 0);
512    }
513
514    #[cfg(feature = "eviction")]
515    #[test]
516    fn test_series_handle_protects_from_eviction() {
517        let _cycle_guard = lock_eviction_cycle_for_test();
518        let gauge = DynamicGaugeI64::new(4);
519        let labels = &[("endpoint_id", "tombstone_i64")];
520
521        let series = gauge.series(labels);
522        series.add(5);
523        assert!(!series.is_evicted());
524
525        // Try to evict - but handle protects the series
526        advance_cycle();
527        advance_cycle();
528        let removed = gauge.evict_stale(1);
529
530        // Handle protects series from eviction (Arc::strong_count > 1)
531        assert_eq!(removed, 0);
532        assert!(!series.is_evicted());
533        assert_eq!(gauge.get(labels), 5);
534    }
535
536    #[test]
537    fn test_overflow_bucket_routes_new_series_at_capacity() {
538        let gauge = DynamicGaugeI64::with_max_series(4, 1);
539        gauge.add(&[("endpoint_id", "1")], 1);
540        gauge.add(&[("endpoint_id", "2")], 2);
541
542        assert_eq!(gauge.cardinality(), 2);
543        assert_eq!(gauge.get(&[(OVERFLOW_LABEL_KEY, OVERFLOW_LABEL_VALUE)]), 2);
544    }
545}