use std::sync::atomic::{AtomicUsize, Ordering};
#[derive(Debug, Clone)]
pub struct BudgetConfig {
pub max_entries: usize,
pub max_memory_bytes: usize,
pub estimated_symbol_size: usize,
pub estimated_parse_tree_size: usize,
}
impl Default for BudgetConfig {
fn default() -> Self {
Self {
max_entries: 10_000,
max_memory_bytes: 100 * 1024 * 1024, estimated_symbol_size: 512,
estimated_parse_tree_size: 2048,
}
}
}
pub struct CacheBudgetController {
config: BudgetConfig,
total_entries: AtomicUsize,
estimated_memory: AtomicUsize,
clamp_count: AtomicUsize,
}
impl CacheBudgetController {
#[must_use]
pub fn new() -> Self {
Self::with_config(BudgetConfig::default())
}
#[must_use]
pub fn with_config(config: BudgetConfig) -> Self {
Self {
config,
total_entries: AtomicUsize::new(0),
estimated_memory: AtomicUsize::new(0),
clamp_count: AtomicUsize::new(0),
}
}
pub fn record_insert(&self, entry_count: usize, estimated_bytes: usize) {
self.total_entries.fetch_add(entry_count, Ordering::Relaxed);
self.estimated_memory
.fetch_add(estimated_bytes, Ordering::Relaxed);
}
pub fn record_remove(&self, entry_count: usize, estimated_bytes: usize) {
self.total_entries.fetch_sub(entry_count, Ordering::Relaxed);
self.estimated_memory
.fetch_sub(estimated_bytes, Ordering::Relaxed);
}
pub fn check_budget(&self) -> ClampAction {
let entries = self.total_entries.load(Ordering::Relaxed);
let memory = self.estimated_memory.load(Ordering::Relaxed);
let entries_over = entries.saturating_sub(self.config.max_entries);
let memory_over = memory.saturating_sub(self.config.max_memory_bytes);
if entries_over > 0 || memory_over > 0 {
let entries_to_evict_for_count = entries_over;
let entries_to_evict_for_memory = if memory_over > 0 {
(memory_over / self.config.estimated_symbol_size).max(1)
} else {
0
};
let entries_to_evict = entries_to_evict_for_count.max(entries_to_evict_for_memory);
ClampAction::Evict {
count: entries_to_evict,
reason: if entries_over > memory_over {
ClampReason::EntryLimit
} else {
ClampReason::MemoryLimit
},
}
} else {
ClampAction::None
}
}
pub fn record_clamp(&self) {
self.clamp_count.fetch_add(1, Ordering::Relaxed);
}
pub fn stats(&self) -> BudgetStats {
BudgetStats {
total_entries: self.total_entries.load(Ordering::Relaxed),
estimated_memory_bytes: self.estimated_memory.load(Ordering::Relaxed),
clamp_count: self.clamp_count.load(Ordering::Relaxed),
max_entries: self.config.max_entries,
max_memory_bytes: self.config.max_memory_bytes,
}
}
pub fn reset(&self) {
self.total_entries.store(0, Ordering::Relaxed);
self.estimated_memory.store(0, Ordering::Relaxed);
}
pub fn config(&self) -> &BudgetConfig {
&self.config
}
}
impl Default for CacheBudgetController {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClampAction {
None,
Evict {
count: usize,
reason: ClampReason,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClampReason {
EntryLimit,
MemoryLimit,
}
#[derive(Debug, Clone)]
pub struct BudgetStats {
pub total_entries: usize,
pub estimated_memory_bytes: usize,
pub clamp_count: usize,
pub max_entries: usize,
pub max_memory_bytes: usize,
}
impl BudgetStats {
#[must_use]
#[allow(
clippy::cast_precision_loss,
reason = "Utilization percentages are informational; precision is sufficient"
)]
pub fn entry_utilization(&self) -> f64 {
if self.max_entries == 0 {
0.0
} else {
self.total_entries as f64 / self.max_entries as f64
}
}
#[must_use]
#[allow(
clippy::cast_precision_loss,
reason = "Utilization percentages are informational; precision is sufficient"
)]
pub fn memory_utilization(&self) -> f64 {
if self.max_memory_bytes == 0 {
0.0
} else {
self.estimated_memory_bytes as f64 / self.max_memory_bytes as f64
}
}
#[must_use]
pub fn is_over_budget(&self) -> bool {
self.total_entries > self.max_entries || self.estimated_memory_bytes > self.max_memory_bytes
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_abs_diff_eq;
#[test]
fn test_default_config() {
let config = BudgetConfig::default();
assert_eq!(config.max_entries, 10_000);
assert_eq!(config.max_memory_bytes, 100 * 1024 * 1024);
assert_eq!(config.estimated_symbol_size, 512);
}
#[test]
fn test_record_insert() {
let controller = CacheBudgetController::new();
controller.record_insert(10, 5120);
let stats = controller.stats();
assert_eq!(stats.total_entries, 10);
assert_eq!(stats.estimated_memory_bytes, 5120);
}
#[test]
fn test_record_remove() {
let controller = CacheBudgetController::new();
controller.record_insert(20, 10240);
controller.record_remove(5, 2560);
let stats = controller.stats();
assert_eq!(stats.total_entries, 15);
assert_eq!(stats.estimated_memory_bytes, 7680);
}
#[test]
fn test_budget_within_limits() {
let config = BudgetConfig {
max_entries: 100,
max_memory_bytes: 10240,
..Default::default()
};
let controller = CacheBudgetController::with_config(config);
controller.record_insert(50, 5000);
let action = controller.check_budget();
assert_eq!(action, ClampAction::None);
}
#[test]
fn test_budget_entry_limit_exceeded() {
let config = BudgetConfig {
max_entries: 100,
max_memory_bytes: 100_000,
..Default::default()
};
let controller = CacheBudgetController::with_config(config);
controller.record_insert(150, 5000);
let action = controller.check_budget();
match action {
ClampAction::Evict { count, reason } => {
assert_eq!(count, 50);
assert_eq!(reason, ClampReason::EntryLimit);
}
ClampAction::None => panic!("Expected eviction"),
}
}
#[test]
fn test_budget_memory_limit_exceeded() {
let config = BudgetConfig {
max_entries: 1000,
max_memory_bytes: 10_000,
estimated_symbol_size: 512,
..Default::default()
};
let controller = CacheBudgetController::with_config(config);
controller.record_insert(50, 15_000);
let action = controller.check_budget();
match action {
ClampAction::Evict { count, reason } => {
assert!(count > 0);
assert_eq!(reason, ClampReason::MemoryLimit);
}
ClampAction::None => panic!("Expected eviction"),
}
}
#[test]
fn test_clamp_count_tracking() {
let controller = CacheBudgetController::new();
assert_eq!(controller.stats().clamp_count, 0);
controller.record_clamp();
controller.record_clamp();
assert_eq!(controller.stats().clamp_count, 2);
}
#[test]
fn test_reset() {
let controller = CacheBudgetController::new();
controller.record_insert(100, 5000);
controller.record_clamp();
controller.reset();
let stats = controller.stats();
assert_eq!(stats.total_entries, 0);
assert_eq!(stats.estimated_memory_bytes, 0);
assert_eq!(stats.clamp_count, 1); }
#[test]
fn test_budget_stats_utilization() {
let config = BudgetConfig {
max_entries: 100,
max_memory_bytes: 10_000,
..Default::default()
};
let controller = CacheBudgetController::with_config(config);
controller.record_insert(50, 5_000);
let stats = controller.stats();
assert_abs_diff_eq!(stats.entry_utilization(), 0.5, epsilon = 1e-10);
assert_abs_diff_eq!(stats.memory_utilization(), 0.5, epsilon = 1e-10);
assert!(!stats.is_over_budget());
}
#[test]
fn test_budget_stats_over_budget() {
let config = BudgetConfig {
max_entries: 100,
max_memory_bytes: 10_000,
..Default::default()
};
let controller = CacheBudgetController::with_config(config);
controller.record_insert(150, 5_000);
let stats = controller.stats();
assert!(stats.is_over_budget());
assert!(stats.entry_utilization() > 1.0);
}
#[test]
fn test_multiple_inserts_and_removes() {
let controller = CacheBudgetController::new();
controller.record_insert(10, 1000);
controller.record_insert(20, 2000);
controller.record_remove(5, 500);
controller.record_insert(15, 1500);
controller.record_remove(10, 1000);
let stats = controller.stats();
assert_eq!(stats.total_entries, 30);
assert_eq!(stats.estimated_memory_bytes, 3000);
}
}