use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
use async_trait::async_trait;
use camel_api::CamelError;
use camel_api::cache::CacheEntry;
use camel_api::cache::CacheRepository;
use camel_api::cache::CacheStats;
use camel_api::cache::ContentType;
use parking_lot::Mutex;
use tokio::io::AsyncWriteExt;
use tokio_util::sync::CancellationToken;
use tracing::{info, warn};
pub type OffloadClock = Arc<dyn Fn() -> SystemTime + Send + Sync>;
pub fn default_offload_clock() -> OffloadClock {
Arc::new(SystemTime::now)
}
const TMP_NAME_ATTEMPTS: u32 = 8;
pub struct DiskOffloadRepository {
inner: Arc<dyn CacheRepository>,
dir: PathBuf,
stale_retention: Duration,
sweep_interval: Duration,
payload_max_ttl: Duration,
clock: OffloadClock,
sweep_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
}
impl DiskOffloadRepository {
pub fn new(
inner: Arc<dyn CacheRepository>,
dir: PathBuf,
stale_retention: Duration,
sweep_interval: Duration,
payload_max_ttl: Duration,
shutdown_token: CancellationToken,
) -> Self {
Self::with_clock(
inner,
dir,
stale_retention,
sweep_interval,
payload_max_ttl,
shutdown_token,
default_offload_clock(),
)
}
pub fn with_clock(
inner: Arc<dyn CacheRepository>,
dir: PathBuf,
stale_retention: Duration,
sweep_interval: Duration,
payload_max_ttl: Duration,
shutdown_token: CancellationToken,
clock: OffloadClock,
) -> Self {
let sweep_handle = spawn_sweeper(dir.clone(), sweep_interval, shutdown_token);
Self {
inner,
dir,
stale_retention,
sweep_interval,
payload_max_ttl,
clock,
sweep_handle: Mutex::new(Some(sweep_handle)),
}
}
async fn write_blob(
&self,
key: &str,
entry: &CacheEntry,
death_epoch: u64,
) -> std::io::Result<String> {
tokio::fs::create_dir_all(&self.dir).await?;
let dest_name = blob_filename(key, death_epoch, entry);
let dest_path = self.dir.join(&dest_name);
let (mut file, tmp_path) = self.open_tmp_exclusive(key, &dest_name).await?;
if let Err(e) = file.write_all(&entry.bytes).await {
let _ = tokio::fs::remove_file(&tmp_path).await;
return Err(e);
}
if let Err(e) = file.sync_all().await {
let _ = tokio::fs::remove_file(&tmp_path).await;
return Err(e);
}
if let Err(e) = tokio::fs::rename(&tmp_path, &dest_path).await {
let _ = tokio::fs::remove_file(&tmp_path).await;
return Err(e);
}
self.fsync_dir_best_effort().await;
Ok(dest_name)
}
async fn open_tmp_exclusive(
&self,
key: &str,
dest_name: &str,
) -> std::io::Result<(tokio::fs::File, PathBuf)> {
let clock_nanos = (self.clock)()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let mut last_collision: Option<std::io::Error> = None;
for attempt in 0..TMP_NAME_ATTEMPTS {
let mut hasher = blake3::Hasher::new();
hasher.update(key.as_bytes());
hasher.update(&clock_nanos.to_le_bytes());
hasher.update(&attempt.to_le_bytes());
let nonce = hasher_128hex(hasher);
let tmp_path = self.dir.join(format!("{dest_name}.{nonce}.tmp"));
match tokio::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp_path)
.await
{
Ok(file) => return Ok((file, tmp_path)),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
last_collision = Some(e);
}
Err(e) => return Err(e),
}
}
Err(last_collision
.unwrap_or_else(|| std::io::Error::other("tmp blob name collisions exhausted")))
}
async fn fsync_dir_best_effort(&self) {
let result = match tokio::fs::File::open(&self.dir).await {
Ok(dir_file) => dir_file.sync_all().await,
Err(e) => Err(e),
};
if let Err(e) = result {
warn!(
dir = %self.dir.display(),
error = %e,
"cache blob directory fsync failed (best-effort, ignored)"
);
}
}
async fn hydrate(
&self,
key: &str,
mut entry: CacheEntry,
) -> Result<Option<CacheEntry>, CamelError> {
let Some(raw_path) = entry.payload_path.clone() else {
return Ok(Some(entry));
};
let Some(name) = sanitize_blob_name(&raw_path) else {
warn!(
key = key,
backend = self.inner.name(),
payload_path = %raw_path,
"corrupt cache row: payload_path must be a bare file name; treating as miss"
);
return Ok(None);
};
let blob_path = self.dir.join(name);
match tokio::fs::read(&blob_path).await {
Ok(bytes) => {
entry.bytes = bytes;
entry.payload_path = None;
Ok(Some(entry))
}
Err(e)
if matches!(
e.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) =>
{
warn!(
key = key,
backend = self.inner.name(),
blob = %blob_path.display(),
"cache payload blob gone; treating as miss"
);
Ok(None)
}
Err(e) => Err(CamelError::Io(format!(
"cache payload blob read '{}': {e}",
blob_path.display()
))),
}
}
async fn unlink_payload_dir_best_effort(&self) {
let mut read_dir = match tokio::fs::read_dir(&self.dir).await {
Ok(read_dir) => read_dir,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
Err(e) => {
warn!(
dir = %self.dir.display(),
error = %e,
"cache payload dir read failed during clear (best-effort, skipped)"
);
return;
}
};
loop {
let entry = match read_dir.next_entry().await {
Ok(Some(entry)) => entry,
Ok(None) => return,
Err(e) => {
warn!(
dir = %self.dir.display(),
error = %e,
"cache payload dir iteration failed during clear (best-effort, stopped)"
);
return;
}
};
let path = entry.path();
match tokio::fs::remove_file(&path).await {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
warn!(
dir = %self.dir.display(),
blob = %path.display(),
error = %e,
"cache payload blob unlink failed during clear (best-effort, skipped)"
);
}
}
}
}
}
#[async_trait]
impl CacheRepository for DiskOffloadRepository {
fn name(&self) -> &str {
self.inner.name()
}
async fn get(&self, key: &str) -> Result<Option<CacheEntry>, CamelError> {
match self.inner.get(key).await? {
Some(entry) => self.hydrate(key, entry).await,
None => Ok(None),
}
}
async fn set(
&self,
key: &str,
mut entry: CacheEntry,
ttl: Option<Duration>,
) -> Result<(), CamelError> {
let effective_ttl = ttl.unwrap_or(self.payload_max_ttl);
let death_epoch = (self.clock)()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.saturating_add(effective_ttl)
.saturating_add(self.stale_retention)
.saturating_add(self.sweep_interval)
.as_secs();
match self.write_blob(key, &entry, death_epoch).await {
Ok(dest_name) => {
entry.bytes = Vec::new();
entry.payload_path = Some(dest_name);
self.inner.set(key, entry, Some(effective_ttl)).await
}
Err(e) => {
warn!(
key = key,
backend = self.inner.name(),
dir = %self.dir.display(),
error = %e,
"cache blob write failed; storing entry inline instead"
);
self.inner.set(key, entry, Some(effective_ttl)).await
}
}
}
async fn peek_stale(&self, key: &str) -> Result<Option<CacheEntry>, CamelError> {
match self.inner.peek_stale(key).await? {
Some(entry) => self.hydrate(key, entry).await,
None => Ok(None),
}
}
async fn invalidate(&self, key: &str) -> Result<(), CamelError> {
self.inner.invalidate(key).await
}
async fn clear(&self) -> Result<(), CamelError> {
self.unlink_payload_dir_best_effort().await;
self.inner.clear().await
}
async fn invalidate_prefix(&self, prefix: &str) -> Result<u64, CamelError> {
self.inner.invalidate_prefix(prefix).await
}
async fn stats(&self) -> CacheStats {
self.inner.stats().await
}
}
impl std::fmt::Debug for DiskOffloadRepository {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DiskOffloadRepository")
.field("inner", &self.inner)
.field("dir", &self.dir)
.field("stale_retention", &self.stale_retention)
.field("sweep_interval", &self.sweep_interval)
.field("payload_max_ttl", &self.payload_max_ttl)
.field("sweep_attached", &self.sweep_handle.lock().is_some())
.finish()
}
}
impl Drop for DiskOffloadRepository {
fn drop(&mut self) {
if let Some(handle) = self.sweep_handle.lock().take() {
handle.abort();
}
}
}
fn content_type_discriminant(content_type: ContentType) -> u8 {
match content_type {
ContentType::Bytes => 0,
ContentType::Text => 1,
ContentType::Json => 2,
ContentType::Xml => 3,
}
}
fn hasher_128hex(hasher: blake3::Hasher) -> String {
let hex = hasher.finalize().to_hex().to_string();
hex[..32].to_string()
}
fn blake3_128hex(data: &[u8]) -> String {
let mut hasher = blake3::Hasher::new();
hasher.update(data);
hasher_128hex(hasher)
}
fn content_fingerprint(entry: &CacheEntry) -> String {
let mut hasher = blake3::Hasher::new();
hasher.update(&entry.bytes);
hasher.update(&[content_type_discriminant(entry.content_type)]);
hasher_128hex(hasher)
}
fn blob_filename(key: &str, death_epoch: u64, entry: &CacheEntry) -> String {
format!(
"{}.{}.{}.blob",
blake3_128hex(key.as_bytes()),
death_epoch,
content_fingerprint(entry)
)
}
fn parse_death_epoch(file_name: &str) -> Option<u64> {
file_name.split('.').nth(1)?.parse().ok()
}
fn sanitize_blob_name(path: &str) -> Option<&str> {
if path.is_empty() || path.contains('/') || path.contains('\\') || path.contains("..") {
return None;
}
Some(path)
}
async fn unlink_payload_file(
path: &Path,
now: SystemTime,
sweep_interval: Duration,
) -> std::io::Result<bool> {
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
return Ok(false);
};
let now_secs = now.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
let dead = if name.ends_with(".blob") {
parse_death_epoch(name).is_some_and(|death| death < now_secs)
} else if name.ends_with(".tmp") {
let threshold = now.checked_sub(sweep_interval).unwrap_or(UNIX_EPOCH);
let mtime = match tokio::fs::metadata(path).await {
Ok(meta) => meta.modified()?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(e) => return Err(e),
};
mtime < threshold
} else {
return Ok(false);
};
if !dead {
return Ok(false);
}
match tokio::fs::remove_file(path).await {
Ok(()) => Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(e),
}
}
#[derive(Debug, Default, PartialEq, Eq, Clone, Copy)]
struct SweepStats {
blobs_unlinked: u64,
blob_bytes_reclaimed: u64,
tmps_unlinked: u64,
live_blobs: u64,
live_blob_bytes: u64,
}
async fn sweep_payload_dir(dir: &Path, now: SystemTime, sweep_interval: Duration) -> SweepStats {
let mut read_dir = match tokio::fs::read_dir(dir).await {
Ok(read_dir) => read_dir,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return SweepStats::default(),
Err(e) => {
warn!(
dir = %dir.display(),
error = %e,
"cache payload dir read failed during sweep (skipped)"
);
return SweepStats::default();
}
};
let mut stats = SweepStats::default();
loop {
let entry = match read_dir.next_entry().await {
Ok(Some(entry)) => entry,
Ok(None) => break,
Err(e) => {
warn!(
dir = %dir.display(),
error = %e,
"cache payload dir iteration failed during sweep (stopped)"
);
break;
}
};
let path = entry.path();
let is_tmp = path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.ends_with(".tmp"));
let size = entry.metadata().await.map(|m| m.len()).unwrap_or(0);
match unlink_payload_file(&path, now, sweep_interval).await {
Ok(true) => {
if is_tmp {
stats.tmps_unlinked += 1;
} else {
stats.blobs_unlinked += 1;
stats.blob_bytes_reclaimed += size;
}
}
Ok(false) => {
if !is_tmp {
stats.live_blobs += 1;
stats.live_blob_bytes += size;
}
}
Err(e) => warn!(
dir = %dir.display(),
file = %path.display(),
error = %e,
"cache payload file unlink failed during sweep (skipped)"
),
}
}
stats
}
fn spawn_sweeper(
dir: PathBuf,
sweep_interval: Duration,
shutdown_token: CancellationToken,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(sweep_interval);
loop {
tokio::select! {
_ = ticker.tick() => {
let s = sweep_payload_dir(&dir, SystemTime::now(), sweep_interval).await;
info!(
dir = %dir.display(),
live_blobs = s.live_blobs,
live_blob_bytes = s.live_blob_bytes,
blobs_unlinked = s.blobs_unlinked,
blob_bytes_reclaimed = s.blob_bytes_reclaimed,
tmps_unlinked = s.tmps_unlinked,
"cache payload sweep pass"
);
}
_ = shutdown_token.cancelled() => break,
}
}
})
}
#[cfg(test)]
#[path = "disk_offload_tests.rs"]
mod disk_offload_tests;