1use std::sync::atomic::{AtomicUsize, Ordering};
8
9#[derive(Debug, Clone)]
11pub struct BudgetConfig {
12 pub max_entries: usize,
14
15 pub max_memory_bytes: usize,
18
19 pub estimated_symbol_size: usize,
22
23 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, estimated_symbol_size: 512,
33 estimated_parse_tree_size: 2048,
34 }
35 }
36}
37
38pub struct CacheBudgetController {
40 config: BudgetConfig,
41
42 total_entries: AtomicUsize,
44
45 estimated_memory: AtomicUsize,
47
48 clamp_count: AtomicUsize,
50}
51
52impl CacheBudgetController {
53 #[must_use]
55 pub fn new() -> Self {
56 Self::with_config(BudgetConfig::default())
57 }
58
59 #[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 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 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 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 let entries_to_evict_for_count = entries_over;
107 let entries_to_evict_for_memory = if memory_over > 0 {
108 (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 pub fn record_clamp(&self) {
131 self.clamp_count.fetch_add(1, Ordering::Relaxed);
132 }
133
134 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 pub fn reset(&self) {
147 self.total_entries.store(0, Ordering::Relaxed);
148 self.estimated_memory.store(0, Ordering::Relaxed);
149 }
151
152 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#[derive(Debug, Clone, PartialEq, Eq)]
166pub enum ClampAction {
167 None,
169
170 Evict {
172 count: usize,
174 reason: ClampReason,
176 },
177}
178
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub enum ClampReason {
182 EntryLimit,
184
185 MemoryLimit,
187}
188
189#[derive(Debug, Clone)]
191pub struct BudgetStats {
192 pub total_entries: usize,
194
195 pub estimated_memory_bytes: usize,
197
198 pub clamp_count: usize,
200
201 pub max_entries: usize,
203
204 pub max_memory_bytes: usize,
206}
207
208impl BudgetStats {
209 #[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 #[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 #[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); }
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}