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