Skip to main content

sqry_core/query/cache/
budget.rs

1//! Cache budget controller for enforcing memory limits.
2//!
3//! This module provides adaptive cache budgeting to prevent unbounded memory growth
4//! while maintaining high cache hit rates. The controller uses synchronous clamping
5//! logic to enforce entry and memory limits across multiple caches.
6
7use std::sync::atomic::{AtomicUsize, Ordering};
8
9/// Configuration for cache budget limits
10#[derive(Debug, Clone)]
11pub struct BudgetConfig {
12    /// Maximum number of entries across all caches (default: 10,000)
13    pub max_entries: usize,
14
15    /// Maximum memory in bytes (default: 100 MB)
16    /// This is a soft limit based on estimated size tracking
17    pub max_memory_bytes: usize,
18
19    /// Estimated bytes per symbol (default: 512 bytes)
20    /// Used for rough memory estimation when actual size unavailable
21    pub estimated_symbol_size: usize,
22
23    /// Estimated bytes per parse tree (default: 2048 bytes)
24    pub estimated_parse_tree_size: usize,
25}
26
27impl Default for BudgetConfig {
28    fn default() -> Self {
29        Self {
30            max_entries: 10_000,
31            max_memory_bytes: 100 * 1024 * 1024, // 100 MB
32            estimated_symbol_size: 512,
33            estimated_parse_tree_size: 2048,
34        }
35    }
36}
37
38/// Budget controller for tracking and enforcing cache limits
39pub struct CacheBudgetController {
40    config: BudgetConfig,
41
42    /// Current total entries across all caches
43    total_entries: AtomicUsize,
44
45    /// Estimated total memory usage in bytes
46    estimated_memory: AtomicUsize,
47
48    /// Number of clamp operations performed
49    clamp_count: AtomicUsize,
50}
51
52impl CacheBudgetController {
53    /// Create a new budget controller with default configuration
54    #[must_use]
55    pub fn new() -> Self {
56        Self::with_config(BudgetConfig::default())
57    }
58
59    /// Create a new budget controller with custom configuration
60    #[must_use]
61    pub fn with_config(config: BudgetConfig) -> Self {
62        Self {
63            config,
64            total_entries: AtomicUsize::new(0),
65            estimated_memory: AtomicUsize::new(0),
66            clamp_count: AtomicUsize::new(0),
67        }
68    }
69
70    /// Record an insert operation
71    ///
72    /// # Arguments
73    ///
74    /// * `entry_count` - Number of entries being inserted
75    /// * `estimated_bytes` - Estimated memory size of the entries
76    pub fn record_insert(&self, entry_count: usize, estimated_bytes: usize) {
77        self.total_entries.fetch_add(entry_count, Ordering::Relaxed);
78        self.estimated_memory
79            .fetch_add(estimated_bytes, Ordering::Relaxed);
80    }
81
82    /// Record a remove/eviction operation
83    ///
84    /// # Arguments
85    ///
86    /// * `entry_count` - Number of entries being removed
87    /// * `estimated_bytes` - Estimated memory size of the entries
88    pub fn record_remove(&self, entry_count: usize, estimated_bytes: usize) {
89        self.total_entries.fetch_sub(entry_count, Ordering::Relaxed);
90        self.estimated_memory
91            .fetch_sub(estimated_bytes, Ordering::Relaxed);
92    }
93
94    /// Check if budget limits are exceeded
95    ///
96    /// Returns `ClampAction` indicating how to adjust caches
97    pub fn check_budget(&self) -> ClampAction {
98        let entries = self.total_entries.load(Ordering::Relaxed);
99        let memory = self.estimated_memory.load(Ordering::Relaxed);
100
101        let entries_over = entries.saturating_sub(self.config.max_entries);
102        let memory_over = memory.saturating_sub(self.config.max_memory_bytes);
103
104        if entries_over > 0 || memory_over > 0 {
105            // Calculate how many entries to evict based on whichever limit is more exceeded
106            let entries_to_evict_for_count = entries_over;
107            let entries_to_evict_for_memory = if memory_over > 0 {
108                // Estimate entries needed to free memory (conservative)
109                (memory_over / self.config.estimated_symbol_size).max(1)
110            } else {
111                0
112            };
113
114            let entries_to_evict = entries_to_evict_for_count.max(entries_to_evict_for_memory);
115
116            ClampAction::Evict {
117                count: entries_to_evict,
118                reason: if entries_over > memory_over {
119                    ClampReason::EntryLimit
120                } else {
121                    ClampReason::MemoryLimit
122                },
123            }
124        } else {
125            ClampAction::None
126        }
127    }
128
129    /// Record that a clamp operation was performed
130    pub fn record_clamp(&self) {
131        self.clamp_count.fetch_add(1, Ordering::Relaxed);
132    }
133
134    /// Get current budget statistics
135    pub fn stats(&self) -> BudgetStats {
136        BudgetStats {
137            total_entries: self.total_entries.load(Ordering::Relaxed),
138            estimated_memory_bytes: self.estimated_memory.load(Ordering::Relaxed),
139            clamp_count: self.clamp_count.load(Ordering::Relaxed),
140            max_entries: self.config.max_entries,
141            max_memory_bytes: self.config.max_memory_bytes,
142        }
143    }
144
145    /// Reset budget tracking (used when clearing all caches)
146    pub fn reset(&self) {
147        self.total_entries.store(0, Ordering::Relaxed);
148        self.estimated_memory.store(0, Ordering::Relaxed);
149        // Note: We don't reset clamp_count as it's a cumulative statistic
150    }
151
152    /// Get the current configuration
153    pub fn config(&self) -> &BudgetConfig {
154        &self.config
155    }
156}
157
158impl Default for CacheBudgetController {
159    fn default() -> Self {
160        Self::new()
161    }
162}
163
164/// Action to take when budget is exceeded
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub enum ClampAction {
167    /// No action needed, within budget
168    None,
169
170    /// Evict entries to get back within budget
171    Evict {
172        /// Number of entries to evict
173        count: usize,
174        /// Reason for clamping
175        reason: ClampReason,
176    },
177}
178
179/// Reason why clamping is needed
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub enum ClampReason {
182    /// Entry count limit exceeded
183    EntryLimit,
184
185    /// Memory limit exceeded
186    MemoryLimit,
187}
188
189/// Statistics about current budget usage
190#[derive(Debug, Clone)]
191pub struct BudgetStats {
192    /// Current total entries
193    pub total_entries: usize,
194
195    /// Estimated current memory usage
196    pub estimated_memory_bytes: usize,
197
198    /// Number of times clamping was performed
199    pub clamp_count: usize,
200
201    /// Maximum allowed entries
202    pub max_entries: usize,
203
204    /// Maximum allowed memory
205    pub max_memory_bytes: usize,
206}
207
208impl BudgetStats {
209    /// Calculate entry utilization as a percentage (0.0-1.0)
210    #[must_use]
211    #[allow(
212        clippy::cast_precision_loss,
213        reason = "Utilization percentages are informational; precision is sufficient"
214    )]
215    pub fn entry_utilization(&self) -> f64 {
216        if self.max_entries == 0 {
217            0.0
218        } else {
219            self.total_entries as f64 / self.max_entries as f64
220        }
221    }
222
223    /// Calculate memory utilization as a percentage (0.0-1.0)
224    #[must_use]
225    #[allow(
226        clippy::cast_precision_loss,
227        reason = "Utilization percentages are informational; precision is sufficient"
228    )]
229    pub fn memory_utilization(&self) -> f64 {
230        if self.max_memory_bytes == 0 {
231            0.0
232        } else {
233            self.estimated_memory_bytes as f64 / self.max_memory_bytes as f64
234        }
235    }
236
237    /// Check if budget is exceeded
238    #[must_use]
239    pub fn is_over_budget(&self) -> bool {
240        self.total_entries > self.max_entries || self.estimated_memory_bytes > self.max_memory_bytes
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use approx::assert_abs_diff_eq;
248
249    #[test]
250    fn test_default_config() {
251        let config = BudgetConfig::default();
252        assert_eq!(config.max_entries, 10_000);
253        assert_eq!(config.max_memory_bytes, 100 * 1024 * 1024);
254        assert_eq!(config.estimated_symbol_size, 512);
255    }
256
257    #[test]
258    fn test_record_insert() {
259        let controller = CacheBudgetController::new();
260
261        controller.record_insert(10, 5120);
262
263        let stats = controller.stats();
264        assert_eq!(stats.total_entries, 10);
265        assert_eq!(stats.estimated_memory_bytes, 5120);
266    }
267
268    #[test]
269    fn test_record_remove() {
270        let controller = CacheBudgetController::new();
271
272        controller.record_insert(20, 10240);
273        controller.record_remove(5, 2560);
274
275        let stats = controller.stats();
276        assert_eq!(stats.total_entries, 15);
277        assert_eq!(stats.estimated_memory_bytes, 7680);
278    }
279
280    #[test]
281    fn test_budget_within_limits() {
282        let config = BudgetConfig {
283            max_entries: 100,
284            max_memory_bytes: 10240,
285            ..Default::default()
286        };
287        let controller = CacheBudgetController::with_config(config);
288
289        controller.record_insert(50, 5000);
290
291        let action = controller.check_budget();
292        assert_eq!(action, ClampAction::None);
293    }
294
295    #[test]
296    fn test_budget_entry_limit_exceeded() {
297        let config = BudgetConfig {
298            max_entries: 100,
299            max_memory_bytes: 100_000,
300            ..Default::default()
301        };
302        let controller = CacheBudgetController::with_config(config);
303
304        controller.record_insert(150, 5000);
305
306        let action = controller.check_budget();
307        match action {
308            ClampAction::Evict { count, reason } => {
309                assert_eq!(count, 50);
310                assert_eq!(reason, ClampReason::EntryLimit);
311            }
312            ClampAction::None => panic!("Expected eviction"),
313        }
314    }
315
316    #[test]
317    fn test_budget_memory_limit_exceeded() {
318        let config = BudgetConfig {
319            max_entries: 1000,
320            max_memory_bytes: 10_000,
321            estimated_symbol_size: 512,
322            ..Default::default()
323        };
324        let controller = CacheBudgetController::with_config(config);
325
326        controller.record_insert(50, 15_000);
327
328        let action = controller.check_budget();
329        match action {
330            ClampAction::Evict { count, reason } => {
331                assert!(count > 0);
332                assert_eq!(reason, ClampReason::MemoryLimit);
333            }
334            ClampAction::None => panic!("Expected eviction"),
335        }
336    }
337
338    #[test]
339    fn test_clamp_count_tracking() {
340        let controller = CacheBudgetController::new();
341
342        assert_eq!(controller.stats().clamp_count, 0);
343
344        controller.record_clamp();
345        controller.record_clamp();
346
347        assert_eq!(controller.stats().clamp_count, 2);
348    }
349
350    #[test]
351    fn test_reset() {
352        let controller = CacheBudgetController::new();
353
354        controller.record_insert(100, 5000);
355        controller.record_clamp();
356
357        controller.reset();
358
359        let stats = controller.stats();
360        assert_eq!(stats.total_entries, 0);
361        assert_eq!(stats.estimated_memory_bytes, 0);
362        assert_eq!(stats.clamp_count, 1); // Clamp count not reset
363    }
364
365    #[test]
366    fn test_budget_stats_utilization() {
367        let config = BudgetConfig {
368            max_entries: 100,
369            max_memory_bytes: 10_000,
370            ..Default::default()
371        };
372        let controller = CacheBudgetController::with_config(config);
373
374        controller.record_insert(50, 5_000);
375
376        let stats = controller.stats();
377        assert_abs_diff_eq!(stats.entry_utilization(), 0.5, epsilon = 1e-10);
378        assert_abs_diff_eq!(stats.memory_utilization(), 0.5, epsilon = 1e-10);
379        assert!(!stats.is_over_budget());
380    }
381
382    #[test]
383    fn test_budget_stats_over_budget() {
384        let config = BudgetConfig {
385            max_entries: 100,
386            max_memory_bytes: 10_000,
387            ..Default::default()
388        };
389        let controller = CacheBudgetController::with_config(config);
390
391        controller.record_insert(150, 5_000);
392
393        let stats = controller.stats();
394        assert!(stats.is_over_budget());
395        assert!(stats.entry_utilization() > 1.0);
396    }
397
398    #[test]
399    fn test_multiple_inserts_and_removes() {
400        let controller = CacheBudgetController::new();
401
402        controller.record_insert(10, 1000);
403        controller.record_insert(20, 2000);
404        controller.record_remove(5, 500);
405        controller.record_insert(15, 1500);
406        controller.record_remove(10, 1000);
407
408        let stats = controller.stats();
409        assert_eq!(stats.total_entries, 30);
410        assert_eq!(stats.estimated_memory_bytes, 3000);
411    }
412}