use std::collections::{HashMap, HashSet};
use std::hash::{Hash, Hasher};
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex as StdMutex, Weak};
use std::time::{Duration, Instant};
use async_trait::async_trait;
use bytes::Bytes;
use dashmap::DashMap;
use tokio::sync::{RwLock, Semaphore};
use tracing::{debug, warn};
use xxhash_rust::xxh3::Xxh3Default;
use crate::cache::allocator::{Allocator, HashAllocator};
use crate::cache::evictor::{build_evictor, CacheEvictor};
use crate::cache::metric_name as mn;
use crate::cache::options::CacheManagerOptions;
use crate::cache::page_id::{CacheScope, PageId, PageInfo};
#[cfg(target_os = "linux")]
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::GoosefsConfig;
use crate::error::Result;
use crate::metrics::{counter, gauge};
use futures::future::join_all;
const LOCK_SIZE: usize = 1024;
struct DirState {
evictor: Box<dyn CacheEvictor>,
used_bytes: AtomicU64,
capacity: u64,
}
type ByFileMap = HashMap<Arc<str>, HashSet<u64>>;
pub struct LocalCacheManager {
options: CacheManagerOptions,
stores: Vec<Arc<dyn PageStore>>,
allocator: Box<dyn Allocator>,
meta: DashMap<PageId, PageInfo>,
dirs: Vec<DirState>,
dir_locks: Vec<StdMutex<()>>,
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<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 mut stores: Vec<Arc<dyn PageStore>> = Vec::with_capacity(dir_paths.len());
let mut dirs = Vec::with_capacity(dir_paths.len());
for dir in &dir_paths {
let store: Arc<dyn PageStore> = if use_uring {
#[cfg(target_os = "linux")]
{
match UringPageStore::create(dir, options.page_size).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(target_os = "linux"))]
{
Arc::new(LocalPageStore::create(dir, options.page_size).await?)
}
} else {
Arc::new(LocalPageStore::create(dir, options.page_size).await?)
};
stores.push(store);
dirs.push(DirState {
evictor: build_evictor(options.evictor),
used_bytes: AtomicU64::new(0),
capacity: options.dir_capacity,
});
}
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 dir_locks: Vec<StdMutex<()>> = (0..dirs.len()).map(|_| StdMutex::new(())).collect();
let mgr = Self {
options,
stores,
allocator: Box::new(HashAllocator::new()),
meta: DashMap::new(),
dirs,
dir_locks,
by_file: RwLock::new(HashMap::new()),
versions: RwLock::new(HashMap::new()),
page_locks,
async_write_sem,
state: CacheState::ReadWrite,
};
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);
let mgr = Arc::new(Self::create(options).await?);
mgr.clone().maybe_spawn_ttl_sweeper();
Ok(mgr)
}
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
.dirs
.iter()
.map(|d| d.used_bytes.load(Ordering::Relaxed))
.sum();
let pages = self.meta.len() as i64;
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);
}
async fn pop_victim(&self, dir_index: usize) -> Option<(PageId, u64, bool)> {
let (victim, size) = {
let _guard = self.dir_locks[dir_index].lock().unwrap();
let victim = self.dirs[dir_index].evictor.evict_candidate()?;
let size = self
.meta
.remove(&victim)
.map(|(_, v)| v.page_size)
.unwrap_or(0);
self.dirs[dir_index].evictor.on_remove(&victim);
self.dirs[dir_index]
.used_bytes
.fetch_sub(size, Ordering::Relaxed);
(victim, size)
};
let mut file_empty = false;
{
let mut by_file = self.by_file.write().await;
if let Some(set) = by_file.get_mut(&victim.file_id) {
set.remove(&victim.page_index);
if set.is_empty() {
by_file.remove(&victim.file_id);
file_empty = true;
}
}
}
Some((victim, size, file_empty))
}
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.dirs[dir_index].used_bytes.load(Ordering::Relaxed) + size
> self.dirs[dir_index].capacity
|| self.meta.contains_key(&page_id)
{
let _ = tokio::fs::remove_file(page.path()).await;
continue;
}
self.meta.insert(
page_id.clone(),
PageInfo {
page_id: page_id.clone(),
page_size: size,
dir_index,
created_at: Instant::now(),
scope: CacheScope::Global,
},
);
{
let _dir_guard = self.dir_locks[dir_index].lock().unwrap();
self.dirs[dir_index].evictor.on_add(&page_id);
drop(_dir_guard);
}
self.dirs[dir_index]
.used_bytes
.fetch_add(size, Ordering::Relaxed);
{
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(())
}
fn maybe_spawn_ttl_sweeper(self: Arc<Self>) {
let Some(ttl) = self.options.ttl else {
return;
};
let interval = ttl.min(Duration::from_secs(60)).max(Duration::from_secs(1));
let weak: Weak<Self> = Arc::downgrade(&self);
tokio::spawn(async move {
loop {
tokio::time::sleep(interval).await;
let Some(mgr) = weak.upgrade() else {
break; };
mgr.sweep_expired().await;
}
});
}
pub async fn sweep_expired(&self) {
let Some(ttl) = self.options.ttl else {
return;
};
let expired: Vec<PageId> = self
.meta
.iter()
.filter(|entry| entry.value().created_at.elapsed() > ttl)
.map(|entry| entry.key().clone())
.collect();
for pid in expired {
self.delete(&pid).await;
}
}
async fn get_expired_path(&self, page_id: &PageId) -> usize {
let Some(ttl) = self.options.ttl else {
return 0; };
let dir_index = match self.meta.get(page_id) {
Some(info) => info.dir_index,
None => return 0,
};
let info = {
let _guard = self.dir_locks[dir_index].lock().unwrap();
let is_expired = self
.meta
.get(page_id)
.is_some_and(|info| info.created_at.elapsed() > ttl);
if !is_expired {
return 0;
}
self.meta.remove(page_id).map(|(_, v)| v)
};
if let Some(info) = info {
let di = info.dir_index;
self.dirs[di].evictor.on_remove(page_id);
self.dirs[di]
.used_bytes
.fetch_sub(info.page_size, Ordering::Relaxed);
{
let mut by_file = self.by_file.write().await;
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);
}
}
}
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.meta.contains_key(page_id) {
counter(mn::CLIENT_CACHE_PUT_BENIGN_RACING_ERRORS).inc(1);
return false;
}
let mut victims: Vec<(PageId, bool)> = Vec::new();
loop {
let current = self.dirs[dir_index].used_bytes.load(Ordering::Relaxed);
if current + page_len <= self.dirs[dir_index].capacity {
if self.dirs[dir_index]
.used_bytes
.compare_exchange(
current,
current + page_len,
Ordering::AcqRel,
Ordering::Relaxed,
)
.is_ok()
{
break;
}
continue;
}
match self.pop_victim(dir_index).await {
Some((victim, size, file_empty)) => {
counter(mn::CLIENT_CACHE_BYTES_EVICTED).inc(size as i64);
counter(mn::CLIENT_CACHE_PAGES_EVICTED).inc(1);
victims.push((victim, file_empty));
}
None => {
counter(mn::CLIENT_CACHE_PUT_INSUFFICIENT_SPACE_ERRORS).inc(1);
counter(mn::CLIENT_CACHE_PUT_ERRORS).inc(1);
return false;
}
}
}
for (victim, file_empty) in &victims {
if let Err(e) = self.stores[dir_index].delete(victim).await {
warn!(error = %e, "evict: 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(&victim.file_id)
.await;
}
}
if let Err(e) = self.stores[dir_index].put(page_id, &page).await {
warn!(error = %e, "put: failed to write page to store");
self.dirs[dir_index]
.used_bytes
.fetch_sub(page_len, Ordering::Relaxed);
self.publish_occupancy();
counter(mn::CLIENT_CACHE_PUT_STORE_WRITE_ERRORS).inc(1);
counter(mn::CLIENT_CACHE_PUT_ERRORS).inc(1);
return false;
}
{
let _dir_guard = self.dir_locks[dir_index].lock().unwrap();
let info = PageInfo {
page_id: page_id.clone(),
page_size: page_len,
dir_index,
created_at: Instant::now(),
scope: CacheScope::Global,
};
self.meta.insert(page_id.clone(), info);
self.dirs[dir_index].evictor.on_add(page_id);
counter(mn::CLIENT_CACHE_BYTES_WRITTEN_CACHE).inc(page_len as i64);
drop(_dir_guard);
}
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 = match self.meta.get(page_id) {
Some(info) => {
if let Some(ttl) = self.options.ttl {
if info.created_at.elapsed() > ttl {
drop(info);
let _ = self.get_expired_path(page_id).await;
return Bytes::new();
}
}
let di = info.dir_index;
self.dirs[di].evictor.on_access(page_id);
di
}
None => 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 = match self.meta.get(page_id) {
Some(info) => info.dir_index,
None => {
counter(mn::CLIENT_CACHE_DELETE_NON_EXISTING_PAGE_ERRORS).inc(1);
return false;
}
};
let _info = {
let _dir_guard = self.dir_locks[dir_index].lock().unwrap();
let Some((_, info)) = self.meta.remove(page_id) else {
counter(mn::CLIENT_CACHE_DELETE_NON_EXISTING_PAGE_ERRORS).inc(1);
return false;
};
self.dirs[info.dir_index].evictor.on_remove(page_id);
self.dirs[info.dir_index]
.used_bytes
.fetch_sub(info.page_size, Ordering::Relaxed);
info
};
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,
},
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);
(Arc::new(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_moka() {
let (o, dirs) = opts(8, 16, 1, CacheEvictorType::Lru, 4);
let mgr = Arc::new(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 moka_evictor_concurrent_gets_no_deadlock() {
let (o, dirs) = opts(256, 1024 * 1024, 1, CacheEvictorType::Lru, 4);
let mgr = Arc::new(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 = Arc::new(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);
(Arc::new(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 sweep_expired_removes_all_stale_pages() {
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
);
}
tokio::time::sleep(Duration::from_millis(50)).await;
mgr.sweep_expired().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);
}
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,
};
Arc::new(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;
}
}