use crate::{
CacheKey, CachePolicy, CacheStorage, PutHandle, StoredEntry, fs_shims, policy::PolicyRepr,
};
use futures_lite::{AsyncRead, AsyncWrite, AsyncWriteExt};
use moka::{notification::RemovalCause, sync::Cache};
use sha2::{Digest, Sha256};
use std::{
fmt::{self, Debug, Formatter, Write as _},
io,
path::{Path, PathBuf},
pin::Pin,
sync::{
Arc,
atomic::{AtomicU64, Ordering},
},
task::{Context, Poll},
};
use trillium_http::{Body, BodySource, Headers};
const META_SUFFIX: &str = ".meta";
const BODY_SUFFIX: &str = ".body";
const DEFAULT_MAX_CAPACITY_BYTES: u64 = 1024 * 1024 * 1024;
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
#[derive(Clone)]
pub struct FileSystemStorage {
root: Arc<PathBuf>,
index: Cache<VariantId, u64>,
max_capacity_bytes: Option<u64>,
}
impl Debug for FileSystemStorage {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.debug_struct("FileSystemStorage")
.field("root", &self.root)
.field("weighted_size", &self.index.weighted_size())
.field("max_capacity_bytes", &self.max_capacity_bytes)
.finish()
}
}
impl FileSystemStorage {
pub fn new(root: impl Into<PathBuf>) -> Self {
let root = Arc::new(root.into());
let max_capacity_bytes = Some(DEFAULT_MAX_CAPACITY_BYTES);
let index = build_index(Arc::clone(&root), max_capacity_bytes);
scan_root(&root, &index);
Self {
root,
index,
max_capacity_bytes,
}
}
pub fn with_max_capacity_bytes(mut self, bytes: u64) -> Self {
self.max_capacity_bytes = Some(bytes);
self.rebuild();
self
}
pub fn unbounded(mut self) -> Self {
self.max_capacity_bytes = None;
self.rebuild();
self
}
pub fn weighted_size(&self) -> u64 {
self.index.weighted_size()
}
pub fn entry_count(&self) -> u64 {
self.index.entry_count()
}
pub async fn run_pending_tasks(&self) {
self.index.run_pending_tasks();
}
fn rebuild(&mut self) {
self.index = build_index(Arc::clone(&self.root), self.max_capacity_bytes);
scan_root(&self.root, &self.index);
}
}
#[derive(Clone, Hash, PartialEq, Eq)]
struct VariantId {
key_hash: String,
variant_hash: String,
}
fn build_index(root: Arc<PathBuf>, max_capacity_bytes: Option<u64>) -> Cache<VariantId, u64> {
let mut builder = Cache::<VariantId, u64>::builder()
.weigher(|_key, &body_len| u32::try_from(body_len).unwrap_or(u32::MAX))
.eviction_listener(move |id: Arc<VariantId>, _body_len, cause: RemovalCause| {
if cause.was_evicted() {
let dir = root.join(&id.key_hash);
let _ = std::fs::remove_file(dir.join(format!("{}{META_SUFFIX}", id.variant_hash)));
let _ = std::fs::remove_file(dir.join(format!("{}{BODY_SUFFIX}", id.variant_hash)));
}
});
if let Some(cap) = max_capacity_bytes {
builder = builder.max_capacity(cap);
}
builder.build()
}
fn scan_root(root: &Path, index: &Cache<VariantId, u64>) {
let Ok(key_dirs) = std::fs::read_dir(root) else {
return;
};
for key_entry in key_dirs.flatten() {
let key_dir = key_entry.path();
let Some(key_hash) = file_stem_string(&key_dir) else {
continue;
};
let Ok(files) = std::fs::read_dir(&key_dir) else {
continue;
};
for file in files.flatten() {
let path = file.path();
let Some(variant_hash) = path
.file_name()
.and_then(|name| name.to_str())
.and_then(|name| name.strip_suffix(META_SUFFIX))
.map(str::to_string)
else {
continue;
};
let body = key_dir.join(format!("{variant_hash}{BODY_SUFFIX}"));
let Ok(metadata) = std::fs::metadata(&body) else {
continue;
};
index.insert(
VariantId {
key_hash: key_hash.clone(),
variant_hash,
},
metadata.len(),
);
}
}
index.run_pending_tasks();
}
fn file_stem_string(path: &Path) -> Option<String> {
path.file_name()
.and_then(|name| name.to_str())
.map(str::to_string)
}
#[derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
struct StoredMeta {
policy: PolicyRepr,
trailers: Option<Headers>,
}
impl CacheStorage for FileSystemStorage {
type PutHandle = FsPutHandle;
type StoredEntry = FsStoredEntry;
async fn get(&self, key: &CacheKey) -> Vec<Self::StoredEntry> {
let key_hash = key_hash(key);
let dir = self.root.join(&key_hash);
let Ok(paths) = fs_shims::read_dir_paths(&dir).await else {
return Vec::new();
};
let mut entries = Vec::new();
for path in paths {
let Some(variant_hash) = path
.file_name()
.and_then(|name| name.to_str())
.and_then(|name| name.strip_suffix(META_SUFFIX))
.map(str::to_string)
else {
continue;
};
let Ok(bytes) = fs_shims::read(&path).await else {
continue;
};
let Ok(meta) = deserialize_meta(&bytes) else {
continue;
};
self.index.get(&VariantId {
key_hash: key_hash.clone(),
variant_hash: variant_hash.clone(),
});
entries.push(FsStoredEntry {
meta_path: path,
body_path: dir.join(format!("{variant_hash}{BODY_SUFFIX}")),
policy: meta.policy.into(),
trailers: meta.trailers,
});
}
entries
}
async fn put(&self, key: CacheKey, policy: CachePolicy) -> io::Result<Self::PutHandle> {
let key_hash = key_hash(&key);
let dir = self.root.join(&key_hash);
fs_shims::create_dir_all(&dir).await?;
let variant_hash = variant_hash(&policy);
let n = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let body_tmp = dir.join(format!("{variant_hash}{BODY_SUFFIX}.tmp.{n}"));
let writer = fs_shims::create(&body_tmp).await?;
Ok(FsPutHandle {
writer,
body_tmp,
body_final: dir.join(format!("{variant_hash}{BODY_SUFFIX}")),
meta_tmp: dir.join(format!("{variant_hash}{META_SUFFIX}.tmp.{n}")),
meta_final: dir.join(format!("{variant_hash}{META_SUFFIX}")),
policy,
index: self.index.clone(),
variant_id: VariantId {
key_hash,
variant_hash,
},
written: 0,
committed: false,
})
}
async fn invalidate(&self, key: &CacheKey) {
let key_hash = key_hash(key);
let dir = self.root.join(&key_hash);
if let Ok(paths) = fs_shims::read_dir_paths(&dir).await {
for path in paths {
if let Some(variant_hash) = path
.file_name()
.and_then(|name| name.to_str())
.and_then(|name| name.strip_suffix(META_SUFFIX))
{
self.index.invalidate(&VariantId {
key_hash: key_hash.clone(),
variant_hash: variant_hash.to_string(),
});
}
}
}
let _ = fs_shims::remove_dir_all(&dir).await;
}
}
pub struct FsPutHandle {
writer: fs_shims::Writer,
body_tmp: PathBuf,
body_final: PathBuf,
meta_tmp: PathBuf,
meta_final: PathBuf,
policy: CachePolicy,
index: Cache<VariantId, u64>,
variant_id: VariantId,
written: u64,
committed: bool,
}
impl Debug for FsPutHandle {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.debug_struct("FsPutHandle")
.field("body_final", &self.body_final)
.finish_non_exhaustive()
}
}
impl AsyncWrite for FsPutHandle {
fn poll_write(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
let this = self.get_mut();
let poll = Pin::new(&mut this.writer).poll_write(cx, buf);
if let Poll::Ready(Ok(n)) = &poll {
this.written += *n as u64;
}
poll
}
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.get_mut().writer).poll_flush(cx)
}
fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.get_mut().writer).poll_close(cx)
}
}
impl PutHandle for FsPutHandle {
async fn finalize(mut self, trailers: Option<Headers>) -> io::Result<()> {
self.writer.close().await?;
fs_shims::rename(&self.body_tmp, &self.body_final).await?;
let meta = StoredMeta {
policy: PolicyRepr::from(&self.policy),
trailers,
};
let bytes = serialize_meta(&meta)?;
fs_shims::write(&self.meta_tmp, &bytes).await?;
fs_shims::rename(&self.meta_tmp, &self.meta_final).await?;
self.index.insert(self.variant_id.clone(), self.written);
self.committed = true;
Ok(())
}
}
impl Drop for FsPutHandle {
fn drop(&mut self) {
if !self.committed {
let _ = std::fs::remove_file(&self.body_tmp);
}
}
}
#[derive(Clone)]
pub struct FsStoredEntry {
meta_path: PathBuf,
body_path: PathBuf,
policy: CachePolicy,
trailers: Option<Headers>,
}
impl Debug for FsStoredEntry {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
f.debug_struct("FsStoredEntry")
.field("body_path", &self.body_path)
.field("has_trailers", &self.trailers.is_some())
.finish_non_exhaustive()
}
}
impl StoredEntry for FsStoredEntry {
fn policy(&self) -> &CachePolicy {
&self.policy
}
async fn refresh_policy(&mut self, new_policy: CachePolicy) -> io::Result<()> {
let meta = StoredMeta {
policy: PolicyRepr::from(&new_policy),
trailers: self.trailers.clone(),
};
let bytes = serialize_meta(&meta)?;
let tmp = temp_sibling(&self.meta_path);
fs_shims::write(&tmp, &bytes).await?;
fs_shims::rename(&tmp, &self.meta_path).await?;
self.policy = new_policy;
Ok(())
}
async fn open(self) -> io::Result<Body> {
let len = fs_shims::metadata_len(&self.body_path).await?;
let reader = fs_shims::open(&self.body_path).await?;
let source = FsBodySource {
reader,
trailers: self.trailers,
};
Ok(Body::new_with_trailers(source, Some(len)))
}
}
struct FsBodySource {
reader: fs_shims::Reader,
trailers: Option<Headers>,
}
impl AsyncRead for FsBodySource {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut [u8],
) -> Poll<io::Result<usize>> {
Pin::new(&mut self.get_mut().reader).poll_read(cx, buf)
}
}
impl BodySource for FsBodySource {
fn trailers(self: Pin<&mut Self>) -> Option<Headers> {
self.get_mut().trailers.take()
}
}
fn hash_hex(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
finalize_hex(hasher)
}
fn key_hash(key: &CacheKey) -> String {
hash_hex(key.to_string().as_bytes())
}
fn variant_hash(policy: &CachePolicy) -> String {
let mut hasher = Sha256::new();
for (name, value) in &policy.vary_snapshot {
hasher.update(name.as_bytes());
hasher.update([0]);
match value {
Some(value) => {
hasher.update([1]);
hasher.update(value.as_bytes());
}
None => hasher.update([0]),
}
hasher.update([0]);
}
finalize_hex(hasher)
}
fn finalize_hex(hasher: Sha256) -> String {
let digest = hasher.finalize();
let mut out = String::with_capacity(digest.len() * 2);
for byte in digest {
write!(out, "{byte:02x}").expect("writing to a String cannot fail");
}
out
}
fn temp_sibling(path: &Path) -> PathBuf {
let n = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let mut name = path.as_os_str().to_owned();
name.push(format!(".tmp.{n}"));
PathBuf::from(name)
}
fn serialize_meta(meta: &StoredMeta) -> io::Result<rkyv::util::AlignedVec> {
rkyv::to_bytes::<rkyv::rancor::Error>(meta)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
}
fn deserialize_meta(bytes: &[u8]) -> io::Result<StoredMeta> {
let mut aligned = rkyv::util::AlignedVec::<16>::new();
aligned.extend_from_slice(bytes);
rkyv::from_bytes::<StoredMeta, rkyv::rancor::Error>(&aligned)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::*;
use futures_lite::{AsyncReadExt, AsyncWriteExt};
use std::time::{Duration, SystemTime};
use tempfile::TempDir;
use trillium_client::Conn;
use trillium_http::{KnownHeaderName::*, Method, Status};
use trillium_testing::{TestResult, harness, test};
fn key() -> CacheKey {
CacheKey::new(Method::Get, "http://example.com/".parse().unwrap())
}
fn new_storage() -> (TempDir, FileSystemStorage) {
let dir = tempfile::tempdir().unwrap();
let storage = FileSystemStorage::new(dir.path());
(dir, storage)
}
async fn store_at(storage: &FileSystemStorage, url: &str, body: &[u8]) {
let key = CacheKey::new(Method::Get, url.parse().unwrap());
let conn = exchange(
Method::Get,
&[],
Status::Ok,
&[(CacheControl, "max-age=600")],
);
let policy = policy_from(&conn, SystemTime::now(), private_cache());
let mut handle = storage.put(key, policy).await.unwrap();
handle.write_all(body).await.unwrap();
handle.finalize(None).await.unwrap();
}
async fn store(storage: &FileSystemStorage, key: CacheKey, conn: &Conn, body: &[u8]) {
let policy = policy_from(conn, SystemTime::now(), private_cache());
let mut handle = storage.put(key, policy).await.unwrap();
handle.write_all(body).await.unwrap();
handle.finalize(None).await.unwrap();
}
async fn read_body(entry: FsStoredEntry) -> Vec<u8> {
let mut body = entry.open().await.unwrap();
let mut buf = Vec::new();
body.read_to_end(&mut buf).await.unwrap();
buf
}
#[test(harness)]
async fn get_missing_key_returns_empty() -> TestResult {
let (_dir, storage) = new_storage();
assert!(storage.get(&key()).await.is_empty());
Ok(())
}
#[test(harness)]
async fn put_then_get_round_trips_through_disk() -> TestResult {
let (_dir, storage) = new_storage();
let conn = exchange(
Method::Get,
&[],
Status::Ok,
&[(CacheControl, "max-age=600")],
);
store(&storage, key(), &conn, b"hello").await;
let result = storage.get(&key()).await;
assert_eq!(result.len(), 1);
assert_eq!(read_body(result[0].clone()).await, b"hello");
Ok(())
}
#[test(harness)]
async fn put_with_same_vary_replaces() -> TestResult {
let (_dir, storage) = new_storage();
let conn = exchange(
Method::Get,
&[(AcceptEncoding, "gzip")],
Status::Ok,
&[(CacheControl, "max-age=600"), (Vary, "Accept-Encoding")],
);
store(&storage, key(), &conn, b"v1").await;
store(&storage, key(), &conn, b"v2").await;
let result = storage.get(&key()).await;
assert_eq!(result.len(), 1);
assert_eq!(read_body(result[0].clone()).await, b"v2");
Ok(())
}
#[test(harness)]
async fn put_with_different_vary_appends() -> TestResult {
let (_dir, storage) = new_storage();
let gzip = exchange(
Method::Get,
&[(AcceptEncoding, "gzip")],
Status::Ok,
&[(CacheControl, "max-age=600"), (Vary, "Accept-Encoding")],
);
let br = exchange(
Method::Get,
&[(AcceptEncoding, "br")],
Status::Ok,
&[(CacheControl, "max-age=600"), (Vary, "Accept-Encoding")],
);
store(&storage, key(), &gzip, b"gz").await;
store(&storage, key(), &br, b"br").await;
assert_eq!(storage.get(&key()).await.len(), 2);
Ok(())
}
#[test(harness)]
async fn invalidate_removes_all_entries_for_key() -> TestResult {
let (_dir, storage) = new_storage();
let conn = exchange(
Method::Get,
&[],
Status::Ok,
&[(CacheControl, "max-age=600")],
);
store(&storage, key(), &conn, b"x").await;
storage.invalidate(&key()).await;
assert!(storage.get(&key()).await.is_empty());
Ok(())
}
#[test(harness)]
async fn invalidate_does_not_touch_other_keys() -> TestResult {
let (_dir, storage) = new_storage();
let conn = exchange(
Method::Get,
&[],
Status::Ok,
&[(CacheControl, "max-age=600")],
);
let key_a = CacheKey::new(Method::Get, "http://a.example/".parse().unwrap());
let key_b = CacheKey::new(Method::Get, "http://b.example/".parse().unwrap());
store(&storage, key_a.clone(), &conn, b"a").await;
store(&storage, key_b.clone(), &conn, b"b").await;
storage.invalidate(&key_a).await;
assert!(storage.get(&key_a).await.is_empty());
assert_eq!(storage.get(&key_b).await.len(), 1);
Ok(())
}
#[test(harness)]
async fn drop_put_handle_without_finalize_discards() -> TestResult {
let (_dir, storage) = new_storage();
let conn = exchange(
Method::Get,
&[],
Status::Ok,
&[(CacheControl, "max-age=600")],
);
let policy = policy_from(&conn, SystemTime::now(), private_cache());
let mut handle = storage.put(key(), policy).await.unwrap();
handle.write_all(b"partial").await.unwrap();
drop(handle);
assert!(storage.get(&key()).await.is_empty());
Ok(())
}
#[test(harness)]
async fn refresh_policy_updates_meta_and_keeps_body() -> TestResult {
let (_dir, storage) = new_storage();
let conn = exchange(
Method::Get,
&[],
Status::Ok,
&[(CacheControl, "max-age=600")],
);
store(&storage, key(), &conn, b"body").await;
let mut entries = storage.get(&key()).await;
let original_time = entries[0].policy().response_time;
let refreshed = exchange(
Method::Get,
&[],
Status::Ok,
&[(CacheControl, "max-age=1200")],
);
let new_policy = policy_from(
&refreshed,
original_time + Duration::from_secs(100),
private_cache(),
);
entries[0].refresh_policy(new_policy).await.unwrap();
let fresh = storage.get(&key()).await;
assert_eq!(fresh.len(), 1);
assert_ne!(fresh[0].policy().response_time, original_time);
assert_eq!(read_body(fresh[0].clone()).await, b"body");
Ok(())
}
#[test(harness)]
async fn trailers_round_trip() -> TestResult {
let (_dir, storage) = new_storage();
let conn = exchange(
Method::Get,
&[],
Status::Ok,
&[(CacheControl, "max-age=600")],
);
let policy = policy_from(&conn, SystemTime::now(), private_cache());
let mut handle = storage.put(key(), policy).await.unwrap();
handle.write_all(b"data").await.unwrap();
let mut trailers = Headers::new();
trailers.insert("x-checksum", "abc123");
handle.finalize(Some(trailers)).await.unwrap();
let entry = storage.get(&key()).await.remove(0);
let mut body = entry.open().await.unwrap();
let mut buf = Vec::new();
body.read_to_end(&mut buf).await.unwrap();
assert_eq!(buf, b"data");
let trailers = body
.trailers()
.expect("stored trailers should surface after EOF");
assert_eq!(trailers.get_str("x-checksum"), Some("abc123"));
Ok(())
}
#[test(harness)]
async fn persists_across_new_storage_on_same_root() -> TestResult {
let dir = tempfile::tempdir().unwrap();
let conn = exchange(
Method::Get,
&[],
Status::Ok,
&[(CacheControl, "max-age=600")],
);
{
let storage = FileSystemStorage::new(dir.path());
store(&storage, key(), &conn, b"persisted").await;
}
let reopened = FileSystemStorage::new(dir.path());
let result = reopened.get(&key()).await;
assert_eq!(result.len(), 1);
assert_eq!(read_body(result[0].clone()).await, b"persisted");
Ok(())
}
#[test(harness)]
async fn size_cap_evicts_and_deletes_files() -> TestResult {
let dir = tempfile::tempdir().unwrap();
let storage = FileSystemStorage::new(dir.path()).with_max_capacity_bytes(1024);
let body = vec![b'x'; 600];
for i in 0..10 {
store_at(&storage, &format!("http://example.com/{i}"), &body).await;
}
storage.run_pending_tasks().await;
assert!(
storage.weighted_size() <= 1024,
"weighted size {} should be within cap of 1024",
storage.weighted_size()
);
let reopened = FileSystemStorage::new(dir.path()).unbounded();
assert!(
reopened.weighted_size() <= 1024,
"on-disk bytes {} should be within cap of 1024",
reopened.weighted_size()
);
Ok(())
}
#[test(harness)]
async fn rebuild_scan_trims_over_cap_directory() -> TestResult {
let dir = tempfile::tempdir().unwrap();
let body = vec![b'x'; 600];
{
let unbounded = FileSystemStorage::new(dir.path()).unbounded();
for i in 0..10 {
store_at(&unbounded, &format!("http://example.com/{i}"), &body).await;
}
unbounded.run_pending_tasks().await;
assert_eq!(unbounded.entry_count(), 10);
}
let capped = FileSystemStorage::new(dir.path()).with_max_capacity_bytes(1024);
assert!(
capped.weighted_size() <= 1024,
"weighted size {} should be within cap of 1024",
capped.weighted_size()
);
Ok(())
}
#[test(harness)]
async fn unbounded_keeps_all_entries() -> TestResult {
let dir = tempfile::tempdir().unwrap();
let storage = FileSystemStorage::new(dir.path()).unbounded();
let body = vec![b'x'; 600];
for i in 0..10 {
store_at(&storage, &format!("http://example.com/{i}"), &body).await;
}
storage.run_pending_tasks().await;
assert_eq!(storage.entry_count(), 10);
assert_eq!(storage.weighted_size(), 6000);
Ok(())
}
#[test(harness)]
async fn replacing_a_variant_does_not_double_count() -> TestResult {
let (_dir, storage) = new_storage();
store_at(&storage, "http://example.com/", &vec![b'x'; 600]).await;
store_at(&storage, "http://example.com/", &vec![b'y'; 300]).await;
storage.run_pending_tasks().await;
assert_eq!(storage.entry_count(), 1);
assert_eq!(storage.weighted_size(), 300);
Ok(())
}
}