use std::{
fs,
os::unix::fs::FileExt,
path::PathBuf,
sync::{
Arc, Mutex, OnceLock, Weak,
atomic::{AtomicU64, Ordering},
},
};
use async_trait::async_trait;
use bytes::Bytes;
use roaring::RoaringBitmap;
use super::disk::DiskCacheStore;
use crate::{
superfile::{LazyByteSource, LazyByteSourceError},
supertable::manifest::SuperfileUri,
};
const CACHE_BLOCK_BYTES: u64 = 512 * 1024;
struct BlockFile {
file: fs::File,
size: u64,
}
pub(crate) struct BlockCachedSource {
inner: Arc<dyn LazyByteSource>,
store: Weak<DiskCacheStore>,
uri: SuperfileUri,
path: PathBuf,
entry_token: Arc<()>,
owns_accounting: bool,
passthrough: Option<(u64, u64)>,
state: OnceLock<Option<BlockFile>>,
filled: Mutex<RoaringBitmap>,
filled_bytes: Arc<AtomicU64>,
}
impl BlockCachedSource {
#[cfg(test)]
pub(crate) fn new(
inner: Arc<dyn LazyByteSource>,
store: Weak<DiskCacheStore>,
uri: SuperfileUri,
path: PathBuf,
) -> Arc<Self> {
Self::new_with_accounting(inner, store, uri, path, true, None)
}
pub(crate) fn new_pre_reserved(
inner: Arc<dyn LazyByteSource>,
store: Weak<DiskCacheStore>,
uri: SuperfileUri,
path: PathBuf,
passthrough: Option<(u64, u64)>,
) -> Arc<Self> {
Self::new_with_accounting(inner, store, uri, path, false, passthrough)
}
fn new_with_accounting(
inner: Arc<dyn LazyByteSource>,
store: Weak<DiskCacheStore>,
uri: SuperfileUri,
path: PathBuf,
owns_accounting: bool,
passthrough: Option<(u64, u64)>,
) -> Arc<Self> {
Arc::new(Self {
inner,
store,
uri,
path,
entry_token: Arc::new(()),
owns_accounting,
passthrough,
state: OnceLock::new(),
filled: Mutex::new(RoaringBitmap::new()),
filled_bytes: Arc::new(AtomicU64::new(0)),
})
}
fn in_passthrough(&self, start: u64, len: u64) -> bool {
match self.passthrough {
Some((off, hole_len)) => start >= off && start + len <= off + hole_len,
None => false,
}
}
pub(crate) fn entry_token(&self) -> Arc<()> {
Arc::clone(&self.entry_token)
}
#[cfg(test)]
pub(crate) fn filled_bytes_handle(&self) -> Arc<AtomicU64> {
Arc::clone(&self.filled_bytes)
}
fn block_file(&self) -> Option<&BlockFile> {
let size = self.inner.size();
if size == 0 {
return None;
}
self.state
.get_or_init(|| {
let file = fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(&self.path)
.ok()?;
file.set_len(size).ok()?;
Some(BlockFile { file, size })
})
.as_ref()
}
fn block_len(size: u64, b: u32) -> u64 {
let start = u64::from(b) * CACHE_BLOCK_BYTES;
(size - start).min(CACHE_BLOCK_BYTES)
}
fn block_span(start: u64, len: u64) -> (u32, u32) {
let b0 = start / CACHE_BLOCK_BYTES;
let b1 = (start + len - 1) / CACHE_BLOCK_BYTES;
(b0 as u32, b1 as u32)
}
fn all_filled(&self, b0: u32, b1: u32) -> bool {
let filled = self.filled.lock().expect("filled bitmap mutex poisoned");
(b0..=b1).all(|b| filled.contains(b))
}
fn missing_runs(&self, b0: u32, b1: u32) -> Vec<(u32, u32)> {
let filled = self.filled.lock().expect("filled bitmap mutex poisoned");
let mut runs = Vec::new();
let mut run_start: Option<u32> = None;
for b in b0..=b1 {
if filled.contains(b) {
if let Some(s) = run_start.take() {
runs.push((s, b - 1));
}
} else if run_start.is_none() {
run_start = Some(b);
}
}
if let Some(s) = run_start {
runs.push((s, b1));
}
runs
}
fn mark_filled(&self, size: u64, b0: u32, b1: u32) -> u64 {
let mut filled = self.filled.lock().expect("filled bitmap mutex poisoned");
let mut newly = 0u64;
for b in b0..=b1 {
if filled.insert(b) {
newly += Self::block_len(size, b);
}
}
newly
}
fn read_local(&self, bf: &BlockFile, start: u64, len: u64) -> Option<Bytes> {
let mut out = vec![0u8; len as usize];
bf.file.read_exact_at(&mut out, start).ok()?;
Some(Bytes::from(out))
}
async fn fill_missing(
&self,
bf: &BlockFile,
b0: u32,
b1: u32,
) -> Result<bool, LazyByteSourceError> {
let Some(store) = self.store.upgrade() else {
return Ok(false);
};
if !store.lazy_block_entry_is_current(&self.uri, &self.entry_token) {
return Ok(false);
}
for (rb0, rb1) in self.missing_runs(b0, b1) {
let run_start = u64::from(rb0) * CACHE_BLOCK_BYTES;
let run_end = (u64::from(rb1) + 1) * CACHE_BLOCK_BYTES;
let run_len = run_end.min(bf.size) - run_start;
if self.owns_accounting && store.reserve_block_bytes(run_len).await.is_err() {
return Ok(false);
}
let bytes = match self.inner.range(run_start, run_len).await {
Ok(b) => b,
Err(e) => {
if self.owns_accounting {
store.release_block_bytes(run_len);
}
return Err(e);
}
};
if bf.file.write_all_at(&bytes, run_start).is_err() {
if self.owns_accounting {
store.release_block_bytes(run_len);
}
return Ok(false);
}
let newly = self.mark_filled(bf.size, rb0, rb1);
self.filled_bytes.fetch_add(newly, Ordering::AcqRel);
if self.owns_accounting && newly < run_len {
store.release_block_bytes(run_len - newly);
}
}
Ok(true)
}
}
impl Drop for BlockCachedSource {
fn drop(&mut self) {
if self.owns_accounting
&& let Some(store) = self.store.upgrade()
{
let filled = self.filled_bytes.load(Ordering::Acquire);
if filled > 0 {
store.release_block_bytes(filled);
}
}
if self.state.get().is_some_and(|s| s.is_some()) {
let _ = fs::remove_file(&self.path);
}
}
}
#[async_trait]
impl LazyByteSource for BlockCachedSource {
fn size(&self) -> u64 {
self.inner.size()
}
async fn range(&self, start: u64, len: u64) -> Result<Bytes, LazyByteSourceError> {
if len == 0 {
return Ok(Bytes::new());
}
if self.in_passthrough(start, len) {
return self.inner.range(start, len).await;
}
let Some(bf) = self.block_file() else {
return self.inner.range(start, len).await;
};
if start.saturating_add(len) > bf.size {
return self.inner.range(start, len).await;
}
let (b0, b1) = Self::block_span(start, len);
if !self.all_filled(b0, b1) && !self.fill_missing(bf, b0, b1).await? {
return self.inner.range(start, len).await;
}
match self.read_local(bf, start, len) {
Some(bytes) => Ok(bytes),
None => self.inner.range(start, len).await,
}
}
fn try_get_range_sync(&self, start: u64, len: u64) -> Option<Bytes> {
if len == 0 {
return Some(Bytes::new());
}
if self.in_passthrough(start, len) {
return self.inner.try_get_range_sync(start, len);
}
let bf = self.block_file()?;
if start.saturating_add(len) > bf.size {
return None;
}
let (b0, b1) = Self::block_span(start, len);
if !self.all_filled(b0, b1) {
return self.inner.try_get_range_sync(start, len);
}
self.read_local(bf, start, len)
}
async fn tail(&self, len: u64) -> Result<(Bytes, u64), LazyByteSourceError> {
self.inner.tail(len).await
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::AtomicUsize;
use tempfile::tempdir;
use super::*;
use crate::supertable::reader_cache::{ColdFetchMode, DiskCacheConfig, LruPolicy};
struct CountingSource {
blob: Bytes,
calls: AtomicUsize,
}
impl CountingSource {
fn new(n: usize) -> Self {
let blob: Vec<u8> = (0..n).map(|i| (i % 251) as u8).collect();
Self {
blob: Bytes::from(blob),
calls: AtomicUsize::new(0),
}
}
fn calls(&self) -> usize {
self.calls.load(Ordering::Acquire)
}
}
#[async_trait]
impl LazyByteSource for CountingSource {
fn size(&self) -> u64 {
self.blob.len() as u64
}
async fn range(&self, start: u64, len: u64) -> Result<Bytes, LazyByteSourceError> {
self.calls.fetch_add(1, Ordering::AcqRel);
let (s, e) = (start as usize, (start + len) as usize);
if e > self.blob.len() {
return Err(LazyByteSourceError::OutOfBounds {
start,
len,
size: self.blob.len() as u64,
});
}
Ok(self.blob.slice(s..e))
}
}
fn test_store(dir: &std::path::Path, budget: u64) -> Arc<DiskCacheStore> {
use std::{ops::Range, time::SystemTime};
use object_store::MultipartUpload;
use crate::storage::{ObjectMeta, StorageError, StorageProvider};
#[derive(Debug)]
struct NoopStorage;
fn unimplemented_err(uri: &str) -> StorageError {
StorageError::Permanent {
uri: uri.into(),
source: "noop storage".into(),
}
}
#[async_trait]
impl StorageProvider for NoopStorage {
async fn head(&self, uri: &str) -> Result<ObjectMeta, StorageError> {
let _ = uri;
Ok(ObjectMeta {
size: 0,
etag: None,
last_modified: SystemTime::UNIX_EPOCH,
})
}
async fn get(&self, uri: &str) -> Result<(Bytes, ObjectMeta), StorageError> {
Err(unimplemented_err(uri))
}
async fn get_range(&self, uri: &str, _r: Range<u64>) -> Result<Bytes, StorageError> {
Err(unimplemented_err(uri))
}
async fn put_atomic(
&self,
uri: &str,
_b: Bytes,
) -> Result<Option<String>, StorageError> {
Err(unimplemented_err(uri))
}
async fn put_if_match(
&self,
uri: &str,
_b: Bytes,
_etag: Option<&str>,
) -> Result<Option<String>, StorageError> {
Err(unimplemented_err(uri))
}
async fn put_multipart(
&self,
uri: &str,
) -> Result<Box<dyn MultipartUpload>, StorageError> {
Err(unimplemented_err(uri))
}
async fn delete(&self, _uri: &str) -> Result<(), StorageError> {
Ok(())
}
}
let cfg = DiskCacheConfig {
cache_root: dir.to_path_buf(),
disk_budget_bytes: budget,
cold_fetch_mode: ColdFetchMode::LazyForegroundWithBackgroundFill,
eviction: Box::new(LruPolicy::new()),
..DiskCacheConfig::default()
};
DiskCacheStore::new_unpinned(Arc::new(NoopStorage), cfg).expect("test store")
}
#[tokio::test]
async fn repeat_reads_are_served_locally_and_accounted_by_blocks() {
const OBJ: usize = 3 * CACHE_BLOCK_BYTES as usize + 1000;
let dir = tempdir().expect("tempdir");
let store = test_store(dir.path(), u64::MAX);
let uri = SuperfileUri::new_v4();
let inner = Arc::new(CountingSource::new(OBJ));
let src = BlockCachedSource::new(
Arc::clone(&inner) as Arc<dyn LazyByteSource>,
Arc::downgrade(&store),
uri,
dir.path().join("t.blocks"),
);
store.install_block_entry_for_test(uri, src.filled_bytes_handle(), src.entry_token());
let start = 100u64;
let len = 2 * CACHE_BLOCK_BYTES + 500;
let first = src.range(start, len).await.expect("first read");
assert_eq!(first, inner.blob.slice(100..(start + len) as usize));
assert_eq!(inner.calls(), 1, "one block-run GET for the miss");
let second = src.range(start, len).await.expect("second read");
assert_eq!(second, first);
assert_eq!(inner.calls(), 1, "repeat read must not touch the source");
let sub = src.range(start + 10, 100).await.expect("sub read");
assert_eq!(sub, inner.blob.slice(110..210));
let sync = src
.try_get_range_sync(start + 10, 100)
.expect("sync read of filled blocks");
assert_eq!(sync, sub);
assert_eq!(inner.calls(), 1);
let expected = 3 * CACHE_BLOCK_BYTES;
assert_eq!(src.filled_bytes_handle().load(Ordering::Acquire), expected);
assert_eq!(store.stats().current_bytes, expected);
let tail_start = 3 * CACHE_BLOCK_BYTES + 10;
let t = src.range(tail_start, 50).await.expect("tail block read");
assert_eq!(
t,
inner
.blob
.slice(tail_start as usize..tail_start as usize + 50)
);
assert_eq!(inner.calls(), 2);
assert_eq!(
src.filled_bytes_handle().load(Ordering::Acquire),
expected + 1000,
"trailing partial block accounts its real length"
);
let path = dir.path().join("t.blocks");
assert!(path.exists());
store.remove_block_entry_for_test(&uri);
drop(src);
assert_eq!(store.stats().current_bytes, 0);
assert!(!path.exists());
}
#[tokio::test]
async fn disjoint_missing_runs_fetch_separately() {
const OBJ: usize = 6 * CACHE_BLOCK_BYTES as usize;
let dir = tempdir().expect("tempdir");
let store = test_store(dir.path(), u64::MAX);
let uri = SuperfileUri::new_v4();
let inner = Arc::new(CountingSource::new(OBJ));
let src = BlockCachedSource::new(
Arc::clone(&inner) as Arc<dyn LazyByteSource>,
Arc::downgrade(&store),
uri,
dir.path().join("runs.blocks"),
);
store.install_block_entry_for_test(uri, src.filled_bytes_handle(), src.entry_token());
let b = CACHE_BLOCK_BYTES;
let _ = src.range(2 * b, 10).await.expect("fill middle block");
assert_eq!(inner.calls(), 1);
let got = src.range(b, 3 * b).await.expect("spanning read");
assert_eq!(got, inner.blob.slice(b as usize..(4 * b) as usize));
assert_eq!(inner.calls(), 3, "two missing runs around the filled block");
store.remove_block_entry_for_test(&uri);
}
#[tokio::test]
async fn stale_entry_degrades_to_passthrough() {
const OBJ: usize = 2 * CACHE_BLOCK_BYTES as usize;
let dir = tempdir().expect("tempdir");
let store = test_store(dir.path(), u64::MAX);
let uri = SuperfileUri::new_v4();
let inner = Arc::new(CountingSource::new(OBJ));
let src = BlockCachedSource::new(
Arc::clone(&inner) as Arc<dyn LazyByteSource>,
Arc::downgrade(&store),
uri,
dir.path().join("stale.blocks"),
);
let a = src.range(0, 64).await.expect("passthrough read");
let bb = src.range(0, 64).await.expect("passthrough read again");
assert_eq!(a, bb);
assert_eq!(inner.calls(), 2, "uncached passthrough on both reads");
assert_eq!(src.filled_bytes_handle().load(Ordering::Acquire), 0);
assert_eq!(store.stats().current_bytes, 0);
}
}