Skip to main content

claw_core/
cache.rs

1//! In-memory LRU cache layer for claw-core.
2//!
3//! [`ClawCache`] is a simple, generic LRU (Least-Recently-Used) cache that
4//! sits in front of the SQLite store for frequently accessed records. Cache
5//! capacity is expressed in a configurable maximum number of entries; actual
6//! memory usage depends on the size of the cached values.
7//!
8//! This module provides the cache infrastructure only. The individual store
9//! modules in [`crate::store`] decide which records to cache and for how long.
10
11use std::collections::HashMap;
12use std::hash::Hash;
13
14use crate::error::{ClawError, ClawResult};
15
16/// A minimal LRU cache with a fixed maximum capacity.
17///
18/// When the cache is full and a new entry is inserted, the least-recently-used
19/// entry is evicted.  All operations are O(1) amortised.
20///
21/// # Type Parameters
22///
23/// * `K` — cache key type; must implement [`Eq`] + [`Hash`] + [`Clone`].
24/// * `V` — cached value type.
25#[derive(Debug)]
26pub struct ClawCache<K, V> {
27    capacity: usize,
28    /// Insertion-order tracking for LRU eviction.
29    order: std::collections::VecDeque<K>,
30    store: HashMap<K, V>,
31}
32
33impl<K, V> ClawCache<K, V>
34where
35    K: Eq + Hash + Clone,
36{
37    /// Create a new cache with the given maximum `capacity` (number of entries).
38    ///
39    /// # Errors
40    ///
41    /// Returns [`ClawError::Cache`] if `capacity` is zero.
42    pub fn new(capacity: usize) -> ClawResult<Self> {
43        if capacity == 0 {
44            return Err(ClawError::Cache("cache capacity must be >= 1".to_string()));
45        }
46        Ok(ClawCache {
47            capacity,
48            order: std::collections::VecDeque::new(),
49            store: HashMap::with_capacity(capacity),
50        })
51    }
52
53    /// Insert `value` under `key`. If the cache is full, the LRU entry is
54    /// evicted first.
55    pub fn insert(&mut self, key: K, value: V) {
56        if self.store.contains_key(&key) {
57            self.order.retain(|k| k != &key);
58        } else if self.store.len() >= self.capacity {
59            if let Some(lru_key) = self.order.pop_front() {
60                self.store.remove(&lru_key);
61            }
62        }
63        self.order.push_back(key.clone());
64        self.store.insert(key, value);
65    }
66
67    /// Return a reference to the cached value for `key`, or `None` if not
68    /// present. Accessing an entry marks it as most-recently used.
69    pub fn get(&mut self, key: &K) -> Option<&V> {
70        if self.store.contains_key(key) {
71            self.order.retain(|k| k != key);
72            self.order.push_back(key.clone());
73            self.store.get(key)
74        } else {
75            None
76        }
77    }
78
79    /// Remove and return the value for `key`, if present.
80    pub fn remove(&mut self, key: &K) -> Option<V> {
81        let value = self.store.remove(key);
82        if value.is_some() {
83            self.order.retain(|k| k != key);
84        }
85        value
86    }
87
88    /// Invalidate (remove) the value for `key`, if present.
89    pub fn invalidate(&mut self, key: &K) {
90        if self.store.remove(key).is_some() {
91            self.order.retain(|k| k != key);
92        }
93    }
94
95    /// Remove all entries from the cache.
96    pub fn clear(&mut self) {
97        self.store.clear();
98        self.order.clear();
99    }
100
101    /// Return the number of entries currently in the cache.
102    pub fn len(&self) -> usize {
103        self.store.len()
104    }
105
106    /// Return `true` if the cache contains no entries.
107    pub fn is_empty(&self) -> bool {
108        self.store.is_empty()
109    }
110}
111
112/// Statistics collected by a [`ClawCache`] instance.
113///
114/// Retrieve via [`crate::ClawEngine::cache_stats`].
115///
116/// # Example
117///
118/// ```rust
119/// use claw_core::CacheStats;
120/// let stats = CacheStats::new();
121/// assert_eq!(stats.hit_ratio(), 0.0);
122/// ```
123#[derive(Debug, Clone, Default)]
124pub struct CacheStats {
125    /// Number of cache lookups that found an entry.
126    pub hit_count: u64,
127    /// Number of cache lookups that found no entry.
128    pub miss_count: u64,
129    /// Total number of entries inserted into the cache.
130    pub insert_count: u64,
131    /// Total number of entries evicted from the cache.
132    pub evict_count: u64,
133    // Rolling window (private) for the last 1 000 lookups.
134    rolling: std::collections::VecDeque<bool>,
135}
136
137impl CacheStats {
138    /// Create a zeroed [`CacheStats`].
139    ///
140    /// # Example
141    ///
142    /// ```rust
143    /// use claw_core::CacheStats;
144    /// let s = CacheStats::new();
145    /// assert_eq!(s.hit_count, 0);
146    /// ```
147    pub fn new() -> Self {
148        CacheStats {
149            hit_count: 0,
150            miss_count: 0,
151            insert_count: 0,
152            evict_count: 0,
153            rolling: std::collections::VecDeque::with_capacity(1001),
154        }
155    }
156
157    /// Record a cache hit, updating both the lifetime counter and the rolling
158    /// 1 000-op window used by [`CacheStats::rolling_hit_rate`].
159    pub(crate) fn record_hit(&mut self) {
160        self.hit_count += 1;
161        if self.rolling.len() >= 1000 {
162            self.rolling.pop_front();
163        }
164        self.rolling.push_back(true);
165    }
166
167    /// Record a cache miss, updating both the lifetime counter and the rolling
168    /// 1 000-op window used by [`CacheStats::rolling_hit_rate`].
169    pub(crate) fn record_miss(&mut self) {
170        self.miss_count += 1;
171        if self.rolling.len() >= 1000 {
172            self.rolling.pop_front();
173        }
174        self.rolling.push_back(false);
175    }
176
177    /// Cache hit rate over the most recent 1 000 lookups (0.0 – 1.0).
178    ///
179    /// Returns `0.0` when fewer than one lookup has been recorded.
180    pub fn rolling_hit_rate(&self) -> f64 {
181        if self.rolling.is_empty() {
182            return 0.0;
183        }
184        let hits = self.rolling.iter().filter(|&&h| h).count();
185        hits as f64 / self.rolling.len() as f64
186    }
187
188    /// Cache hit ratio as a value between `0.0` and `1.0`.
189    ///
190    /// Returns `0.0` if no lookups have been performed yet.
191    ///
192    /// # Example
193    ///
194    /// ```rust
195    /// use claw_core::CacheStats;
196    /// let mut s = CacheStats::new();
197    /// s.hit_count = 3;
198    /// s.miss_count = 1;
199    /// assert!((s.hit_ratio() - 0.75).abs() < f64::EPSILON);
200    /// ```
201    pub fn hit_ratio(&self) -> f64 {
202        let total = self.hit_count + self.miss_count;
203        if total == 0 {
204            0.0
205        } else {
206            self.hit_count as f64 / total as f64
207        }
208    }
209}