1#![allow(clippy::len_without_is_empty)] use std::collections::{HashMap, VecDeque};
38use std::sync::atomic::{AtomicU64, Ordering};
39
40use parking_lot::RwLock;
41
42use super::entity::UnifiedEntity;
43
44const N_SHARDS: usize = 16;
50const SHARD_MASK: u64 = (N_SHARDS as u64) - 1;
51
52const DEFAULT_CAPACITY: usize = 10_000;
55
56#[inline]
57fn shard_capacity(total: usize) -> usize {
58 total.div_ceil(N_SHARDS)
60}
61
62struct Shard {
74 entries: HashMap<u64, (String, UnifiedEntity)>,
75 order: VecDeque<u64>,
76 capacity: usize,
77}
78
79impl Shard {
80 fn new(capacity: usize) -> Self {
81 Self {
82 entries: HashMap::with_capacity(capacity),
83 order: VecDeque::with_capacity(capacity),
84 capacity,
85 }
86 }
87
88 fn touch(&mut self, key: u64) {
89 if let Some(pos) = self.order.iter().position(|&k| k == key) {
91 self.order.remove(pos);
92 }
93 self.order.push_back(key);
94 }
95
96 fn get(&mut self, key: u64) -> Option<(String, UnifiedEntity)> {
97 let value = self.entries.get(&key).cloned()?;
98 self.touch(key);
99 Some(value)
100 }
101
102 fn insert(&mut self, key: u64, value: (String, UnifiedEntity)) -> usize {
104 if self.entries.insert(key, value).is_some() {
105 self.touch(key);
106 return 0;
107 }
108 self.order.push_back(key);
109 if self.entries.len() > self.capacity {
110 if let Some(victim) = self.order.pop_front() {
111 self.entries.remove(&victim);
112 return 1;
113 }
114 }
115 0
116 }
117
118 fn remove(&mut self, key: u64) -> bool {
119 if self.entries.remove(&key).is_some() {
120 if let Some(pos) = self.order.iter().position(|&k| k == key) {
121 self.order.remove(pos);
122 }
123 true
124 } else {
125 false
126 }
127 }
128
129 fn retain<F>(&mut self, mut keep: F)
130 where
131 F: FnMut(u64, &(String, UnifiedEntity)) -> bool,
132 {
133 self.entries.retain(|k, v| keep(*k, v));
134 self.order.retain(|k| self.entries.contains_key(k));
135 }
136
137 fn is_empty(&self) -> bool {
138 self.entries.is_empty()
139 }
140}
141
142pub struct EntityCache {
144 shards: [RwLock<Shard>; N_SHARDS],
145 hits: AtomicU64,
146 misses: AtomicU64,
147 evictions: AtomicU64,
148}
149
150impl EntityCache {
151 pub fn new() -> Self {
152 Self::with_capacity(DEFAULT_CAPACITY)
153 }
154
155 pub fn with_capacity(total_capacity: usize) -> Self {
156 let per_shard = shard_capacity(total_capacity);
157 let shards = std::array::from_fn(|_| RwLock::new(Shard::new(per_shard)));
160 Self {
161 shards,
162 hits: AtomicU64::new(0),
163 misses: AtomicU64::new(0),
164 evictions: AtomicU64::new(0),
165 }
166 }
167
168 #[inline]
169 fn shard_for(&self, key: u64) -> &RwLock<Shard> {
170 &self.shards[(key & SHARD_MASK) as usize]
171 }
172
173 pub fn get(&self, key: u64) -> Option<(String, UnifiedEntity)> {
179 let mut shard = self.shard_for(key).write();
180 match shard.get(key) {
181 Some(v) => {
182 self.hits.fetch_add(1, Ordering::Relaxed);
183 Some(v)
184 }
185 None => {
186 self.misses.fetch_add(1, Ordering::Relaxed);
187 None
188 }
189 }
190 }
191
192 pub fn insert(&self, key: u64, value: (String, UnifiedEntity)) {
194 let mut shard = self.shard_for(key).write();
195 let evicted = shard.insert(key, value);
196 if evicted > 0 {
197 self.evictions.fetch_add(evicted as u64, Ordering::Relaxed);
198 }
199 }
200
201 pub fn remove(&self, key: u64) -> bool {
210 let lock = self.shard_for(key);
211 {
212 let shard = lock.read();
213 if !shard.entries.contains_key(&key) {
214 return false;
215 }
216 }
217 lock.write().remove(key)
218 }
219
220 pub fn remove_many(&self, keys: impl IntoIterator<Item = u64>) {
224 let mut buckets: [Vec<u64>; N_SHARDS] = std::array::from_fn(|_| Vec::new());
226 for key in keys {
227 buckets[(key & SHARD_MASK) as usize].push(key);
228 }
229 for (idx, bucket) in buckets.into_iter().enumerate() {
230 if bucket.is_empty() {
231 continue;
232 }
233 let lock = &self.shards[idx];
234 {
236 let shard = lock.read();
237 if shard.is_empty() {
238 continue;
239 }
240 if !bucket.iter().any(|k| shard.entries.contains_key(k)) {
241 continue;
242 }
243 }
244 let mut shard = lock.write();
245 for key in bucket {
246 shard.remove(key);
247 }
248 }
249 }
250
251 pub fn retain<F>(&self, mut keep: F)
254 where
255 F: FnMut(u64, &(String, UnifiedEntity)) -> bool,
256 {
257 for shard in &self.shards {
258 shard.write().retain(&mut keep);
259 }
260 }
261
262 pub fn len(&self) -> usize {
264 self.shards.iter().map(|s| s.read().entries.len()).sum()
265 }
266
267 pub fn hit_rate(&self) -> Option<f64> {
269 let hits = self.hits.load(Ordering::Relaxed);
270 let misses = self.misses.load(Ordering::Relaxed);
271 let total = hits + misses;
272 if total == 0 {
273 None
274 } else {
275 Some(hits as f64 / total as f64)
276 }
277 }
278
279 pub fn stats(&self) -> EntityCacheStats {
281 EntityCacheStats {
282 hits: self.hits.load(Ordering::Relaxed),
283 misses: self.misses.load(Ordering::Relaxed),
284 evictions: self.evictions.load(Ordering::Relaxed),
285 entries: self.len(),
286 }
287 }
288}
289
290impl Default for EntityCache {
291 fn default() -> Self {
292 Self::new()
293 }
294}
295
296#[derive(Debug, Clone, Copy, PartialEq, Eq)]
297pub struct EntityCacheStats {
298 pub hits: u64,
299 pub misses: u64,
300 pub evictions: u64,
301 pub entries: usize,
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307 use crate::storage::entity::{EntityData, EntityId, EntityKind, NodeData, UnifiedEntity};
308
309 fn make_entity(id: u64) -> UnifiedEntity {
310 UnifiedEntity::new(
311 EntityId::new(id),
312 EntityKind::Vector {
313 collection: "test".to_string(),
314 },
315 EntityData::Node(NodeData::new()),
316 )
317 }
318
319 #[test]
320 fn miss_then_hit_updates_counters() {
321 let cache = EntityCache::new();
322 assert!(cache.get(42).is_none());
323 cache.insert(42, ("col".into(), make_entity(42)));
324 let got = cache.get(42).expect("should hit");
325 assert_eq!(got.0, "col");
326 let stats = cache.stats();
327 assert_eq!(stats.hits, 1);
328 assert_eq!(stats.misses, 1);
329 assert_eq!(cache.hit_rate(), Some(0.5));
330 }
331
332 #[test]
333 fn lru_evicts_oldest_when_shard_full() {
334 let cache = EntityCache::with_capacity(32);
336 let s = 16; cache.insert(s, ("c".into(), make_entity(s)));
339 cache.insert(s * 2, ("c".into(), make_entity(s * 2)));
340 let _ = cache.get(s);
342 cache.insert(s * 3, ("c".into(), make_entity(s * 3)));
343
344 assert!(cache.get(s).is_some(), "first key recently used, kept");
345 assert!(cache.get(s * 2).is_none(), "second key was LRU, evicted");
346 assert!(cache.get(s * 3).is_some(), "newest key kept");
347 assert!(cache.stats().evictions >= 1);
348 }
349
350 #[test]
351 fn remove_fast_path_skips_write_lock_on_cold_cache() {
352 let cache = EntityCache::new();
355 assert!(!cache.remove(123));
356 assert_eq!(cache.len(), 0);
357 }
358
359 #[test]
360 fn remove_many_handles_mixed_shards() {
361 let cache = EntityCache::new();
362 for k in 0..32u64 {
363 cache.insert(k, ("c".into(), make_entity(k)));
364 }
365 assert_eq!(cache.len(), 32);
366 cache.remove_many(0..32);
367 assert_eq!(cache.len(), 0);
368 }
369
370 #[test]
371 fn retain_drops_entries_failing_predicate() {
372 let cache = EntityCache::new();
373 cache.insert(1, ("keep".into(), make_entity(1)));
374 cache.insert(2, ("drop".into(), make_entity(2)));
375 cache.retain(|_, (col, _)| col == "keep");
376 assert!(cache.get(1).is_some());
377 assert!(cache.get(2).is_none());
378 }
379
380 #[test]
381 fn hit_rate_none_until_first_lookup() {
382 let cache = EntityCache::new();
383 assert_eq!(cache.hit_rate(), None);
384 cache.insert(1, ("c".into(), make_entity(1)));
385 assert_eq!(cache.hit_rate(), None);
387 let _ = cache.get(1);
388 assert_eq!(cache.hit_rate(), Some(1.0));
389 }
390}