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::atomic::{AtomicUsize, Ordering};
20use std::sync::Arc;
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    /// Remove all entries for a measurement.
336    pub fn evict_measurement(&self, measurement: &str) {
337        self.cache
338            .retain(|key, _point| key.measurement() != measurement);
339        if self.max_entries > 0 {
340            self.order.lock().retain_present(&self.cache);
341        }
342    }
343
344    /// Clear all entries.
345    pub fn clear(&self) {
346        self.cache.clear();
347        if self.max_entries > 0 {
348            self.order.lock().clear();
349        }
350    }
351
352    /// Returns the number of cached series.
353    #[must_use]
354    pub fn len(&self) -> usize {
355        self.cache.len()
356    }
357
358    /// Returns `true` if the cache is empty.
359    #[must_use]
360    pub fn is_empty(&self) -> bool {
361        self.cache.is_empty()
362    }
363
364    /// Returns the total number of updates applied.
365    #[must_use]
366    pub fn update_count(&self) -> usize {
367        self.update_count.load(Ordering::Relaxed)
368    }
369}
370
371impl Default for LastValueCache {
372    fn default() -> Self {
373        Self::new()
374    }
375}
376
377impl std::fmt::Debug for LastValueCache {
378    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
379        f.debug_struct("LastValueCache")
380            .field("entries", &self.cache.len())
381            .field("updates", &self.update_count.load(Ordering::Relaxed))
382            .finish()
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use chronix_core::FieldValue;
390
391    fn make_point(host: &str, value: f64, ts: i64) -> Point {
392        let tags = BTreeMap::from([("host".to_string(), host.to_string())]);
393        let fields = BTreeMap::from([("cpu".to_string(), FieldValue::F64(value))]);
394        let key = SeriesKey::new("cpu", tags).unwrap();
395        Point::new(key, fields, ts).unwrap()
396    }
397
398    #[test]
399    fn update_and_get() {
400        let lvc = LastValueCache::new();
401        let p = make_point("srv1", 42.0, 1000);
402        lvc.update(&p);
403
404        let tags = BTreeMap::from([("host".to_string(), "srv1".to_string())]);
405        let result = lvc.get("cpu", &tags);
406        assert!(result.is_some());
407        assert_eq!(result.unwrap().timestamp(), 1000);
408    }
409
410    #[test]
411    fn newer_overwrites_older() {
412        let lvc = LastValueCache::new();
413        lvc.update(&make_point("srv1", 10.0, 100));
414        lvc.update(&make_point("srv1", 20.0, 200));
415
416        let tags = BTreeMap::from([("host".to_string(), "srv1".to_string())]);
417        let result = lvc.get("cpu", &tags).unwrap();
418        assert_eq!(result.timestamp(), 200);
419    }
420
421    #[test]
422    fn older_does_not_overwrite() {
423        let lvc = LastValueCache::new();
424        lvc.update(&make_point("srv1", 20.0, 200));
425        lvc.update(&make_point("srv1", 10.0, 100));
426
427        let tags = BTreeMap::from([("host".to_string(), "srv1".to_string())]);
428        let result = lvc.get("cpu", &tags).unwrap();
429        assert_eq!(result.timestamp(), 200);
430    }
431
432    #[test]
433    fn get_nonexistent_returns_none() {
434        let lvc = LastValueCache::new();
435        let tags = BTreeMap::from([("host".to_string(), "ghost".to_string())]);
436        assert!(lvc.get("cpu", &tags).is_none());
437    }
438
439    #[test]
440    fn evict_removes_entry() {
441        let lvc = LastValueCache::new();
442        lvc.update(&make_point("srv1", 1.0, 100));
443        assert_eq!(lvc.len(), 1);
444
445        let tags = BTreeMap::from([("host".to_string(), "srv1".to_string())]);
446        lvc.evict("cpu", &tags);
447        assert!(lvc.get("cpu", &tags).is_none());
448        assert_eq!(lvc.len(), 0);
449    }
450
451    #[test]
452    fn evict_measurement_removes_all_series() {
453        let lvc = LastValueCache::new();
454        lvc.update(&make_point("srv1", 1.0, 100));
455        lvc.update(&make_point("srv2", 2.0, 100));
456        assert_eq!(lvc.len(), 2);
457
458        lvc.evict_measurement("cpu");
459        assert_eq!(lvc.len(), 0);
460    }
461
462    #[test]
463    fn clear_empties_cache() {
464        let lvc = LastValueCache::new();
465        lvc.update(&make_point("srv1", 1.0, 100));
466        lvc.update(&make_point("srv2", 2.0, 100));
467        lvc.clear();
468        assert!(lvc.is_empty());
469    }
470
471    #[test]
472    fn update_count_tracks_operations() {
473        let lvc = LastValueCache::new();
474        assert_eq!(lvc.update_count(), 0);
475        lvc.update(&make_point("srv1", 1.0, 100));
476        lvc.update(&make_point("srv1", 2.0, 200));
477        assert_eq!(lvc.update_count(), 2);
478    }
479
480    #[test]
481    fn multiple_series_independent() {
482        let lvc = LastValueCache::new();
483        lvc.update(&make_point("srv1", 1.0, 100));
484        lvc.update(&make_point("srv2", 2.0, 200));
485
486        let tags1 = BTreeMap::from([("host".to_string(), "srv1".to_string())]);
487        let tags2 = BTreeMap::from([("host".to_string(), "srv2".to_string())]);
488
489        assert_eq!(lvc.get("cpu", &tags1).unwrap().timestamp(), 100);
490        assert_eq!(lvc.get("cpu", &tags2).unwrap().timestamp(), 200);
491    }
492
493    #[test]
494    fn get_by_key() {
495        let lvc = LastValueCache::new();
496        let p = make_point("srv1", 42.0, 1000);
497        let key = p.series_key().clone();
498        lvc.update(&p);
499
500        assert!(lvc.get_by_key(&key).is_some());
501        let ghost_key = SeriesKey::new(
502            "cpu",
503            BTreeMap::from([("host".to_string(), "ghost".to_string())]),
504        )
505        .unwrap();
506        assert!(lvc.get_by_key(&ghost_key).is_none());
507    }
508
509    // ── LRU O(1) eviction tests ──────────────────────────────
510
511    #[test]
512    fn lru_eviction_respects_max_entries() {
513        let lvc = LastValueCache::with_max_entries(2);
514        lvc.update(&make_point("srv1", 1.0, 100));
515        lvc.update(&make_point("srv2", 2.0, 200));
516        assert_eq!(lvc.len(), 2);
517
518        // Adding a 3rd entry should evict srv1 (LRU).
519        lvc.update(&make_point("srv3", 3.0, 300));
520        assert_eq!(lvc.len(), 2);
521
522        let tags1 = BTreeMap::from([("host".to_string(), "srv1".to_string())]);
523        let tags3 = BTreeMap::from([("host".to_string(), "srv3".to_string())]);
524        assert!(lvc.get("cpu", &tags1).is_none(), "srv1 should be evicted");
525        assert!(lvc.get("cpu", &tags3).is_some(), "srv3 should be present");
526    }
527
528    #[test]
529    fn lru_touch_moves_to_back() {
530        let lvc = LastValueCache::with_max_entries(2);
531        lvc.update(&make_point("srv1", 1.0, 100));
532        lvc.update(&make_point("srv2", 2.0, 200));
533
534        // Touch srv1 again — it should now be MRU, srv2 is LRU.
535        lvc.update(&make_point("srv1", 1.5, 300));
536
537        // Adding srv3 should evict srv2 (now LRU), not srv1.
538        lvc.update(&make_point("srv3", 3.0, 400));
539
540        let tags1 = BTreeMap::from([("host".to_string(), "srv1".to_string())]);
541        let tags2 = BTreeMap::from([("host".to_string(), "srv2".to_string())]);
542        let tags3 = BTreeMap::from([("host".to_string(), "srv3".to_string())]);
543        assert!(
544            lvc.get("cpu", &tags1).is_some(),
545            "srv1 was touched, should survive"
546        );
547        assert!(
548            lvc.get("cpu", &tags2).is_none(),
549            "srv2 should be evicted (LRU)"
550        );
551        assert!(lvc.get("cpu", &tags3).is_some(), "srv3 should be present");
552    }
553
554    #[test]
555    fn lru_evict_by_key_updates_order() {
556        let lvc = LastValueCache::with_max_entries(3);
557        lvc.update(&make_point("srv1", 1.0, 100));
558        lvc.update(&make_point("srv2", 2.0, 200));
559        lvc.update(&make_point("srv3", 3.0, 300));
560
561        // Explicitly evict srv2. Order should have only srv1, srv3.
562        let key2 = make_point("srv2", 0.0, 0).series_key().clone();
563        lvc.evict_by_key(&key2);
564        assert_eq!(lvc.len(), 2);
565        assert_eq!(lvc.order.lock().len(), 2);
566    }
567
568    #[test]
569    fn lru_order_internal_consistency() {
570        let lvc = LastValueCache::with_max_entries(100);
571        // Insert 50 entries, update 25 of them, evict 10.
572        for i in 0..50 {
573            lvc.update(&make_point(&format!("s{i}"), i as f64, i as i64));
574        }
575        assert_eq!(lvc.len(), 50);
576        assert_eq!(lvc.order.lock().len(), 50);
577
578        // Touch first 25 again.
579        for i in 0..25 {
580            lvc.update(&make_point(&format!("s{i}"), i as f64, 1000 + i as i64));
581        }
582        assert_eq!(lvc.len(), 50);
583        assert_eq!(lvc.order.lock().len(), 50);
584
585        // Evict by measurement clears everything.
586        lvc.evict_measurement("cpu");
587        assert_eq!(lvc.len(), 0);
588        assert_eq!(lvc.order.lock().len(), 0);
589    }
590}