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