use std::collections::{HashMap, HashSet};
use std::hash::{Hash, Hasher};
use std::path::Path;
use std::sync::Arc;
use std::sync::Weak;
use std::time::{Duration, Instant};
use async_trait::async_trait;
use bytes::Bytes;
use foyer_common::event::{Event, EventListener};
use foyer_memory::{Cache, CacheBuilder, LfuConfig, LruConfig, S3FifoConfig};
use tokio::sync::{mpsc, RwLock, Semaphore};
use tracing::{debug, warn};
use xxhash_rust::xxh3::Xxh3Default;
use crate::cache::allocator::{Allocator, HashAllocator};
use crate::cache::metric_name as mn;
use crate::cache::options::CacheManagerOptions;
use crate::cache::page_id::{CacheScope, PageId, PageInfo};
#[cfg(all(target_os = "linux", feature = "page-cache-io-uring"))]
use crate::cache::store::UringPageStore;
use crate::cache::store::{init_uring_config, is_uring_available, LocalPageStore, PageStore};
use crate::cache::{CacheManager, CacheState, PageReadRequest};
use crate::config::{CacheEvictorType, GoosefsConfig};
use crate::error::Result;
use crate::metrics::{counter, gauge};
use futures::future::join_all;
const LOCK_SIZE: usize = 1024;
const MIN_PAGES_PER_SHARD: u64 = 4;
const MAX_SHARDS: usize = 256;
const REAP_QUEUE_CAP: usize = 1024;
#[derive(Debug)]
struct ReapTask {
page_id: PageId,
page_size: u64,
dir_index: usize,
}
struct ReapListener {
tx: mpsc::Sender<ReapTask>,
dir_index: usize,
}
impl EventListener for ReapListener {
type Key = PageId;
type Value = PageInfo;
fn on_leave(&self, event: Event, key: &Self::Key, value: &Self::Value) {
if event != Event::Evict {
return;
}
let task = ReapTask {
page_id: key.clone(),
page_size: value.page_size,
dir_index: self.dir_index,
};
if self.tx.try_send(task).is_err() {
counter(mn::CLIENT_CACHE_REAP_DROPPED).inc(1);
}
}
}
fn shard_count(dir_capacity: u64, page_size: u64) -> usize {
if page_size == 0 {
return 1;
}
let pages = dir_capacity / page_size;
((pages / MIN_PAGES_PER_SHARD).max(1) as usize).min(MAX_SHARDS)
}
fn build_page_cache(
policy: CacheEvictorType,
dir_capacity: u64,
page_size: u64,
reap_tx: mpsc::Sender<ReapTask>,
dir_index: usize,
) -> Cache<PageId, PageInfo> {
let shards = shard_count(dir_capacity, page_size);
let capacity = usize::try_from(dir_capacity).unwrap_or(usize::MAX);
let builder = CacheBuilder::new(capacity)
.with_name("goosefs-page-meta")
.with_shards(shards)
.with_weighter(|_: &PageId, info: &PageInfo| info.page_size as usize)
.with_event_listener(Arc::new(ReapListener {
tx: reap_tx,
dir_index,
}));
match policy {
CacheEvictorType::Lru => builder.with_eviction_config(LruConfig {
high_priority_pool_ratio: 0.0,
}),
CacheEvictorType::Lfu => builder.with_eviction_config(LfuConfig::default()),
CacheEvictorType::S3Fifo => builder.with_eviction_config(S3FifoConfig::default()),
}
.build()
}
type ByFileMap = HashMap<Arc<str>, HashSet<u64>>;
pub struct LocalCacheManager {
options: CacheManagerOptions,
stores: Vec<Arc<dyn PageStore>>,
allocator: Box<dyn Allocator>,
caches: Vec<Cache<PageId, PageInfo>>,
reap_tx: mpsc::Sender<ReapTask>,
by_file: RwLock<ByFileMap>,
versions: RwLock<HashMap<Arc<str>, (i64, i64)>>,
page_locks: Vec<RwLock<()>>,
async_write_sem: Arc<Semaphore>,
state: CacheState,
}
fn page_lock_index(page_id: &PageId) -> usize {
let mut h = Xxh3Default::default();
page_id.file_id.hash(&mut h);
page_id.page_index.hash(&mut h);
(h.finish() % LOCK_SIZE as u64) as usize
}
impl LocalCacheManager {
pub async fn create(options: CacheManagerOptions) -> Result<Arc<Self>> {
let dir_paths: Vec<&Path> = if options.dirs.is_empty() {
vec![Path::new("/tmp/goosefs_cache")]
} else {
options.dirs.iter().map(|p| p.as_path()).collect()
};
let use_uring = options.uring_enabled && is_uring_available();
if options.uring_enabled && !use_uring {
warn!("io_uring requested but unavailable; falling back to tokio::fs backend");
}
if use_uring {
init_uring_config(options.uring_queue_depth, options.uring_thread_count);
}
let (reap_tx, reap_rx) = mpsc::channel::<ReapTask>(REAP_QUEUE_CAP);
if options.page_size > options.dir_capacity {
warn!(
page_size = options.page_size,
dir_capacity = options.dir_capacity,
"page_size exceeds dir_capacity: the cache will hold at most one page \
and its reported usage will exceed the configured capacity"
);
}
let mut stores: Vec<Arc<dyn PageStore>> = Vec::with_capacity(dir_paths.len());
let mut caches: Vec<Cache<PageId, PageInfo>> = Vec::with_capacity(dir_paths.len());
for dir in &dir_paths {
let store: Arc<dyn PageStore> = if use_uring {
#[cfg(all(target_os = "linux", feature = "page-cache-io-uring"))]
{
match UringPageStore::create_with_pread(
dir,
options.page_size,
options.sync_read_enabled,
)
.await
{
Ok(s) => Arc::new(s),
Err(e) => {
warn!(error = %e, "UringPageStore creation failed; fallback to LocalPageStore");
Arc::new(LocalPageStore::create(dir, options.page_size).await?)
}
}
}
#[cfg(not(all(target_os = "linux", feature = "page-cache-io-uring")))]
{
Arc::new(LocalPageStore::create(dir, options.page_size).await?)
}
} else {
Arc::new(LocalPageStore::create(dir, options.page_size).await?)
};
stores.push(store);
caches.push(build_page_cache(
options.evictor,
options.dir_capacity,
options.page_size,
reap_tx.clone(),
caches.len(),
));
}
let page_locks = (0..LOCK_SIZE).map(|_| RwLock::new(())).collect();
let async_write_sem = Arc::new(Semaphore::new(options.async_write_threads.max(1)));
let mgr = Arc::new(Self {
options,
stores,
allocator: Box::new(HashAllocator::new()),
caches,
reap_tx,
by_file: RwLock::new(HashMap::new()),
versions: RwLock::new(HashMap::new()),
page_locks,
async_write_sem,
state: CacheState::ReadWrite,
});
mgr.clone().spawn_reaper(reap_rx);
if let Err(e) = mgr.restore().await {
warn!(error = %e, "cache restore failed; starting with empty cache");
}
mgr.publish_capacity_gauges_initial();
debug!(
page_size = mgr.options.page_size,
num_dirs = mgr.stores.len(),
dir_capacity = mgr.options.dir_capacity,
async_write_threads = mgr.options.async_write_threads,
evictor = ?mgr.options.evictor,
ttl = ?mgr.options.ttl,
"LocalCacheManager initialized"
);
Ok(mgr)
}
pub async fn from_config(config: &GoosefsConfig) -> Result<Arc<Self>> {
let options = CacheManagerOptions::from_config(config);
Self::create(options).await
}
pub fn options(&self) -> &CacheManagerOptions {
&self.options
}
fn total_capacity(&self) -> u64 {
self.options
.dir_capacity
.saturating_mul(self.stores.len() as u64)
}
fn publish_capacity_gauges_initial(&self) {
gauge(mn::CLIENT_CACHE_SPACE_AVAILABLE).set(self.total_capacity() as i64);
gauge(mn::CLIENT_CACHE_SPACE_USED).set(0);
gauge(mn::CLIENT_CACHE_PAGES).set(0);
gauge(mn::CLIENT_CACHE_SPACE_USED_COUNT).set(0);
gauge(mn::CLIENT_CACHE_HIT_RATE).set(0);
gauge(mn::CLIENT_CACHE_STATE).set(self.state.as_i64());
}
fn publish_occupancy(&self) {
let used: u64 = self.caches.iter().map(|c| c.usage() as u64).sum();
let pages: i64 = self.caches.iter().map(|c| c.entries() as i64).sum();
gauge(mn::CLIENT_CACHE_PAGES).set(pages);
gauge(mn::CLIENT_CACHE_SPACE_USED_COUNT).set(pages);
gauge(mn::CLIENT_CACHE_SPACE_USED).set(used as i64);
gauge(mn::CLIENT_CACHE_SPACE_AVAILABLE)
.set(self.total_capacity().saturating_sub(used) as i64);
}
fn spawn_reaper(self: Arc<Self>, mut rx: mpsc::Receiver<ReapTask>) {
let weak: Weak<Self> = Arc::downgrade(&self);
drop(self);
tokio::spawn(async move {
while let Some(task) = rx.recv().await {
let Some(mgr) = weak.upgrade() else {
break; };
mgr.reap_one(task).await;
gauge(mn::CLIENT_CACHE_REAP_QUEUE_DEPTH).set(rx.len() as i64);
}
});
}
async fn reap_one(&self, task: ReapTask) {
counter(mn::CLIENT_CACHE_BYTES_EVICTED).inc(task.page_size as i64);
counter(mn::CLIENT_CACHE_PAGES_EVICTED).inc(1);
let file_empty = {
let mut by_file = self.by_file.write().await;
let mut empty = false;
if let Some(set) = by_file.get_mut(&task.page_id.file_id) {
set.remove(&task.page_id.page_index);
if set.is_empty() {
by_file.remove(&task.page_id.file_id);
empty = true;
}
}
empty
};
{
let _wl = self.page_locks[page_lock_index(&task.page_id)]
.write()
.await;
if self.caches[task.dir_index].contains(&task.page_id) {
counter(mn::CLIENT_CACHE_REAP_SKIPPED_READMITTED).inc(1);
return;
}
if let Err(e) = self.stores[task.dir_index].delete(&task.page_id).await {
warn!(error = %e, "reap: failed to delete evicted page from store");
counter(mn::CLIENT_CACHE_DELETE_FROM_STORE_ERRORS).inc(1);
}
}
if file_empty {
let _ = self.stores[task.dir_index]
.delete_identity(&task.page_id.file_id)
.await;
}
self.publish_occupancy();
}
pub async fn close(&self) {
self.drain_reaper().await;
}
async fn drain_reaper(&self) {
for _ in 0..1_000 {
if self.reap_tx.capacity() == REAP_QUEUE_CAP {
tokio::task::yield_now().await;
tokio::time::sleep(Duration::from_millis(1)).await;
return;
}
tokio::time::sleep(Duration::from_millis(1)).await;
}
warn!("reaper did not drain within 1s");
}
#[cfg(test)]
pub(crate) async fn flush_reaper(&self) {
self.drain_reaper().await;
}
async fn restore(&self) -> Result<()> {
let mut restored_pages = 0u64;
let mut restored_bytes = 0u64;
for (dir_index, store) in self.stores.iter().enumerate() {
let root = store.root_dir().to_path_buf();
let mut buckets = match tokio::fs::read_dir(&root).await {
Ok(rd) => rd,
Err(_) => continue, };
while let Ok(Some(bucket)) = buckets.next_entry().await {
if !bucket.path().is_dir() {
continue;
}
let mut files = match tokio::fs::read_dir(bucket.path()).await {
Ok(rd) => rd,
Err(_) => continue,
};
while let Ok(Some(file_dir)) = files.next_entry().await {
let file_id_os = file_dir.file_name();
let Some(file_id) = file_id_os.to_str() else {
continue;
};
let file_id: Arc<str> = Arc::from(file_id);
let Some(identity) = store.read_identity(&file_id).await else {
let _ = tokio::fs::remove_dir_all(file_dir.path()).await;
continue;
};
let mut pages = match tokio::fs::read_dir(file_dir.path()).await {
Ok(rd) => rd,
Err(_) => continue,
};
let mut file_pages_restored = 0u64;
while let Ok(Some(page)) = pages.next_entry().await {
let name = page.file_name();
let Some(name) = name.to_str() else { continue };
if name.contains(".tmp-") {
let _ = tokio::fs::remove_file(page.path()).await;
continue;
}
if LocalPageStore::is_identity_file(name) {
continue;
}
let Ok(page_index) = name.parse::<u64>() else {
continue;
};
let Ok(md) = page.metadata().await else {
continue;
};
let size = md.len();
if size == 0 || size > self.options.page_size {
let _ = tokio::fs::remove_file(page.path()).await;
continue;
}
let page_id = PageId::new(file_id.clone(), page_index);
if self.caches[dir_index].contains(&page_id) {
let _ = tokio::fs::remove_file(page.path()).await;
continue;
}
drop(self.caches[dir_index].insert(
page_id.clone(),
PageInfo {
page_id: page_id.clone(),
page_size: size,
dir_index,
created_at: Instant::now(),
scope: CacheScope::Global,
},
));
{
let mut by_file = self.by_file.write().await;
by_file
.entry(file_id.clone())
.or_default()
.insert(page_index);
}
file_pages_restored += 1;
restored_pages += 1;
restored_bytes += size;
}
if file_pages_restored > 0 {
self.versions
.write()
.await
.insert(file_id.clone(), identity);
} else {
let _ = tokio::fs::remove_dir_all(file_dir.path()).await;
}
}
}
}
if restored_pages > 0 {
debug!(
pages = restored_pages,
bytes = restored_bytes,
"restored cache pages from disk"
);
}
Ok(())
}
async fn get_expired_path(&self, page_id: &PageId) -> usize {
let Some(ttl) = self.options.ttl else {
return 0; };
let dir_index = self.allocator.allocate(page_id, self.stores.len());
let _wl = self.page_locks[page_lock_index(page_id)].write().await;
let info = {
let Some(entry) = self.caches[dir_index].get(page_id) else {
return 0;
};
let info = entry.value().clone();
drop(entry);
if info.created_at.elapsed() <= ttl {
return 0; }
info
};
self.caches[dir_index].remove(page_id);
let file_empty = {
let mut by_file = self.by_file.write().await;
let mut empty = false;
if let Some(set) = by_file.get_mut(&page_id.file_id) {
set.remove(&page_id.page_index);
if set.is_empty() {
by_file.remove(&page_id.file_id);
empty = true;
}
}
empty
};
if let Err(e) = self.stores[dir_index].delete(page_id).await {
warn!(error = %e, "expire: failed to delete page from store");
counter(mn::CLIENT_CACHE_DELETE_FROM_STORE_ERRORS).inc(1);
}
if file_empty {
let _ = self.stores[dir_index]
.delete_identity(&page_id.file_id)
.await;
}
counter(mn::CLIENT_CACHE_PAGES_DISCARDED).inc(1);
counter(mn::CLIENT_CACHE_BYTES_DISCARDED).inc(info.page_size as i64);
self.publish_occupancy();
0 }
}
#[async_trait]
impl CacheManager for LocalCacheManager {
async fn put(&self, page_id: &PageId, page: Bytes) -> bool {
if self.state != CacheState::ReadWrite {
counter(mn::CLIENT_CACHE_PUT_NOT_READY_ERRORS).inc(1);
counter(mn::CLIENT_CACHE_PUT_ERRORS).inc(1);
return false;
}
let page_len = page.len() as u64;
if page_len == 0 || page_len > self.options.page_size {
return false;
}
let _wl = self.page_locks[page_lock_index(page_id)].write().await;
let dir_index = self.allocator.allocate(page_id, self.stores.len());
if self.caches[dir_index].contains(page_id) {
counter(mn::CLIENT_CACHE_PUT_BENIGN_RACING_ERRORS).inc(1);
return false;
}
if let Err(e) = self.stores[dir_index].put(page_id, &page).await {
warn!(error = %e, "put: failed to write page to store");
counter(mn::CLIENT_CACHE_PUT_STORE_WRITE_ERRORS).inc(1);
counter(mn::CLIENT_CACHE_PUT_ERRORS).inc(1);
return false;
}
drop(self.caches[dir_index].insert(
page_id.clone(),
PageInfo {
page_id: page_id.clone(),
page_size: page_len,
dir_index,
created_at: Instant::now(),
scope: CacheScope::Global,
},
));
if !self.caches[dir_index].contains(page_id) {
counter(mn::CLIENT_CACHE_PUT_INSUFFICIENT_SPACE_ERRORS).inc(1);
counter(mn::CLIENT_CACHE_PUT_ERRORS).inc(1);
return false;
}
counter(mn::CLIENT_CACHE_BYTES_WRITTEN_CACHE).inc(page_len as i64);
self.publish_occupancy();
let first_page = {
let by_file = self.by_file.read().await;
!by_file.contains_key(&page_id.file_id)
};
let identity = if first_page {
self.versions.read().await.get(&page_id.file_id).copied()
} else {
None
};
{
let mut by_file = self.by_file.write().await;
by_file
.entry(page_id.file_id.clone())
.or_default()
.insert(page_id.page_index);
}
if let Some((length, mtime)) = identity {
if let Err(e) = self.stores[dir_index]
.write_identity(&page_id.file_id, length, mtime)
.await
{
debug!(file_id = %page_id.file_id, error = %e,
"failed to persist cache identity");
}
}
true
}
async fn get(&self, page_id: &PageId, page_offset: usize, dst: &mut [u8]) -> usize {
let bytes = self.get_bytes(page_id, page_offset, dst.len()).await;
let n = bytes.len().min(dst.len());
if n > 0 {
dst[..n].copy_from_slice(&bytes[..n]);
}
n
}
async fn get_bytes(&self, page_id: &PageId, page_offset: usize, len: usize) -> Bytes {
if self.state == CacheState::NotInUse {
counter(mn::CLIENT_CACHE_GET_NOT_READY_ERRORS).inc(1);
return Bytes::new();
}
if len == 0 {
return Bytes::new();
}
let _rl = self.page_locks[page_lock_index(page_id)].read().await;
let dir_index = self.allocator.allocate(page_id, self.stores.len());
{
let Some(entry) = self.caches[dir_index].get(page_id) else {
return Bytes::new(); };
let created_at = entry.value().created_at;
drop(entry);
if let Some(ttl) = self.options.ttl {
if created_at.elapsed() > ttl {
drop(_rl);
let _ = self.get_expired_path(page_id).await;
return Bytes::new();
}
}
}
let start = Instant::now();
let bytes = match self.stores[dir_index]
.get_bytes(page_id, page_offset, len)
.await
{
Ok(bytes) => bytes,
Err(e) => {
warn!(error = %e, "get: failed to read page from store");
counter(mn::CLIENT_CACHE_GET_STORE_READ_ERRORS).inc(1);
counter(mn::CLIENT_CACHE_GET_ERRORS).inc(1);
return Bytes::new();
}
};
if bytes.is_empty() {
return Bytes::new(); }
counter(mn::CLIENT_CACHE_BYTES_READ_CACHE).inc(bytes.len() as i64);
counter(mn::CLIENT_CACHE_PAGE_READ_CACHE_TIME_NS).inc(start.elapsed().as_nanos() as i64);
crate::cache::metrics::publish_hit_rate();
bytes
}
async fn get_batch_bytes(&self, requests: &[PageReadRequest]) -> Vec<Bytes> {
join_all(
requests
.iter()
.map(|req| self.get_bytes(&req.page_id, req.page_offset, req.len)),
)
.await
}
async fn delete(&self, page_id: &PageId) -> bool {
let _wl = self.page_locks[page_lock_index(page_id)].write().await;
let dir_index = self.allocator.allocate(page_id, self.stores.len());
let Some(entry) = self.caches[dir_index].remove(page_id) else {
counter(mn::CLIENT_CACHE_DELETE_NON_EXISTING_PAGE_ERRORS).inc(1);
return false;
};
drop(entry);
let file_empty = {
let mut by_file = self.by_file.write().await;
let mut empty = false;
if let Some(set) = by_file.get_mut(&page_id.file_id) {
set.remove(&page_id.page_index);
if set.is_empty() {
by_file.remove(&page_id.file_id);
empty = true;
}
}
empty
};
self.publish_occupancy();
if let Err(e) = self.stores[dir_index].delete(page_id).await {
warn!(error = %e, "delete: failed to remove page from store");
counter(mn::CLIENT_CACHE_DELETE_STORE_DELETE_ERRORS).inc(1);
counter(mn::CLIENT_CACHE_DELETE_ERRORS).inc(1);
}
if file_empty {
let _ = self.stores[dir_index]
.delete_identity(&page_id.file_id)
.await;
}
true
}
async fn invalidate(&self, file_id: &str) {
let pages: Vec<PageId> = {
let by_file = self.by_file.read().await;
match by_file.get(file_id) {
Some(set) => set.iter().map(|idx| PageId::new(file_id, *idx)).collect(),
None => return,
}
};
for pid in pages {
self.delete(&pid).await;
}
debug!(file_id = %file_id, "invalidated cached pages for file");
}
async fn on_file_open(&self, file_id: &str, length: i64, last_modification_time_ms: i64) {
let changed = {
let versions = self.versions.read().await;
match versions.get(file_id) {
Some(&(l, m)) if l == length && m == last_modification_time_ms => false,
Some(_) => true,
None => {
drop(versions); let mut versions = self.versions.write().await;
match versions.get(file_id) {
Some(&(l, m)) if l == length && m == last_modification_time_ms => false,
Some(_) => true,
None => {
versions
.insert(Arc::from(file_id), (length, last_modification_time_ms));
false
}
}
}
}
};
if changed {
warn!(file_id = %file_id, "file overwritten; invalidating cached pages");
self.invalidate(file_id).await;
self.versions
.write()
.await
.insert(Arc::from(file_id), (length, last_modification_time_ms));
}
}
fn schedule_fill(self: Arc<Self>, page_id: PageId, page: Bytes) {
match self.async_write_sem.clone().try_acquire_owned() {
Ok(permit) => {
tokio::spawn(async move {
let _permit = permit; let _ = self.put(&page_id, page).await;
});
}
Err(_) => {
counter(mn::CLIENT_CACHE_PUT_ASYNC_REJECTION_ERRORS).inc(1);
}
}
}
fn state(&self) -> CacheState {
self.state
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use std::time::Duration;
use crate::config::CacheEvictorType;
fn opts(
page_size: u64,
capacity: u64,
num_dirs: usize,
evictor: CacheEvictorType,
async_threads: usize,
) -> (CacheManagerOptions, Vec<PathBuf>) {
let dirs: Vec<PathBuf> = (0..num_dirs)
.map(|_| std::env::temp_dir().join(format!("gfs_mgr_test_{}", uuid::Uuid::new_v4())))
.collect();
(
CacheManagerOptions {
page_size,
dir_capacity: capacity,
dirs: dirs.clone(),
evictor,
async_write_enabled: async_threads > 0,
async_write_threads: async_threads.max(1),
quota_enabled: false,
ttl: None,
uring_enabled: false,
uring_queue_depth: 0,
uring_thread_count: 0,
sync_read_enabled: false,
},
dirs,
)
}
async fn manager(
page_size: u64,
capacity: u64,
num_dirs: usize,
) -> (Arc<LocalCacheManager>, Vec<PathBuf>) {
let (o, dirs) = opts(page_size, capacity, num_dirs, CacheEvictorType::Lru, 4);
(LocalCacheManager::create(o).await.unwrap(), dirs)
}
async fn cleanup(dirs: &[PathBuf]) {
for d in dirs {
let _ = tokio::fs::remove_dir_all(d).await;
}
}
#[tokio::test]
async fn put_then_get_hit_single_dir() {
let (mgr, dirs) = manager(16, 1024, 1).await;
let id = PageId::new("f1", 0);
assert!(mgr.put(&id, Bytes::from_static(b"0123456789")).await);
let mut dst = vec![0u8; 5];
assert_eq!(mgr.get(&id, 2, &mut dst).await, 5);
assert_eq!(&dst, b"23456");
cleanup(&dirs).await;
}
#[tokio::test]
async fn multi_dir_roundtrip_and_affinity() {
let (mgr, dirs) = manager(16, 1024, 4).await;
for f in 0..10 {
for p in 0..3u64 {
let id = PageId::new(format!("file-{f}"), p);
assert!(mgr.put(&id, Bytes::from(vec![f as u8; 8])).await);
}
}
for f in 0..10 {
for p in 0..3u64 {
let id = PageId::new(format!("file-{f}"), p);
let mut dst = vec![0u8; 8];
assert_eq!(mgr.get(&id, 0, &mut dst).await, 8);
assert_eq!(dst, vec![f as u8; 8]);
}
}
cleanup(&dirs).await;
}
#[tokio::test]
async fn eviction_per_dir_lru() {
let (mgr, dirs) = manager(8, 16, 1).await;
let p0 = PageId::new("f", 0);
let p1 = PageId::new("f", 1);
let p2 = PageId::new("f", 2);
assert!(mgr.put(&p0, Bytes::from_static(b"00000000")).await);
assert!(mgr.put(&p1, Bytes::from_static(b"11111111")).await);
let mut dst = vec![0u8; 8];
assert_eq!(mgr.get(&p0, 0, &mut dst).await, 8); assert!(mgr.put(&p2, Bytes::from_static(b"22222222")).await); assert_eq!(mgr.get(&p1, 0, &mut dst).await, 0, "p1 evicted");
assert_eq!(mgr.get(&p0, 0, &mut dst).await, 8, "p0 survives");
assert_eq!(mgr.get(&p2, 0, &mut dst).await, 8, "p2 present");
cleanup(&dirs).await;
}
#[tokio::test]
async fn eviction_per_dir_lru_explicit_policy() {
let (o, dirs) = opts(8, 16, 1, CacheEvictorType::Lru, 4);
let mgr = LocalCacheManager::create(o).await.unwrap();
let p0 = PageId::new("f", 0);
let p1 = PageId::new("f", 1);
let p2 = PageId::new("f", 2);
assert!(mgr.put(&p0, Bytes::from_static(b"00000000")).await);
assert!(mgr.put(&p1, Bytes::from_static(b"11111111")).await);
let mut dst = vec![0u8; 8];
assert_eq!(mgr.get(&p0, 0, &mut dst).await, 8); assert!(mgr.put(&p2, Bytes::from_static(b"22222222")).await); assert_eq!(mgr.get(&p1, 0, &mut dst).await, 0, "p1 evicted");
assert_eq!(mgr.get(&p0, 0, &mut dst).await, 8, "p0 survives");
assert_eq!(mgr.get(&p2, 0, &mut dst).await, 8, "p2 present");
cleanup(&dirs).await;
}
#[tokio::test]
async fn concurrent_gets_do_not_deadlock() {
let (o, dirs) = opts(256, 1024 * 1024, 1, CacheEvictorType::Lru, 4);
let mgr = LocalCacheManager::create(o).await.unwrap();
let id = PageId::new("conc-file", 0);
assert!(
mgr.put(&id, Bytes::from(vec![0x42u8; 256])).await,
"put should succeed"
);
let mut handles = Vec::new();
for _ in 0..32 {
let m = mgr.clone();
let id = id.clone();
handles.push(tokio::spawn(async move {
let mut dst = vec![0u8; 256];
let n = m.get(&id, 0, &mut dst).await;
assert_eq!(n, 256);
assert_eq!(dst, vec![0x42u8; 256]);
}));
}
for h in handles {
h.await.unwrap();
}
cleanup(&dirs).await;
}
#[tokio::test]
async fn lfu_evictor_keeps_frequent_pages() {
let (o, dirs) = opts(8, 16, 1, CacheEvictorType::Lfu, 4);
let mgr = LocalCacheManager::create(o).await.unwrap();
let p0 = PageId::new("f", 0);
let p1 = PageId::new("f", 1);
let p2 = PageId::new("f", 2);
assert!(mgr.put(&p0, Bytes::from_static(b"00000000")).await);
assert!(mgr.put(&p1, Bytes::from_static(b"11111111")).await);
let mut dst = vec![0u8; 8];
for _ in 0..3 {
assert_eq!(mgr.get(&p0, 0, &mut dst).await, 8);
}
assert!(mgr.put(&p2, Bytes::from_static(b"22222222")).await);
assert_eq!(mgr.get(&p1, 0, &mut dst).await, 0, "p1 (LFU) evicted");
assert_eq!(mgr.get(&p0, 0, &mut dst).await, 8, "p0 (frequent) survives");
cleanup(&dirs).await;
}
#[tokio::test]
async fn invalidate_removes_all_file_pages() {
let (mgr, dirs) = manager(8, 1024, 2).await;
assert!(
mgr.put(&PageId::new("fileX", 0), Bytes::from_static(b"aaaa"))
.await
);
assert!(
mgr.put(&PageId::new("fileX", 1), Bytes::from_static(b"bbbb"))
.await
);
assert!(
mgr.put(&PageId::new("fileY", 0), Bytes::from_static(b"cccc"))
.await
);
mgr.invalidate("fileX").await;
let mut dst = vec![0u8; 4];
assert_eq!(mgr.get(&PageId::new("fileX", 0), 0, &mut dst).await, 0);
assert_eq!(mgr.get(&PageId::new("fileX", 1), 0, &mut dst).await, 0);
assert_eq!(mgr.get(&PageId::new("fileY", 0), 0, &mut dst).await, 4);
cleanup(&dirs).await;
}
#[tokio::test]
async fn schedule_fill_eventually_caches() {
let (mgr, dirs) = manager(16, 1024, 1).await;
let id = PageId::new("async-f", 0);
mgr.clone()
.schedule_fill(id.clone(), Bytes::from_static(b"async-bytes!"));
let mut dst = vec![0u8; 12];
let mut hit = false;
for _ in 0..100 {
if mgr.get(&id, 0, &mut dst).await == 12 {
hit = true;
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(hit, "schedule_fill should eventually cache the page");
assert_eq!(&dst, b"async-bytes!");
cleanup(&dirs).await;
}
#[tokio::test]
async fn concurrent_puts_and_gets_same_and_distinct_pages() {
let (mgr, dirs) = manager(32, 64 * 1024, 2).await;
let mut handles = Vec::new();
for i in 0..32u64 {
let m = mgr.clone();
handles.push(tokio::spawn(async move {
let id = PageId::new(format!("file-{}", i % 4), i);
let payload = vec![i as u8; 16];
m.put(&id, Bytes::from(payload.clone())).await;
let mut dst = vec![0u8; 16];
let n = m.get(&id, 0, &mut dst).await;
if n == 16 {
assert_eq!(dst, payload);
}
}));
}
for h in handles {
h.await.unwrap();
}
cleanup(&dirs).await;
}
#[tokio::test]
async fn benign_racing_put_rejected() {
let (mgr, dirs) = manager(16, 1024, 1).await;
let id = PageId::new("f", 0);
assert!(mgr.put(&id, Bytes::from_static(b"aaa")).await);
assert!(!mgr.put(&id, Bytes::from_static(b"bbb")).await);
cleanup(&dirs).await;
}
async fn manager_with_ttl(
page_size: u64,
capacity: u64,
ttl: Duration,
) -> (Arc<LocalCacheManager>, Vec<PathBuf>) {
let (mut o, dirs) = opts(page_size, capacity, 1, CacheEvictorType::Lru, 4);
o.ttl = Some(ttl);
(LocalCacheManager::create(o).await.unwrap(), dirs)
}
#[tokio::test]
async fn get_lazily_expires_page() {
let (mgr, dirs) = manager_with_ttl(16, 1024, Duration::from_millis(40)).await;
let id = PageId::new("ttl-f", 0);
assert!(mgr.put(&id, Bytes::from_static(b"0123456789")).await);
let mut dst = vec![0u8; 10];
assert_eq!(mgr.get(&id, 0, &mut dst).await, 10);
tokio::time::sleep(Duration::from_millis(60)).await;
assert_eq!(mgr.get(&id, 0, &mut dst).await, 0, "expired page is a miss");
assert!(
mgr.put(&id, Bytes::from_static(b"refilled..")).await,
"expired page should be re-fillable"
);
cleanup(&dirs).await;
}
#[tokio::test]
async fn no_ttl_never_expires() {
let (mgr, dirs) = manager(16, 1024, 1).await; let id = PageId::new("no-ttl", 0);
assert!(mgr.put(&id, Bytes::from_static(b"abcd")).await);
tokio::time::sleep(Duration::from_millis(30)).await;
let mut dst = vec![0u8; 4];
assert_eq!(mgr.get(&id, 0, &mut dst).await, 4, "no TTL → never expires");
cleanup(&dirs).await;
}
#[tokio::test]
async fn on_file_open_first_time_keeps_pages() {
let (mgr, dirs) = manager(16, 1024, 1).await;
let id = PageId::new("100", 0);
assert!(mgr.put(&id, Bytes::from_static(b"aaaa")).await);
mgr.on_file_open("100", 4, 1_700_000_000_000).await;
let mut dst = vec![0u8; 4];
assert_eq!(mgr.get(&id, 0, &mut dst).await, 4);
cleanup(&dirs).await;
}
#[tokio::test]
async fn on_file_open_invalidates_on_overwrite() {
let (mgr, dirs) = manager(16, 1024, 1).await;
let id = PageId::new("200", 0);
assert!(mgr.put(&id, Bytes::from_static(b"aaaa")).await);
mgr.on_file_open("200", 4, 1_700_000_000_000).await;
mgr.on_file_open("200", 4, 1_700_000_999_000).await;
let mut dst = vec![0u8; 4];
assert_eq!(mgr.get(&id, 0, &mut dst).await, 0, "stale page invalidated");
assert!(
mgr.put(&PageId::new("200", 0), Bytes::from_static(b"bbbb"))
.await
);
mgr.on_file_open("200", 8, 1_700_000_999_000).await;
assert_eq!(mgr.get(&PageId::new("200", 0), 0, &mut dst).await, 0);
cleanup(&dirs).await;
}
#[tokio::test]
async fn on_file_open_same_identity_is_noop() {
let (mgr, dirs) = manager(16, 1024, 1).await;
let id = PageId::new("300", 0);
assert!(mgr.put(&id, Bytes::from_static(b"keep")).await);
mgr.on_file_open("300", 4, 1_700_000_000_000).await;
mgr.on_file_open("300", 4, 1_700_000_000_000).await;
let mut dst = vec![0u8; 4];
assert_eq!(mgr.get(&id, 0, &mut dst).await, 4);
cleanup(&dirs).await;
}
#[tokio::test]
async fn expired_pages_are_never_served_and_are_reclaimed_on_access() {
let (mgr, dirs) = manager_with_ttl(16, 1024, Duration::from_millis(30)).await;
for p in 0..3u64 {
assert!(
mgr.put(&PageId::new("sweep", p), Bytes::from_static(b"xxxx"))
.await
);
}
let used_before = mgr.caches.iter().map(|c| c.usage()).sum::<usize>();
assert!(used_before > 0, "pages should be resident before the TTL");
tokio::time::sleep(Duration::from_millis(50)).await;
let mut dst = vec![0u8; 4];
for p in 0..3u64 {
assert_eq!(mgr.get(&PageId::new("sweep", p), 0, &mut dst).await, 0);
}
let used_after = mgr.caches.iter().map(|c| c.usage()).sum::<usize>();
assert_eq!(
used_after, 0,
"expired pages should be reclaimed by the access that found them stale"
);
cleanup(&dirs).await;
}
async fn manager_at(
page_size: u64,
capacity: u64,
dirs: Vec<PathBuf>,
) -> Arc<LocalCacheManager> {
let options = CacheManagerOptions {
page_size,
dir_capacity: capacity,
dirs,
evictor: CacheEvictorType::Lru,
async_write_enabled: false,
async_write_threads: 1,
quota_enabled: false,
ttl: None,
uring_enabled: false,
uring_queue_depth: 0,
uring_thread_count: 0,
sync_read_enabled: false,
};
LocalCacheManager::create(options).await.unwrap()
}
fn walk_files(root: &std::path::Path) -> Vec<PathBuf> {
let mut out = Vec::new();
if let Ok(rd) = std::fs::read_dir(root) {
for e in rd.flatten() {
let p = e.path();
if p.is_dir() {
out.extend(walk_files(&p));
} else {
out.push(p);
}
}
}
out
}
fn count_identity_files(root: &std::path::Path) -> usize {
walk_files(root)
.iter()
.filter(|p| p.file_name().and_then(|s| s.to_str()) == Some(".identity"))
.count()
}
#[tokio::test]
async fn restore_preserves_pages_when_identity_unchanged() {
let dirs = vec![std::env::temp_dir().join(format!("gfs_restore_{}", uuid::Uuid::new_v4()))];
{
let mgr = manager_at(16, 1024, dirs.clone()).await;
mgr.on_file_open("file-r", 4, 1_700_000_000_000).await;
assert!(
mgr.put(&PageId::new("file-r", 0), Bytes::from_static(b"abcd"))
.await
);
}
let mgr2 = manager_at(16, 1024, dirs.clone()).await;
let mut dst = vec![0u8; 4];
assert_eq!(mgr2.get(&PageId::new("file-r", 0), 0, &mut dst).await, 4);
mgr2.on_file_open("file-r", 4, 1_700_000_000_000).await;
assert_eq!(mgr2.get(&PageId::new("file-r", 0), 0, &mut dst).await, 4);
assert_eq!(&dst, b"abcd");
cleanup(&dirs).await;
}
#[tokio::test]
async fn restore_invalidates_pages_on_overwrite_after_restart() {
let dirs = vec![std::env::temp_dir().join(format!("gfs_restore_{}", uuid::Uuid::new_v4()))];
{
let mgr = manager_at(16, 1024, dirs.clone()).await;
mgr.on_file_open("file-o", 4, 1_700_000_000_000).await;
assert!(
mgr.put(&PageId::new("file-o", 0), Bytes::from_static(b"old!"))
.await
);
}
let mgr2 = manager_at(16, 1024, dirs.clone()).await;
let mut dst = vec![0u8; 4];
assert_eq!(mgr2.get(&PageId::new("file-o", 0), 0, &mut dst).await, 4);
mgr2.on_file_open("file-o", 4, 1_700_000_999_000).await;
assert_eq!(
mgr2.get(&PageId::new("file-o", 0), 0, &mut dst).await,
0,
"stale restored page must be invalidated after a detected overwrite"
);
cleanup(&dirs).await;
}
#[tokio::test]
async fn identity_sidecar_reclaimed_when_last_page_removed() {
let dirs = vec![std::env::temp_dir().join(format!("gfs_ident_{}", uuid::Uuid::new_v4()))];
let mgr = manager_at(16, 1024, dirs.clone()).await;
mgr.on_file_open("gone", 4, 1_700_000_000_000).await;
assert!(
mgr.put(&PageId::new("gone", 0), Bytes::from_static(b"data"))
.await
);
assert!(mgr.delete(&PageId::new("gone", 0)).await);
let mgr2 = manager_at(16, 1024, dirs.clone()).await;
let mut dst = vec![0u8; 4];
assert_eq!(mgr2.get(&PageId::new("gone", 0), 0, &mut dst).await, 0);
cleanup(&dirs).await;
}
#[tokio::test]
async fn restore_drops_pages_without_identity_sidecar() {
let dirs =
vec![std::env::temp_dir().join(format!("gfs_nosidecar_{}", uuid::Uuid::new_v4()))];
{
let mgr = manager_at(16, 1024, dirs.clone()).await;
assert!(
mgr.put(&PageId::new("orphan", 0), Bytes::from_static(b"data"))
.await
);
let mut dst = vec![0u8; 4];
assert_eq!(mgr.get(&PageId::new("orphan", 0), 0, &mut dst).await, 4);
}
let mgr2 = manager_at(16, 1024, dirs.clone()).await;
let mut dst = vec![0u8; 4];
assert_eq!(
mgr2.get(&PageId::new("orphan", 0), 0, &mut dst).await,
0,
"page without an identity sidecar must not be restored"
);
cleanup(&dirs).await;
}
#[tokio::test]
async fn restore_reclaims_empty_shell_dir_with_only_sidecar() {
let dirs = vec![std::env::temp_dir().join(format!("gfs_shell_{}", uuid::Uuid::new_v4()))];
{
let mgr = manager_at(16, 1024, dirs.clone()).await;
mgr.on_file_open("shell", 4, 1_700_000_000_000).await;
assert!(
mgr.put(&PageId::new("shell", 0), Bytes::from_static(b"data"))
.await
);
}
for p in walk_files(&dirs[0]) {
if p.file_name()
.and_then(|s| s.to_str())
.and_then(|n| n.parse::<u64>().ok())
.is_some()
{
let _ = std::fs::remove_file(&p);
}
}
assert!(
count_identity_files(&dirs[0]) > 0,
"precondition: an orphan sidecar exists before restart"
);
let _mgr2 = manager_at(16, 1024, dirs.clone()).await;
assert_eq!(
count_identity_files(&dirs[0]),
0,
"empty shell directory (sidecar but no pages) must be reclaimed on restore"
);
cleanup(&dirs).await;
}
#[tokio::test]
async fn get_bytes_returns_page_slice_and_miss_is_empty() {
let (mgr, dirs) = manager(16, 1024, 1).await;
let id = PageId::new("bytes-file", 0);
assert!(mgr.put(&id, Bytes::from_static(b"0123456789abcdef")).await);
let hit = mgr.get_bytes(&id, 4, 6).await;
assert_eq!(&hit[..], b"456789");
let miss = mgr.get_bytes(&PageId::new("bytes-file", 99), 0, 8).await;
assert!(miss.is_empty(), "missing page must return empty Bytes");
let zero_len = mgr.get_bytes(&id, 0, 0).await;
assert!(zero_len.is_empty());
cleanup(&dirs).await;
}
#[tokio::test]
async fn get_batch_bytes_preserves_order_and_miss_slots() {
let (mgr, dirs) = manager(8, 1024, 1).await;
let p0 = PageId::new("batch", 0);
let p1 = PageId::new("batch", 1);
let p2 = PageId::new("batch", 2);
assert!(mgr.put(&p0, Bytes::from_static(b"00000000")).await);
assert!(mgr.put(&p2, Bytes::from_static(b"22222222")).await);
let out = mgr
.get_batch_bytes(&[
crate::cache::PageReadRequest {
page_id: p0.clone(),
page_offset: 0,
len: 8,
},
crate::cache::PageReadRequest {
page_id: p1.clone(),
page_offset: 0,
len: 8,
},
crate::cache::PageReadRequest {
page_id: p2.clone(),
page_offset: 2,
len: 4,
},
])
.await;
assert_eq!(out.len(), 3);
assert_eq!(&out[0][..], b"00000000");
assert!(out[1].is_empty(), "miss slot must be empty Bytes");
assert_eq!(&out[2][..], b"2222");
cleanup(&dirs).await;
}
#[test]
fn shard_count_never_starves_a_shard() {
const GIB: u64 = 1 << 30;
const MIB: u64 = 1 << 20;
const KIB: u64 = 1 << 10;
let cases: &[(u64, u64)] = &[
(9_961_472, MIB), (MIB, MIB), (MIB, KIB), (100 * MIB, MIB),
(GIB, 64 * KIB),
(20 * GIB, MIB), (100 * GIB, MIB),
(100 * GIB, 64 * KIB),
(1024 * GIB, MIB), (1024 * GIB, 64 * KIB),
];
for &(capacity, page_size) in cases {
let shards = shard_count(capacity, page_size);
assert!(
shards >= 1 && shards <= MAX_SHARDS,
"{capacity}/{page_size}: {shards} shards"
);
let per_shard = capacity / shards as u64;
assert!(
shards == 1 || per_shard >= page_size * MIN_PAGES_PER_SHARD,
"{capacity}/{page_size}: {shards} shards give {per_shard} B each, \
under {MIN_PAGES_PER_SHARD} pages"
);
}
assert_eq!(shard_count(MIB, 0), 1);
}
#[tokio::test]
async fn reads_do_not_stall_eviction_under_lru() {
const PAGE: usize = 8;
const CAP_PAGES: u64 = 64;
const A_PAGES: u64 = CAP_PAGES / 2;
async fn b_survivors(read_a_first: bool) -> usize {
let (o, dirs) = opts(
PAGE as u64,
PAGE as u64 * CAP_PAGES,
1,
CacheEvictorType::Lru,
0,
);
let mgr = LocalCacheManager::create(o).await.unwrap();
for i in 0..A_PAGES {
assert!(
mgr.put(&PageId::new("A", i), Bytes::from(vec![b'a'; PAGE]))
.await
);
}
if read_a_first {
let mut dst = vec![0u8; PAGE];
for i in 0..A_PAGES {
assert_eq!(mgr.get(&PageId::new("A", i), 0, &mut dst).await, PAGE);
}
}
for i in 0..CAP_PAGES {
let _ = mgr
.put(&PageId::new("B", i), Bytes::from(vec![b'b'; PAGE]))
.await;
}
mgr.flush_reaper().await;
let survivors = (0..CAP_PAGES)
.filter(|i| mgr.caches[0].contains(&PageId::new("B", *i)))
.count();
cleanup(&dirs).await;
survivors
}
let without_reads = b_survivors(false).await;
let with_reads = b_survivors(true).await;
assert!(
without_reads > 0,
"baseline is broken: set B never became resident even without reads"
);
assert!(
with_reads * 2 >= without_reads,
"eviction stalled after reads: {with_reads} of set B resident when A was \
read first, vs {without_reads} when it was not — reads are pinning pages \
out of the eviction order"
);
}
#[tokio::test]
async fn reaper_does_not_delete_readmitted_page() {
const PAGES: u64 = 16;
const PAGE: usize = 8;
let (o, dirs) = opts(
PAGE as u64,
PAGE as u64 * PAGES,
1,
CacheEvictorType::Lfu,
0,
);
let mgr = LocalCacheManager::create(o).await.unwrap();
let victim = PageId::new("V", 0);
assert!(mgr.put(&victim, Bytes::from(vec![b'v'; PAGE])).await);
for i in 0..(PAGES * 2) {
let _ = mgr
.put(&PageId::new("F", i), Bytes::from(vec![b'f'; PAGE]))
.await;
}
mgr.flush_reaper().await;
assert!(
!mgr.caches[0].contains(&victim),
"victim should have been evicted by the fill"
);
assert!(mgr.put(&victim, Bytes::from(vec![b'w'; PAGE])).await);
mgr.flush_reaper().await;
let mut dst = vec![0u8; PAGE];
let n = mgr.get(&victim, 0, &mut dst).await;
assert_eq!(
n, PAGE,
"reaper deleted the file of a re-admitted page: metadata says resident \
but the bytes are gone"
);
assert_eq!(
&dst,
&vec![b'w'; PAGE],
"should read the re-admitted content"
);
cleanup(&dirs).await;
}
#[tokio::test]
async fn eviction_cost_does_not_scale_with_page_count() {
const PAGE: usize = 8;
async fn evict_nanos(pages: u64) -> u128 {
let (o, dirs) = opts(
PAGE as u64,
PAGE as u64 * pages,
1,
CacheEvictorType::Lfu,
0,
);
let mgr = LocalCacheManager::create(o).await.unwrap();
for i in 0..pages {
let _ = mgr
.put(&PageId::new("fill", i), Bytes::from(vec![b'x'; PAGE]))
.await;
}
let mut best = u128::MAX;
for round in 0..3u64 {
const BATCH: u64 = 50;
let start = Instant::now();
for b in 0..BATCH {
let id = PageId::new("probe", round * BATCH + b);
let _ = mgr.put(&id, Bytes::from(vec![b'y'; PAGE])).await;
}
best = best.min(start.elapsed().as_nanos() / BATCH as u128);
}
cleanup(&dirs).await;
best
}
let small = evict_nanos(1_000).await;
let large = evict_nanos(10_000).await;
assert!(
large < small * 5,
"eviction looks superlinear: {small}ns at 1k pages vs {large}ns at 10k"
);
}
#[tokio::test]
async fn oversized_page_is_admitted_and_capacity_is_exceeded() {
let (o, dirs) = opts(64, 16, 1, CacheEvictorType::Lfu, 0);
let mgr = LocalCacheManager::create(o).await.unwrap();
let id = PageId::new("too-big", 0);
assert!(
mgr.put(&id, Bytes::from(vec![b'z'; 32])).await,
"foyer admits an entry heavier than the whole cache"
);
let mut dst = vec![0u8; 32];
assert_eq!(
mgr.get(&id, 0, &mut dst).await,
32,
"and it is readable afterwards"
);
assert!(
mgr.caches[0].usage() > 16,
"usage exceeds capacity here — this is why page_size > dir_capacity \
is a misconfiguration that `create` warns about"
);
cleanup(&dirs).await;
}
#[tokio::test]
async fn reaper_deletes_evicted_page_files_from_disk() {
const PAGES: u64 = 8;
const PAGE: usize = 8;
let (o, dirs) = opts(
PAGE as u64,
PAGE as u64 * PAGES,
1,
CacheEvictorType::Lfu,
0,
);
let mgr = LocalCacheManager::create(o).await.unwrap();
for i in 0..(PAGES * 4) {
let _ = mgr
.put(&PageId::new("disk", i), Bytes::from(vec![b'd'; PAGE]))
.await;
}
mgr.flush_reaper().await;
let on_disk = walk_files(&dirs[0])
.into_iter()
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.parse::<u64>().is_ok())
})
.count();
let resident = mgr.caches[0].entries();
assert!(
on_disk <= resident + 2,
"{on_disk} page files on disk but only {resident} resident — \
the reaper is not deleting evicted files"
);
cleanup(&dirs).await;
}
#[tokio::test]
async fn usage_never_exceeds_capacity() {
for policy in [
CacheEvictorType::Lru,
CacheEvictorType::Lfu,
CacheEvictorType::S3Fifo,
] {
const PAGES: u64 = 32;
const PAGE: usize = 8;
let capacity = PAGE as u64 * PAGES;
let (o, dirs) = opts(PAGE as u64, capacity, 1, policy, 0);
let mgr = LocalCacheManager::create(o).await.unwrap();
for i in 0..(PAGES * 3) {
let _ = mgr
.put(&PageId::new("cap", i), Bytes::from(vec![b'c'; PAGE]))
.await;
}
let usage = mgr.caches[0].usage() as u64;
assert!(
usage <= capacity,
"{policy:?}: usage {usage} exceeds capacity {capacity}"
);
cleanup(&dirs).await;
}
}
#[tokio::test]
async fn s3fifo_policy_roundtrips() {
let (o, dirs) = opts(8, 1024, 1, CacheEvictorType::S3Fifo, 0);
let mgr = LocalCacheManager::create(o).await.unwrap();
let id = PageId::new("s3", 0);
assert!(mgr.put(&id, Bytes::from_static(b"s3fifo!!")).await);
let mut dst = vec![0u8; 8];
assert_eq!(mgr.get(&id, 0, &mut dst).await, 8);
assert_eq!(&dst, b"s3fifo!!");
cleanup(&dirs).await;
}
#[tokio::test]
async fn reap_queue_pressure_does_not_block_puts() {
const PAGES: u64 = 4;
const PAGE: usize = 8;
let (o, dirs) = opts(
PAGE as u64,
PAGE as u64 * PAGES,
1,
CacheEvictorType::Lfu,
0,
);
let mgr = LocalCacheManager::create(o).await.unwrap();
for i in 0..2_000u64 {
let _ = mgr
.put(&PageId::new("flood", i), Bytes::from(vec![b'p'; PAGE]))
.await;
}
mgr.flush_reaper().await;
assert!(
mgr.caches[0].usage() as u64 <= PAGE as u64 * PAGES,
"capacity must still hold under reaper pressure"
);
cleanup(&dirs).await;
}
#[tokio::test]
async fn close_drains_pending_evictions() {
const PAGES: u64 = 8;
const PAGE: usize = 8;
let (o, dirs) = opts(
PAGE as u64,
PAGE as u64 * PAGES,
1,
CacheEvictorType::Lfu,
0,
);
let mgr = LocalCacheManager::create(o).await.unwrap();
for i in 0..(PAGES * 3) {
let _ = mgr
.put(&PageId::new("close", i), Bytes::from(vec![b'k'; PAGE]))
.await;
}
mgr.close().await;
let on_disk = walk_files(&dirs[0])
.into_iter()
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.parse::<u64>().is_ok())
})
.count();
assert!(
on_disk <= mgr.caches[0].entries() + 2,
"close() should have drained the reaper: {on_disk} files on disk"
);
cleanup(&dirs).await;
}
#[tokio::test]
async fn reaper_reclaims_identity_sidecar_on_last_page() {
const PAGE: usize = 8;
const PAGES: u64 = 8;
let (o, dirs) = opts(
PAGE as u64,
PAGE as u64 * PAGES,
1,
CacheEvictorType::Lfu,
0,
);
let mgr = LocalCacheManager::create(o).await.unwrap();
mgr.on_file_open("victim", PAGE as i64, 1_700_000_000_000)
.await;
assert!(
mgr.put(&PageId::new("victim", 0), Bytes::from(vec![b'v'; PAGE]))
.await
);
mgr.on_file_open("filler", (PAGE as i64) * 32, 1_700_000_000_000)
.await;
for i in 0..(PAGES * 4) {
let _ = mgr
.put(&PageId::new("filler", i), Bytes::from(vec![b'f'; PAGE]))
.await;
}
mgr.flush_reaper().await;
assert!(
!mgr.caches[0].contains(&PageId::new("victim", 0)),
"the victim page should have been evicted by the fill"
);
let (o2, _) = opts(
PAGE as u64,
PAGE as u64 * PAGES,
1,
CacheEvictorType::Lfu,
0,
);
let o2 = CacheManagerOptions {
dirs: dirs.clone(),
..o2
};
let mgr2 = LocalCacheManager::create(o2).await.unwrap();
let mut dst = vec![0u8; PAGE];
assert_eq!(
mgr2.get(&PageId::new("victim", 0), 0, &mut dst).await,
0,
"evicting a file's last page must drop its identity sidecar, so nothing \
of that file is restorable"
);
cleanup(&dirs).await;
}
#[tokio::test]
async fn tail_page_accounted_by_real_size() {
let (o, dirs) = opts(64, 4096, 1, CacheEvictorType::Lfu, 0);
let mgr = LocalCacheManager::create(o).await.unwrap();
let before = mgr.caches[0].usage();
assert!(
mgr.put(&PageId::new("tail", 0), Bytes::from(vec![b't'; 20]))
.await
);
let delta = mgr.caches[0].usage() - before;
assert_eq!(
delta, 20,
"a 20-byte tail page must be charged 20 bytes, not the 64-byte page size"
);
cleanup(&dirs).await;
}
#[tokio::test]
async fn restore_over_capacity_evicts_excess() {
const PAGE: usize = 8;
const CAP_PAGES: u64 = 16;
let capacity = PAGE as u64 * CAP_PAGES;
let (o1, dirs) = opts(PAGE as u64, capacity * 2, 1, CacheEvictorType::Lfu, 0);
let mgr1 = LocalCacheManager::create(o1).await.unwrap();
mgr1.on_file_open("big", (PAGE as i64) * 32, 1_700_000_000_000)
.await;
for i in 0..(CAP_PAGES * 2) {
assert!(
mgr1.put(&PageId::new("big", i), Bytes::from(vec![b'b'; PAGE]))
.await
);
}
mgr1.close().await;
let (o2, _) = opts(PAGE as u64, capacity, 1, CacheEvictorType::Lfu, 0);
let o2 = CacheManagerOptions {
dirs: dirs.clone(),
..o2
};
let mgr2 = LocalCacheManager::create(o2).await.unwrap();
mgr2.flush_reaper().await;
let usage = mgr2.caches[0].usage() as u64;
assert!(
usage <= capacity,
"restore left {usage} B resident against a {capacity} B capacity"
);
let on_disk = walk_files(&dirs[0])
.into_iter()
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.parse::<u64>().is_ok())
})
.count();
assert!(
on_disk <= mgr2.caches[0].entries() + 2,
"{on_disk} page files left on disk after restore evicted down to \
{} resident — the reaper did not run during restore",
mgr2.caches[0].entries()
);
cleanup(&dirs).await;
}
#[tokio::test]
async fn delete_does_not_double_delete() {
let (o, dirs) = opts(8, 4096, 1, CacheEvictorType::Lfu, 0);
let mgr = LocalCacheManager::create(o).await.unwrap();
let id = PageId::new("dd", 0);
assert!(mgr.put(&id, Bytes::from_static(b"first!!!")).await);
assert!(mgr.delete(&id).await);
assert!(mgr.put(&id, Bytes::from_static(b"second!!")).await);
mgr.flush_reaper().await;
let mut dst = vec![0u8; 8];
assert_eq!(
mgr.get(&id, 0, &mut dst).await,
8,
"delete() enqueued a reap that then deleted the re-added page"
);
assert_eq!(&dst, b"second!!");
cleanup(&dirs).await;
}
}