Skip to main content

reddb_engine/storage/
entity_cache.rs

1//! Sharded, bounded LRU cache for entity lookups by raw id.
2//!
3//! Replaces the original store-wide `RwLock<HashMap<u64, (String, UnifiedEntity)>>`
4//! that backed `UnifiedStore::get_any()`. The original cache had three problems
5//! flagged in `docs/perf/delete-sequential-2026-05-06.md` (#85):
6//!
7//! 1. A single `RwLock` serialised every lookup and every invalidation,
8//!    so a delete-heavy workload paid one global write lock per row.
9//! 2. Eviction was naïve ("drop the first key the iterator yields") with no
10//!    recency tracking, so hot entries could be discarded before cold ones.
11//! 3. There was no observability: hit-rate had to be guessed from access
12//!    patterns. The OLTP DELETE workload (hit-rate ≈ 0%) was indistinguishable
13//!    from a graph-traversal workload (hit-rate > 0%) at the metric layer.
14//!
15//! This module solves all three:
16//!
17//! - **Sharding** by `id & (N_SHARDS - 1)` cuts contention by `N_SHARDS`×.
18//!   Invalidations from `delete_batch` only collide with reads on the same
19//!   shard.
20//! - **Bounded LRU** per shard: a `HashMap` carries the entries and a
21//!   `VecDeque<u64>` carries access order. On a hit we mark the key
22//!   most-recently-used; on capacity overflow we drop the least-recently-used.
23//! - **Counters** (`AtomicU64` for hits / misses / evictions) drive
24//!   `EntityCache::hit_rate()` for live observability.
25//!
26//! ## Shape
27//!
28//! Same key/value shape as the original cache so callers don't change:
29//!
30//! ```ignore
31//! type Key   = u64;                    // EntityId::raw()
32//! type Value = (String, UnifiedEntity);// (collection, entity)
33//! ```
34
35#![allow(clippy::len_without_is_empty)] // Retain the native cache API.
36
37use std::collections::{HashMap, VecDeque};
38use std::sync::atomic::{AtomicU64, Ordering};
39
40use parking_lot::RwLock;
41
42use super::entity::UnifiedEntity;
43
44/// Number of shards. Must be a power of two so we can replace the modulo
45/// with a bitmask. Sixteen is a reasonable compromise between extra memory
46/// (one `RwLock` + two collections per shard) and contention reduction —
47/// we expect at most a handful of concurrent writers, so doubling beyond
48/// 16 buys nothing on the workloads that motivated this rewrite.
49const N_SHARDS: usize = 16;
50const SHARD_MASK: u64 = (N_SHARDS as u64) - 1;
51
52/// Total cache capacity (across all shards). Matches the previous 10_000
53/// behaviour from the flat `HashMap`.
54const DEFAULT_CAPACITY: usize = 10_000;
55
56#[inline]
57fn shard_capacity(total: usize) -> usize {
58    // Round up so total ≥ DEFAULT_CAPACITY even if not divisible.
59    total.div_ceil(N_SHARDS)
60}
61
62/// Per-shard bounded LRU.
63///
64/// `entries` owns the values; `order` is a recency queue from oldest
65/// (front) to newest (back). On hit we move the key to the back; on miss
66/// we push and, if over capacity, pop the front.
67///
68/// We keep the implementation minimal: O(n) `retain` for the recency queue
69/// on hits is fine for our shard size (≈ 625 entries) and avoids the
70/// borrow-checker pain of a hand-rolled doubly-linked list. If we ever
71/// want true O(1), the `lru` crate is a drop-in replacement — but the
72/// hot path in #85 is the *invalidation*, not the read.
73struct 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        // Move `key` to the back of the recency queue. O(n) in shard size.
90        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    /// Insert and return the number of evicted entries (0 or 1).
103    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
142/// Sharded, bounded LRU cache for `UnifiedStore::get_any` lookups.
143pub 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        // `[T; N]` with non-Copy T can't be built via `[expr; N]`; build
158        // by repeated initialisation.
159        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    /// Cache lookup. Updates `hits` / `misses`.
174    ///
175    /// Acquires the shard write lock on a hit (LRU touch needs it). If the
176    /// touch cost ever shows up in profiles we can split into a fast read-only
177    /// probe + deferred touch, but at shard size ≈ 625 it's not the bottleneck.
178    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    /// Insert. Counts evictions for observability.
193    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    /// Remove a single key. Returns `true` if the key was present.
202    ///
203    /// Hot path on `delete_batch` / `delete`. Probes with a *read* lock first:
204    /// most deletes target rows that were never read, so the cache miss case
205    /// avoids the write-lock acquisition entirely. This is the load-bearing
206    /// optimisation for #85's `delete_sequential` regression — the original
207    /// code took the write lock unconditionally, even for a 100 % miss
208    /// workload.
209    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    /// Bulk remove. Same fast-path optimisation as `remove`: skip the
221    /// shard's write lock entirely when none of its candidate keys are
222    /// present.
223    pub fn remove_many(&self, keys: impl IntoIterator<Item = u64>) {
224        // Bucket keys by shard so we acquire each lock at most once.
225        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            // Read-lock probe — skip the write lock if nothing matches.
235            {
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    /// Drop every entry whose `(collection, entity)` value fails `keep`.
252    /// Used by collection-drop paths.
253    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    /// Total entries across all shards. Diagnostic only — locks every shard.
263    pub fn len(&self) -> usize {
264        self.shards.iter().map(|s| s.read().entries.len()).sum()
265    }
266
267    /// Hit / (hit + miss). Returns `None` if no lookups have occurred.
268    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    /// Snapshot of the three counters: `(hits, misses, evictions)`.
280    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        // Capacity 32 → 2/shard. Pin keys to one shard by using multiples of 16.
335        let cache = EntityCache::with_capacity(32);
336        let s = 16; // shard 0 stride
337
338        cache.insert(s, ("c".into(), make_entity(s)));
339        cache.insert(s * 2, ("c".into(), make_entity(s * 2)));
340        // Touch first key — second becomes LRU.
341        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        // We can't observe the lock acquisition directly, but we can check
353        // remove() returns false without panicking and the cache stays empty.
354        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        // insert alone shouldn't drive hit_rate
386        assert_eq!(cache.hit_rate(), None);
387        let _ = cache.get(1);
388        assert_eq!(cache.hit_rate(), Some(1.0));
389    }
390}