Files
clawhdf5/crates/clawhdf5-format/src/metadata_cache.rs
T

275 lines
7.7 KiB
Rust

//! Metadata cache for parsed object headers and B-tree nodes.
//!
//! The [`MetadataCache`] avoids re-parsing object headers and other metadata
//! structures on repeated accesses. It uses a HashMap keyed by file offset
//! with LRU eviction controlled by a byte budget.
#[cfg(feature = "std")]
use core::fmt;
#[cfg(feature = "std")]
use std::collections::HashMap;
/// Default metadata cache size: 2 MiB.
pub const DEFAULT_METADATA_CACHE_BYTES: usize = 2 * 1024 * 1024;
/// Maximum metadata cache size: 32 MiB.
pub const MAX_METADATA_CACHE_BYTES: usize = 32 * 1024 * 1024;
/// A cached metadata entry (opaque bytes representing a parsed structure).
#[cfg(feature = "std")]
struct CacheEntry {
/// Cached data (serialized or parsed form).
data: Vec<u8>,
/// Approximate size in bytes for budget tracking.
approx_size: usize,
/// Monotonic access counter for LRU.
last_access: u64,
}
/// A metadata cache with LRU eviction.
///
/// Caches parsed object headers and B-tree nodes keyed by file offset.
/// Thread-safe via internal `Mutex`.
///
/// Only available with the `std` feature because it requires `std::sync::Mutex`.
#[cfg(feature = "std")]
pub struct MetadataCache {
inner: std::sync::Mutex<CacheInner>,
}
#[cfg(feature = "std")]
struct CacheInner {
entries: HashMap<u64, CacheEntry>,
current_bytes: usize,
max_bytes: usize,
tick: u64,
hits: u64,
misses: u64,
}
#[cfg(feature = "std")]
impl MetadataCache {
/// Create a new metadata cache with the default size (2 MiB).
pub fn new() -> Self {
Self::with_capacity(DEFAULT_METADATA_CACHE_BYTES)
}
/// Create a new metadata cache with a custom byte budget.
///
/// Clamped to [`MAX_METADATA_CACHE_BYTES`].
pub fn with_capacity(max_bytes: usize) -> Self {
let max_bytes = max_bytes.min(MAX_METADATA_CACHE_BYTES);
Self {
inner: std::sync::Mutex::new(CacheInner {
entries: HashMap::new(),
current_bytes: 0,
max_bytes,
tick: 0,
hits: 0,
misses: 0,
}),
}
}
/// Look up cached metadata by file offset.
///
/// Returns a clone of the cached bytes, or `None` if not present.
pub fn get(&self, offset: u64) -> Option<Vec<u8>> {
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
inner.tick += 1;
let tick = inner.tick;
let result = if let Some(entry) = inner.entries.get_mut(&offset) {
entry.last_access = tick;
Some(entry.data.clone())
} else {
None
};
if result.is_some() {
inner.hits += 1;
} else {
inner.misses += 1;
}
result
}
/// Insert metadata into the cache.
///
/// If the entry already exists, it is updated. LRU eviction occurs
/// if the cache exceeds its byte budget.
pub fn put(&self, offset: u64, data: Vec<u8>) {
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let approx_size = data.len() + 64; // overhead estimate
// Don't cache if single entry exceeds budget
if approx_size > inner.max_bytes {
return;
}
inner.tick += 1;
let tick = inner.tick;
// Remove existing entry if present
if let Some(old) = inner.entries.remove(&offset) {
inner.current_bytes -= old.approx_size;
}
// Evict until we have room
while inner.current_bytes + approx_size > inner.max_bytes && !inner.entries.is_empty() {
// Find LRU entry
let lru_key = inner
.entries
.iter()
.min_by_key(|(_, e)| e.last_access)
.map(|(&k, _)| k)
.unwrap();
let removed = inner.entries.remove(&lru_key).unwrap();
inner.current_bytes -= removed.approx_size;
}
inner.current_bytes += approx_size;
inner.entries.insert(
offset,
CacheEntry {
data,
approx_size,
last_access: tick,
},
);
}
/// Clear all cached entries.
pub fn clear(&self) {
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
inner.entries.clear();
inner.current_bytes = 0;
inner.tick = 0;
}
/// Number of cached entries.
pub fn len(&self) -> usize {
self.inner
.lock()
.unwrap_or_else(|e| e.into_inner())
.entries
.len()
}
/// Whether the cache is empty.
pub fn is_empty(&self) -> bool {
self.inner
.lock()
.unwrap_or_else(|e| e.into_inner())
.entries
.is_empty()
}
/// Current bytes used by cached entries.
pub fn current_bytes(&self) -> usize {
self.inner
.lock()
.unwrap_or_else(|e| e.into_inner())
.current_bytes
}
/// Cache hit count.
pub fn hits(&self) -> u64 {
self.inner.lock().unwrap_or_else(|e| e.into_inner()).hits
}
/// Cache miss count.
pub fn misses(&self) -> u64 {
self.inner.lock().unwrap_or_else(|e| e.into_inner()).misses
}
/// Hit rate as a fraction in [0.0, 1.0].
pub fn hit_rate(&self) -> f64 {
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let total = inner.hits + inner.misses;
if total == 0 {
0.0
} else {
inner.hits as f64 / total as f64
}
}
}
#[cfg(feature = "std")]
impl Default for MetadataCache {
fn default() -> Self {
Self::new()
}
}
#[cfg(feature = "std")]
impl fmt::Debug for MetadataCache {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
f.debug_struct("MetadataCache")
.field("entries", &inner.entries.len())
.field("current_bytes", &inner.current_bytes)
.field("max_bytes", &inner.max_bytes)
.field("hits", &inner.hits)
.field("misses", &inner.misses)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn basic_put_get() {
let cache = MetadataCache::new();
cache.put(100, vec![1, 2, 3]);
assert_eq!(cache.get(100), Some(vec![1, 2, 3]));
assert_eq!(cache.get(200), None);
}
#[test]
fn lru_eviction() {
let cache = MetadataCache::with_capacity(256);
// Each entry ~64 + data_len bytes overhead
cache.put(1, vec![0; 64]); // ~128 bytes
cache.put(2, vec![0; 64]); // ~128 bytes = 256 total
// Access entry 1 to make it more recent
cache.get(1);
// Adding entry 3 should evict entry 2 (LRU)
cache.put(3, vec![0; 64]);
assert!(cache.get(1).is_some());
assert!(cache.get(2).is_none()); // evicted
assert!(cache.get(3).is_some());
}
#[test]
fn hit_miss_tracking() {
let cache = MetadataCache::new();
cache.put(1, vec![1]);
cache.get(1); // hit
cache.get(2); // miss
cache.get(1); // hit
assert_eq!(cache.hits(), 2);
assert_eq!(cache.misses(), 1);
assert!((cache.hit_rate() - 2.0 / 3.0).abs() < 1e-10);
}
#[test]
fn clear_resets() {
let cache = MetadataCache::new();
cache.put(1, vec![1, 2, 3]);
cache.clear();
assert!(cache.is_empty());
assert_eq!(cache.current_bytes(), 0);
}
#[test]
fn oversized_entry_not_cached() {
let cache = MetadataCache::with_capacity(100);
cache.put(1, vec![0; 200]); // too big
assert!(cache.is_empty());
}
}