use std::{
collections::HashSet,
env::temp_dir,
fmt::{self, Debug},
path::PathBuf,
sync::atomic::AtomicU64,
};
use crate::supertable::manifest::SuperfileUri;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ColdFetchMode {
HybridWithPrefetch,
RangeOnly,
#[default]
LazyForegroundWithBackgroundFill,
}
pub struct DiskCacheConfig {
pub cache_root: PathBuf,
pub disk_budget_bytes: u64,
pub cold_fetch_mode: ColdFetchMode,
pub cold_fetch_streams: usize,
pub cold_fetch_chunk_bytes: u64,
pub prefetch_concurrency: usize,
pub mmap_cold_threshold_secs: u64,
pub mmap_sweep_interval_secs: u64,
pub eviction: Box<dyn CacheEvictionPolicy>,
pub verify_crc_on_open: bool,
}
const DEFAULT_DISK_BUDGET_BYTES: u64 = 10 * (1 << 30);
const DEFAULT_COLD_FETCH_STREAMS: usize = 16;
const DEFAULT_COLD_FETCH_CHUNK_BYTES: u64 = 16 * (1 << 20);
const DEFAULT_PREFETCH_CONCURRENCY: usize = 8;
const DEFAULT_MMAP_COLD_THRESHOLD_SECS: u64 = 300;
const DEFAULT_MMAP_SWEEP_INTERVAL_SECS: u64 = 75;
impl Default for DiskCacheConfig {
fn default() -> Self {
Self {
cache_root: temp_dir().join("infino-disk-cache"),
disk_budget_bytes: DEFAULT_DISK_BUDGET_BYTES,
cold_fetch_mode: ColdFetchMode::default(),
cold_fetch_streams: DEFAULT_COLD_FETCH_STREAMS,
cold_fetch_chunk_bytes: DEFAULT_COLD_FETCH_CHUNK_BYTES,
prefetch_concurrency: DEFAULT_PREFETCH_CONCURRENCY,
mmap_cold_threshold_secs: DEFAULT_MMAP_COLD_THRESHOLD_SECS,
mmap_sweep_interval_secs: DEFAULT_MMAP_SWEEP_INTERVAL_SECS,
eviction: Box::new(LruPolicy::new()),
verify_crc_on_open: true,
}
}
}
impl Debug for DiskCacheConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DiskCacheConfig")
.field("cache_root", &self.cache_root)
.field("disk_budget_bytes", &self.disk_budget_bytes)
.field("cold_fetch_mode", &self.cold_fetch_mode)
.field("cold_fetch_streams", &self.cold_fetch_streams)
.field("cold_fetch_chunk_bytes", &self.cold_fetch_chunk_bytes)
.field("prefetch_concurrency", &self.prefetch_concurrency)
.field("mmap_cold_threshold_secs", &self.mmap_cold_threshold_secs)
.field("mmap_sweep_interval_secs", &self.mmap_sweep_interval_secs)
.field("eviction", &"<dyn CacheEvictionPolicy>")
.finish()
}
}
#[derive(Debug, Clone)]
pub struct EvictionCandidate {
pub uri: SuperfileUri,
pub size_bytes: u64,
pub last_access_us: u64,
}
pub trait CacheEvictionPolicy: Send + Sync {
fn select_for_eviction(
&self,
candidates: &[EvictionCandidate],
pinned: &HashSet<SuperfileUri>,
bytes_needed: u64,
) -> Vec<SuperfileUri>;
}
#[derive(Debug, Default)]
pub struct LruPolicy {
_seq: AtomicU64,
}
impl LruPolicy {
pub fn new() -> Self {
Self::default()
}
}
impl CacheEvictionPolicy for LruPolicy {
fn select_for_eviction(
&self,
candidates: &[EvictionCandidate],
pinned: &HashSet<SuperfileUri>,
bytes_needed: u64,
) -> Vec<SuperfileUri> {
let mut eligible: Vec<&EvictionCandidate> = candidates
.iter()
.filter(|c| !pinned.contains(&c.uri))
.collect();
eligible.sort_by_key(|c| c.last_access_us);
let mut victims = Vec::new();
let mut freed = 0u64;
for c in eligible {
if freed >= bytes_needed {
break;
}
victims.push(c.uri);
freed = freed.saturating_add(c.size_bytes);
}
if freed < bytes_needed {
Vec::new()
} else {
victims
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const CANDIDATE_BYTES: u64 = 100;
fn candidate(last_access_us: u64) -> EvictionCandidate {
EvictionCandidate {
uri: SuperfileUri::new_v4(),
size_bytes: CANDIDATE_BYTES,
last_access_us,
}
}
#[test]
fn lru_evicts_oldest_first_and_stops_once_freed() {
let policy = LruPolicy::new();
let (old, mid, new) = (candidate(10), candidate(20), candidate(30));
let victims = policy.select_for_eviction(
&[mid.clone(), new.clone(), old.clone()],
&HashSet::new(),
2 * CANDIDATE_BYTES,
);
assert_eq!(victims, vec![old.uri, mid.uri]);
}
#[test]
fn lru_never_selects_pinned_entries() {
let policy = LruPolicy::new();
let (old, mid, new) = (candidate(10), candidate(20), candidate(30));
let pinned: HashSet<SuperfileUri> = [old.uri].into_iter().collect();
let victims = policy.select_for_eviction(
&[old.clone(), mid.clone(), new.clone()],
&pinned,
CANDIDATE_BYTES,
);
assert_eq!(victims, vec![mid.uri]);
}
#[test]
fn lru_returns_empty_when_eligible_bytes_cannot_cover_request() {
let policy = LruPolicy::new();
let (old, new) = (candidate(10), candidate(20));
let pinned: HashSet<SuperfileUri> = [new.uri].into_iter().collect();
let victims =
policy.select_for_eviction(&[old.clone(), new.clone()], &pinned, 2 * CANDIDATE_BYTES);
assert!(victims.is_empty(), "partial frees must select nothing");
}
#[test]
fn default_config_matches_documented_defaults() {
let config = DiskCacheConfig::default();
assert_eq!(config.disk_budget_bytes, DEFAULT_DISK_BUDGET_BYTES);
assert_eq!(
config.cold_fetch_mode,
ColdFetchMode::LazyForegroundWithBackgroundFill
);
assert_eq!(config.cold_fetch_streams, DEFAULT_COLD_FETCH_STREAMS);
assert_eq!(
config.cold_fetch_chunk_bytes,
DEFAULT_COLD_FETCH_CHUNK_BYTES
);
assert_eq!(config.prefetch_concurrency, DEFAULT_PREFETCH_CONCURRENCY);
assert_eq!(
config.mmap_cold_threshold_secs,
DEFAULT_MMAP_COLD_THRESHOLD_SECS
);
assert_eq!(
config.mmap_sweep_interval_secs,
DEFAULT_MMAP_SWEEP_INTERVAL_SECS
);
assert!(config.verify_crc_on_open);
let rendered = format!("{config:?}");
assert!(rendered.contains("disk_budget_bytes"));
assert!(rendered.contains("cold_fetch_mode"));
assert!(rendered.contains("<dyn CacheEvictionPolicy>"));
}
#[test]
fn disk_cache_config_debug_elides_the_policy() {
let cfg = DiskCacheConfig::default();
let debug = format!("{cfg:?}");
for field in [
"cache_root",
"disk_budget_bytes",
"cold_fetch_mode",
"prefetch_concurrency",
"<dyn CacheEvictionPolicy>",
] {
assert!(debug.contains(field), "Debug must print {field}: {debug}");
}
assert!(cfg.verify_crc_on_open, "CRC verification defaults on");
}
}