Skip to main content

velesdb_core/cache/
lockfree.rs

1//! Lock-Free LRU Cache with `DashMap` L1 (US-CORE-003-15)
2//!
3//! Two-tier cache architecture for maximum concurrent throughput:
4//! - **L1**: `DashMap` (lock-free concurrent `HashMap`) for hot keys
5//! - **L2**: `LruCache` (with LRU eviction) for capacity management
6//!
7//! # Performance
8//!
9//! No recorded run times these operations; what each one touches:
10//!
11//! | Operation | L1 Hit | L1 Miss + L2 Hit |
12//! |-----------|--------|------------------|
13//! | get() | lock-free | L2, then promotion to L1 |
14//! | peek() | L1 only | N/A |
15//! | insert() | write-through | - |
16
17// Reason: Numeric casts in cache metrics are intentional:
18// - All casts are for hit rate calculations and statistics
19// - f64/u64 conversions for computing cache hit ratios
20// - Values bounded by cache size and access patterns
21// - Precision loss acceptable for cache metrics
22#![allow(clippy::cast_precision_loss)]
23
24use dashmap::DashMap;
25use std::hash::Hash;
26use std::sync::atomic::{AtomicU64, Ordering};
27
28use super::LruCache;
29
30/// Lock-free two-tier cache with `DashMap` L1 and `LruCache` L2.
31///
32/// Optimized for read-heavy workloads with hot keys.
33pub struct LockFreeLruCache<K, V>
34where
35    K: Hash + Eq + Clone + Send + Sync + 'static,
36    V: Clone + Send + Sync + 'static,
37{
38    /// L1: Lock-free concurrent cache for hot keys.
39    l1: DashMap<K, V>,
40    /// L2: LRU cache with eviction for capacity management.
41    pub(crate) l2: LruCache<K, V>,
42    /// Maximum L1 entries before eviction to L2.
43    l1_capacity: usize,
44    /// L1 hit counter.
45    l1_hits: AtomicU64,
46    /// L2 hit counter (L1 miss, L2 hit).
47    l2_hits: AtomicU64,
48    /// Total miss counter.
49    misses: AtomicU64,
50    /// L1 eviction overflow counter (P2 Audit 2026-01-29).
51    /// Counts times when L1 exceeded capacity after max eviction attempts.
52    l1_overflow: AtomicU64,
53}
54
55/// Statistics for the two-tier cache.
56#[derive(Debug, Clone, Default)]
57pub struct LockFreeCacheStats {
58    /// L1 cache hits.
59    pub l1_hits: u64,
60    /// L2 cache hits (L1 miss, L2 hit).
61    pub l2_hits: u64,
62    /// Total misses.
63    pub misses: u64,
64    /// L1 current size.
65    pub l1_size: usize,
66    /// L2 current size.
67    pub l2_size: usize,
68    /// L1 overflow count (times L1 exceeded capacity after max eviction attempts).
69    pub l1_overflow: u64,
70}
71
72impl LockFreeCacheStats {
73    /// Calculate L1 hit rate.
74    #[must_use]
75    pub fn l1_hit_rate(&self) -> f64 {
76        let total = self.l1_hits + self.l2_hits + self.misses;
77        if total == 0 {
78            0.0
79        } else {
80            self.l1_hits as f64 / total as f64
81        }
82    }
83
84    /// Calculate total hit rate (L1 + L2).
85    #[must_use]
86    pub fn total_hit_rate(&self) -> f64 {
87        let total = self.l1_hits + self.l2_hits + self.misses;
88        if total == 0 {
89            0.0
90        } else {
91            (self.l1_hits + self.l2_hits) as f64 / total as f64
92        }
93    }
94}
95
96impl<K, V> LockFreeLruCache<K, V>
97where
98    K: Hash + Eq + Clone + Send + Sync + 'static,
99    V: Clone + Send + Sync + 'static,
100{
101    /// Create a new lock-free LRU cache.
102    ///
103    /// # Arguments
104    ///
105    /// * `l1_capacity` - Maximum entries in L1 (hot cache)
106    /// * `l2_capacity` - Maximum entries in L2 (LRU backing store)
107    #[must_use]
108    pub fn new(l1_capacity: usize, l2_capacity: usize) -> Self {
109        Self {
110            l1: DashMap::with_capacity(l1_capacity),
111            l2: LruCache::new(l2_capacity),
112            l1_capacity,
113            l1_hits: AtomicU64::new(0),
114            l2_hits: AtomicU64::new(0),
115            misses: AtomicU64::new(0),
116            l1_overflow: AtomicU64::new(0),
117        }
118    }
119
120    /// Get a value, checking L1 first then L2.
121    ///
122    /// If found in L2, promotes to L1 for faster subsequent access.
123    #[must_use]
124    pub fn get(&self, key: &K) -> Option<V> {
125        // Fast path: L1 lookup (lock-free)
126        if let Some(entry) = self.l1.get(key) {
127            self.l1_hits.fetch_add(1, Ordering::Relaxed);
128            return Some(entry.value().clone());
129        }
130
131        // Slow path: L2 lookup
132        if let Some(value) = self.l2.get(key) {
133            self.l2_hits.fetch_add(1, Ordering::Relaxed);
134            // Promote to L1
135            self.promote_to_l1(key.clone(), value.clone());
136            return Some(value);
137        }
138
139        self.misses.fetch_add(1, Ordering::Relaxed);
140        None
141    }
142
143    /// Peek at L1 only (ultra-fast, no promotion).
144    ///
145    /// Returns None if not in L1, even if present in L2.
146    #[must_use]
147    pub fn peek_l1(&self, key: &K) -> Option<V> {
148        self.l1.get(key).map(|entry| entry.value().clone())
149    }
150
151    /// Peek at L2 only (no L1 check, no promotion).
152    #[must_use]
153    pub fn peek_l2(&self, key: &K) -> Option<V> {
154        self.l2.peek(key)
155    }
156
157    /// Insert a key-value pair (write-through to L1 and L2).
158    pub fn insert(&self, key: K, value: V) {
159        // Write to L1
160        self.l1.insert(key.clone(), value.clone());
161
162        // Evict from L1 if over capacity
163        self.maybe_evict_l1();
164
165        // Write to L2 (backing store)
166        self.l2.insert(key, value);
167    }
168
169    /// Remove a key from both L1 and L2.
170    pub fn remove(&self, key: &K) {
171        self.l1.remove(key);
172        self.l2.remove(key);
173    }
174
175    /// Clear both L1 and L2.
176    pub fn clear(&self) {
177        self.l1.clear();
178        self.l2.clear();
179    }
180
181    /// Get cache statistics.
182    #[must_use]
183    pub fn stats(&self) -> LockFreeCacheStats {
184        LockFreeCacheStats {
185            l1_hits: self.l1_hits.load(Ordering::Relaxed),
186            l2_hits: self.l2_hits.load(Ordering::Relaxed),
187            misses: self.misses.load(Ordering::Relaxed),
188            l1_size: self.l1.len(),
189            l2_size: self.l2.len(),
190            l1_overflow: self.l1_overflow.load(Ordering::Relaxed),
191        }
192    }
193
194    /// Get L1 capacity.
195    #[must_use]
196    pub fn l1_capacity(&self) -> usize {
197        self.l1_capacity
198    }
199
200    /// Get L2 capacity.
201    #[must_use]
202    pub fn l2_capacity(&self) -> usize {
203        self.l2.capacity()
204    }
205
206    /// Promote a key from L2 to L1.
207    fn promote_to_l1(&self, key: K, value: V) {
208        self.l1.insert(key, value);
209        self.maybe_evict_l1();
210    }
211
212    /// Evict entries from L1 if over capacity.
213    /// Uses a bounded loop to prevent infinite spinning under contention.
214    fn maybe_evict_l1(&self) {
215        // Bounded eviction: max attempts to prevent infinite loop under contention
216        let mut attempts = 0;
217        let max_attempts = 10;
218
219        while self.l1.len() > self.l1_capacity && attempts < max_attempts {
220            attempts += 1;
221
222            // Collect keys to remove (avoid holding iterator while removing)
223            let keys_to_remove: Vec<K> = self
224                .l1
225                .iter()
226                .take(self.l1.len().saturating_sub(self.l1_capacity).max(1))
227                .map(|entry| entry.key().clone())
228                .collect();
229
230            if keys_to_remove.is_empty() {
231                break;
232            }
233
234            for key in keys_to_remove {
235                self.l1.remove(&key);
236            }
237        }
238
239        // P2 Audit 2026-01-29: Track overflow when L1 still exceeds capacity after max attempts
240        if self.l1.len() > self.l1_capacity {
241            self.l1_overflow.fetch_add(1, Ordering::Relaxed);
242        }
243    }
244}
245
246impl<K, V> Default for LockFreeLruCache<K, V>
247where
248    K: Hash + Eq + Clone + Send + Sync + 'static,
249    V: Clone + Send + Sync + 'static,
250{
251    fn default() -> Self {
252        // Default: L1 = 1K hot entries, L2 = 10K LRU entries
253        Self::new(1_000, 10_000)
254    }
255}
256
257// Tests moved to lockfree_tests.rs per project rules