use std::{
cell::{Cell, RefCell},
fs, io,
path::{Path, PathBuf},
sync::{
atomic::{AtomicBool, Ordering},
Mutex,
},
};
pub trait BatchCache: Send + Sync {
fn push(&self, id: &str, body: &[u8]) -> io::Result<Vec<String>>;
fn replace(&self, old: &str, new: &[(String, Vec<u8>)]) -> io::Result<Vec<String>>;
fn pending(&self) -> Vec<String>;
fn read(&self, id: &str) -> io::Result<Vec<u8>>;
fn remove(&self, id: &str) -> io::Result<()>;
fn clear(&self) -> io::Result<()>;
fn take_evicted(&self) -> Vec<String> {
Vec::new()
}
}
pub(crate) const MAX_BATCHES: usize = 512;
pub struct MemoryCache {
batches: Mutex<Vec<(String, Vec<u8>)>>,
max_bytes: usize,
}
impl MemoryCache {
pub fn new(max_bytes: u64) -> Self {
Self {
batches: Mutex::new(Vec::new()),
max_bytes: usize::try_from(max_bytes).unwrap_or(usize::MAX),
}
}
fn lock(&self) -> std::sync::MutexGuard<'_, Vec<(String, Vec<u8>)>> {
self.batches.lock().unwrap_or_else(|e| e.into_inner())
}
fn evict(&self, batches: &mut Vec<(String, Vec<u8>)>) -> Vec<String> {
let mut total: usize = batches.iter().map(|(_, b)| b.len()).sum();
let mut evicted = Vec::new();
while (total > self.max_bytes || batches.len() > MAX_BATCHES) && batches.len() > 1 {
let (id, body) = batches.remove(0);
total -= body.len();
evicted.push(id);
}
evicted
}
}
impl BatchCache for MemoryCache {
fn push(&self, id: &str, body: &[u8]) -> io::Result<Vec<String>> {
let mut batches = self.lock();
batches.push((id.to_owned(), body.to_vec()));
batches.sort_by(|a, b| a.0.cmp(&b.0));
Ok(self.evict(&mut batches))
}
fn replace(&self, old: &str, new: &[(String, Vec<u8>)]) -> io::Result<Vec<String>> {
let mut batches = self.lock();
if !batches.iter().any(|(id, _)| id == old) {
return Err(io::ErrorKind::NotFound.into());
}
batches.retain(|(id, _)| id != old);
batches.extend(new.iter().cloned());
batches.sort_by(|a, b| a.0.cmp(&b.0));
Ok(self.evict(&mut batches))
}
fn pending(&self) -> Vec<String> {
self.lock().iter().map(|(id, _)| id.clone()).collect()
}
fn read(&self, id: &str) -> io::Result<Vec<u8>> {
self.lock()
.iter()
.find(|(i, _)| i == id)
.map(|(_, body)| body.clone())
.ok_or_else(|| io::ErrorKind::NotFound.into())
}
fn remove(&self, id: &str) -> io::Result<()> {
self.lock().retain(|(i, _)| i != id);
Ok(())
}
fn clear(&self) -> io::Result<()> {
self.lock().clear();
Ok(())
}
}
const BATCH_EXT: &str = "otlp";
const TMP_EXT: &str = "tmp";
pub struct FileCache {
dir: PathBuf,
max_bytes: u64,
max_batches: usize,
evicted: Mutex<Vec<String>>,
recovery_failed: std::sync::atomic::AtomicBool,
#[cfg(test)]
recoveries: std::sync::atomic::AtomicUsize,
#[cfg(test)]
fault: Mutex<Option<Fault>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Step {
Write,
Sync,
Rename,
SyncDir,
Delete,
Publish,
#[cfg(test)]
ClearListed,
}
#[cfg(test)]
type Fault = Box<dyn Fn(Step, &Path) -> io::Result<()> + Send + Sync>;
const JOURNAL_EXT: &str = "pend";
const JOURNAL_SEP: char = '@';
static DIR_LOCK: Mutex<()> = Mutex::new(());
static FILE_WRITE_LOCK: Mutex<()> = Mutex::new(());
static SPILL_WRITE_LOCK: Mutex<()> = Mutex::new(());
thread_local! {
static SPILL_ACTIVE: Cell<bool> = const { Cell::new(false) };
}
struct SpillGuard {
_lock: std::sync::MutexGuard<'static, ()>,
}
impl Drop for SpillGuard {
fn drop(&mut self) {
SPILL_ACTIVE.with(|active| active.set(false));
}
}
fn spill_gate() -> io::Result<SpillGuard> {
if SPILL_ACTIVE.with(Cell::get) {
return Err(io::Error::new(
io::ErrorKind::WouldBlock,
"reentrant telemetry cache mutation",
));
}
let lock = lock_commit(&SPILL_WRITE_LOCK);
SPILL_ACTIVE.with(|active| active.set(true));
Ok(SpillGuard { _lock: lock })
}
fn lock_commit(lock: &'static Mutex<()>) -> std::sync::MutexGuard<'static, ()> {
#[cfg(test)]
if matches!(lock.try_lock(), Err(std::sync::TryLockError::WouldBlock)) {
tests::COMMIT_WAIT.with(|slot| {
if let Some(waiting) = slot.borrow().as_ref() {
waiting();
}
});
}
lock.lock().unwrap_or_else(|e| e.into_inner())
}
fn file_operation<T>(operation: impl FnOnce() -> T) -> T {
outside_gate(|| {
let _gate = lock_commit(&FILE_WRITE_LOCK);
operation()
})
}
fn lock_dir() -> std::sync::MutexGuard<'static, ()> {
DIR_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
impl FileCache {
pub fn open(dir: impl Into<PathBuf>, max_bytes: u64) -> io::Result<Self> {
Self::open_bounded(dir, max_bytes, MAX_BATCHES)
}
fn open_bounded(
dir: impl Into<PathBuf>,
max_bytes: u64,
max_batches: usize,
) -> io::Result<Self> {
file_operation(|| Self::open_locked(dir.into(), max_bytes, max_batches))
}
fn open_locked(dir: PathBuf, max_bytes: u64, max_batches: usize) -> io::Result<Self> {
create_dir_synced(&dir)?;
let cache = Self {
dir,
max_bytes,
max_batches,
evicted: Mutex::new(Vec::new()),
recovery_failed: Default::default(),
#[cfg(test)]
recoveries: Default::default(),
#[cfg(test)]
fault: Mutex::new(None),
};
cache.recover()?;
{
let _locked = lock_dir();
for entry in fs::read_dir(&cache.dir)?.flatten() {
let path = entry.path();
if !is_batch(&path) && path.extension().is_none_or(|e| e != JOURNAL_EXT) {
let _ = fs::remove_file(&path);
}
}
}
let evicted = cache.prune()?;
*cache.evicted.lock().unwrap_or_else(|e| e.into_inner()) = evicted;
Ok(cache)
}
#[cfg(test)]
pub(crate) fn open_with_max_batches(
dir: impl Into<PathBuf>,
max_bytes: u64,
max_batches: usize,
) -> io::Result<Self> {
Self::open_bounded(dir, max_bytes, max_batches)
}
#[cfg(test)]
pub(crate) fn inject(
&self,
fault: impl Fn(Step, &Path) -> io::Result<()> + Send + Sync + 'static,
) {
*self.fault.lock().unwrap_or_else(|e| e.into_inner()) = Some(Box::new(fault));
}
#[cfg_attr(not(test), expect(unused_variables, reason = "only the test fault hook reads them"))]
fn step(&self, step: Step, path: &Path) -> io::Result<()> {
#[cfg(test)]
if let Some(fault) = self.fault.lock().unwrap_or_else(|e| e.into_inner()).as_ref() {
return fault(step, path);
}
Ok(())
}
fn path(&self, id: &str, ext: &str) -> PathBuf {
self.dir.join(format!("{id}.{ext}"))
}
fn pend(&self, parent: &str, half: &str) -> PathBuf {
self.dir.join(format!("{parent}{JOURNAL_SEP}{half}.{JOURNAL_EXT}"))
}
fn journal(&self) -> io::Result<Vec<PathBuf>> {
Ok(fs::read_dir(&self.dir)?
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == JOURNAL_EXT))
.collect())
}
fn write_synced(&self, tmp: &Path, body: &[u8]) -> io::Result<()> {
let write = |path: &Path| -> io::Result<()> {
self.step(Step::Write, path)?;
let mut file = fs::File::create(path)?;
io::Write::write_all(&mut file, body)?;
self.step(Step::Sync, path)?;
file.sync_all()
};
match write(tmp) {
Err(err) if err.kind() == io::ErrorKind::NotFound => {
create_dir_synced(&self.dir)?;
write(tmp)
}
other => other,
}
}
fn rename(&self, from: &Path, to: &Path, step: Step) -> io::Result<()> {
self.step(step, to)?;
fs::rename(from, to)
}
fn after_write(&self) -> Vec<String> {
self.recovery_failed.store(false, Ordering::Relaxed);
self.prune().unwrap_or_else(|err| {
diagnostic(log::Level::Debug, format!("could not prune the cache: {err}"));
Vec::new()
})
}
fn sync_dir(&self) -> io::Result<()> {
self.step(Step::SyncDir, &self.dir)?;
#[cfg(unix)]
fs::File::open(&self.dir)?.sync_all()?;
Ok(())
}
fn ids(&self) -> io::Result<Vec<String>> {
let list = || -> io::Result<(Vec<String>, bool)> {
let (mut ids, mut journaled) = (Vec::new(), false);
for path in fs::read_dir(&self.dir)?.flatten().map(|e| e.path()) {
journaled |= path.extension().is_some_and(|e| e == JOURNAL_EXT);
if let Some(id) =
path.file_stem().and_then(|s| s.to_str()).filter(|_| is_batch(&path))
{
ids.push(id.to_owned());
}
}
Ok((ids, journaled))
};
let (mut ids, journaled) = list()?;
if journaled && !self.recovery_failed.load(Ordering::Relaxed) {
match self.recover() {
Ok(()) => ids = list()?.0,
Err(err) => {
self.recovery_failed.store(true, Ordering::Relaxed);
diagnostic(
log::Level::Debug,
format!("journaled split not published yet: {err}; retried after a write"),
);
}
}
}
ids.sort_unstable();
Ok(ids)
}
fn recover(&self) -> io::Result<()> {
let _locked = lock_dir();
#[cfg(test)]
self.recoveries.fetch_add(1, Ordering::Relaxed);
let journal = self.journal()?;
if journal.is_empty() {
return Ok(());
}
for pend in journal {
let Some((parent, half)) =
pend.file_stem().and_then(|s| s.to_str()).and_then(|s| s.split_once(JOURNAL_SEP))
else {
let _ = fs::remove_file(&pend);
continue;
};
let rebinding = is_rebinding(parent, half);
if self.path(parent, BATCH_EXT).exists() {
if !rebinding {
fs::remove_file(&pend)?;
continue;
}
fs::remove_file(self.path(parent, BATCH_EXT))?;
}
fs::rename(&pend, self.path(half, BATCH_EXT))?;
}
self.sync_dir()
}
fn prune(&self) -> io::Result<Vec<String>> {
let mut removed = Vec::new();
let kept = self.ids()?;
let sizes: Vec<u64> = kept
.iter()
.map(|id| fs::metadata(self.path(id, BATCH_EXT)).map(|m| m.len()).unwrap_or(0))
.collect();
let mut total: u64 = sizes.iter().sum();
let mut count = kept.len();
for (id, len) in kept.iter().zip(sizes).take(kept.len().saturating_sub(1)) {
if total <= self.max_bytes && count <= self.max_batches {
break;
}
let (mine, _) = self.unlink(id);
let went = mine || matches!(self.path(id, BATCH_EXT).try_exists(), Ok(false));
if went {
total -= len;
count -= 1;
if mine {
removed.push(id.clone());
}
}
}
Ok(removed)
}
fn unlink(&self, id: &str) -> (bool, io::Result<()>) {
let path = self.path(id, BATCH_EXT);
if let Err(err) = self.step(Step::Delete, &path) {
return (false, Err(err));
}
let unlinked = match fs::remove_file(&path) {
Ok(()) => true,
Err(err) if err.kind() == io::ErrorKind::NotFound => false,
Err(err) => return (false, Err(err)),
};
let synced = match self.sync_dir() {
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
other => other,
};
(unlinked, synced)
}
fn split(&self, old: &str, new: &[(String, Vec<u8>)]) -> io::Result<()> {
let locked = lock_dir();
if !self.path(old, BATCH_EXT).try_exists()? {
return Err(io::ErrorKind::NotFound.into());
}
if new.len() > 1 && new.iter().any(|(half, _)| is_rebinding(old, half)) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"a rebinding replaces a batch with exactly one batch",
));
}
let pends: Vec<PathBuf> = new.iter().map(|(half, _)| self.pend(old, half)).collect();
let roll_back = |err: io::Error| {
for ((half, _), pend) in new.iter().zip(&pends) {
let _ = fs::remove_file(self.path(half, TMP_EXT));
let _ = fs::remove_file(pend);
}
Err(err)
};
for ((half, body), pend) in new.iter().zip(&pends) {
let tmp = self.path(half, TMP_EXT);
if let Err(err) =
self.write_synced(&tmp, body).and_then(|()| self.rename(&tmp, pend, Step::Rename))
{
return roll_back(err);
}
}
if let Err(err) = self.sync_dir() {
return roll_back(err);
}
if let Err(err) = self
.step(Step::Delete, &self.path(old, BATCH_EXT))
.and_then(|()| fs::remove_file(self.path(old, BATCH_EXT)))
{
return roll_back(err);
}
let published = self
.sync_dir()
.and_then(|()| {
new.iter().zip(&pends).try_for_each(|((half, _), pend)| {
self.rename(pend, &self.path(half, BATCH_EXT), Step::Publish)
})
})
.and_then(|()| self.sync_dir());
drop(locked);
if let Err(err) = published {
diagnostic(
log::Level::Warn,
format!("split committed but not published yet ({err}); it will be"),
);
}
Ok(())
}
}
impl BatchCache for FileCache {
fn push(&self, id: &str, body: &[u8]) -> io::Result<Vec<String>> {
file_operation(|| {
let (tmp, dest) = (self.path(id, TMP_EXT), self.path(id, BATCH_EXT));
let locked = lock_dir();
let written = self
.write_synced(&tmp, body)
.and_then(|()| self.rename(&tmp, &dest, Step::Rename))
.and_then(|()| self.sync_dir());
if let Err(err) = written {
let _ = fs::remove_file(&tmp);
let _ = fs::remove_file(&dest);
return Err(err);
}
drop(locked);
Ok(self.after_write())
})
}
fn replace(&self, old: &str, new: &[(String, Vec<u8>)]) -> io::Result<Vec<String>> {
file_operation(|| {
self.split(old, new)?;
Ok(self.after_write())
})
}
fn pending(&self) -> Vec<String> {
file_operation(|| self.ids().unwrap_or_default())
}
fn read(&self, id: &str) -> io::Result<Vec<u8>> {
fs::read(self.path(id, BATCH_EXT))
}
fn remove(&self, id: &str) -> io::Result<()> {
file_operation(|| self.unlink(id).1)
}
fn clear(&self) -> io::Result<()> {
file_operation(|| {
let ids = match self.ids() {
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
other => other?,
};
#[cfg(test)]
self.step(Step::ClearListed, &self.dir)?;
let mut first = Ok(());
for id in ids {
if let Err(err) = self.unlink(&id).1 {
first = first.and(Err(err));
}
}
for pend in self.journal()? {
if let Err(err) = fs::remove_file(&pend) {
first = first.and(Err(err));
}
}
{
let _locked = lock_dir();
let entries = match fs::read_dir(&self.dir) {
Err(err) if err.kind() == io::ErrorKind::NotFound => return first,
other => other?,
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == TMP_EXT) {
if let Err(err) = fs::remove_file(&path) {
first = first.and(Err(err));
}
}
}
}
self.recovery_failed.store(false, Ordering::Relaxed);
first.and_then(|()| self.sync_dir())
})
}
fn take_evicted(&self) -> Vec<String> {
std::mem::take(&mut *self.evicted.lock().unwrap_or_else(|e| e.into_inner()))
}
}
#[cfg_attr(
not(test),
expect(dead_code, reason = "the pipeline (`Telemetry`, `Exporter`) uses it, not in place yet")
)]
pub(crate) struct SpillCache {
primary: std::sync::Arc<dyn BatchCache>,
spill: MemoryCache,
counters: std::sync::Arc<crate::stats::Counters>,
revoked: std::sync::Arc<AtomicBool>,
spilling: AtomicBool,
}
#[cfg_attr(
not(test),
expect(dead_code, reason = "the pipeline (`Telemetry`, `Exporter`) uses it, not in place yet")
)]
impl SpillCache {
pub fn new(
primary: std::sync::Arc<dyn BatchCache>,
max_bytes: u64,
counters: std::sync::Arc<crate::stats::Counters>,
revoked: std::sync::Arc<AtomicBool>,
) -> Self {
Self {
primary,
spill: MemoryCache::new(max_bytes),
counters,
revoked,
spilling: AtomicBool::new(false),
}
}
fn open_gate(&self) -> io::Result<SpillGuard> {
let gate = spill_gate()?;
if self.revoked.load(Ordering::SeqCst) {
return Err(io::Error::other("telemetry was disabled"));
}
Ok(gate)
}
}
impl BatchCache for SpillCache {
fn push(&self, id: &str, body: &[u8]) -> io::Result<Vec<String>> {
outside_gate(|| {
let gate = self.open_gate()?;
let err = match self.primary.push(id, body) {
Ok(evicted) => {
self.spilling.store(false, Ordering::Relaxed);
return Ok(evicted);
}
Err(err) => err,
};
crate::stats::Counters::add(&self.counters.cache_write_errors, 1);
let first = !self.spilling.swap(true, Ordering::Relaxed);
let spilled = self.spill.push(id, body);
drop(gate);
if first {
log::warn!("cannot write the telemetry cache ({err}); keeping batches in memory");
}
spilled
})
}
fn replace(&self, old: &str, new: &[(String, Vec<u8>)]) -> io::Result<Vec<String>> {
outside_gate(|| {
let _gate = self.open_gate()?;
if self.spill.pending().iter().any(|id| id == old) {
self.spill.replace(old, new)
} else {
self.primary.replace(old, new)
}
})
}
fn pending(&self) -> Vec<String> {
let mut ids = self.primary.pending();
ids.extend(self.spill.pending());
ids.sort_unstable();
ids
}
fn read(&self, id: &str) -> io::Result<Vec<u8>> {
self.spill.read(id).or_else(|_| self.primary.read(id))
}
fn remove(&self, id: &str) -> io::Result<()> {
self.spill.remove(id)?;
self.primary.remove(id)
}
fn clear(&self) -> io::Result<()> {
outside_gate(|| {
let _gate = spill_gate()?;
self.spill.clear()?;
self.primary.clear()
})
}
fn take_evicted(&self) -> Vec<String> {
self.primary.take_evicted()
}
}
thread_local! {
static HELD_BACK: RefCell<Option<Vec<(log::Level, String)>>> = const { RefCell::new(None) };
}
fn diagnostic(level: log::Level, message: String) {
let now = HELD_BACK.with(|held| match held.borrow_mut().as_mut() {
Some(queue) => {
queue.push((level, message));
None
}
None => Some(message),
});
if let Some(message) = now {
log::log!(level, "{message}");
}
}
#[cfg_attr(
not(test),
expect(dead_code, reason = "`SpillCache` uses it, and the pipeline is not in place yet")
)]
fn outside_gate<T>(gated: impl FnOnce() -> T) -> T {
struct Release(Option<Vec<(log::Level, String)>>);
impl Drop for Release {
fn drop(&mut self) {
let held =
HELD_BACK.with(|held| std::mem::replace(&mut *held.borrow_mut(), self.0.take()));
for (level, message) in held.unwrap_or_default() {
diagnostic(level, message);
}
}
}
let _release = Release(HELD_BACK.with(|held| held.borrow_mut().replace(Vec::new())));
gated()
}
fn create_dir_synced(dir: &Path) -> io::Result<()> {
match fs::create_dir(dir) {
Ok(()) => {
let parent =
dir.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
#[cfg(unix)]
fs::File::open(parent)?.sync_all()?;
#[cfg(test)]
tests::SYNCED_PARENT.with(|synced| *synced.borrow_mut() = Some(parent.to_path_buf()));
Ok(())
}
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => Ok(()),
Err(err) => Err(err),
}
}
fn is_batch(path: &Path) -> bool {
path.extension().is_some_and(|e| e == BATCH_EXT)
&& path
.file_stem()
.and_then(|s| s.to_str())
.and_then(|s| s.split('-').next())
.is_some_and(|stamp| stamp.parse::<u64>().is_ok())
}
fn is_rebinding(parent: &str, half: &str) -> bool {
parent.ends_with('-') && half.len() > parent.len() && half.starts_with(parent)
}
#[cfg(test)]
pub(crate) fn temp_dir(tag: &str) -> PathBuf {
temp_dir_at(tag, crate::event::now_unix_nanos())
}
#[cfg(test)]
fn temp_dir_at(tag: &str, unix_nanos: u64) -> PathBuf {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir()
.join(format!("livekit-telemetry-{tag}-{}-{n}-{unix_nanos}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
dir
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
thread_local! {
pub(super) static SYNCED_PARENT: std::cell::RefCell<Option<PathBuf>> =
const { std::cell::RefCell::new(None) };
pub(super) static COMMIT_WAIT: RefCell<Option<Box<dyn Fn()>>> = const { RefCell::new(None) };
}
#[test]
fn temp_dirs_are_unique_within_one_clock_tick() {
let first = temp_dir_at("same-tick", 42);
fs::create_dir_all(&first).expect("first directory");
let second = temp_dir_at("same-tick", 42);
let kept = first.exists();
let _ = fs::remove_dir_all(&first);
assert_ne!(first, second);
assert!(kept, "the second request deleted the first directory");
}
#[test]
fn replacement_requires_a_parent_in_both_caches() {
let dir = temp_dir("missing-parent");
let file = FileCache::open(&dir, 1).unwrap();
let memory = MemoryCache::new(1);
let mut results = Vec::new();
let mut listings = Vec::new();
for cache in [&file as &dyn BatchCache, &memory as &dyn BatchCache] {
cache.push(&id(1), b"parent").unwrap();
assert_eq!(cache.push(&id(2), b"newest").unwrap(), [id(1)]);
for children in [vec![], vec![(id(3), b"child".to_vec())]] {
results.push(cache.replace(&id(1), &children).map_err(|e| e.kind()));
listings.push(cache.pending());
}
}
drop(file);
fs::remove_dir_all(dir).unwrap();
assert!(
listings.iter().all(|ids| ids == &[id(2)]),
"no children on missing-parent error: {listings:?}"
);
assert!(results.iter().all(|r| matches!(r, Err(io::ErrorKind::NotFound))), "{results:?}");
}
#[test]
fn standalone_clear_waits_before_listing_another_instances_held_write() {
use std::{sync::mpsc::sync_channel, thread, time::Duration};
let dir = temp_dir("clear-held-write");
let writer = Arc::new(FileCache::open(&dir, 1024).unwrap());
let clearer = Arc::new(FileCache::open(&dir, 1024).unwrap());
let writer_weak = Arc::downgrade(&writer);
let clearer_weak = Arc::downgrade(&clearer);
let (entered_tx, entered_rx) = sync_channel(1);
let (write_tx, write_rx) = sync_channel(1);
let write_rx = Mutex::new(write_rx);
writer.inject(move |step, _| {
if step == Step::Write {
let _ = entered_tx.send(());
write_rx
.lock()
.unwrap()
.recv_timeout(Duration::from_secs(5))
.map_err(io::Error::other)?;
}
Ok(())
});
let (observed_tx, observed_rx) = sync_channel(2);
let listed_tx = observed_tx.clone();
let (clear_tx, clear_rx) = sync_channel(1);
let clear_rx = Mutex::new(clear_rx);
clearer.inject(move |step, _| {
if step == Step::ClearListed {
let _ = listed_tx.send("listed");
clear_rx
.lock()
.unwrap()
.recv_timeout(Duration::from_secs(5))
.map_err(io::Error::other)?;
}
Ok(())
});
let writing = thread::spawn(move || writer.push(&id(1), b"payload"));
let entered = entered_rx.recv_timeout(Duration::from_secs(5));
let clearing = thread::spawn(move || {
COMMIT_WAIT.with(|slot| {
*slot.borrow_mut() = Some(Box::new(move || {
let _ = observed_tx.send("blocked");
}))
});
clearer.clear()
});
let observed = observed_rx.recv_timeout(Duration::from_secs(5));
let _ = write_tx.send(());
let wrote = writing.join();
let _ = clear_tx.send(());
let cleared = clearing.join();
let files: Vec<_> = fs::read_dir(&dir).unwrap().collect();
fs::remove_dir_all(dir).unwrap();
assert!(writer_weak.upgrade().is_none() && clearer_weak.upgrade().is_none());
assert!(entered.is_ok() && matches!(wrote, Ok(Ok(_))) && matches!(cleared, Ok(Ok(()))));
assert_eq!(observed.unwrap(), "blocked", "clear must wait before taking its listing");
assert!(files.is_empty(), "a clear cannot miss a held writer's later commit");
}
struct HeldPrimary {
memory: MemoryCache,
entered: std::sync::mpsc::SyncSender<()>,
resume: Mutex<std::sync::mpsc::Receiver<()>>,
cleared: std::sync::mpsc::SyncSender<&'static str>,
}
impl BatchCache for HeldPrimary {
fn push(&self, id: &str, body: &[u8]) -> io::Result<Vec<String>> {
let _ = self.entered.send(());
self.resume
.lock()
.unwrap()
.recv_timeout(std::time::Duration::from_secs(5))
.map_err(io::Error::other)?;
self.memory.push(id, body)
}
fn replace(&self, old: &str, new: &[(String, Vec<u8>)]) -> io::Result<Vec<String>> {
self.memory.replace(old, new)
}
fn pending(&self) -> Vec<String> {
self.memory.pending()
}
fn read(&self, id: &str) -> io::Result<Vec<u8>> {
self.memory.read(id)
}
fn remove(&self, id: &str) -> io::Result<()> {
self.memory.remove(id)
}
fn clear(&self) -> io::Result<()> {
let _ = self.cleared.send("cleared");
self.memory.clear()
}
}
#[test]
fn spill_clear_waits_for_another_generations_admitted_write() {
use std::{sync::mpsc::sync_channel, thread, time::Duration};
let (entered_tx, entered_rx) = sync_channel(1);
let (resume_tx, resume_rx) = sync_channel(1);
let (observed_tx, observed_rx) = sync_channel(2);
let primary = Arc::new(HeldPrimary {
memory: MemoryCache::new(1024),
entered: entered_tx,
resume: Mutex::new(resume_rx),
cleared: observed_tx.clone(),
});
let primary_weak = Arc::downgrade(&primary);
let revoked = Arc::new(AtomicBool::new(false));
let writer =
Arc::new(SpillCache::new(primary.clone(), 1024, Arc::default(), revoked.clone()));
let clearer =
Arc::new(SpillCache::new(primary.clone(), 1024, Arc::default(), revoked.clone()));
let writer_weak = Arc::downgrade(&writer);
let clearer_weak = Arc::downgrade(&clearer);
let writing = thread::spawn(move || writer.push(&id(1), b"payload"));
let entered = entered_rx.recv_timeout(Duration::from_secs(5));
revoked.store(true, Ordering::SeqCst);
let clearing = thread::spawn(move || {
COMMIT_WAIT.with(|slot| {
*slot.borrow_mut() = Some(Box::new(move || {
let _ = observed_tx.send("blocked");
}))
});
clearer.clear()
});
let observed = observed_rx.recv_timeout(Duration::from_secs(5));
let _ = resume_tx.send(());
let wrote = writing.join();
let cleared = clearing.join();
let pending = primary.pending();
let late = SpillCache::new(primary.clone(), 1024, Arc::default(), revoked);
let refused = late.push(&id(2), b"late").is_err();
drop(late);
drop(primary);
assert!(
primary_weak.upgrade().is_none()
&& writer_weak.upgrade().is_none()
&& clearer_weak.upgrade().is_none()
);
assert!(entered.is_ok() && matches!(wrote, Ok(Ok(_))) && matches!(cleared, Ok(Ok(()))));
assert_eq!(observed.unwrap(), "blocked");
assert!(pending.is_empty() && refused);
}
#[test]
fn commit_barriers_release_after_failed_or_cancelled_writes() {
for crash in [false, true] {
let dir = temp_dir("barrier-failure");
let file = Arc::new(FileCache::open(&dir, 1024).unwrap());
let weak = Arc::downgrade(&file);
file.inject(move |step, _| {
if step == Step::Write {
assert!(!crash, "cancelled synchronous write");
return Err(io::Error::other("failed write"));
}
Ok(())
});
let cache = SpillCache::new(file.clone(), 1024, Arc::default(), Arc::default());
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
cache.push(&id(1), b"body")
}));
let spilled = cache.spill.pending();
file.inject(|_, _| Ok(()));
let cleared = cache.clear();
let empty = fs::read_dir(&dir).unwrap().count() == 0 && cache.pending().is_empty();
drop(cache);
drop(file);
fs::remove_dir_all(dir).unwrap();
assert!(weak.upgrade().is_none() && cleared.is_ok() && empty);
if crash {
assert!(result.is_err());
} else {
assert!(matches!(result, Ok(Ok(_))) && spilled == [id(1)]);
}
}
}
#[test]
fn spill_mutation_reentry_returns_would_block() {
let cache =
SpillCache::new(Arc::new(MemoryCache::new(1024)), 1024, Arc::default(), Arc::default());
let held = spill_gate().unwrap();
let push = cache.push(&id(1), b"body").map_err(|err| err.kind());
let replace = cache.replace(&id(1), &[]).map_err(|err| err.kind());
let clear = cache.clear().map_err(|err| err.kind());
drop(held);
let next = cache.push(&id(2), b"next");
let cleared = cache.clear();
assert_eq!(push, Err(io::ErrorKind::WouldBlock));
assert_eq!(replace, Err(io::ErrorKind::WouldBlock));
assert_eq!(clear, Err(io::ErrorKind::WouldBlock));
assert!(next.is_ok() && cleared.is_ok());
}
fn id(n: u64) -> String {
static BASE: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
let base =
*BASE.get_or_init(|| crate::event::now_unix_nanos() / 1_000_000_000 * 1_000_000_000);
format!("{:020}-{n:06}-1", base + n)
}
#[test]
fn the_newest_batch_always_stays_whatever_the_cap() {
let dir = temp_dir("floor");
let cache = FileCache::open(&dir, 25).expect("open");
assert_eq!(cache.push(&id(1), &[b'a'; 30]).expect("push"), Vec::<String>::new());
assert_eq!(cache.pending(), [id(1)], "a batch over the cap still gets its upload");
assert_eq!(cache.push(&id(2), &[b'b'; 30]).expect("push"), [id(1)]);
assert_eq!(cache.pending(), [id(2)]);
let _ = fs::remove_dir_all(&dir);
let dir = temp_dir("floor-zero");
let cache = FileCache::open(&dir, 0).expect("open");
cache.push(&id(1), b"x").expect("push");
assert_eq!(cache.pending(), [id(1)], "a cap of 0 keeps the push");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn an_eviction_whose_directory_sync_fails_still_counts() {
use std::sync::atomic::{AtomicBool, Ordering};
let dir = temp_dir("evict-sync");
let cache = FileCache::open(&dir, 10).expect("open");
cache.push(&id(1), &[b'a'; 10]).expect("push");
let deleted = Arc::new(AtomicBool::new(false));
cache.inject(move |step, _| {
if step == Step::Delete {
deleted.store(true, Ordering::SeqCst);
}
if step == Step::SyncDir && deleted.load(Ordering::SeqCst) {
return Err(io::Error::other("injected"));
}
Ok(())
});
assert_eq!(cache.push(&id(2), &[b'b'; 10]).expect("push"), [id(1)]);
assert_eq!(cache.pending(), [id(2)], "the batch just committed stays");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn a_purged_directory_is_already_empty() {
let dir = temp_dir("purged");
let cache = FileCache::open(&dir, 1 << 20).expect("open");
cache.push(&id(1), b"x").expect("push");
fs::remove_dir_all(&dir).expect("the OS purges the directory");
assert!(cache.remove(&id(1)).is_ok());
assert!(cache.clear().is_ok(), "an opt-out with nothing on disk succeeds");
}
#[test]
fn a_rebinding_replaces_a_batch_with_exactly_one() {
let dir = temp_dir("rebinding");
let cache = FileCache::open(&dir, 1 << 20).expect("open");
let parent = format!("{}-l-s-", id(1));
cache.push(&parent, b"parent").expect("push");
let two = [
(format!("{parent}a.example"), b"a".to_vec()),
(format!("{parent}b.example"), b"b".to_vec()),
];
let err = cache.replace(&parent, &two).expect_err("rejected");
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
assert_eq!(cache.pending(), std::slice::from_ref(&parent), "the parent is kept");
let one = [(format!("{parent}a.example"), b"a".to_vec())];
cache.replace(&parent, &one).expect("one member");
assert_eq!(cache.pending(), [format!("{parent}a.example")]);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn clear_lets_listings_publish_a_journal_again() {
let dir = temp_dir("clear-recovery");
let cache = FileCache::open(&dir, 1 << 20).expect("open");
cache.recovery_failed.store(true, Ordering::Relaxed);
cache.clear().expect("clear");
assert!(!cache.recovery_failed.load(Ordering::Relaxed));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn a_second_cache_never_breaks_a_push_in_progress() {
let dir = temp_dir("push-two-generations");
let old = FileCache::open(&dir, 1 << 20).expect("open");
let (opened, path) = (Arc::new(Mutex::new(None)), dir.clone());
let handle = opened.clone();
old.inject(move |step, _| {
let mut handle = handle.lock().expect("lock");
if step == Step::Rename && handle.is_none() {
let path = path.clone();
*handle = Some(std::thread::spawn(move || {
FileCache::open(&path, 1 << 20).expect("open");
}));
std::thread::sleep(std::time::Duration::from_millis(100));
}
Ok(())
});
old.push(&id(1), b"in flight").expect("the push commits");
opened.lock().expect("lock").take().expect("opened").join().expect("open succeeded");
assert_eq!(old.pending(), [id(1)]);
let _ = fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn clear_deletes_a_tmp_a_failed_write_left_behind() {
use std::os::unix::fs::PermissionsExt;
let dir = temp_dir("clear-tmp");
let cache = FileCache::open(&dir, 1024).expect("open");
cache.inject(|step, path| {
if step == Step::Sync {
let parent = path.parent().expect("the cache directory");
fs::set_permissions(parent, fs::Permissions::from_mode(0o555))?;
return Err(io::Error::other("disk gone"));
}
Ok(())
});
let pushed = cache.push(&id(1), b"telemetry bytes");
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).expect("writable again");
cache.inject(|_, _| Ok(()));
assert!(pushed.is_err());
assert_eq!(fs::read_dir(&dir).expect("dir").count(), 1, "the failed write left its .tmp");
cache.clear().expect("clear");
let left: Vec<_> = fs::read_dir(&dir).expect("dir").flatten().map(|e| e.path()).collect();
let _ = fs::remove_dir_all(&dir);
assert!(left.is_empty(), "{left:?}");
}
#[test]
fn diagnostics_reach_the_logger_outside_the_spill_gate() {
crate::test_log::capture();
let dir = temp_dir("diagnostics-outside-the-gate");
let primary = Arc::new(FileCache::open(&dir, 1024).expect("open"));
primary.push(&id(1), b"parent").expect("push");
primary.inject(|step, _| match step {
Step::Publish => Err(io::Error::other("publish failed")),
_ => Ok(()),
});
let cache = Arc::new(SpillCache::new(primary, 1024, Arc::default(), Arc::default()));
let gated = Arc::downgrade(&cache);
crate::test_log::mark_while(move || {
gated.upgrade().is_some_and(|_| SPILL_ACTIVE.with(Cell::get))
});
let halves =
[(format!("{}a", id(1)), b"a".to_vec()), (format!("{}b", id(1)), b"b".to_vec())];
cache.replace(&id(1), &halves).expect("split");
let lines = crate::test_log::take();
let _ = fs::remove_dir_all(&dir);
assert!(lines.iter().any(|l| l.contains("split committed but not published")), "{lines:?}");
assert!(lines.iter().all(|l| !l.starts_with("held: ")), "{lines:?}");
}
struct Flaky(std::sync::atomic::AtomicBool);
impl BatchCache for Flaky {
fn push(&self, _: &str, _: &[u8]) -> io::Result<Vec<String>> {
if self.0.load(Ordering::SeqCst) {
return Err(io::Error::other("disk full"));
}
Ok(Vec::new())
}
fn replace(&self, _: &str, _: &[(String, Vec<u8>)]) -> io::Result<Vec<String>> {
Ok(Vec::new())
}
fn pending(&self) -> Vec<String> {
Vec::new()
}
fn read(&self, _: &str) -> io::Result<Vec<u8>> {
Err(io::ErrorKind::NotFound.into())
}
fn remove(&self, _: &str) -> io::Result<()> {
Ok(())
}
fn clear(&self) -> io::Result<()> {
Ok(())
}
}
#[test]
fn each_spill_episode_warns_once() {
crate::test_log::capture();
let primary = Arc::new(Flaky(true.into()));
let counters = Arc::new(crate::stats::Counters::default());
let cache = SpillCache::new(primary.clone(), 1 << 20, counters.clone(), Arc::default());
let warning = "cannot write the telemetry cache (disk full); keeping batches in memory";
cache.push(&id(1), b"a").expect("spilled");
cache.push(&id(2), b"b").expect("spilled");
assert_eq!(crate::test_log::take(), [warning], "two spills, one warning");
primary.0.store(false, Ordering::SeqCst);
cache.push(&id(3), b"c").expect("written");
primary.0.store(true, Ordering::SeqCst);
cache.push(&id(4), b"d").expect("spilled");
assert_eq!(crate::test_log::take(), [warning], "a new episode warns again");
assert_eq!(counters.snapshot().cache_write_errors, 3);
}
#[test]
fn memory_cache_evicts_oldest_beyond_max_bytes() {
let cache = MemoryCache::new(25);
for (n, body) in [b"aaaaaaaaaa", b"bbbbbbbbbb", b"cccccccccc"].into_iter().enumerate() {
cache.push(&id(n as u64), body).expect("push");
}
assert_eq!(cache.pending(), [id(1), id(2)]);
assert_eq!(cache.read(&id(1)).expect("read"), b"bbbbbbbbbb");
cache.remove(&id(1)).expect("remove");
assert_eq!(cache.pending(), [id(2)]);
cache.clear().expect("clear");
assert!(cache.pending().is_empty());
}
#[test]
fn file_cache_evicts_oldest_beyond_max_bytes_and_drops_stray_tmp() {
let dir = temp_dir("cache");
let cache = FileCache::open(&dir, 25).expect("open");
fs::write(dir.join("crashed.tmp"), b"half").expect("write");
for (n, body) in [b"aaaaaaaaaa", b"bbbbbbbbbb", b"cccccccccc"].into_iter().enumerate() {
cache.push(&id(n as u64), body).expect("push");
}
assert_eq!(cache.pending(), [id(1), id(2)], "10-byte batches under a 25-byte cap");
assert_eq!(cache.read(&id(1)).expect("read"), b"bbbbbbbbbb");
assert!(dir.join("crashed.tmp").exists());
let cache = FileCache::open(&dir, 25).expect("reopen");
assert!(!dir.join("crashed.tmp").exists());
cache.clear().expect("clear");
assert!(cache.pending().is_empty());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn file_cache_caps_the_number_of_batches() {
let dir = temp_dir("count");
let cache = FileCache::open(&dir, 1 << 20).expect("open");
let ids: Vec<String> = (0..(MAX_BATCHES as u64 + 8)).map(id).collect();
for batch in &ids {
cache.push(batch, b"x").expect("push");
}
let kept = cache.pending();
assert_eq!(kept.len(), MAX_BATCHES, "far under the byte cap, still bounded");
assert_eq!(kept.first(), Some(&ids[8]), "the oldest went first");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn file_cache_recreates_a_purged_directory() {
let dir = temp_dir("purged");
let cache = FileCache::open(&dir, 1 << 20).expect("open");
fs::remove_dir_all(&dir).expect("purge like iOS does");
cache.push(&id(1), b"after purge").expect("push recreates the dir");
assert_eq!(cache.pending(), [id(1)]);
let _ = fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn file_cache_failed_write_leaves_no_partial_file() {
use std::os::unix::fs::PermissionsExt;
let dir = temp_dir("readonly");
let cache = FileCache::open(&dir, 1 << 20).expect("open");
fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).expect("chmod");
let result = cache.push(&id(1), b"no room");
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).expect("chmod back");
assert!(result.is_err());
assert_eq!(fs::read_dir(&dir).expect("dir").count(), 0, "no stray .tmp");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn a_split_crashed_at_every_step_loses_and_duplicates_nothing() {
use std::sync::atomic::{AtomicUsize, Ordering};
let (parent, a, b) = (id(1), format!("{}a", id(1)), format!("{}b", id(1)));
let halves = [(a.clone(), vec![b'a'; 60]), (b.clone(), vec![b'b'; 60])];
for (max_bytes, max_batches) in [(120, MAX_BATCHES), (1 << 20, 2)] {
let mut step = 0;
loop {
let dir = temp_dir(&format!("split-{step}-{max_batches}"));
let cache =
FileCache::open_with_max_batches(&dir, max_bytes, max_batches).expect("open");
cache.push(&parent, &[b'p'; 120]).expect("push");
let calls = Arc::new(AtomicUsize::new(0));
let (counter, crash_at) = (calls.clone(), step);
cache.inject(move |_, _| {
if counter.fetch_add(1, Ordering::SeqCst) == crash_at {
panic!("killed");
}
Ok(())
});
let crashed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _ = cache.replace(&parent, &halves);
}))
.is_err();
drop(cache);
let cache =
FileCache::open_with_max_batches(&dir, max_bytes, max_batches).expect("reopen");
assert!(cache.take_evicted().is_empty(), "step {step}: nothing evicted");
let pending = cache.pending();
let whole = pending == [parent.clone()]
&& cache.read(&parent).expect("parent").len() == 120;
let split = pending == [a.clone(), b.clone()];
assert!(whole || split, "step {step}: {pending:?}");
let _ = fs::remove_dir_all(&dir);
if !crashed {
assert!(split, "a replace that ran to the end split the batch");
break;
}
step += 1;
}
assert!(
step >= 10,
"every write, sync, rename, delete and publish was crashed at: {step}"
);
}
}
#[test]
fn a_split_failing_after_its_commit_point_is_kept_and_purgeable() {
use std::sync::atomic::{AtomicBool, Ordering};
let (parent, a, b) = (id(1), format!("{}a", id(1)), format!("{}b", id(1)));
let halves = [(a.clone(), b"half a".to_vec()), (b.clone(), b"half b".to_vec())];
for failing in [Step::Publish, Step::SyncDir] {
let dir = temp_dir(&format!("post-commit-{failing:?}"));
let cache = FileCache::open(&dir, 1 << 20).expect("open");
cache.push(&parent, b"parent").expect("push");
let seen = Arc::new(AtomicBool::new(false));
cache.inject(move |step, _| {
if step == Step::Delete {
seen.store(true, Ordering::SeqCst);
}
if step == failing && seen.load(Ordering::SeqCst) {
return Err(io::Error::other("injected"));
}
Ok(())
});
cache.replace(&parent, &halves).expect("committed: not an error");
assert!(!cache.pending().contains(&parent), "{failing:?}: the parent is gone");
cache.inject(|_, _| Ok(())); assert!(cache.clear().is_ok());
assert_eq!(
fs::read_dir(&dir).expect("dir").count(),
0,
"{failing:?}: nothing survives the opt-out"
);
let dir2 = temp_dir(&format!("post-commit-publish-{failing:?}"));
let cache = FileCache::open(&dir2, 1 << 20).expect("open");
cache.push(&parent, b"parent").expect("push");
let flag = Arc::new(AtomicBool::new(true));
let fail = flag.clone();
let seen = Arc::new(AtomicBool::new(false));
cache.inject(move |step, _| {
if step == Step::Delete {
seen.store(true, Ordering::SeqCst);
}
if step == failing && seen.load(Ordering::SeqCst) && fail.load(Ordering::SeqCst) {
return Err(io::Error::other("injected"));
}
Ok(())
});
cache.replace(&parent, &halves).expect("committed");
flag.store(false, Ordering::SeqCst);
cache.push(&id(9), b"next").expect("push");
assert_eq!(cache.pending(), [a.clone(), b.clone(), id(9)], "{failing:?}: published");
let _ = fs::remove_dir_all(&dir);
let _ = fs::remove_dir_all(&dir2);
}
}
#[test]
fn a_second_cache_on_the_directory_never_breaks_a_split_in_progress() {
let dir = temp_dir("two-generations");
let (parent, a, b) = (id(1), format!("{}a", id(1)), format!("{}b", id(1)));
let halves = [(a.clone(), b"half a".to_vec()), (b.clone(), b"half b".to_vec())];
let old = FileCache::open(&dir, 1 << 20).expect("open");
old.push(&parent, b"parent").expect("push");
let other = Arc::new(Mutex::new(None));
let (handle, path) = (other.clone(), dir.clone());
let (waiting_tx, waiting_rx) = std::sync::mpsc::sync_channel(1);
let waiting_rx = Mutex::new(waiting_rx);
old.inject(move |step, _| {
let mut handle = handle.lock().expect("lock");
if step == Step::Delete && handle.is_none() {
let path = path.clone();
let waiting_tx = waiting_tx.clone();
*handle = Some(std::thread::spawn(move || {
COMMIT_WAIT.with(|slot| {
*slot.borrow_mut() = Some(Box::new(move || {
let _ = waiting_tx.try_send(());
}))
});
let new = FileCache::open(&path, 1 << 20).expect("open");
new.push(&id(2), b"new generation").expect("push");
}));
waiting_rx
.lock()
.unwrap()
.recv_timeout(std::time::Duration::from_secs(5))
.map_err(io::Error::other)?;
}
Ok(())
});
old.replace(&parent, &halves).expect("split");
let new = other.lock().expect("lock").take().expect("the new generation ran");
new.join().expect("the new generation succeeded");
let reopened = FileCache::open(&dir, 1 << 20).expect("reopen");
assert_eq!(reopened.pending(), [a, b, id(2)], "every record exactly once");
let _ = fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn a_stuck_journal_is_retried_after_a_write_not_every_listing() {
let dir = temp_dir("stuck-journal");
let cache = FileCache::open(&dir, 1 << 20).expect("open");
let (parent, half) = (id(1), format!("{}a", id(1)));
fs::write(cache.pend(&parent, &half), b"half").expect("journal");
fs::create_dir(cache.path(&half, BATCH_EXT)).expect("blocker");
fs::write(cache.path(&half, BATCH_EXT).join("x"), b"").expect("blocker content");
let before = cache.recoveries.load(Ordering::Relaxed);
for _ in 0..5 {
cache.pending();
}
assert_eq!(cache.recoveries.load(Ordering::Relaxed), before + 1, "tried once");
cache.push(&id(2), b"next").expect("push");
assert_eq!(cache.recoveries.load(Ordering::Relaxed), before + 2, "a write retries");
fs::remove_dir_all(cache.path(&half, BATCH_EXT)).expect("unblock");
cache.push(&id(3), b"next").expect("push");
assert_eq!(cache.pending(), [half, id(2), id(3)], "published once unblocked");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn a_write_killed_half_way_leaves_no_partial_batch() {
let dir = temp_dir("torn");
let cache = FileCache::open(&dir, 1 << 20).expect("open");
cache.push(&id(1), b"committed").expect("push");
cache.inject(|step, path| {
if step == Step::Write {
fs::write(path, b"half of a batc").expect("partial");
panic!("killed mid-write");
}
Ok(())
});
let killed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _ = cache.push(&id(2), b"half of a batch that never lands");
}));
assert!(killed.is_err());
drop(cache);
let cache = FileCache::open(&dir, 1 << 20).expect("reopen");
assert_eq!(cache.pending(), [id(1)]);
assert_eq!(cache.read(&id(1)).expect("read"), b"committed");
assert_eq!(fs::read_dir(&dir).expect("dir").count(), 1, "the torn .tmp is gone");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn durability_failures_are_errors_not_silent_successes() {
for failing in [Step::Sync, Step::SyncDir] {
let dir = temp_dir("sync");
let cache = FileCache::open(&dir, 1 << 20).expect("open");
cache.push(&id(1), b"parent").expect("push");
cache.inject(move |step, _| {
if step == failing {
return Err(io::Error::other("fsync failed"));
}
Ok(())
});
assert!(cache.push(&id(2), b"new").is_err(), "{failing:?}: not committed");
assert_eq!(cache.pending(), [id(1)], "{failing:?}: nothing left of it");
let halves =
[(format!("{}a", id(1)), b"a".to_vec()), (format!("{}b", id(1)), b"b".to_vec())];
assert!(cache.replace(&id(1), &halves).is_err());
assert_eq!(cache.pending(), [id(1)], "{failing:?}: the parent stays whole");
let _ = fs::remove_dir_all(&dir);
}
}
#[cfg(unix)]
#[test]
fn clearing_an_unreadable_directory_fails() {
use std::os::unix::fs::PermissionsExt;
let dir = temp_dir("unreadable");
let cache = FileCache::open(&dir, 1 << 20).expect("open");
cache.push(&id(1), b"batch").expect("push");
fs::set_permissions(&dir, fs::Permissions::from_mode(0o300)).expect("chmod");
assert!(cache.clear().is_err(), "cannot list, cannot claim it is empty");
fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).expect("chmod");
assert_eq!(cache.pending(), [id(1)]);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn the_newest_batch_stays_when_older_deletions_fail() {
let dir = temp_dir("floor-undeletable");
let cache = FileCache::open(&dir, 25).expect("open");
cache.push(&id(1), &[b'a'; 10]).expect("push");
cache.push(&id(2), &[b'b'; 10]).expect("push");
let newest = cache.path(&id(3), BATCH_EXT);
cache.inject(move |step, path| {
if step == Step::Delete && path != newest {
return Err(io::Error::other("undeletable"));
}
Ok(())
});
let evicted = cache.push(&id(3), &[b'c'; 10]).expect("push");
assert_eq!(evicted, Vec::<String>::new(), "nothing older could go, the newest stays");
assert_eq!(cache.pending(), [id(1), id(2), id(3)]);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn an_eviction_another_pass_already_made_is_not_counted_twice() {
let dir = temp_dir("evict-twice");
let cache = FileCache::open(&dir, 10).expect("open");
cache.push(&id(1), &[b'a'; 10]).expect("push");
let gone = cache.path(&id(1), BATCH_EXT);
cache.inject(move |step, path| {
if step == Step::Delete && path == gone {
let _ = fs::remove_file(path);
}
Ok(())
});
let evicted = cache.push(&id(2), &[b'b'; 10]).expect("push");
assert_eq!(evicted, Vec::<String>::new(), "the other pass counts it");
assert_eq!(cache.pending(), [id(2)]);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn an_eviction_another_pass_made_is_not_counted_even_when_the_sync_fails() {
use std::sync::atomic::{AtomicBool, Ordering};
let dir = temp_dir("evict-twice-sync");
let cache = FileCache::open(&dir, 10).expect("open");
cache.push(&id(1), &[b'a'; 10]).expect("push");
let gone = cache.path(&id(1), BATCH_EXT);
let taken = Arc::new(AtomicBool::new(false));
cache.inject({
let taken = taken.clone();
move |step, path| {
if step == Step::Delete && path == gone {
let _ = fs::remove_file(path);
taken.store(true, Ordering::SeqCst);
}
if step == Step::SyncDir && taken.load(Ordering::SeqCst) {
return Err(io::Error::other("injected"));
}
Ok(())
}
});
let evicted = cache.push(&id(2), &[b'b'; 10]).expect("push");
assert_eq!(evicted, Vec::<String>::new(), "the other pass counts it");
assert_eq!(cache.pending(), [id(2)]);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn creating_the_directory_syncs_its_parent() {
let dir = temp_dir("parent-sync");
SYNCED_PARENT.with(|synced| synced.take());
FileCache::open(&dir, 1 << 20).expect("open");
let parent = dir.parent().map(Path::to_path_buf);
assert_eq!(SYNCED_PARENT.with(|synced| synced.take()), parent, "an absolute path");
FileCache::open(&dir, 1 << 20).expect("open again");
assert_eq!(SYNCED_PARENT.with(|synced| synced.take()), None, "it existed");
let _ = fs::remove_dir_all(&dir);
let relative =
PathBuf::from(format!("lk-telemetry-parent-sync-{}", crate::event::now_unix_nanos()));
FileCache::open(&relative, 1 << 20).expect("open");
let _ = fs::remove_dir_all(&relative);
assert_eq!(
SYNCED_PARENT.with(|synced| synced.take()),
Some(PathBuf::from(".")),
"a bare relative name lives in the current directory"
);
}
#[test]
fn purge_recovery_tolerates_a_directory_another_cache_recreated() {
use std::sync::atomic::{AtomicBool, Ordering};
let dir = temp_dir("purge-race");
let cache = FileCache::open(&dir, 1 << 20).expect("open");
let first = AtomicBool::new(true);
cache.inject(move |step, _| {
if step == Step::Write && first.swap(false, Ordering::SeqCst) {
return Err(io::ErrorKind::NotFound.into());
}
Ok(())
});
cache.push(&id(1), b"x").expect("the recreated directory is used");
assert_eq!(cache.pending(), [id(1)]);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn a_lowered_file_count_bound_applies_when_the_cache_opens() {
let dir = temp_dir("open-bound");
let cache = FileCache::open(&dir, 1 << 20).expect("open");
for n in 1..=3 {
cache.push(&id(n), b"x").expect("push");
}
drop(cache);
let cache = FileCache::open_with_max_batches(&dir, 1 << 20, 2).expect("reopen");
assert_eq!(cache.take_evicted(), [id(1)], "opening pruned to the requested bound");
assert_eq!(cache.pending(), [id(2), id(3)]);
let _ = fs::remove_dir_all(&dir);
}
}