use super::driver::{submit_request, try_submit_request};
use super::future::UringOpFuture;
use super::requests::{IoRequest, RequestState, UringOpType};
use super::{hash_file_id, io_error, NUM_BUCKETS};
use crate::cache::page_id::PageId;
use crate::cache::store::{LocalPageStore, PageStore};
use crate::error::Result;
use bytes::{Bytes, BytesMut};
use dashmap::DashMap;
use moka::future::Cache;
use std::ffi::CString;
use std::fs::File;
use std::os::unix::io::{AsRawFd, FromRawFd, RawFd};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, LazyLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const IDENTITY_FILE: &str = ".identity";
const URING_OP_TIMEOUT: Duration = Duration::from_secs(30);
const DIR_FD_TTL: Duration = Duration::from_secs(300);
const DIR_FD_CACHE_SOFT_CAP: usize = 4096;
const PAGE_FD_CACHE_TTL: Duration = Duration::from_secs(60);
const PAGE_FD_CACHE_MAX_CAPACITY: u64 = 10_000;
struct DirFdEntry {
fd: RawFd,
last_access: AtomicU64,
}
impl Drop for DirFdEntry {
fn drop(&mut self) {
unsafe { libc::close(self.fd) };
}
}
fn now_nanos() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0)
}
static PAGE_FD_CACHE: LazyLock<Cache<PageId, Arc<PageFdEntry>>> = LazyLock::new(|| {
Cache::builder()
.time_to_live(PAGE_FD_CACHE_TTL)
.max_capacity(PAGE_FD_CACHE_MAX_CAPACITY)
.build()
});
struct PageFdEntry {
fd: RawFd,
#[allow(dead_code)]
file: Arc<File>,
}
impl PageFdEntry {
fn new(file: File) -> Self {
let fd = file.as_raw_fd();
Self {
fd,
file: Arc::new(file),
}
}
}
pub struct UringPageStore {
root: PathBuf,
#[allow(dead_code)]
page_size: u64,
dir_fd_cache: DashMap<Arc<str>, DirFdEntry>,
}
impl UringPageStore {
pub async fn create(dir: &Path, page_size: u64) -> Result<Self> {
let root = dir.join(page_size.to_string());
tokio::fs::create_dir_all(&root)
.await
.map_err(|e| io_error(format!("create uring cache dir {}", root.display()), e))?;
Ok(Self {
root,
page_size,
dir_fd_cache: DashMap::new(),
})
}
fn page_path(&self, page_id: &PageId) -> PathBuf {
let bucket = hash_file_id(&page_id.file_id) % NUM_BUCKETS;
self.root
.join(bucket.to_string())
.join(page_id.file_id.as_ref())
.join(page_id.page_index.to_string())
}
fn file_dir_path(&self, file_id: &str) -> PathBuf {
let bucket = hash_file_id(file_id) % NUM_BUCKETS;
self.root.join(bucket.to_string()).join(file_id)
}
fn identity_path(&self, file_id: &str) -> PathBuf {
let bucket = hash_file_id(file_id) % NUM_BUCKETS;
self.root
.join(bucket.to_string())
.join(file_id)
.join(IDENTITY_FILE)
}
async fn get_dir_fd(&self, file_id: &Arc<str>) -> std::io::Result<RawFd> {
if let Some(entry) = self.dir_fd_cache.get(file_id) {
entry.last_access.store(now_nanos(), Ordering::Relaxed);
return Ok(entry.fd);
}
let dir_path = self.file_dir_path(file_id);
let dirfd = self
.open_fd(&dir_path, libc::O_RDONLY | libc::O_DIRECTORY)
.await?;
match self.dir_fd_cache.entry(file_id.clone()) {
dashmap::mapref::entry::Entry::Occupied(existing) => {
unsafe { libc::close(dirfd) };
Ok(existing.get().fd)
}
dashmap::mapref::entry::Entry::Vacant(vacant) => {
vacant.insert(DirFdEntry {
fd: dirfd,
last_access: AtomicU64::new(now_nanos()),
});
self.maybe_cleanup_dir_fd_cache();
Ok(dirfd)
}
}
}
fn maybe_cleanup_dir_fd_cache(&self) {
if self.dir_fd_cache.len() <= DIR_FD_CACHE_SOFT_CAP {
return;
}
let now = now_nanos();
let ttl_nanos = DIR_FD_TTL.as_nanos() as u64;
let stale: Vec<Arc<str>> = self
.dir_fd_cache
.iter()
.filter(|entry| {
now.saturating_sub(entry.last_access.load(Ordering::Relaxed)) > ttl_nanos
})
.map(|entry| entry.key().clone())
.collect();
for key in stale {
self.dir_fd_cache.remove(&key);
}
}
fn path_buffer_with_nul(path: &str) -> std::io::Result<BytesMut> {
let cstring = CString::new(path)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
Ok(BytesMut::from(cstring.to_bytes_with_nul()))
}
async fn open_fd(&self, path: &Path, flags: i32) -> std::io::Result<RawFd> {
let buffer = Self::path_buffer_with_nul(&path.to_string_lossy())?;
let request = Arc::new(IoRequest {
fd: libc::AT_FDCWD,
offset: 0,
length: 0,
op_type: UringOpType::OpenAt,
open_flags: flags,
state: std::sync::Mutex::new(RequestState {
completed: false,
waker: None,
err: None,
buffer,
bytes_transferred: 0,
consumed: false,
result_code: 0,
}),
});
submit_request(Arc::clone(&request));
let (result, _bytes) =
match tokio::time::timeout(URING_OP_TIMEOUT, UringOpFuture { request }).await {
Ok(res) => res,
Err(_) => {
return Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"io_uring open timed out",
));
}
};
if result < 0 {
Err(std::io::Error::from_raw_os_error(-result))
} else {
Ok(result as RawFd)
}
}
async fn openat_relative(
&self,
dirfd: RawFd,
name: &str,
flags: i32,
) -> std::io::Result<RawFd> {
let buffer = Self::path_buffer_with_nul(name)?;
let request = Arc::new(IoRequest {
fd: dirfd,
offset: 0,
length: 0,
op_type: UringOpType::OpenAt,
open_flags: flags,
state: std::sync::Mutex::new(RequestState {
completed: false,
waker: None,
err: None,
buffer,
bytes_transferred: 0,
consumed: false,
result_code: 0,
}),
});
submit_request(Arc::clone(&request));
let (result, _bytes) =
match tokio::time::timeout(URING_OP_TIMEOUT, UringOpFuture { request }).await {
Ok(res) => res,
Err(_) => {
return Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"io_uring openat_relative timed out",
));
}
};
if result < 0 {
Err(std::io::Error::from_raw_os_error(-result))
} else {
Ok(result as RawFd)
}
}
fn close_fd_background(&self, fd: RawFd) {
let request = Arc::new(IoRequest {
fd,
offset: 0,
length: 0,
op_type: UringOpType::Close,
open_flags: 0,
state: std::sync::Mutex::new(RequestState {
completed: false,
waker: None,
err: None,
buffer: BytesMut::new(),
bytes_transferred: 0,
consumed: false,
result_code: 0,
}),
});
if !try_submit_request(request) {
unsafe { libc::close(fd) };
}
}
async fn unlink_path(&self, path: &Path) -> std::io::Result<()> {
let buffer = Self::path_buffer_with_nul(&path.to_string_lossy())?;
let request = Arc::new(IoRequest {
fd: libc::AT_FDCWD,
offset: 0,
length: 0,
op_type: UringOpType::UnlinkAt,
open_flags: 0,
state: std::sync::Mutex::new(RequestState {
completed: false,
waker: None,
err: None,
buffer,
bytes_transferred: 0,
consumed: false,
result_code: 0,
}),
});
submit_request(Arc::clone(&request));
let (result, _) =
match tokio::time::timeout(URING_OP_TIMEOUT, UringOpFuture { request }).await {
Ok(res) => res,
Err(_) => {
return Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"io_uring unlink timed out",
));
}
};
if result < 0 {
let e = std::io::Error::from_raw_os_error(-result);
if e.kind() == std::io::ErrorKind::NotFound {
return Ok(()); }
return Err(e);
}
Ok(())
}
fn new_read_request(fd: RawFd, offset: usize, len: usize) -> Arc<IoRequest> {
let mut buffer = BytesMut::with_capacity(len);
unsafe {
buffer.set_len(len);
}
Arc::new(IoRequest {
fd,
offset: offset as u64,
length: len,
op_type: UringOpType::Read,
open_flags: 0,
state: std::sync::Mutex::new(RequestState {
completed: false,
waker: None,
err: None,
buffer,
bytes_transferred: 0,
consumed: false,
result_code: 0,
}),
})
}
async fn wait_read_request(request: Arc<IoRequest>) -> std::io::Result<Bytes> {
let (result, read_bytes) =
match tokio::time::timeout(URING_OP_TIMEOUT, UringOpFuture { request }).await {
Ok(res) => res,
Err(_) => {
return Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"io_uring read timed out",
));
}
};
if result < 0 {
Err(std::io::Error::from_raw_os_error(-result))
} else {
Ok(read_bytes)
}
}
async fn read_with_fd(&self, fd: RawFd, offset: usize, len: usize) -> std::io::Result<Bytes> {
let request = Self::new_read_request(fd, offset, len);
submit_request(Arc::clone(&request));
Self::wait_read_request(request).await
}
pub async fn get_batch(
&self,
requests: Vec<(PageId, usize, usize)>,
results: Vec<&mut [u8]>,
) -> Result<()> {
assert_eq!(
requests.len(),
results.len(),
"requests and results must have the same length"
);
for ((page_id, offset, len), dst) in requests.into_iter().zip(results.into_iter()) {
let bytes = self.get_bytes(&page_id, offset, len).await?;
let n = bytes.len().min(dst.len());
if n > 0 {
dst[..n].copy_from_slice(&bytes[..n]);
}
}
Ok(())
}
}
#[async_trait::async_trait]
impl PageStore for UringPageStore {
async fn get(&self, page_id: &PageId, offset: usize, dst: &mut [u8]) -> Result<usize> {
let bytes = self.get_bytes(page_id, offset, dst.len()).await?;
let n = bytes.len().min(dst.len());
if n > 0 {
dst[..n].copy_from_slice(&bytes[..n]);
}
Ok(n)
}
async fn get_bytes(&self, page_id: &PageId, offset: usize, len: usize) -> Result<Bytes> {
if len == 0 {
return Ok(Bytes::new());
}
if let Some(entry) = PAGE_FD_CACHE.get(page_id).await {
let fd = entry.fd;
let _entry = entry;
return match self.read_with_fd(fd, offset, len).await {
Ok(bytes) => Ok(bytes),
Err(e) => {
PAGE_FD_CACHE.invalidate(page_id).await;
if e.kind() == std::io::ErrorKind::NotFound {
Ok(Bytes::new())
} else {
Err(io_error("uring read (page fd cache hit)", e))
}
}
};
}
let dirfd = match self.get_dir_fd(&page_id.file_id).await {
Ok(fd) => fd,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Bytes::new()),
Err(e) => return Err(io_error("uring open dir", e)),
};
let page_name = page_id.page_index.to_string();
let fd = match self
.openat_relative(dirfd, &page_name, libc::O_RDONLY)
.await
{
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Bytes::new()),
Err(e) => return Err(io_error("uring open page", e)),
};
let read_bytes = match self.read_with_fd(fd, offset, len).await {
Ok(bytes) => bytes,
Err(e) => {
self.close_fd_background(fd);
if e.kind() == std::io::ErrorKind::NotFound {
return Ok(Bytes::new());
}
return Err(io_error("uring read", e));
}
};
let file = unsafe { std::fs::File::from_raw_fd(fd) };
PAGE_FD_CACHE
.insert(page_id.clone(), Arc::new(PageFdEntry::new(file)))
.await;
Ok(read_bytes)
}
async fn put(&self, page_id: &PageId, page: &[u8]) -> Result<()> {
let final_path = self.page_path(page_id);
let parent = final_path
.parent()
.expect("page path always has a parent")
.to_path_buf();
tokio::fs::create_dir_all(&parent)
.await
.map_err(|e| io_error("create page dir", e))?;
let tmp_path = parent.join(format!(
"{}.tmp-{}",
page_id.page_index,
uuid::Uuid::new_v4()
));
let tmp_buffer = Self::path_buffer_with_nul(&tmp_path.to_string_lossy())
.map_err(|e| io_error("cstring", e))?;
let fd = {
let request = Arc::new(IoRequest {
fd: libc::AT_FDCWD,
offset: 0,
length: 0,
op_type: UringOpType::OpenAt,
open_flags: libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC,
state: std::sync::Mutex::new(RequestState {
completed: false,
waker: None,
err: None,
buffer: tmp_buffer,
bytes_transferred: 0,
consumed: false,
result_code: 0,
}),
});
submit_request(Arc::clone(&request));
let (result, _) =
match tokio::time::timeout(URING_OP_TIMEOUT, UringOpFuture { request }).await {
Ok(res) => res,
Err(_) => {
return Err(io_error(
"uring open tmp timeout",
std::io::Error::new(
std::io::ErrorKind::TimedOut,
"io_uring open timed out",
),
));
}
};
if result < 0 {
let e = std::io::Error::from_raw_os_error(-result);
return Err(io_error("uring open tmp", e));
}
result as RawFd
};
{
let request = Arc::new(IoRequest {
fd,
offset: 0,
length: page.len(),
op_type: UringOpType::Write,
open_flags: 0,
state: std::sync::Mutex::new(RequestState {
completed: false,
waker: None,
err: None,
buffer: BytesMut::from(page),
bytes_transferred: 0,
consumed: false,
result_code: 0,
}),
});
submit_request(Arc::clone(&request));
let (result, _) =
match tokio::time::timeout(URING_OP_TIMEOUT, UringOpFuture { request }).await {
Ok(res) => res,
Err(_) => {
self.close_fd_background(fd);
return Err(io_error(
"uring write timeout",
std::io::Error::new(
std::io::ErrorKind::TimedOut,
"io_uring write timed out",
),
));
}
};
if result < 0 {
self.close_fd_background(fd);
let e = std::io::Error::from_raw_os_error(-result);
return Err(io_error("uring write", e));
}
}
self.close_fd_background(fd);
let rename_result = tokio::fs::rename(&tmp_path, &final_path).await;
if rename_result.is_err() {
let _ = tokio::fs::remove_file(&tmp_path).await;
}
rename_result.map_err(|e| io_error("rename temp page file", e))?;
PAGE_FD_CACHE.invalidate(page_id).await;
Ok(())
}
async fn delete(&self, page_id: &PageId) -> Result<()> {
PAGE_FD_CACHE.invalidate(page_id).await;
let path = self.page_path(page_id);
self.unlink_path(&path)
.await
.map_err(|e| io_error("uring unlink", e))?;
Ok(())
}
fn root_dir(&self) -> &Path {
&self.root
}
async fn write_identity(&self, file_id: &str, length: i64, mtime: i64) -> Result<()> {
let final_path = self.identity_path(file_id);
let parent = final_path
.parent()
.expect("identity path always has a parent")
.to_path_buf();
tokio::fs::create_dir_all(&parent)
.await
.map_err(|e| io_error(format!("create identity dir {}", parent.display()), e))?;
let tmp_path = parent.join(format!("{}.tmp-{}", IDENTITY_FILE, uuid::Uuid::new_v4()));
let contents = format!("{length},{mtime}");
let write_result = async {
tokio::fs::write(&tmp_path, contents.as_bytes())
.await
.map_err(|e| io_error("write temp identity file", e))?;
tokio::fs::rename(&tmp_path, &final_path)
.await
.map_err(|e| io_error("rename temp identity file", e))?;
Ok::<(), crate::error::Error>(())
}
.await;
if write_result.is_err() {
let _ = tokio::fs::remove_file(&tmp_path).await;
}
write_result
}
async fn read_identity(&self, file_id: &str) -> Option<(i64, i64)> {
let path = self.identity_path(file_id);
let contents = tokio::fs::read_to_string(&path).await.ok()?;
LocalPageStore::parse_identity(&contents)
}
async fn delete_identity(&self, file_id: &str) -> Result<()> {
let path = self.identity_path(file_id);
match tokio::fs::remove_file(&path).await {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(io_error("delete identity file", e)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
async fn temp_store(page_size: u64) -> (UringPageStore, PathBuf) {
let base = std::env::temp_dir().join(format!("gfs_uring_test_{}", uuid::Uuid::new_v4()));
let store = UringPageStore::create(&base, page_size).await.unwrap();
(store, base)
}
#[tokio::test]
async fn uring_put_get_roundtrip() {
let (store, base) = temp_store(1024).await;
let id = PageId::new("file-uring-a", 0);
let data = b"hello uring page cache".to_vec();
store.put(&id, &data).await.unwrap();
let mut dst = vec![0u8; data.len()];
let n = store.get(&id, 0, &mut dst).await.unwrap();
assert_eq!(n, data.len());
assert_eq!(&dst, &data);
let _ = tokio::fs::remove_dir_all(&base).await;
}
#[tokio::test]
async fn uring_get_with_offset() {
let (store, base) = temp_store(1024).await;
let id = PageId::new("file-uring-b", 0);
store.put(&id, b"0123456789").await.unwrap();
let mut dst = vec![0u8; 4];
let n = store.get(&id, 3, &mut dst).await.unwrap();
assert_eq!(n, 4);
assert_eq!(&dst, b"3456");
let _ = tokio::fs::remove_dir_all(&base).await;
}
#[tokio::test]
async fn uring_get_missing_returns_zero() {
let (store, base) = temp_store(1024).await;
let id = PageId::new("nope-uring", 0);
let mut dst = vec![0u8; 8];
assert_eq!(store.get(&id, 0, &mut dst).await.unwrap(), 0);
let _ = tokio::fs::remove_dir_all(&base).await;
}
#[tokio::test]
async fn uring_get_short_read_at_tail() {
let (store, base) = temp_store(1024).await;
let id = PageId::new("file-uring-c", 0);
store.put(&id, b"abc").await.unwrap();
let mut dst = vec![0u8; 16];
let n = store.get(&id, 0, &mut dst).await.unwrap();
assert_eq!(n, 3);
assert_eq!(&dst[..3], b"abc");
let _ = tokio::fs::remove_dir_all(&base).await;
}
#[tokio::test]
async fn uring_delete_then_miss() {
let (store, base) = temp_store(1024).await;
let id = PageId::new("file-uring-d", 1);
store.put(&id, b"data").await.unwrap();
store.delete(&id).await.unwrap();
let mut dst = vec![0u8; 4];
assert_eq!(store.get(&id, 0, &mut dst).await.unwrap(), 0);
store.delete(&id).await.unwrap();
let _ = tokio::fs::remove_dir_all(&base).await;
}
#[tokio::test]
async fn uring_concurrent_get_same_page() {
let (store, base) = temp_store(1024).await;
let id = PageId::new("file-uring-conc", 0);
let data = vec![0x42u8; 64];
store.put(&id, &data).await.unwrap();
let store = Arc::new(store);
let mut handles = Vec::new();
for _ in 0..32 {
let store = Arc::clone(&store);
let id = id.clone();
handles.push(tokio::spawn(async move {
let mut dst = vec![0u8; 64];
let n = store.get(&id, 0, &mut dst).await.unwrap();
assert_eq!(n, 64);
assert_eq!(dst, vec![0x42u8; 64]);
}));
}
for h in handles {
h.await.unwrap();
}
let _ = tokio::fs::remove_dir_all(&base).await;
}
#[tokio::test]
async fn uring_repeated_reads() {
let (store, base) = temp_store(1024).await;
let id = PageId::new("file-repeat", 0);
store.put(&id, b"repeated-read-data").await.unwrap();
for _ in 0..10 {
let mut dst = vec![0u8; 18];
let n = store.get(&id, 0, &mut dst).await.unwrap();
assert_eq!(n, 18);
assert_eq!(&dst, b"repeated-read-data");
}
let _ = tokio::fs::remove_dir_all(&base).await;
}
#[tokio::test]
async fn uring_dir_fd_cache_reused_across_pages() {
let (store, base) = temp_store(1024).await;
let id0 = PageId::new("file-multi", 0);
let id1 = PageId::new("file-multi", 1);
let id2 = PageId::new("file-multi", 2);
store.put(&id0, b"page-zero-data!!").await.unwrap();
store.put(&id1, b"page-one-data!!!").await.unwrap();
store.put(&id2, b"page-two-data!!!").await.unwrap();
let mut dst = vec![0u8; 16];
assert_eq!(store.get(&id0, 0, &mut dst).await.unwrap(), 16);
assert_eq!(&dst, b"page-zero-data!!");
assert_eq!(store.get(&id1, 0, &mut dst).await.unwrap(), 16);
assert_eq!(&dst, b"page-one-data!!!");
assert_eq!(store.get(&id2, 0, &mut dst).await.unwrap(), 16);
assert_eq!(&dst, b"page-two-data!!!");
assert_eq!(store.dir_fd_cache.len(), 1);
let _ = tokio::fs::remove_dir_all(&base).await;
}
#[tokio::test]
async fn uring_dir_fd_cache_concurrent_different_pages() {
let (store, base) = temp_store(1024).await;
let file_id: Arc<str> = Arc::from("file-conc-multi");
for i in 0..32u64 {
let id = PageId::new(file_id.clone(), i);
store.put(&id, &[i as u8; 8]).await.unwrap();
}
let store = Arc::new(store);
let mut handles = Vec::new();
for i in 0..32u64 {
let store = Arc::clone(&store);
let file_id = file_id.clone();
handles.push(tokio::spawn(async move {
let id = PageId::new(file_id, i);
let mut dst = vec![0u8; 8];
let n = store.get(&id, 0, &mut dst).await.unwrap();
assert_eq!(n, 8);
assert_eq!(dst, vec![i as u8; 8]);
}));
}
for h in handles {
h.await.unwrap();
}
assert_eq!(store.dir_fd_cache.len(), 1);
let _ = tokio::fs::remove_dir_all(&base).await;
}
#[tokio::test]
async fn uring_get_batch_concurrent() {
let (store, base) = temp_store(1024).await;
let file_id: Arc<str> = Arc::from("file-batch");
let n_pages = 8u64;
for i in 0..n_pages {
let id = PageId::new(file_id.clone(), i);
let data: Vec<u8> = (0..16u8).map(|b| b.wrapping_add(i as u8)).collect();
store.put(&id, &data).await.unwrap();
}
let requests: Vec<(PageId, usize, usize)> = (0..n_pages)
.map(|i| (PageId::new(file_id.clone(), i), 0, 16))
.collect();
let mut bufs: Vec<Vec<u8>> = (0..n_pages).map(|_| vec![0u8; 16]).collect();
let results: Vec<&mut [u8]> = bufs.iter_mut().map(|b| b.as_mut_slice()).collect();
store
.get_batch(requests, results)
.await
.expect("batch read should succeed");
for (i, buf) in bufs.iter().enumerate() {
let expected: Vec<u8> = (0..16u8).map(|b| b.wrapping_add(i as u8)).collect();
assert_eq!(buf, &expected, "page {i} data mismatch");
}
let _ = tokio::fs::remove_dir_all(&base).await;
}
#[tokio::test]
async fn uring_get_batch_multi_file() {
let (store, base) = temp_store(1024).await;
let file_a: Arc<str> = Arc::from("file-a");
let file_b: Arc<str> = Arc::from("file-b");
for i in 0..3u64 {
store
.put(&PageId::new(file_a.clone(), i), &[0xA0 + i as u8; 8])
.await
.unwrap();
store
.put(&PageId::new(file_b.clone(), i), &[0xB0 + i as u8; 8])
.await
.unwrap();
}
let requests = vec![
(PageId::new(file_a.clone(), 0), 0, 8),
(PageId::new(file_b.clone(), 0), 0, 8),
(PageId::new(file_a.clone(), 1), 0, 8),
(PageId::new(file_b.clone(), 1), 0, 8),
];
let mut bufs: Vec<Vec<u8>> = (0..4).map(|_| vec![0u8; 8]).collect();
let results: Vec<&mut [u8]> = bufs.iter_mut().map(|b| b.as_mut_slice()).collect();
store.get_batch(requests, results).await.unwrap();
assert_eq!(
bufs[0],
vec![0xA0, 0xA0, 0xA0, 0xA0, 0xA0, 0xA0, 0xA0, 0xA0]
);
assert_eq!(
bufs[1],
vec![0xB0, 0xB0, 0xB0, 0xB0, 0xB0, 0xB0, 0xB0, 0xB0]
);
assert_eq!(
bufs[2],
vec![0xA1, 0xA1, 0xA1, 0xA1, 0xA1, 0xA1, 0xA1, 0xA1]
);
assert_eq!(
bufs[3],
vec![0xB1, 0xB1, 0xB1, 0xB1, 0xB1, 0xB1, 0xB1, 0xB1]
);
assert_eq!(store.dir_fd_cache.len(), 2);
let _ = tokio::fs::remove_dir_all(&base).await;
}
#[tokio::test]
async fn uring_identity_roundtrip() {
let (store, base) = temp_store(1024).await;
store
.write_identity("file-id-1", 4096, 1_700_000_000_000)
.await
.unwrap();
let identity = store.read_identity("file-id-1").await;
assert_eq!(identity, Some((4096, 1_700_000_000_000)));
store.delete_identity("file-id-1").await.unwrap();
assert_eq!(store.read_identity("file-id-1").await, None);
let _ = tokio::fs::remove_dir_all(&base).await;
}
fn unique_id(label: &str, idx: u64) -> PageId {
PageId::new(format!("file-{label}-{idx}-test"), idx)
}
async fn is_in_page_cache(id: &PageId) -> bool {
PAGE_FD_CACHE.get(id).await.is_some()
}
#[tokio::test]
async fn uring_page_fd_cache_hit_after_first_read() {
let (store, base) = temp_store(1024).await;
let id = unique_id("pgcache-hit", 0);
let data = b"page fd cache test data".to_vec();
store.put(&id, &data).await.unwrap();
let mut dst = vec![0u8; data.len()];
let n1 = store.get(&id, 0, &mut dst).await.unwrap();
assert_eq!(n1, data.len());
assert_eq!(&dst, &data);
assert!(
is_in_page_cache(&id).await,
"page fd cache should contain entry after first read"
);
let mut dst2 = vec![0u8; data.len()];
let n2 = store.get(&id, 0, &mut dst2).await.unwrap();
assert_eq!(n2, data.len());
assert_eq!(&dst2, &data);
assert!(
is_in_page_cache(&id).await,
"page fd cache should still contain entry (reuse)"
);
PAGE_FD_CACHE.invalidate(&id).await;
let _ = tokio::fs::remove_dir_all(&base).await;
}
#[tokio::test]
async fn uring_page_fd_cache_different_pages() {
let (store, base) = temp_store(1024).await;
let id0 = unique_id("multi-pg", 0);
let id1 = unique_id("multi-pg", 1);
let id2 = unique_id("multi-pg", 2);
store.put(&id0, b"page-zero!!").await.unwrap();
store.put(&id1, b"page-one!!").await.unwrap();
store.put(&id2, b"page-two!!").await.unwrap();
let mut dst = vec![0u8; 10];
assert_eq!(store.get(&id0, 0, &mut dst).await.unwrap(), 10);
assert_eq!(&dst, b"page-zero!!");
assert_eq!(store.get(&id1, 0, &mut dst).await.unwrap(), 10);
assert_eq!(&dst, b"page-one!!");
assert_eq!(store.get(&id2, 0, &mut dst).await.unwrap(), 10);
assert_eq!(&dst, b"page-two!!");
assert!(is_in_page_cache(&id0).await);
assert!(is_in_page_cache(&id1).await);
assert!(is_in_page_cache(&id2).await);
assert_eq!(store.dir_fd_cache.len(), 1);
assert_eq!(store.get(&id0, 0, &mut dst).await.unwrap(), 10);
assert_eq!(&dst, b"page-zero!!");
assert_eq!(store.get(&id1, 0, &mut dst).await.unwrap(), 10);
assert_eq!(&dst, b"page-one!!");
assert_eq!(store.get(&id2, 0, &mut dst).await.unwrap(), 10);
assert_eq!(&dst, b"page-two!!");
for id in [&id0, &id1, &id2] {
PAGE_FD_CACHE.invalidate(id).await;
}
let _ = tokio::fs::remove_dir_all(&base).await;
}
#[tokio::test]
async fn uring_page_fd_cache_concurrent_same_page() {
let (store, base) = temp_store(1024).await;
let id = unique_id("conc-pgcache", 0);
let data = vec![0xABu8; 64];
store.put(&id, &data).await.unwrap();
let store = Arc::new(store);
let mut handles = Vec::new();
for _ in 0..32 {
let store = Arc::clone(&store);
let id = id.clone();
handles.push(tokio::spawn(async move {
let mut dst = vec![0u8; 64];
let n = store.get(&id, 0, &mut dst).await.unwrap();
assert_eq!(n, 64);
assert_eq!(dst, vec![0xABu8; 64]);
}));
}
for h in handles {
h.await.unwrap();
}
assert!(
is_in_page_cache(&id).await,
"page fd cache should contain entry after concurrent reads"
);
PAGE_FD_CACHE.invalidate(&id).await;
let _ = tokio::fs::remove_dir_all(&base).await;
}
#[tokio::test]
async fn uring_page_fd_cache_invalidation_on_delete() {
let (store, base) = temp_store(1024).await;
let id = unique_id("invalidate-del", 0);
store.put(&id, b"will-be-deleted").await.unwrap();
let mut dst = vec![0u8; 16];
assert_eq!(store.get(&id, 0, &mut dst).await.unwrap(), 16);
assert!(is_in_page_cache(&id).await);
store.delete(&id).await.unwrap();
assert!(
!is_in_page_cache(&id).await,
"page fd cache should be empty after delete"
);
let n = store.get(&id, 0, &mut dst).await.unwrap();
assert_eq!(n, 0, "get should return 0 after delete");
let _ = tokio::fs::remove_dir_all(&base).await;
}
#[tokio::test]
async fn uring_page_fd_cache_invalidation_on_put_overwrite() {
let (store, base) = temp_store(1024).await;
let id = unique_id("overwrite", 0);
store.put(&id, b"old-data-here!").await.unwrap();
let mut dst = vec![0u8; 14];
assert_eq!(store.get(&id, 0, &mut dst).await.unwrap(), 14);
assert_eq!(&dst, b"old-data-here!");
assert!(is_in_page_cache(&id).await);
store.put(&id, b"new-data-here!!").await.unwrap();
assert!(
!is_in_page_cache(&id).await,
"page fd cache should be empty after overwrite"
);
let mut dst2 = vec![0u8; 15];
assert_eq!(store.get(&id, 0, &mut dst2).await.unwrap(), 15);
assert_eq!(&dst2, b"new-data-here!!");
PAGE_FD_CACHE.invalidate(&id).await;
let _ = tokio::fs::remove_dir_all(&base).await;
}
#[test]
fn path_buffer_with_nul_terminates_and_rejects_interior_nul() {
let buf = UringPageStore::path_buffer_with_nul("/tmp/page-42").unwrap();
assert_eq!(
*buf.last().unwrap(),
0,
"OP_OPENAT/OP_UNLINKAT path buffer must end with NUL"
);
assert_eq!(&buf[..buf.len() - 1], b"/tmp/page-42");
let err = UringPageStore::path_buffer_with_nul("bad\0name").unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
}
#[tokio::test]
async fn uring_get_bytes_returns_page_slice() {
let (store, base) = temp_store(1024).await;
let id = unique_id("get-bytes", 0);
store.put(&id, b"0123456789").await.unwrap();
let bytes = store.get_bytes(&id, 2, 5).await.unwrap();
assert_eq!(&bytes[..], b"23456");
let missing = store
.get_bytes(&unique_id("missing-bytes", 0), 0, 8)
.await
.unwrap();
assert!(missing.is_empty());
PAGE_FD_CACHE.invalidate(&id).await;
let _ = tokio::fs::remove_dir_all(&base).await;
}
}