use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::Instant;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArtifactMemoryPressureSnapshot {
pub resident_bytes: u64,
pub max_resident_bytes: u64,
pub hot_resident_bytes: u64,
pub cold_resident_bytes: u64,
pub spill_eligible_bytes: u64,
pub remote_numa_bytes: u64,
pub pressure_bps: u16,
pub high_pressure: bool,
pub duplicate_bytes_avoided: u64,
pub artifact_count: u32,
}
impl Default for ArtifactMemoryPressureSnapshot {
fn default() -> Self {
Self {
resident_bytes: 0,
max_resident_bytes: 1024 * 1024 * 1024, hot_resident_bytes: 0,
cold_resident_bytes: 0,
spill_eligible_bytes: 0,
remote_numa_bytes: 0,
pressure_bps: 0,
high_pressure: false,
duplicate_bytes_avoided: 0,
artifact_count: 0,
}
}
}
impl ArtifactMemoryPressureSnapshot {
#[must_use]
pub fn now() -> Self {
Self::default()
}
#[must_use]
pub fn pressure_ratio(&self) -> f64 {
f64::from(self.pressure_bps) / 10_000.0
}
#[must_use]
pub fn utilization_ratio(&self) -> f64 {
if self.max_resident_bytes == 0 {
0.0
} else {
self.resident_bytes as f64 / self.max_resident_bytes as f64
}
}
#[must_use]
pub const fn is_under_pressure(&self) -> bool {
self.high_pressure
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArtifactCacheConfig {
pub max_cache_size_bytes: u64,
pub eviction_threshold_ratio: u32, pub default_ttl_secs: u64,
pub max_artifact_count: u32,
pub numa_aware: bool,
pub eviction_policy: EvictionPolicy,
}
impl Default for ArtifactCacheConfig {
fn default() -> Self {
Self {
max_cache_size_bytes: 1024 * 1024 * 1024, eviction_threshold_ratio: 7500, default_ttl_secs: 3600, max_artifact_count: 10_000,
numa_aware: true,
eviction_policy: EvictionPolicy::LruWithTtl,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum EvictionPolicy {
LruWithTtl,
Mru,
LargestFirst,
Random,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArtifactMetadata {
pub id: String,
pub size_bytes: u64,
pub cached_at_nanos: u64,
pub last_accessed_nanos: u64,
pub access_count: u32,
pub expires_at_nanos: u64,
pub numa_node_hint: Option<u8>,
pub priority: u8,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CacheStatistics {
pub total_hits: u64,
pub total_misses: u64,
pub total_evictions: u64,
pub total_stored: u64,
pub current_hit_rate_bps: u16,
pub avg_access_time_nanos: u64,
pub peak_memory_bytes: u64,
}
#[derive(Debug)]
pub struct ArtifactCache {
config: ArtifactCacheConfig,
metadata: HashMap<String, ArtifactMetadata>,
data: HashMap<String, Vec<u8>>,
statistics: CacheStatistics,
current_size_bytes: u64,
}
impl ArtifactCache {
#[must_use]
pub fn new(config: ArtifactCacheConfig) -> Self {
Self {
config,
metadata: HashMap::new(),
data: HashMap::new(),
statistics: CacheStatistics::default(),
current_size_bytes: 0,
}
}
#[must_use]
pub fn default_config() -> Self {
Self::new(ArtifactCacheConfig::default())
}
#[must_use]
pub fn memory_pressure_snapshot(&self) -> ArtifactMemoryPressureSnapshot {
let threshold_nanos =
(Instant::now().elapsed().as_nanos() as u64).saturating_sub(300_000_000_000); let (hot_bytes, cold_bytes) = self.metadata.values().fold((0u64, 0u64), |acc, meta| {
if meta.last_accessed_nanos > threshold_nanos {
(acc.0 + meta.size_bytes, acc.1)
} else {
(acc.0, acc.1 + meta.size_bytes)
}
});
let utilization = if self.config.max_cache_size_bytes == 0 {
0.0
} else {
self.current_size_bytes as f64 / self.config.max_cache_size_bytes as f64
};
let pressure_bps = (utilization * 10_000.0).min(10_000.0) as u16;
let high_pressure = pressure_bps >= 7_500;
let spill_eligible_bytes = cold_bytes.min(self.current_size_bytes / 2);
ArtifactMemoryPressureSnapshot {
resident_bytes: self.current_size_bytes,
max_resident_bytes: self.config.max_cache_size_bytes,
hot_resident_bytes: hot_bytes,
cold_resident_bytes: cold_bytes,
spill_eligible_bytes,
remote_numa_bytes: 0, pressure_bps,
high_pressure,
duplicate_bytes_avoided: 0, artifact_count: self.metadata.len() as u32,
}
}
#[must_use]
pub fn contains(&self, id: &str) -> bool {
self.metadata.contains_key(id)
}
#[must_use]
pub const fn statistics(&self) -> &CacheStatistics {
&self.statistics
}
#[must_use]
pub const fn config(&self) -> &ArtifactCacheConfig {
&self.config
}
#[must_use]
pub fn len(&self) -> usize {
self.metadata.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.metadata.is_empty()
}
#[must_use]
pub const fn current_size_bytes(&self) -> u64 {
self.current_size_bytes
}
pub fn get(&mut self, id: &str) -> Option<&[u8]> {
let current_time_nanos = self.current_time_nanos();
if let Some(meta) = self.metadata.get_mut(id) {
if meta.expires_at_nanos > current_time_nanos {
meta.last_accessed_nanos = current_time_nanos;
meta.access_count = meta.access_count.saturating_add(1);
self.statistics.total_hits = self.statistics.total_hits.saturating_add(1);
self.data.get(id).map(|v| v.as_slice())
} else {
self.remove_expired(id);
self.statistics.total_misses = self.statistics.total_misses.saturating_add(1);
None
}
} else {
self.statistics.total_misses = self.statistics.total_misses.saturating_add(1);
None
}
}
pub fn put(&mut self, id: String, data: Vec<u8>) -> bool {
let current_time_nanos = self.current_time_nanos();
let artifact_size = data.len() as u64;
if !self.ensure_capacity_for(artifact_size) {
return false;
}
if self.metadata.contains_key(&id) {
self.remove_internal(&id);
}
let metadata = ArtifactMetadata {
id: id.clone(),
size_bytes: artifact_size,
cached_at_nanos: current_time_nanos,
last_accessed_nanos: current_time_nanos,
access_count: 0,
expires_at_nanos: current_time_nanos + (self.config.default_ttl_secs * 1_000_000_000),
numa_node_hint: None, priority: 128, };
self.data.insert(id.clone(), data);
self.metadata.insert(id, metadata);
self.current_size_bytes += artifact_size;
self.statistics.total_stored = self.statistics.total_stored.saturating_add(1);
true
}
pub fn remove(&mut self, id: &str) -> bool {
self.remove_internal(id)
}
pub fn evict(&mut self, target_bytes: u64) -> u32 {
let mut evicted_count = 0;
let mut evicted_bytes = 0u64;
let mut candidates: Vec<_> = self
.metadata
.iter()
.map(|(id, meta)| (id.clone(), meta.clone()))
.collect();
match self.config.eviction_policy {
EvictionPolicy::LruWithTtl => {
candidates.sort_by_key(|(_, meta)| meta.last_accessed_nanos);
}
EvictionPolicy::Mru => {
candidates.sort_by_key(|(_, meta)| std::cmp::Reverse(meta.last_accessed_nanos));
}
EvictionPolicy::LargestFirst => {
candidates.sort_by_key(|(_, meta)| std::cmp::Reverse(meta.size_bytes));
}
EvictionPolicy::Random => {
candidates.sort_by_key(|(id, _)| id.len());
}
}
for (id, meta) in candidates {
if evicted_bytes >= target_bytes {
break;
}
evicted_bytes += meta.size_bytes;
self.remove_internal(&id);
evicted_count += 1;
}
self.statistics.total_evictions = self
.statistics
.total_evictions
.saturating_add(u64::from(evicted_count));
evicted_count
}
pub fn invalidate_expired(&mut self) -> u32 {
let current_time_nanos = self.current_time_nanos();
let mut invalidated_count = 0;
let expired_ids: Vec<String> = self
.metadata
.iter()
.filter_map(|(id, meta)| {
if meta.expires_at_nanos <= current_time_nanos {
Some(id.clone())
} else {
None
}
})
.collect();
for id in expired_ids {
self.remove_internal(&id);
invalidated_count += 1;
}
invalidated_count
}
pub fn clear(&mut self) {
self.metadata.clear();
self.data.clear();
self.current_size_bytes = 0;
}
fn remove_internal(&mut self, id: &str) -> bool {
if let Some(meta) = self.metadata.remove(id) {
self.data.remove(id);
self.current_size_bytes = self.current_size_bytes.saturating_sub(meta.size_bytes);
true
} else {
false
}
}
fn remove_expired(&mut self, id: &str) {
self.remove_internal(id);
}
fn ensure_capacity_for(&mut self, needed_bytes: u64) -> bool {
self.invalidate_expired();
let available_bytes = self
.config
.max_cache_size_bytes
.saturating_sub(self.current_size_bytes);
if available_bytes >= needed_bytes {
return true;
}
let bytes_to_free = needed_bytes.saturating_sub(available_bytes);
let eviction_threshold = (self.config.max_cache_size_bytes
* u64::from(self.config.eviction_threshold_ratio))
/ 10_000;
let target_eviction = if self.current_size_bytes > eviction_threshold {
bytes_to_free + (self.current_size_bytes / 4) } else {
bytes_to_free
};
self.evict(target_eviction);
let final_available = self
.config
.max_cache_size_bytes
.saturating_sub(self.current_size_bytes);
final_available >= needed_bytes
}
fn current_time_nanos(&self) -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos() as u64)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn artifact_memory_pressure_snapshot_default() {
let snapshot = ArtifactMemoryPressureSnapshot::default();
assert_eq!(snapshot.resident_bytes, 0);
assert_eq!(snapshot.artifact_count, 0);
assert_eq!(snapshot.pressure_bps, 0);
assert!(!snapshot.is_under_pressure());
assert_eq!(snapshot.pressure_ratio(), 0.0);
assert_eq!(snapshot.utilization_ratio(), 0.0);
}
#[test]
fn artifact_memory_pressure_snapshot_calculations() {
let mut snapshot = ArtifactMemoryPressureSnapshot::default();
snapshot.pressure_bps = 7500; snapshot.resident_bytes = 512 * 1024 * 1024; snapshot.max_resident_bytes = 1024 * 1024 * 1024; snapshot.high_pressure = true;
assert_eq!(snapshot.pressure_ratio(), 0.75);
assert_eq!(snapshot.utilization_ratio(), 0.5);
assert!(snapshot.is_under_pressure()); }
#[test]
fn artifact_cache_creation() {
let config = ArtifactCacheConfig::default();
let cache = ArtifactCache::new(config);
assert!(cache.is_empty());
assert_eq!(cache.len(), 0);
assert_eq!(cache.current_size_bytes(), 0);
}
#[test]
fn artifact_cache_put_and_get() {
let mut cache = ArtifactCache::default_config();
let test_data = b"test artifact data".to_vec();
let test_id = "test-artifact-1".to_string();
assert!(cache.put(test_id.clone(), test_data.clone()));
assert_eq!(cache.len(), 1);
assert_eq!(cache.current_size_bytes(), test_data.len() as u64);
assert!(cache.contains(&test_id));
let retrieved = cache.get(&test_id);
assert!(retrieved.is_some());
assert_eq!(
retrieved.expect("cache should contain stored artifact"),
test_data.as_slice()
);
let stats = cache.statistics();
assert_eq!(stats.total_hits, 1);
assert_eq!(stats.total_misses, 0);
assert_eq!(stats.total_stored, 1);
}
#[test]
fn artifact_cache_remove() {
let mut cache = ArtifactCache::default_config();
let test_data = b"test data".to_vec();
let test_id = "test-id".to_string();
cache.put(test_id.clone(), test_data);
assert!(cache.contains(&test_id));
assert!(cache.remove(&test_id));
assert!(!cache.contains(&test_id));
assert_eq!(cache.len(), 0);
assert_eq!(cache.current_size_bytes(), 0);
assert!(!cache.remove("non-existent"));
}
#[test]
fn artifact_cache_eviction() {
let config = ArtifactCacheConfig {
max_cache_size_bytes: 100, eviction_threshold_ratio: 5000, ..ArtifactCacheConfig::default()
};
let mut cache = ArtifactCache::new(config);
cache.put("item1".to_string(), vec![0u8; 40]);
cache.put("item2".to_string(), vec![1u8; 40]);
cache.put("item3".to_string(), vec![2u8; 40]);
assert!(cache.current_size_bytes() <= 100);
assert!(cache.len() <= 2); }
#[test]
fn artifact_cache_clear() {
let mut cache = ArtifactCache::default_config();
cache.put("item1".to_string(), vec![0u8; 10]);
cache.put("item2".to_string(), vec![1u8; 20]);
assert_eq!(cache.len(), 2);
assert_eq!(cache.current_size_bytes(), 30);
cache.clear();
assert!(cache.is_empty());
assert_eq!(cache.current_size_bytes(), 0);
}
#[test]
fn artifact_cache_miss() {
let mut cache = ArtifactCache::default_config();
let result = cache.get("non-existent");
assert!(result.is_none());
let stats = cache.statistics();
assert_eq!(stats.total_misses, 1);
assert_eq!(stats.total_hits, 0);
assert_eq!(cache.len(), 0);
assert_eq!(cache.current_size_bytes(), 0);
assert!(!cache.contains("test"));
}
#[test]
fn artifact_cache_memory_pressure_snapshot() {
let cache = ArtifactCache::default_config();
let snapshot = cache.memory_pressure_snapshot();
assert_eq!(snapshot.resident_bytes, 0);
assert_eq!(snapshot.artifact_count, 0);
assert_eq!(snapshot.pressure_bps, 0);
assert_eq!(snapshot.hot_resident_bytes, 0);
assert_eq!(snapshot.cold_resident_bytes, 0);
}
#[test]
fn eviction_policy_serialization() {
let policies = [
EvictionPolicy::LruWithTtl,
EvictionPolicy::Mru,
EvictionPolicy::LargestFirst,
EvictionPolicy::Random,
];
for policy in &policies {
let serialized =
serde_json::to_string(policy).expect("eviction policy should serialize to JSON");
let deserialized: EvictionPolicy = serde_json::from_str(&serialized)
.expect("serialized policy should deserialize from JSON");
assert_eq!(*policy, deserialized);
}
}
#[test]
fn cache_config_default_values() {
let config = ArtifactCacheConfig::default();
assert_eq!(config.max_cache_size_bytes, 1024 * 1024 * 1024);
assert_eq!(config.eviction_threshold_ratio, 7500);
assert_eq!(config.default_ttl_secs, 3600);
assert_eq!(config.max_artifact_count, 10_000);
assert!(config.numa_aware);
assert_eq!(config.eviction_policy, EvictionPolicy::LruWithTtl);
}
}