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}