use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant, SystemTime};
use sha2::{Digest, Sha256};
use super::error::ZebFsError;
use super::store::ZebFs;
const RECHECK_AFTER: Duration = Duration::from_secs(30);
pub struct Mirror {
root: PathBuf,
cap_bytes: u64,
}
impl Mirror {
pub fn new(root: PathBuf, cap_bytes: u64) -> Self {
Self { root, cap_bytes }
}
pub fn path(&self, store: &ZebFs, store_id: &str, key: &str) -> Result<PathBuf, ZebFsError> {
if let Some(local) = store.local_path(key)? {
return if local.is_file() {
Ok(local)
} else {
Err(ZebFsError::new("ZEBFS_NOT_FOUND", format!("'{key}' is not in the store")))
};
}
let entry = self.entry_path(store_id, key);
let lock = entry_lock(&entry);
let _held = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
if entry.is_file() && recently_checked(&entry) {
touch(&entry);
return Ok(entry);
}
let stat = store.head(key)?;
let stamp = stamp_of(stat.size, stat.modified);
if entry.is_file() && std::fs::read_to_string(stamp_path(&entry)).ok().as_deref() == Some(stamp.as_str()) {
mark_checked(&entry);
touch(&entry);
return Ok(entry);
}
std::fs::create_dir_all(&self.root)?;
let partial = entry.with_extension(format!("partial-{}", uuid::Uuid::new_v4()));
if let Err(err) = store.get_to_file(key, &partial) {
let _ = std::fs::remove_file(&partial);
return Err(err);
}
std::fs::rename(&partial, &entry)?;
std::fs::write(stamp_path(&entry), &stamp)?;
mark_checked(&entry);
self.evict_beyond_cap(&entry);
Ok(entry)
}
fn entry_path(&self, store_id: &str, key: &str) -> PathBuf {
let digest = hex::encode(Sha256::digest(format!("{store_id}\n{key}").as_bytes()));
let ext = Path::new(key)
.extension()
.and_then(|e| e.to_str())
.filter(|e| e.len() <= 16 && e.bytes().all(|b| b.is_ascii_alphanumeric()))
.map(|e| format!(".{}", e.to_ascii_lowercase()))
.unwrap_or_default();
self.root.join(format!("{digest}{ext}"))
}
fn evict_beyond_cap(&self, keep: &Path) {
let Ok(dir) = std::fs::read_dir(&self.root) else { return };
let mut copies: Vec<(PathBuf, u64, SystemTime)> = dir
.flatten()
.filter_map(|entry| {
let path = entry.path();
let name = path.file_name()?.to_str()?;
if name.ends_with(".stamp") || name.contains(".partial-") {
return None;
}
let meta = entry.metadata().ok()?;
Some((path, meta.len(), meta.modified().ok()?))
})
.collect();
let mut total: u64 = copies.iter().map(|(_, size, _)| size).sum();
copies.sort_by_key(|(_, _, modified)| *modified);
for (path, size, _) in copies {
if total <= self.cap_bytes {
break;
}
if path == keep {
continue;
}
if std::fs::remove_file(&path).is_ok() {
let _ = std::fs::remove_file(stamp_path(&path));
total = total.saturating_sub(size);
}
}
}
}
fn stamp_of(size: u64, modified: Option<SystemTime>) -> String {
let modified = modified
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
format!("{size}:{modified}")
}
fn stamp_path(entry: &Path) -> PathBuf {
let mut name = entry.as_os_str().to_os_string();
name.push(".stamp");
PathBuf::from(name)
}
fn touch(entry: &Path) {
if let Ok(file) = std::fs::File::options().append(true).open(entry) {
let _ = file.set_modified(SystemTime::now());
}
}
fn entry_lock(entry: &Path) -> Arc<Mutex<()>> {
static LOCKS: OnceLock<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>> = OnceLock::new();
let mut locks = LOCKS.get_or_init(Default::default).lock().unwrap_or_else(|p| p.into_inner());
locks.entry(entry.to_path_buf()).or_default().clone()
}
fn checked() -> &'static Mutex<HashMap<PathBuf, Instant>> {
static CHECKED: OnceLock<Mutex<HashMap<PathBuf, Instant>>> = OnceLock::new();
CHECKED.get_or_init(Default::default)
}
fn recently_checked(entry: &Path) -> bool {
checked()
.lock()
.unwrap_or_else(|p| p.into_inner())
.get(entry)
.is_some_and(|at| at.elapsed() < RECHECK_AFTER)
}
fn mark_checked(entry: &Path) {
checked().lock().unwrap_or_else(|p| p.into_inner()).insert(entry.to_path_buf(), Instant::now());
}
#[cfg(test)]
mod tests {
use super::Mirror;
use crate::zebfs::{LocalZebFs, ZebFs};
#[test]
fn a_directory_store_answers_in_place_and_nothing_is_copied() {
let files = tempfile::tempdir().unwrap();
let cache = tempfile::tempdir().unwrap();
let store = ZebFs::Local(LocalZebFs::new(files.path().to_path_buf()));
store.put("maps/a.parquet", b"abc").unwrap();
let mirror = Mirror::new(cache.path().join("m"), 10);
let path = mirror.path(&store, "local", "maps/a.parquet").unwrap();
assert!(path.starts_with(files.path()));
assert!(!cache.path().join("m").exists());
assert!(mirror.path(&store, "local", "maps/missing.parquet").is_err());
assert!(mirror.path(&store, "local", "../escape").is_err());
}
#[test]
fn a_bucket_object_is_fetched_once_and_again_when_it_changes() {
let (port, objects) = crate::zebfs::s3::tests::fake_s3();
let store = ZebFs::S3(crate::zebfs::s3::tests::store(port, "p"));
store.put("maps/a.parquet", b"first").unwrap();
let cache = tempfile::tempdir().unwrap();
let mirror = Mirror::new(cache.path().to_path_buf(), 1024);
let path = mirror.path(&store, "s3-a", "maps/a.parquet").unwrap();
assert!(path.starts_with(cache.path()));
assert_eq!(std::fs::read(&path).unwrap(), b"first");
objects.lock().unwrap().insert("p/maps/a.parquet".into(), b"second!".to_vec());
super::checked().lock().unwrap().clear();
let again = mirror.path(&store, "s3-a", "maps/a.parquet").unwrap();
assert_eq!(again, path);
assert_eq!(std::fs::read(&again).unwrap(), b"second!");
objects.lock().unwrap().clear();
super::checked().lock().unwrap().clear();
assert!(mirror.path(&store, "s3-a", "maps/a.parquet").is_err());
}
#[test]
fn the_mirror_stays_within_its_cap() {
let (port, _objects) = crate::zebfs::s3::tests::fake_s3();
let store = ZebFs::S3(crate::zebfs::s3::tests::store(port, "p"));
let cache = tempfile::tempdir().unwrap();
let mirror = Mirror::new(cache.path().to_path_buf(), 10);
for name in ["a", "b", "c"] {
store.put(&format!("{name}.bin"), b"123456").unwrap();
mirror.path(&store, "s3", &format!("{name}.bin")).unwrap();
}
let copies = std::fs::read_dir(cache.path())
.unwrap()
.flatten()
.filter(|e| e.path().extension().is_some_and(|x| x == "bin"))
.count();
assert_eq!(copies, 1, "two copies of six bytes do not fit in ten");
}
#[test]
fn entries_keep_the_extension_and_differ_per_store() {
let mirror = Mirror::new("/tmp/m".into(), 10);
let a = mirror.entry_path("local", "x/a.parquet");
let b = mirror.entry_path("s3-other", "x/a.parquet");
assert_ne!(a, b);
assert_eq!(a.extension().unwrap(), "parquet");
assert_eq!(mirror.entry_path("s", "noext").extension(), None);
}
#[test]
fn eviction_drops_the_oldest_copies_but_never_the_one_in_use() {
let dir = tempfile::tempdir().unwrap();
let mirror = Mirror::new(dir.path().to_path_buf(), 5);
for (name, age) in [("old.bin", 300), ("mid.bin", 200), ("new.bin", 100)] {
let path = dir.path().join(name);
std::fs::write(&path, b"1234").unwrap();
std::fs::write(super::stamp_path(&path), "4:0").unwrap();
let file = std::fs::File::options().append(true).open(&path).unwrap();
file.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(age)).unwrap();
}
mirror.evict_beyond_cap(&dir.path().join("old.bin"));
assert!(dir.path().join("old.bin").exists(), "the copy being read stays");
assert!(!dir.path().join("mid.bin").exists(), "oldest beside it goes first");
assert!(!dir.path().join("new.bin").exists(), "and on until the mirror fits");
assert!(!dir.path().join("mid.bin.stamp").exists());
}
}