Skip to main content

chronix_engine/cache/
lvc.rs

1//! Last Value Cache (LVC) — O(1) lookup of the most recent point per series.
2//!
3//! The LVC stores the most recent `Point` for each series key, keyed by
4//! the full `SeriesKey` (measurement + tags). It is populated on every
5//! `insert()` and provides sub-microsecond lookups for dashboard-style
6//! "current value" queries.
7//!
8//! Thread-safe via `DashMap` — lock-free concurrent reads and sharded writes.
9//!
10//! # LRU Eviction
11//!
12//! When `max_entries > 0`, the cache uses a `HashMap<SeriesKey, usize>` +
13//! doubly-linked intrusive list for **O(1)** LRU eviction.  Previous
14//! implementation used `VecDeque::retain()` which was O(N) per update —
15//! a critical bottleneck at >100K series.
16
17use std::collections::BTreeMap;
18use std::collections::HashMap;
19use std::sync::Arc;
20use std::sync::atomic::{AtomicUsize, Ordering};
21
22use dashmap::DashMap;
23use parking_lot::Mutex;
24
25use chronix_core::{Point, SeriesKey};
26
27// ── O(1) LRU order tracker ──────────────────────────────────────────
28
29/// Doubly-linked list node for O(1) LRU tracking.
30///
31/// Uses `Arc<SeriesKey>` so the same allocation is shared
32/// between the node and the lookup `HashMap`, avoiding a per-touch clone.
33struct LruNode {
34    key: Arc<SeriesKey>,
35    prev: Option<usize>,
36    next: Option<usize>,
37}
38
39/// O(1) LRU order tracker backed by a HashMap + doubly-linked list.
40///
41/// All operations (`touch`, `push_back`, `pop_front`, `remove`) are O(1).
42/// Uses a slab-style `Vec<LruNode>` with free-list recycling.
43struct LruOrder {
44    /// Slab of linked-list nodes (indexed by handle).
45    nodes: Vec<LruNode>,
46    /// Map from SeriesKey → node index for O(1) lookup.
47    /// `Arc<SeriesKey>` avoids double-cloning.
48    map: HashMap<Arc<SeriesKey>, usize>,
49    /// Free indices for recycling removed nodes.
50    free: Vec<usize>,
51    /// Head of the doubly-linked list (least recently used).
52    head: Option<usize>,
53    /// Tail of the doubly-linked list (most recently used).
54    tail: Option<usize>,
55}
56
57impl LruOrder {
58    /// Number of entries tracked (test-only observability).
59    #[cfg(test)]
60    fn len(&self) -> usize {
61        self.map.len()
62    }
63
64    fn new() -> Self {
65        Self {
66            nodes: Vec::new(),
67            map: HashMap::new(),
68            free: Vec::new(),
69            head: None,
70            tail: None,
71        }
72    }
73
74    fn with_capacity(cap: usize) -> Self {
75        Self {
76            nodes: Vec::with_capacity(cap),
77            map: HashMap::with_capacity(cap),
78            free: Vec::new(),
79            head: None,
80            tail: None,
81        }
82    }
83
84    /// Allocate or recycle a node index.
85    fn alloc(&mut self, key: Arc<SeriesKey>) -> usize {
86        if let Some(idx) = self.free.pop() {
87            self.nodes[idx] = LruNode {
88                key,
89                prev: None,
90                next: None,
91            };
92            idx
93        } else {
94            let idx = self.nodes.len();
95            self.nodes.push(LruNode {
96                key,
97                prev: None,
98                next: None,
99            });
100            idx
101        }
102    }
103
104    /// Unlink a node from the doubly-linked list (does NOT free it).
105    fn unlink(&mut self, idx: usize) {
106        let prev = self.nodes[idx].prev;
107        let next = self.nodes[idx].next;
108
109        match prev {
110            Some(p) => self.nodes[p].next = next,
111            None => self.head = next,
112        }
113        match next {
114            Some(n) => self.nodes[n].prev = prev,
115            None => self.tail = prev,
116        }
117
118        self.nodes[idx].prev = None;
119        self.nodes[idx].next = None;
120    }
121
122    /// Append a node index to the tail (most recently used).
123    fn link_tail(&mut self, idx: usize) {
124        self.nodes[idx].prev = self.tail;
125        self.nodes[idx].next = None;
126
127        if let Some(old_tail) = self.tail {
128            self.nodes[old_tail].next = Some(idx);
129        }
130        self.tail = Some(idx);
131        if self.head.is_none() {
132            self.head = Some(idx);
133        }
134    }
135
136    /// Insert or move `key` to the most-recently-used position. O(1).
137    fn touch(&mut self, key: &SeriesKey) {
138        if let Some(&idx) = self.map.get(key) {
139            // Already present — unlink and re-link at tail.
140            self.unlink(idx);
141            self.link_tail(idx);
142        } else {
143            // New entry — single Arc allocation shared between node + map.
144            let arc = Arc::new(key.clone());
145            let idx = self.alloc(Arc::clone(&arc));
146            self.map.insert(arc, idx);
147            self.link_tail(idx);
148        }
149    }
150
151    /// Remove and return the least-recently-used key. O(1).
152    fn pop_front(&mut self) -> Option<Arc<SeriesKey>> {
153        let idx = self.head?;
154        self.unlink(idx);
155        let key = self.nodes[idx].key.clone();
156        self.map.remove(&key);
157        self.free.push(idx);
158        Some(key)
159    }
160
161    /// Remove a specific key from the tracker. O(1).
162    fn remove(&mut self, key: &SeriesKey) {
163        if let Some(idx) = self.map.remove(key) {
164            self.unlink(idx);
165            self.free.push(idx);
166        }
167    }
168
169    /// Remove all entries whose key is NOT in the DashMap cache.
170    fn retain_present(&mut self, cache: &DashMap<SeriesKey, Point>) {
171        let to_remove: Vec<Arc<SeriesKey>> = self
172            .map
173            .keys()
174            .filter(|k| !cache.contains_key(k.as_ref()))
175            .cloned()
176            .collect();
177        for key in to_remove {
178            self.remove(&key);
179        }
180    }
181
182    /// Clear everything.
183    fn clear(&mut self) {
184        self.nodes.clear();
185        self.map.clear();
186        self.free.clear();
187        self.head = None;
188        self.tail = None;
189    }
190}
191
192// ── LastValueCache ──────────────────────────────────────────────────
193
194/// Last Value Cache — stores the most recent point per series.
195///
196/// # Thread Safety
197///
198/// Uses `DashMap` for lock-free concurrent reads and sharded writes.
199/// Multiple threads can read and update the cache simultaneously.
200///
201/// # Memory
202///
203/// Memory usage scales linearly with the number of unique series.
204/// Each entry stores one `Point` (typically 100-500 bytes).
205///
206/// # LRU Eviction
207///
208/// An optional `max_entries` cap prevents unbounded memory growth in
209/// high-cardinality workloads.  When full, the least recently used
210/// entries are evicted in **O(1)** time using a HashMap-backed
211/// doubly-linked list (replaces an O(N)
212/// `VecDeque::retain` approach).
213pub struct LastValueCache {
214    /// Map from `SeriesKey` → most recent `Point`.
215    cache: DashMap<SeriesKey, Point>,
216    /// Total number of updates applied.
217    update_count: AtomicUsize,
218    /// Maximum number of entries allowed (0 = unlimited).
219    max_entries: usize,
220    /// O(1) LRU order tracker. Only actively maintained when `max_entries > 0`.
221    order: Mutex<LruOrder>,
222}
223
224impl LastValueCache {
225    /// Create a new, empty LVC with no entry limit.
226    #[must_use]
227    pub fn new() -> Self {
228        Self {
229            cache: DashMap::new(),
230            update_count: AtomicUsize::new(0),
231            max_entries: 0,
232            order: Mutex::new(LruOrder::new()),
233        }
234    }
235
236    /// Create an LVC with a maximum number of entries.
237    ///
238    /// When full, the least recently used (LRU) entry is evicted to
239    /// make room for the new one.  Eviction is O(1).
240    #[must_use]
241    pub fn with_max_entries(max: usize) -> Self {
242        Self {
243            cache: DashMap::with_capacity(max.min(1024)),
244            update_count: AtomicUsize::new(0),
245            max_entries: max,
246            order: Mutex::new(LruOrder::with_capacity(max.min(1024))),
247        }
248    }
249
250    /// Create an LVC with pre-allocated capacity for `n` series.
251    #[must_use]
252    pub fn with_capacity(n: usize) -> Self {
253        Self {
254            cache: DashMap::with_capacity(n),
255            update_count: AtomicUsize::new(0),
256            max_entries: 0,
257            order: Mutex::new(LruOrder::new()),
258        }
259    }
260
261    /// Update the cache with a new point. Only overwrites if the new
262    /// point has a timestamp ≥ the existing cached point.
263    ///
264    /// When `max_entries > 0`, the key is moved to the most-recently-used
265    /// position in O(1) time.  If the cache is full and the key is new,
266    /// the least-recently-used entry is evicted — also O(1).
267    pub fn update(&self, point: &Point) {
268        let key = point.series_key().clone();
269
270        // Enforce entry cap with O(1) LRU eviction.
271        if self.max_entries > 0 {
272            let mut order = self.order.lock();
273            let is_new = !self.cache.contains_key(&key);
274
275            // Move existing key to tail (most recently used) — O(1).
276            // For new keys, this inserts at tail — also O(1).
277            order.touch(&key);
278
279            // Evict LRU entries (from the head) while over capacity — O(1) each.
280            while is_new && self.cache.len() >= self.max_entries {
281                if let Some(evict_key) = order.pop_front() {
282                    self.cache.remove(&evict_key);
283                } else {
284                    break;
285                }
286            }
287        }
288
289        self.cache
290            .entry(key)
291            .and_modify(|existing| {
292                if point.timestamp() >= existing.timestamp() {
293                    *existing = point.clone();
294                }
295            })
296            .or_insert_with(|| point.clone());
297
298        self.update_count.fetch_add(1, Ordering::Relaxed);
299    }
300
301    /// Look up the most recent point for a series.
302    ///
303    /// Returns `None` if the series has never been written to, or has
304    /// been evicted.
305    #[must_use]
306    pub fn get(&self, measurement: &str, tags: &BTreeMap<String, String>) -> Option<Point> {
307        let key = SeriesKey::new(measurement, tags.clone()).ok()?;
308        self.cache.get(&key).map(|entry| entry.value().clone())
309    }
310
311    /// Look up by `SeriesKey`.
312    #[must_use]
313    pub fn get_by_key(&self, key: &SeriesKey) -> Option<Point> {
314        self.cache.get(key).map(|entry| entry.value().clone())
315    }
316
317    /// Remove a series from the cache.
318    pub fn evict(&self, measurement: &str, tags: &BTreeMap<String, String>) {
319        if let Ok(key) = SeriesKey::new(measurement, tags.clone()) {
320            self.cache.remove(&key);
321            if self.max_entries > 0 {
322                self.order.lock().remove(&key);
323            }
324        }
325    }
326
327    /// Remove a series by its `SeriesKey`.
328    pub fn evict_by_key(&self, key: &SeriesKey) {
329        self.cache.remove(key);
330        if self.max_entries > 0 {
331            self.order.lock().remove(key);
332        }
333    }
334
335    /// Keep only the series `keep` accepts, by canonical form.
336    ///
337    /// The LVC is a copy of the newest row of every series, and a copy is not
338    /// an answer: once retention has dropped a series' data the raw
339    /// query returns nothing while the cache kept returning its last reading
340    /// — a device offline for longer than the retention window went on
341    /// reporting a "current" value for ever. It is also how the cache stays
342    /// bounded on a long-lived deployment: the live series set is bounded by
343    /// retention, and without this the cache was not.
344    ///
345    /// Called by the cardinality repair, against the budget it just rebuilt —
346    /// as a predicate rather than a materialised set, so pruning the cache
347    /// does not cost a copy of every live series.
348    pub fn retain_series(&self, keep: impl Fn(&str) -> bool) {
349        self.cache.retain(|key, _point| keep(key.canonical_form()));
350        if self.max_entries > 0 {
351            self.order.lock().retain_present(&self.cache);
352        }
353    }
354
355    /// Remove all entries for a measurement.
356    pub fn evict_measurement(&self, measurement: &str) {
357        self.cache
358            .retain(|key, _point| key.measurement() != measurement);
359        if self.max_entries > 0 {
360            self.order.lock().retain_present(&self.cache);
361        }
362    }
363
364    /// Clear all entries.
365    pub fn clear(&self) {
366        self.cache.clear();
367        if self.max_entries > 0 {
368            self.order.lock().clear();
369        }
370    }
371
372    /// Returns the number of cached series.
373    #[must_use]
374    pub fn len(&self) -> usize {
375        self.cache.len()
376    }
377
378    /// Returns `true` if the cache is empty.
379    #[must_use]
380    pub fn is_empty(&self) -> bool {
381        self.cache.is_empty()
382    }
383
384    /// Returns the total number of updates applied.
385    #[must_use]
386    pub fn update_count(&self) -> usize {
387        self.update_count.load(Ordering::Relaxed)
388    }
389}
390
391impl Default for LastValueCache {
392    fn default() -> Self {
393        Self::new()
394    }
395}
396
397impl std::fmt::Debug for LastValueCache {
398    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
399        f.debug_struct("LastValueCache")
400            .field("entries", &self.cache.len())
401            .field("updates", &self.update_count.load(Ordering::Relaxed))
402            .finish()
403    }
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409    use chronix_core::FieldValue;
410
411    fn make_point(host: &str, value: f64, ts: i64) -> Point {
412        let tags = BTreeMap::from([("host".to_string(), host.to_string())]);
413        let fields = BTreeMap::from([("cpu".to_string(), FieldValue::F64(value))]);
414        let key = SeriesKey::new("cpu", tags).unwrap();
415        Point::new(key, fields, ts).unwrap()
416    }
417
418    #[test]
419    fn update_and_get() {
420        let lvc = LastValueCache::new();
421        let p = make_point("srv1", 42.0, 1000);
422        lvc.update(&p);
423
424        let tags = BTreeMap::from([("host".to_string(), "srv1".to_string())]);
425        let result = lvc.get("cpu", &tags);
426        assert!(result.is_some());
427        assert_eq!(result.unwrap().timestamp(), 1000);
428    }
429
430    #[test]
431    fn newer_overwrites_older() {
432        let lvc = LastValueCache::new();
433        lvc.update(&make_point("srv1", 10.0, 100));
434        lvc.update(&make_point("srv1", 20.0, 200));
435
436        let tags = BTreeMap::from([("host".to_string(), "srv1".to_string())]);
437        let result = lvc.get("cpu", &tags).unwrap();
438        assert_eq!(result.timestamp(), 200);
439    }
440
441    #[test]
442    fn older_does_not_overwrite() {
443        let lvc = LastValueCache::new();
444        lvc.update(&make_point("srv1", 20.0, 200));
445        lvc.update(&make_point("srv1", 10.0, 100));
446
447        let tags = BTreeMap::from([("host".to_string(), "srv1".to_string())]);
448        let result = lvc.get("cpu", &tags).unwrap();
449        assert_eq!(result.timestamp(), 200);
450    }
451
452    #[test]
453    fn get_nonexistent_returns_none() {
454        let lvc = LastValueCache::new();
455        let tags = BTreeMap::from([("host".to_string(), "ghost".to_string())]);
456        assert!(lvc.get("cpu", &tags).is_none());
457    }
458
459    #[test]
460    fn evict_removes_entry() {
461        let lvc = LastValueCache::new();
462        lvc.update(&make_point("srv1", 1.0, 100));
463        assert_eq!(lvc.len(), 1);
464
465        let tags = BTreeMap::from([("host".to_string(), "srv1".to_string())]);
466        lvc.evict("cpu", &tags);
467        assert!(lvc.get("cpu", &tags).is_none());
468        assert_eq!(lvc.len(), 0);
469    }
470
471    #[test]
472    fn evict_measurement_removes_all_series() {
473        let lvc = LastValueCache::new();
474        lvc.update(&make_point("srv1", 1.0, 100));
475        lvc.update(&make_point("srv2", 2.0, 100));
476        assert_eq!(lvc.len(), 2);
477
478        lvc.evict_measurement("cpu");
479        assert_eq!(lvc.len(), 0);
480    }
481
482    #[test]
483    fn clear_empties_cache() {
484        let lvc = LastValueCache::new();
485        lvc.update(&make_point("srv1", 1.0, 100));
486        lvc.update(&make_point("srv2", 2.0, 100));
487        lvc.clear();
488        assert!(lvc.is_empty());
489    }
490
491    #[test]
492    fn update_count_tracks_operations() {
493        let lvc = LastValueCache::new();
494        assert_eq!(lvc.update_count(), 0);
495        lvc.update(&make_point("srv1", 1.0, 100));
496        lvc.update(&make_point("srv1", 2.0, 200));
497        assert_eq!(lvc.update_count(), 2);
498    }
499
500    #[test]
501    fn multiple_series_independent() {
502        let lvc = LastValueCache::new();
503        lvc.update(&make_point("srv1", 1.0, 100));
504        lvc.update(&make_point("srv2", 2.0, 200));
505
506        let tags1 = BTreeMap::from([("host".to_string(), "srv1".to_string())]);
507        let tags2 = BTreeMap::from([("host".to_string(), "srv2".to_string())]);
508
509        assert_eq!(lvc.get("cpu", &tags1).unwrap().timestamp(), 100);
510        assert_eq!(lvc.get("cpu", &tags2).unwrap().timestamp(), 200);
511    }
512
513    #[test]
514    fn get_by_key() {
515        let lvc = LastValueCache::new();
516        let p = make_point("srv1", 42.0, 1000);
517        let key = p.series_key().clone();
518        lvc.update(&p);
519
520        assert!(lvc.get_by_key(&key).is_some());
521        let ghost_key = SeriesKey::new(
522            "cpu",
523            BTreeMap::from([("host".to_string(), "ghost".to_string())]),
524        )
525        .unwrap();
526        assert!(lvc.get_by_key(&ghost_key).is_none());
527    }
528
529    // ── LRU O(1) eviction tests ──────────────────────────────
530
531    #[test]
532    fn lru_eviction_respects_max_entries() {
533        let lvc = LastValueCache::with_max_entries(2);
534        lvc.update(&make_point("srv1", 1.0, 100));
535        lvc.update(&make_point("srv2", 2.0, 200));
536        assert_eq!(lvc.len(), 2);
537
538        // Adding a 3rd entry should evict srv1 (LRU).
539        lvc.update(&make_point("srv3", 3.0, 300));
540        assert_eq!(lvc.len(), 2);
541
542        let tags1 = BTreeMap::from([("host".to_string(), "srv1".to_string())]);
543        let tags3 = BTreeMap::from([("host".to_string(), "srv3".to_string())]);
544        assert!(lvc.get("cpu", &tags1).is_none(), "srv1 should be evicted");
545        assert!(lvc.get("cpu", &tags3).is_some(), "srv3 should be present");
546    }
547
548    #[test]
549    fn lru_touch_moves_to_back() {
550        let lvc = LastValueCache::with_max_entries(2);
551        lvc.update(&make_point("srv1", 1.0, 100));
552        lvc.update(&make_point("srv2", 2.0, 200));
553
554        // Touch srv1 again — it should now be MRU, srv2 is LRU.
555        lvc.update(&make_point("srv1", 1.5, 300));
556
557        // Adding srv3 should evict srv2 (now LRU), not srv1.
558        lvc.update(&make_point("srv3", 3.0, 400));
559
560        let tags1 = BTreeMap::from([("host".to_string(), "srv1".to_string())]);
561        let tags2 = BTreeMap::from([("host".to_string(), "srv2".to_string())]);
562        let tags3 = BTreeMap::from([("host".to_string(), "srv3".to_string())]);
563        assert!(
564            lvc.get("cpu", &tags1).is_some(),
565            "srv1 was touched, should survive"
566        );
567        assert!(
568            lvc.get("cpu", &tags2).is_none(),
569            "srv2 should be evicted (LRU)"
570        );
571        assert!(lvc.get("cpu", &tags3).is_some(), "srv3 should be present");
572    }
573
574    #[test]
575    fn lru_evict_by_key_updates_order() {
576        let lvc = LastValueCache::with_max_entries(3);
577        lvc.update(&make_point("srv1", 1.0, 100));
578        lvc.update(&make_point("srv2", 2.0, 200));
579        lvc.update(&make_point("srv3", 3.0, 300));
580
581        // Explicitly evict srv2. Order should have only srv1, srv3.
582        let key2 = make_point("srv2", 0.0, 0).series_key().clone();
583        lvc.evict_by_key(&key2);
584        assert_eq!(lvc.len(), 2);
585        assert_eq!(lvc.order.lock().len(), 2);
586    }
587
588    #[test]
589    fn lru_order_internal_consistency() {
590        let lvc = LastValueCache::with_max_entries(100);
591        // Insert 50 entries, update 25 of them, evict 10.
592        for i in 0..50 {
593            lvc.update(&make_point(&format!("s{i}"), i as f64, i as i64));
594        }
595        assert_eq!(lvc.len(), 50);
596        assert_eq!(lvc.order.lock().len(), 50);
597
598        // Touch first 25 again.
599        for i in 0..25 {
600            lvc.update(&make_point(&format!("s{i}"), i as f64, 1000 + i as i64));
601        }
602        assert_eq!(lvc.len(), 50);
603        assert_eq!(lvc.order.lock().len(), 50);
604
605        // Evict by measurement clears everything.
606        lvc.evict_measurement("cpu");
607        assert_eq!(lvc.len(), 0);
608        assert_eq!(lvc.order.lock().len(), 0);
609    }
610}