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