1use std::{
16 cell::{Cell, RefCell},
17 fs, io,
18 path::{Path, PathBuf},
19 sync::{
20 atomic::{AtomicBool, Ordering},
21 Mutex,
22 },
23};
24
25pub trait BatchCache: Send + Sync {
45 fn push(&self, id: &str, body: &[u8]) -> io::Result<Vec<String>>;
49 fn replace(&self, old: &str, new: &[(String, Vec<u8>)]) -> io::Result<Vec<String>>;
55 fn pending(&self) -> Vec<String>;
57 fn read(&self, id: &str) -> io::Result<Vec<u8>>;
60 fn remove(&self, id: &str) -> io::Result<()>;
62 fn clear(&self) -> io::Result<()>;
65 fn take_evicted(&self) -> Vec<String> {
68 Vec::new()
69 }
70}
71
72pub(crate) const MAX_BATCHES: usize = 512;
78
79pub struct MemoryCache {
85 batches: Mutex<Vec<(String, Vec<u8>)>>,
86 max_bytes: usize,
87}
88
89impl MemoryCache {
90 pub fn new(max_bytes: u64) -> Self {
93 Self {
94 batches: Mutex::new(Vec::new()),
95 max_bytes: usize::try_from(max_bytes).unwrap_or(usize::MAX),
96 }
97 }
98
99 fn lock(&self) -> std::sync::MutexGuard<'_, Vec<(String, Vec<u8>)>> {
100 self.batches.lock().unwrap_or_else(|e| e.into_inner())
101 }
102
103 fn evict(&self, batches: &mut Vec<(String, Vec<u8>)>) -> Vec<String> {
105 let mut total: usize = batches.iter().map(|(_, b)| b.len()).sum();
106 let mut evicted = Vec::new();
107 while (total > self.max_bytes || batches.len() > MAX_BATCHES) && batches.len() > 1 {
108 let (id, body) = batches.remove(0);
109 total -= body.len();
110 evicted.push(id);
111 }
112 evicted
113 }
114}
115
116impl BatchCache for MemoryCache {
117 fn push(&self, id: &str, body: &[u8]) -> io::Result<Vec<String>> {
118 let mut batches = self.lock();
119 batches.push((id.to_owned(), body.to_vec()));
120 batches.sort_by(|a, b| a.0.cmp(&b.0));
121 Ok(self.evict(&mut batches))
122 }
123
124 fn replace(&self, old: &str, new: &[(String, Vec<u8>)]) -> io::Result<Vec<String>> {
125 let mut batches = self.lock();
126 if !batches.iter().any(|(id, _)| id == old) {
127 return Err(io::ErrorKind::NotFound.into());
128 }
129 batches.retain(|(id, _)| id != old);
130 batches.extend(new.iter().cloned());
131 batches.sort_by(|a, b| a.0.cmp(&b.0));
132 Ok(self.evict(&mut batches))
133 }
134
135 fn pending(&self) -> Vec<String> {
136 self.lock().iter().map(|(id, _)| id.clone()).collect()
137 }
138
139 fn read(&self, id: &str) -> io::Result<Vec<u8>> {
140 self.lock()
141 .iter()
142 .find(|(i, _)| i == id)
143 .map(|(_, body)| body.clone())
144 .ok_or_else(|| io::ErrorKind::NotFound.into())
145 }
146
147 fn remove(&self, id: &str) -> io::Result<()> {
148 self.lock().retain(|(i, _)| i != id);
149 Ok(())
150 }
151
152 fn clear(&self) -> io::Result<()> {
153 self.lock().clear();
154 Ok(())
155 }
156}
157
158const BATCH_EXT: &str = "otlp";
160const TMP_EXT: &str = "tmp";
163
164pub struct FileCache {
174 dir: PathBuf,
175 max_bytes: u64,
176 max_batches: usize,
178 evicted: Mutex<Vec<String>>,
180 recovery_failed: std::sync::atomic::AtomicBool,
183 #[cfg(test)]
185 recoveries: std::sync::atomic::AtomicUsize,
186 #[cfg(test)]
188 fault: Mutex<Option<Fault>>,
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub(crate) enum Step {
195 Write,
197 Sync,
199 Rename,
201 SyncDir,
203 Delete,
205 Publish,
207 #[cfg(test)]
209 ClearListed,
210}
211
212#[cfg(test)]
213type Fault = Box<dyn Fn(Step, &Path) -> io::Result<()> + Send + Sync>;
214
215const JOURNAL_EXT: &str = "pend";
217const JOURNAL_SEP: char = '@';
220
221static DIR_LOCK: Mutex<()> = Mutex::new(());
230
231static FILE_WRITE_LOCK: Mutex<()> = Mutex::new(());
236static SPILL_WRITE_LOCK: Mutex<()> = Mutex::new(());
237
238thread_local! {
239 static SPILL_ACTIVE: Cell<bool> = const { Cell::new(false) };
241}
242
243struct SpillGuard {
245 _lock: std::sync::MutexGuard<'static, ()>,
246}
247
248impl Drop for SpillGuard {
249 fn drop(&mut self) {
250 SPILL_ACTIVE.with(|active| active.set(false));
251 }
252}
253
254fn spill_gate() -> io::Result<SpillGuard> {
256 if SPILL_ACTIVE.with(Cell::get) {
257 return Err(io::Error::new(
258 io::ErrorKind::WouldBlock,
259 "reentrant telemetry cache mutation",
260 ));
261 }
262 let lock = lock_commit(&SPILL_WRITE_LOCK);
263 SPILL_ACTIVE.with(|active| active.set(true));
264 Ok(SpillGuard { _lock: lock })
265}
266
267fn lock_commit(lock: &'static Mutex<()>) -> std::sync::MutexGuard<'static, ()> {
269 #[cfg(test)]
270 if matches!(lock.try_lock(), Err(std::sync::TryLockError::WouldBlock)) {
271 tests::COMMIT_WAIT.with(|slot| {
272 if let Some(waiting) = slot.borrow().as_ref() {
273 waiting();
274 }
275 });
276 }
277 lock.lock().unwrap_or_else(|e| e.into_inner())
278}
279
280fn file_operation<T>(operation: impl FnOnce() -> T) -> T {
282 outside_gate(|| {
283 let _gate = lock_commit(&FILE_WRITE_LOCK);
284 operation()
285 })
286}
287
288fn lock_dir() -> std::sync::MutexGuard<'static, ()> {
289 DIR_LOCK.lock().unwrap_or_else(|e| e.into_inner())
290}
291
292impl FileCache {
293 pub fn open(dir: impl Into<PathBuf>, max_bytes: u64) -> io::Result<Self> {
297 Self::open_bounded(dir, max_bytes, MAX_BATCHES)
298 }
299
300 fn open_bounded(
303 dir: impl Into<PathBuf>,
304 max_bytes: u64,
305 max_batches: usize,
306 ) -> io::Result<Self> {
307 file_operation(|| Self::open_locked(dir.into(), max_bytes, max_batches))
308 }
309
310 fn open_locked(dir: PathBuf, max_bytes: u64, max_batches: usize) -> io::Result<Self> {
312 create_dir_synced(&dir)?;
313 let cache = Self {
314 dir,
315 max_bytes,
316 max_batches,
317 evicted: Mutex::new(Vec::new()),
318 recovery_failed: Default::default(),
319 #[cfg(test)]
320 recoveries: Default::default(),
321 #[cfg(test)]
322 fault: Mutex::new(None),
323 };
324 cache.recover()?;
325 {
326 let _locked = lock_dir();
329 for entry in fs::read_dir(&cache.dir)?.flatten() {
330 let path = entry.path();
331 if !is_batch(&path) && path.extension().is_none_or(|e| e != JOURNAL_EXT) {
332 let _ = fs::remove_file(&path);
333 }
334 }
335 }
336 let evicted = cache.prune()?;
337 *cache.evicted.lock().unwrap_or_else(|e| e.into_inner()) = evicted;
338 Ok(cache)
339 }
340
341 #[cfg(test)]
343 pub(crate) fn open_with_max_batches(
344 dir: impl Into<PathBuf>,
345 max_bytes: u64,
346 max_batches: usize,
347 ) -> io::Result<Self> {
348 Self::open_bounded(dir, max_bytes, max_batches)
349 }
350
351 #[cfg(test)]
353 pub(crate) fn inject(
354 &self,
355 fault: impl Fn(Step, &Path) -> io::Result<()> + Send + Sync + 'static,
356 ) {
357 *self.fault.lock().unwrap_or_else(|e| e.into_inner()) = Some(Box::new(fault));
358 }
359
360 #[cfg_attr(not(test), expect(unused_variables, reason = "only the test fault hook reads them"))]
362 fn step(&self, step: Step, path: &Path) -> io::Result<()> {
363 #[cfg(test)]
364 if let Some(fault) = self.fault.lock().unwrap_or_else(|e| e.into_inner()).as_ref() {
365 return fault(step, path);
366 }
367 Ok(())
368 }
369
370 fn path(&self, id: &str, ext: &str) -> PathBuf {
371 self.dir.join(format!("{id}.{ext}"))
372 }
373
374 fn pend(&self, parent: &str, half: &str) -> PathBuf {
376 self.dir.join(format!("{parent}{JOURNAL_SEP}{half}.{JOURNAL_EXT}"))
377 }
378
379 fn journal(&self) -> io::Result<Vec<PathBuf>> {
381 Ok(fs::read_dir(&self.dir)?
382 .flatten()
383 .map(|e| e.path())
384 .filter(|p| p.extension().is_some_and(|e| e == JOURNAL_EXT))
385 .collect())
386 }
387
388 fn write_synced(&self, tmp: &Path, body: &[u8]) -> io::Result<()> {
391 let write = |path: &Path| -> io::Result<()> {
392 self.step(Step::Write, path)?;
393 let mut file = fs::File::create(path)?;
394 io::Write::write_all(&mut file, body)?;
395 self.step(Step::Sync, path)?;
396 file.sync_all()
397 };
398 match write(tmp) {
399 Err(err) if err.kind() == io::ErrorKind::NotFound => {
402 create_dir_synced(&self.dir)?;
403 write(tmp)
404 }
405 other => other,
406 }
407 }
408
409 fn rename(&self, from: &Path, to: &Path, step: Step) -> io::Result<()> {
410 self.step(step, to)?;
411 fs::rename(from, to)
412 }
413
414 fn after_write(&self) -> Vec<String> {
419 self.recovery_failed.store(false, Ordering::Relaxed);
420 self.prune().unwrap_or_else(|err| {
421 diagnostic(log::Level::Debug, format!("could not prune the cache: {err}"));
422 Vec::new()
423 })
424 }
425
426 fn sync_dir(&self) -> io::Result<()> {
429 self.step(Step::SyncDir, &self.dir)?;
430 #[cfg(unix)]
431 fs::File::open(&self.dir)?.sync_all()?;
432 Ok(())
433 }
434
435 fn ids(&self) -> io::Result<Vec<String>> {
439 let list = || -> io::Result<(Vec<String>, bool)> {
440 let (mut ids, mut journaled) = (Vec::new(), false);
441 for path in fs::read_dir(&self.dir)?.flatten().map(|e| e.path()) {
442 journaled |= path.extension().is_some_and(|e| e == JOURNAL_EXT);
443 if let Some(id) =
444 path.file_stem().and_then(|s| s.to_str()).filter(|_| is_batch(&path))
445 {
446 ids.push(id.to_owned());
447 }
448 }
449 Ok((ids, journaled))
450 };
451 let (mut ids, journaled) = list()?;
452 if journaled && !self.recovery_failed.load(Ordering::Relaxed) {
453 match self.recover() {
454 Ok(()) => ids = list()?.0,
455 Err(err) => {
456 self.recovery_failed.store(true, Ordering::Relaxed);
457 diagnostic(
458 log::Level::Debug,
459 format!("journaled split not published yet: {err}; retried after a write"),
460 );
461 }
462 }
463 }
464 ids.sort_unstable();
465 Ok(ids)
466 }
467
468 fn recover(&self) -> io::Result<()> {
477 let _locked = lock_dir();
478 #[cfg(test)]
479 self.recoveries.fetch_add(1, Ordering::Relaxed);
480 let journal = self.journal()?;
481 if journal.is_empty() {
482 return Ok(());
483 }
484 for pend in journal {
485 let Some((parent, half)) =
486 pend.file_stem().and_then(|s| s.to_str()).and_then(|s| s.split_once(JOURNAL_SEP))
487 else {
488 let _ = fs::remove_file(&pend);
489 continue;
490 };
491 let rebinding = is_rebinding(parent, half);
492 if self.path(parent, BATCH_EXT).exists() {
493 if !rebinding {
494 fs::remove_file(&pend)?;
495 continue;
496 }
497 fs::remove_file(self.path(parent, BATCH_EXT))?;
499 }
500 fs::rename(&pend, self.path(half, BATCH_EXT))?;
501 }
502 self.sync_dir()
503 }
504
505 fn prune(&self) -> io::Result<Vec<String>> {
515 let mut removed = Vec::new();
516 let kept = self.ids()?;
517 let sizes: Vec<u64> = kept
518 .iter()
519 .map(|id| fs::metadata(self.path(id, BATCH_EXT)).map(|m| m.len()).unwrap_or(0))
520 .collect();
521 let mut total: u64 = sizes.iter().sum();
522 let mut count = kept.len();
523 for (id, len) in kept.iter().zip(sizes).take(kept.len().saturating_sub(1)) {
524 if total <= self.max_bytes && count <= self.max_batches {
525 break;
526 }
527 let (mine, _) = self.unlink(id);
531 let went = mine || matches!(self.path(id, BATCH_EXT).try_exists(), Ok(false));
532 if went {
533 total -= len;
534 count -= 1;
535 if mine {
536 removed.push(id.clone());
537 }
538 }
539 }
540 Ok(removed)
541 }
542
543 fn unlink(&self, id: &str) -> (bool, io::Result<()>) {
550 let path = self.path(id, BATCH_EXT);
551 if let Err(err) = self.step(Step::Delete, &path) {
552 return (false, Err(err));
553 }
554 let unlinked = match fs::remove_file(&path) {
555 Ok(()) => true,
556 Err(err) if err.kind() == io::ErrorKind::NotFound => false,
557 Err(err) => return (false, Err(err)),
558 };
559 let synced = match self.sync_dir() {
560 Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
561 other => other,
562 };
563 (unlinked, synced)
564 }
565
566 fn split(&self, old: &str, new: &[(String, Vec<u8>)]) -> io::Result<()> {
570 let locked = lock_dir();
571 if !self.path(old, BATCH_EXT).try_exists()? {
572 return Err(io::ErrorKind::NotFound.into());
573 }
574 if new.len() > 1 && new.iter().any(|(half, _)| is_rebinding(old, half)) {
575 return Err(io::Error::new(
576 io::ErrorKind::InvalidInput,
577 "a rebinding replaces a batch with exactly one batch",
578 ));
579 }
580 let pends: Vec<PathBuf> = new.iter().map(|(half, _)| self.pend(old, half)).collect();
581 let roll_back = |err: io::Error| {
582 for ((half, _), pend) in new.iter().zip(&pends) {
583 let _ = fs::remove_file(self.path(half, TMP_EXT));
584 let _ = fs::remove_file(pend);
585 }
586 Err(err)
587 };
588 for ((half, body), pend) in new.iter().zip(&pends) {
589 let tmp = self.path(half, TMP_EXT);
590 if let Err(err) =
591 self.write_synced(&tmp, body).and_then(|()| self.rename(&tmp, pend, Step::Rename))
592 {
593 return roll_back(err);
594 }
595 }
596 if let Err(err) = self.sync_dir() {
597 return roll_back(err);
598 }
599 if let Err(err) = self
600 .step(Step::Delete, &self.path(old, BATCH_EXT))
601 .and_then(|()| fs::remove_file(self.path(old, BATCH_EXT)))
602 {
603 return roll_back(err);
604 }
605 let published = self
609 .sync_dir()
610 .and_then(|()| {
611 new.iter().zip(&pends).try_for_each(|((half, _), pend)| {
612 self.rename(pend, &self.path(half, BATCH_EXT), Step::Publish)
613 })
614 })
615 .and_then(|()| self.sync_dir());
616 drop(locked);
618 if let Err(err) = published {
619 diagnostic(
620 log::Level::Warn,
621 format!("split committed but not published yet ({err}); it will be"),
622 );
623 }
624 Ok(())
625 }
626}
627
628impl BatchCache for FileCache {
635 fn push(&self, id: &str, body: &[u8]) -> io::Result<Vec<String>> {
636 file_operation(|| {
637 let (tmp, dest) = (self.path(id, TMP_EXT), self.path(id, BATCH_EXT));
638 let locked = lock_dir();
640 let written = self
641 .write_synced(&tmp, body)
642 .and_then(|()| self.rename(&tmp, &dest, Step::Rename))
643 .and_then(|()| self.sync_dir());
644 if let Err(err) = written {
645 let _ = fs::remove_file(&tmp);
648 let _ = fs::remove_file(&dest);
649 return Err(err);
650 }
651 drop(locked);
652 Ok(self.after_write())
653 })
654 }
655
656 fn replace(&self, old: &str, new: &[(String, Vec<u8>)]) -> io::Result<Vec<String>> {
664 file_operation(|| {
665 self.split(old, new)?;
666 Ok(self.after_write())
667 })
668 }
669
670 fn pending(&self) -> Vec<String> {
671 file_operation(|| self.ids().unwrap_or_default())
672 }
673
674 fn read(&self, id: &str) -> io::Result<Vec<u8>> {
675 fs::read(self.path(id, BATCH_EXT))
676 }
677
678 fn remove(&self, id: &str) -> io::Result<()> {
679 file_operation(|| self.unlink(id).1)
680 }
681
682 fn clear(&self) -> io::Result<()> {
683 file_operation(|| {
684 let ids = match self.ids() {
687 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
688 other => other?,
689 };
690 #[cfg(test)]
691 self.step(Step::ClearListed, &self.dir)?;
692 let mut first = Ok(());
693 for id in ids {
694 if let Err(err) = self.unlink(&id).1 {
695 first = first.and(Err(err));
696 }
697 }
698 for pend in self.journal()? {
699 if let Err(err) = fs::remove_file(&pend) {
700 first = first.and(Err(err));
701 }
702 }
703 {
706 let _locked = lock_dir();
707 let entries = match fs::read_dir(&self.dir) {
708 Err(err) if err.kind() == io::ErrorKind::NotFound => return first,
709 other => other?,
710 };
711 for entry in entries.flatten() {
712 let path = entry.path();
713 if path.extension().is_some_and(|e| e == TMP_EXT) {
714 if let Err(err) = fs::remove_file(&path) {
715 first = first.and(Err(err));
716 }
717 }
718 }
719 }
720 self.recovery_failed.store(false, Ordering::Relaxed);
722 first.and_then(|()| self.sync_dir())
723 })
724 }
725
726 fn take_evicted(&self) -> Vec<String> {
727 std::mem::take(&mut *self.evicted.lock().unwrap_or_else(|e| e.into_inner()))
728 }
729}
730
731#[cfg_attr(
735 not(test),
736 expect(dead_code, reason = "the pipeline (`Telemetry`, `Exporter`) uses it, not in place yet")
737)]
738pub(crate) struct SpillCache {
739 primary: std::sync::Arc<dyn BatchCache>,
740 spill: MemoryCache,
741 counters: std::sync::Arc<crate::stats::Counters>,
742 revoked: std::sync::Arc<AtomicBool>,
746 spilling: AtomicBool,
748}
749
750#[cfg_attr(
751 not(test),
752 expect(dead_code, reason = "the pipeline (`Telemetry`, `Exporter`) uses it, not in place yet")
753)]
754impl SpillCache {
755 pub fn new(
756 primary: std::sync::Arc<dyn BatchCache>,
757 max_bytes: u64,
758 counters: std::sync::Arc<crate::stats::Counters>,
759 revoked: std::sync::Arc<AtomicBool>,
760 ) -> Self {
761 Self {
762 primary,
763 spill: MemoryCache::new(max_bytes),
764 counters,
765 revoked,
766 spilling: AtomicBool::new(false),
767 }
768 }
769
770 fn open_gate(&self) -> io::Result<SpillGuard> {
772 let gate = spill_gate()?;
773 if self.revoked.load(Ordering::SeqCst) {
774 return Err(io::Error::other("telemetry was disabled"));
775 }
776 Ok(gate)
777 }
778}
779
780impl BatchCache for SpillCache {
781 fn push(&self, id: &str, body: &[u8]) -> io::Result<Vec<String>> {
782 outside_gate(|| {
783 let gate = self.open_gate()?;
784 let err = match self.primary.push(id, body) {
785 Ok(evicted) => {
786 self.spilling.store(false, Ordering::Relaxed);
787 return Ok(evicted);
788 }
789 Err(err) => err,
790 };
791 crate::stats::Counters::add(&self.counters.cache_write_errors, 1);
792 let first = !self.spilling.swap(true, Ordering::Relaxed);
793 let spilled = self.spill.push(id, body);
794 drop(gate);
796 if first {
797 log::warn!("cannot write the telemetry cache ({err}); keeping batches in memory");
798 }
799 spilled
800 })
801 }
802
803 fn replace(&self, old: &str, new: &[(String, Vec<u8>)]) -> io::Result<Vec<String>> {
806 outside_gate(|| {
807 let _gate = self.open_gate()?;
808 if self.spill.pending().iter().any(|id| id == old) {
809 self.spill.replace(old, new)
810 } else {
811 self.primary.replace(old, new)
812 }
813 })
814 }
815
816 fn pending(&self) -> Vec<String> {
817 let mut ids = self.primary.pending();
818 ids.extend(self.spill.pending());
819 ids.sort_unstable();
820 ids
821 }
822
823 fn read(&self, id: &str) -> io::Result<Vec<u8>> {
824 self.spill.read(id).or_else(|_| self.primary.read(id))
825 }
826
827 fn remove(&self, id: &str) -> io::Result<()> {
828 self.spill.remove(id)?;
829 self.primary.remove(id)
830 }
831
832 fn clear(&self) -> io::Result<()> {
833 outside_gate(|| {
834 let _gate = spill_gate()?;
835 self.spill.clear()?;
836 self.primary.clear()
837 })
838 }
839
840 fn take_evicted(&self) -> Vec<String> {
841 self.primary.take_evicted()
842 }
843}
844
845thread_local! {
846 static HELD_BACK: RefCell<Option<Vec<(log::Level, String)>>> = const { RefCell::new(None) };
848}
849
850fn diagnostic(level: log::Level, message: String) {
853 let now = HELD_BACK.with(|held| match held.borrow_mut().as_mut() {
854 Some(queue) => {
855 queue.push((level, message));
856 None
857 }
858 None => Some(message),
859 });
860 if let Some(message) = now {
861 log::log!(level, "{message}");
862 }
863}
864
865#[cfg_attr(
868 not(test),
869 expect(dead_code, reason = "`SpillCache` uses it, and the pipeline is not in place yet")
870)]
871fn outside_gate<T>(gated: impl FnOnce() -> T) -> T {
872 struct Release(Option<Vec<(log::Level, String)>>);
873 impl Drop for Release {
874 fn drop(&mut self) {
875 let held =
876 HELD_BACK.with(|held| std::mem::replace(&mut *held.borrow_mut(), self.0.take()));
877 for (level, message) in held.unwrap_or_default() {
878 diagnostic(level, message);
879 }
880 }
881 }
882 let _release = Release(HELD_BACK.with(|held| held.borrow_mut().replace(Vec::new())));
883 gated()
884}
885
886fn create_dir_synced(dir: &Path) -> io::Result<()> {
892 match fs::create_dir(dir) {
893 Ok(()) => {
894 let parent =
897 dir.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
898 #[cfg(unix)]
899 fs::File::open(parent)?.sync_all()?;
900 #[cfg(test)]
901 tests::SYNCED_PARENT.with(|synced| *synced.borrow_mut() = Some(parent.to_path_buf()));
902 Ok(())
903 }
904 Err(err) if err.kind() == io::ErrorKind::AlreadyExists => Ok(()),
905 Err(err) => Err(err),
906 }
907}
908
909fn is_batch(path: &Path) -> bool {
912 path.extension().is_some_and(|e| e == BATCH_EXT)
913 && path
914 .file_stem()
915 .and_then(|s| s.to_str())
916 .and_then(|s| s.split('-').next())
917 .is_some_and(|stamp| stamp.parse::<u64>().is_ok())
918}
919
920fn is_rebinding(parent: &str, half: &str) -> bool {
923 parent.ends_with('-') && half.len() > parent.len() && half.starts_with(parent)
924}
925
926#[cfg(test)]
930pub(crate) fn temp_dir(tag: &str) -> PathBuf {
931 temp_dir_at(tag, crate::event::now_unix_nanos())
932}
933
934#[cfg(test)]
936fn temp_dir_at(tag: &str, unix_nanos: u64) -> PathBuf {
937 static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
938 let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
939 let dir = std::env::temp_dir()
940 .join(format!("livekit-telemetry-{tag}-{}-{n}-{unix_nanos}", std::process::id()));
941 let _ = fs::remove_dir_all(&dir);
942 dir
943}
944
945#[cfg(test)]
946mod tests {
947 use std::sync::Arc;
948
949 use super::*;
950
951 thread_local! {
952 pub(super) static SYNCED_PARENT: std::cell::RefCell<Option<PathBuf>> =
954 const { std::cell::RefCell::new(None) };
955 pub(super) static COMMIT_WAIT: RefCell<Option<Box<dyn Fn()>>> = const { RefCell::new(None) };
957 }
958
959 #[test]
962 fn temp_dirs_are_unique_within_one_clock_tick() {
963 let first = temp_dir_at("same-tick", 42);
964 fs::create_dir_all(&first).expect("first directory");
965 let second = temp_dir_at("same-tick", 42);
966 let kept = first.exists();
967 let _ = fs::remove_dir_all(&first);
968 assert_ne!(first, second);
969 assert!(kept, "the second request deleted the first directory");
970 }
971
972 #[test]
974 fn replacement_requires_a_parent_in_both_caches() {
975 let dir = temp_dir("missing-parent");
976 let file = FileCache::open(&dir, 1).unwrap();
977 let memory = MemoryCache::new(1);
978 let mut results = Vec::new();
979 let mut listings = Vec::new();
980 for cache in [&file as &dyn BatchCache, &memory as &dyn BatchCache] {
981 cache.push(&id(1), b"parent").unwrap();
982 assert_eq!(cache.push(&id(2), b"newest").unwrap(), [id(1)]);
983 for children in [vec![], vec![(id(3), b"child".to_vec())]] {
984 results.push(cache.replace(&id(1), &children).map_err(|e| e.kind()));
985 listings.push(cache.pending());
986 }
987 }
988 drop(file);
989 fs::remove_dir_all(dir).unwrap();
990 assert!(
991 listings.iter().all(|ids| ids == &[id(2)]),
992 "no children on missing-parent error: {listings:?}"
993 );
994 assert!(results.iter().all(|r| matches!(r, Err(io::ErrorKind::NotFound))), "{results:?}");
995 }
996
997 #[test]
999 fn standalone_clear_waits_before_listing_another_instances_held_write() {
1000 use std::{sync::mpsc::sync_channel, thread, time::Duration};
1001 let dir = temp_dir("clear-held-write");
1002 let writer = Arc::new(FileCache::open(&dir, 1024).unwrap());
1003 let clearer = Arc::new(FileCache::open(&dir, 1024).unwrap());
1004 let writer_weak = Arc::downgrade(&writer);
1005 let clearer_weak = Arc::downgrade(&clearer);
1006 let (entered_tx, entered_rx) = sync_channel(1);
1007 let (write_tx, write_rx) = sync_channel(1);
1008 let write_rx = Mutex::new(write_rx);
1009 writer.inject(move |step, _| {
1010 if step == Step::Write {
1011 let _ = entered_tx.send(());
1012 write_rx
1013 .lock()
1014 .unwrap()
1015 .recv_timeout(Duration::from_secs(5))
1016 .map_err(io::Error::other)?;
1017 }
1018 Ok(())
1019 });
1020 let (observed_tx, observed_rx) = sync_channel(2);
1021 let listed_tx = observed_tx.clone();
1022 let (clear_tx, clear_rx) = sync_channel(1);
1023 let clear_rx = Mutex::new(clear_rx);
1024 clearer.inject(move |step, _| {
1025 if step == Step::ClearListed {
1026 let _ = listed_tx.send("listed");
1027 clear_rx
1028 .lock()
1029 .unwrap()
1030 .recv_timeout(Duration::from_secs(5))
1031 .map_err(io::Error::other)?;
1032 }
1033 Ok(())
1034 });
1035 let writing = thread::spawn(move || writer.push(&id(1), b"payload"));
1036 let entered = entered_rx.recv_timeout(Duration::from_secs(5));
1037 let clearing = thread::spawn(move || {
1038 COMMIT_WAIT.with(|slot| {
1039 *slot.borrow_mut() = Some(Box::new(move || {
1040 let _ = observed_tx.send("blocked");
1041 }))
1042 });
1043 clearer.clear()
1044 });
1045 let observed = observed_rx.recv_timeout(Duration::from_secs(5));
1046 let _ = write_tx.send(());
1047 let wrote = writing.join();
1048 let _ = clear_tx.send(());
1049 let cleared = clearing.join();
1050 let files: Vec<_> = fs::read_dir(&dir).unwrap().collect();
1051 fs::remove_dir_all(dir).unwrap();
1052 assert!(writer_weak.upgrade().is_none() && clearer_weak.upgrade().is_none());
1053 assert!(entered.is_ok() && matches!(wrote, Ok(Ok(_))) && matches!(cleared, Ok(Ok(()))));
1054 assert_eq!(observed.unwrap(), "blocked", "clear must wait before taking its listing");
1055 assert!(files.is_empty(), "a clear cannot miss a held writer's later commit");
1056 }
1057
1058 struct HeldPrimary {
1060 memory: MemoryCache,
1061 entered: std::sync::mpsc::SyncSender<()>,
1062 resume: Mutex<std::sync::mpsc::Receiver<()>>,
1063 cleared: std::sync::mpsc::SyncSender<&'static str>,
1064 }
1065
1066 impl BatchCache for HeldPrimary {
1067 fn push(&self, id: &str, body: &[u8]) -> io::Result<Vec<String>> {
1068 let _ = self.entered.send(());
1069 self.resume
1070 .lock()
1071 .unwrap()
1072 .recv_timeout(std::time::Duration::from_secs(5))
1073 .map_err(io::Error::other)?;
1074 self.memory.push(id, body)
1075 }
1076 fn replace(&self, old: &str, new: &[(String, Vec<u8>)]) -> io::Result<Vec<String>> {
1077 self.memory.replace(old, new)
1078 }
1079 fn pending(&self) -> Vec<String> {
1080 self.memory.pending()
1081 }
1082 fn read(&self, id: &str) -> io::Result<Vec<u8>> {
1083 self.memory.read(id)
1084 }
1085 fn remove(&self, id: &str) -> io::Result<()> {
1086 self.memory.remove(id)
1087 }
1088 fn clear(&self) -> io::Result<()> {
1089 let _ = self.cleared.send("cleared");
1090 self.memory.clear()
1091 }
1092 }
1093
1094 #[test]
1096 fn spill_clear_waits_for_another_generations_admitted_write() {
1097 use std::{sync::mpsc::sync_channel, thread, time::Duration};
1098 let (entered_tx, entered_rx) = sync_channel(1);
1099 let (resume_tx, resume_rx) = sync_channel(1);
1100 let (observed_tx, observed_rx) = sync_channel(2);
1101 let primary = Arc::new(HeldPrimary {
1102 memory: MemoryCache::new(1024),
1103 entered: entered_tx,
1104 resume: Mutex::new(resume_rx),
1105 cleared: observed_tx.clone(),
1106 });
1107 let primary_weak = Arc::downgrade(&primary);
1108 let revoked = Arc::new(AtomicBool::new(false));
1109 let writer =
1110 Arc::new(SpillCache::new(primary.clone(), 1024, Arc::default(), revoked.clone()));
1111 let clearer =
1112 Arc::new(SpillCache::new(primary.clone(), 1024, Arc::default(), revoked.clone()));
1113 let writer_weak = Arc::downgrade(&writer);
1114 let clearer_weak = Arc::downgrade(&clearer);
1115 let writing = thread::spawn(move || writer.push(&id(1), b"payload"));
1116 let entered = entered_rx.recv_timeout(Duration::from_secs(5));
1117 revoked.store(true, Ordering::SeqCst);
1118 let clearing = thread::spawn(move || {
1119 COMMIT_WAIT.with(|slot| {
1120 *slot.borrow_mut() = Some(Box::new(move || {
1121 let _ = observed_tx.send("blocked");
1122 }))
1123 });
1124 clearer.clear()
1125 });
1126 let observed = observed_rx.recv_timeout(Duration::from_secs(5));
1127 let _ = resume_tx.send(());
1128 let wrote = writing.join();
1129 let cleared = clearing.join();
1130 let pending = primary.pending();
1131 let late = SpillCache::new(primary.clone(), 1024, Arc::default(), revoked);
1132 let refused = late.push(&id(2), b"late").is_err();
1133 drop(late);
1134 drop(primary);
1135 assert!(
1136 primary_weak.upgrade().is_none()
1137 && writer_weak.upgrade().is_none()
1138 && clearer_weak.upgrade().is_none()
1139 );
1140 assert!(entered.is_ok() && matches!(wrote, Ok(Ok(_))) && matches!(cleared, Ok(Ok(()))));
1141 assert_eq!(observed.unwrap(), "blocked");
1142 assert!(pending.is_empty() && refused);
1143 }
1144
1145 #[test]
1147 fn commit_barriers_release_after_failed_or_cancelled_writes() {
1148 for crash in [false, true] {
1149 let dir = temp_dir("barrier-failure");
1150 let file = Arc::new(FileCache::open(&dir, 1024).unwrap());
1151 let weak = Arc::downgrade(&file);
1152 file.inject(move |step, _| {
1153 if step == Step::Write {
1154 assert!(!crash, "cancelled synchronous write");
1155 return Err(io::Error::other("failed write"));
1156 }
1157 Ok(())
1158 });
1159 let cache = SpillCache::new(file.clone(), 1024, Arc::default(), Arc::default());
1160 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1161 cache.push(&id(1), b"body")
1162 }));
1163 let spilled = cache.spill.pending();
1164 file.inject(|_, _| Ok(()));
1165 let cleared = cache.clear();
1166 let empty = fs::read_dir(&dir).unwrap().count() == 0 && cache.pending().is_empty();
1167 drop(cache);
1168 drop(file);
1169 fs::remove_dir_all(dir).unwrap();
1170 assert!(weak.upgrade().is_none() && cleared.is_ok() && empty);
1171 if crash {
1172 assert!(result.is_err());
1173 } else {
1174 assert!(matches!(result, Ok(Ok(_))) && spilled == [id(1)]);
1175 }
1176 }
1177 }
1178
1179 #[test]
1181 fn spill_mutation_reentry_returns_would_block() {
1182 let cache =
1183 SpillCache::new(Arc::new(MemoryCache::new(1024)), 1024, Arc::default(), Arc::default());
1184 let held = spill_gate().unwrap();
1185 let push = cache.push(&id(1), b"body").map_err(|err| err.kind());
1186 let replace = cache.replace(&id(1), &[]).map_err(|err| err.kind());
1187 let clear = cache.clear().map_err(|err| err.kind());
1188 drop(held);
1189 let next = cache.push(&id(2), b"next");
1190 let cleared = cache.clear();
1191 assert_eq!(push, Err(io::ErrorKind::WouldBlock));
1192 assert_eq!(replace, Err(io::ErrorKind::WouldBlock));
1193 assert_eq!(clear, Err(io::ErrorKind::WouldBlock));
1194 assert!(next.is_ok() && cleared.is_ok());
1195 }
1196
1197 fn id(n: u64) -> String {
1200 static BASE: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
1201 let base =
1202 *BASE.get_or_init(|| crate::event::now_unix_nanos() / 1_000_000_000 * 1_000_000_000);
1203 format!("{:020}-{n:06}-1", base + n)
1204 }
1205
1206 #[test]
1207 fn the_newest_batch_always_stays_whatever_the_cap() {
1208 let dir = temp_dir("floor");
1209 let cache = FileCache::open(&dir, 25).expect("open");
1210 assert_eq!(cache.push(&id(1), &[b'a'; 30]).expect("push"), Vec::<String>::new());
1211 assert_eq!(cache.pending(), [id(1)], "a batch over the cap still gets its upload");
1212 assert_eq!(cache.push(&id(2), &[b'b'; 30]).expect("push"), [id(1)]);
1213 assert_eq!(cache.pending(), [id(2)]);
1214 let _ = fs::remove_dir_all(&dir);
1215 let dir = temp_dir("floor-zero");
1216 let cache = FileCache::open(&dir, 0).expect("open");
1217 cache.push(&id(1), b"x").expect("push");
1218 assert_eq!(cache.pending(), [id(1)], "a cap of 0 keeps the push");
1219 let _ = fs::remove_dir_all(&dir);
1220 }
1221
1222 #[test]
1223 fn an_eviction_whose_directory_sync_fails_still_counts() {
1224 use std::sync::atomic::{AtomicBool, Ordering};
1225 let dir = temp_dir("evict-sync");
1226 let cache = FileCache::open(&dir, 10).expect("open");
1227 cache.push(&id(1), &[b'a'; 10]).expect("push");
1228 let deleted = Arc::new(AtomicBool::new(false));
1229 cache.inject(move |step, _| {
1230 if step == Step::Delete {
1231 deleted.store(true, Ordering::SeqCst);
1232 }
1233 if step == Step::SyncDir && deleted.load(Ordering::SeqCst) {
1234 return Err(io::Error::other("injected"));
1235 }
1236 Ok(())
1237 });
1238 assert_eq!(cache.push(&id(2), &[b'b'; 10]).expect("push"), [id(1)]);
1239 assert_eq!(cache.pending(), [id(2)], "the batch just committed stays");
1240 let _ = fs::remove_dir_all(&dir);
1241 }
1242
1243 #[test]
1244 fn a_purged_directory_is_already_empty() {
1245 let dir = temp_dir("purged");
1246 let cache = FileCache::open(&dir, 1 << 20).expect("open");
1247 cache.push(&id(1), b"x").expect("push");
1248 fs::remove_dir_all(&dir).expect("the OS purges the directory");
1249 assert!(cache.remove(&id(1)).is_ok());
1250 assert!(cache.clear().is_ok(), "an opt-out with nothing on disk succeeds");
1251 }
1252
1253 #[test]
1254 fn a_rebinding_replaces_a_batch_with_exactly_one() {
1255 let dir = temp_dir("rebinding");
1256 let cache = FileCache::open(&dir, 1 << 20).expect("open");
1257 let parent = format!("{}-l-s-", id(1));
1258 cache.push(&parent, b"parent").expect("push");
1259 let two = [
1260 (format!("{parent}a.example"), b"a".to_vec()),
1261 (format!("{parent}b.example"), b"b".to_vec()),
1262 ];
1263 let err = cache.replace(&parent, &two).expect_err("rejected");
1264 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
1265 assert_eq!(cache.pending(), std::slice::from_ref(&parent), "the parent is kept");
1266 let one = [(format!("{parent}a.example"), b"a".to_vec())];
1267 cache.replace(&parent, &one).expect("one member");
1268 assert_eq!(cache.pending(), [format!("{parent}a.example")]);
1269 let _ = fs::remove_dir_all(&dir);
1270 }
1271
1272 #[test]
1273 fn clear_lets_listings_publish_a_journal_again() {
1274 let dir = temp_dir("clear-recovery");
1275 let cache = FileCache::open(&dir, 1 << 20).expect("open");
1276 cache.recovery_failed.store(true, Ordering::Relaxed);
1277 cache.clear().expect("clear");
1278 assert!(!cache.recovery_failed.load(Ordering::Relaxed));
1279 let _ = fs::remove_dir_all(&dir);
1280 }
1281
1282 #[test]
1285 fn a_second_cache_never_breaks_a_push_in_progress() {
1286 let dir = temp_dir("push-two-generations");
1287 let old = FileCache::open(&dir, 1 << 20).expect("open");
1288 let (opened, path) = (Arc::new(Mutex::new(None)), dir.clone());
1289 let handle = opened.clone();
1290 old.inject(move |step, _| {
1291 let mut handle = handle.lock().expect("lock");
1292 if step == Step::Rename && handle.is_none() {
1293 let path = path.clone();
1294 *handle = Some(std::thread::spawn(move || {
1295 FileCache::open(&path, 1 << 20).expect("open");
1296 }));
1297 std::thread::sleep(std::time::Duration::from_millis(100));
1299 }
1300 Ok(())
1301 });
1302 old.push(&id(1), b"in flight").expect("the push commits");
1303 opened.lock().expect("lock").take().expect("opened").join().expect("open succeeded");
1304 assert_eq!(old.pending(), [id(1)]);
1305 let _ = fs::remove_dir_all(&dir);
1306 }
1307
1308 #[cfg(unix)]
1311 #[test]
1312 fn clear_deletes_a_tmp_a_failed_write_left_behind() {
1313 use std::os::unix::fs::PermissionsExt;
1314 let dir = temp_dir("clear-tmp");
1315 let cache = FileCache::open(&dir, 1024).expect("open");
1316 cache.inject(|step, path| {
1317 if step == Step::Sync {
1318 let parent = path.parent().expect("the cache directory");
1319 fs::set_permissions(parent, fs::Permissions::from_mode(0o555))?;
1320 return Err(io::Error::other("disk gone"));
1321 }
1322 Ok(())
1323 });
1324 let pushed = cache.push(&id(1), b"telemetry bytes");
1325 fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).expect("writable again");
1326 cache.inject(|_, _| Ok(()));
1327 assert!(pushed.is_err());
1328 assert_eq!(fs::read_dir(&dir).expect("dir").count(), 1, "the failed write left its .tmp");
1329 cache.clear().expect("clear");
1330 let left: Vec<_> = fs::read_dir(&dir).expect("dir").flatten().map(|e| e.path()).collect();
1331 let _ = fs::remove_dir_all(&dir);
1332 assert!(left.is_empty(), "{left:?}");
1333 }
1334
1335 #[test]
1338 fn diagnostics_reach_the_logger_outside_the_spill_gate() {
1339 crate::test_log::capture();
1340 let dir = temp_dir("diagnostics-outside-the-gate");
1341 let primary = Arc::new(FileCache::open(&dir, 1024).expect("open"));
1342 primary.push(&id(1), b"parent").expect("push");
1343 primary.inject(|step, _| match step {
1344 Step::Publish => Err(io::Error::other("publish failed")),
1345 _ => Ok(()),
1346 });
1347 let cache = Arc::new(SpillCache::new(primary, 1024, Arc::default(), Arc::default()));
1348 let gated = Arc::downgrade(&cache);
1349 crate::test_log::mark_while(move || {
1350 gated.upgrade().is_some_and(|_| SPILL_ACTIVE.with(Cell::get))
1351 });
1352 let halves =
1353 [(format!("{}a", id(1)), b"a".to_vec()), (format!("{}b", id(1)), b"b".to_vec())];
1354 cache.replace(&id(1), &halves).expect("split");
1355 let lines = crate::test_log::take();
1356 let _ = fs::remove_dir_all(&dir);
1357 assert!(lines.iter().any(|l| l.contains("split committed but not published")), "{lines:?}");
1358 assert!(lines.iter().all(|l| !l.starts_with("held: ")), "{lines:?}");
1359 }
1360
1361 struct Flaky(std::sync::atomic::AtomicBool);
1363
1364 impl BatchCache for Flaky {
1365 fn push(&self, _: &str, _: &[u8]) -> io::Result<Vec<String>> {
1366 if self.0.load(Ordering::SeqCst) {
1367 return Err(io::Error::other("disk full"));
1368 }
1369 Ok(Vec::new())
1370 }
1371 fn replace(&self, _: &str, _: &[(String, Vec<u8>)]) -> io::Result<Vec<String>> {
1372 Ok(Vec::new())
1373 }
1374 fn pending(&self) -> Vec<String> {
1375 Vec::new()
1376 }
1377 fn read(&self, _: &str) -> io::Result<Vec<u8>> {
1378 Err(io::ErrorKind::NotFound.into())
1379 }
1380 fn remove(&self, _: &str) -> io::Result<()> {
1381 Ok(())
1382 }
1383 fn clear(&self) -> io::Result<()> {
1384 Ok(())
1385 }
1386 }
1387
1388 #[test]
1389 fn each_spill_episode_warns_once() {
1390 crate::test_log::capture();
1391 let primary = Arc::new(Flaky(true.into()));
1392 let counters = Arc::new(crate::stats::Counters::default());
1393 let cache = SpillCache::new(primary.clone(), 1 << 20, counters.clone(), Arc::default());
1394 let warning = "cannot write the telemetry cache (disk full); keeping batches in memory";
1395 cache.push(&id(1), b"a").expect("spilled");
1396 cache.push(&id(2), b"b").expect("spilled");
1397 assert_eq!(crate::test_log::take(), [warning], "two spills, one warning");
1398 primary.0.store(false, Ordering::SeqCst);
1399 cache.push(&id(3), b"c").expect("written");
1400 primary.0.store(true, Ordering::SeqCst);
1401 cache.push(&id(4), b"d").expect("spilled");
1402 assert_eq!(crate::test_log::take(), [warning], "a new episode warns again");
1403 assert_eq!(counters.snapshot().cache_write_errors, 3);
1404 }
1405
1406 #[test]
1407 fn memory_cache_evicts_oldest_beyond_max_bytes() {
1408 let cache = MemoryCache::new(25);
1409 for (n, body) in [b"aaaaaaaaaa", b"bbbbbbbbbb", b"cccccccccc"].into_iter().enumerate() {
1410 cache.push(&id(n as u64), body).expect("push");
1411 }
1412 assert_eq!(cache.pending(), [id(1), id(2)]);
1413 assert_eq!(cache.read(&id(1)).expect("read"), b"bbbbbbbbbb");
1414 cache.remove(&id(1)).expect("remove");
1415 assert_eq!(cache.pending(), [id(2)]);
1416 cache.clear().expect("clear");
1417 assert!(cache.pending().is_empty());
1418 }
1419
1420 #[test]
1421 fn file_cache_evicts_oldest_beyond_max_bytes_and_drops_stray_tmp() {
1422 let dir = temp_dir("cache");
1423 let cache = FileCache::open(&dir, 25).expect("open");
1424 fs::write(dir.join("crashed.tmp"), b"half").expect("write");
1425 for (n, body) in [b"aaaaaaaaaa", b"bbbbbbbbbb", b"cccccccccc"].into_iter().enumerate() {
1426 cache.push(&id(n as u64), body).expect("push");
1427 }
1428 assert_eq!(cache.pending(), [id(1), id(2)], "10-byte batches under a 25-byte cap");
1429 assert_eq!(cache.read(&id(1)).expect("read"), b"bbbbbbbbbb");
1430 assert!(dir.join("crashed.tmp").exists());
1433 let cache = FileCache::open(&dir, 25).expect("reopen");
1434 assert!(!dir.join("crashed.tmp").exists());
1435 cache.clear().expect("clear");
1436 assert!(cache.pending().is_empty());
1437 let _ = fs::remove_dir_all(&dir);
1438 }
1439
1440 #[test]
1443 fn file_cache_caps_the_number_of_batches() {
1444 let dir = temp_dir("count");
1445 let cache = FileCache::open(&dir, 1 << 20).expect("open");
1446 let ids: Vec<String> = (0..(MAX_BATCHES as u64 + 8)).map(id).collect();
1448 for batch in &ids {
1449 cache.push(batch, b"x").expect("push");
1450 }
1451 let kept = cache.pending();
1452 assert_eq!(kept.len(), MAX_BATCHES, "far under the byte cap, still bounded");
1453 assert_eq!(kept.first(), Some(&ids[8]), "the oldest went first");
1454 let _ = fs::remove_dir_all(&dir);
1455 }
1456
1457 #[test]
1458 fn file_cache_recreates_a_purged_directory() {
1459 let dir = temp_dir("purged");
1460 let cache = FileCache::open(&dir, 1 << 20).expect("open");
1461 fs::remove_dir_all(&dir).expect("purge like iOS does");
1462 cache.push(&id(1), b"after purge").expect("push recreates the dir");
1463 assert_eq!(cache.pending(), [id(1)]);
1464 let _ = fs::remove_dir_all(&dir);
1465 }
1466
1467 #[cfg(unix)]
1468 #[test]
1469 fn file_cache_failed_write_leaves_no_partial_file() {
1470 use std::os::unix::fs::PermissionsExt;
1471 let dir = temp_dir("readonly");
1472 let cache = FileCache::open(&dir, 1 << 20).expect("open");
1473 fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).expect("chmod");
1475 let result = cache.push(&id(1), b"no room");
1476 fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).expect("chmod back");
1477 assert!(result.is_err());
1478 assert_eq!(fs::read_dir(&dir).expect("dir").count(), 0, "no stray .tmp");
1479 let _ = fs::remove_dir_all(&dir);
1480 }
1481
1482 #[test]
1486 fn a_split_crashed_at_every_step_loses_and_duplicates_nothing() {
1487 use std::sync::atomic::{AtomicUsize, Ordering};
1488 let (parent, a, b) = (id(1), format!("{}a", id(1)), format!("{}b", id(1)));
1489 let halves = [(a.clone(), vec![b'a'; 60]), (b.clone(), vec![b'b'; 60])];
1490 for (max_bytes, max_batches) in [(120, MAX_BATCHES), (1 << 20, 2)] {
1493 let mut step = 0;
1494 loop {
1495 let dir = temp_dir(&format!("split-{step}-{max_batches}"));
1496 let cache =
1497 FileCache::open_with_max_batches(&dir, max_bytes, max_batches).expect("open");
1498 cache.push(&parent, &[b'p'; 120]).expect("push");
1499 let calls = Arc::new(AtomicUsize::new(0));
1500 let (counter, crash_at) = (calls.clone(), step);
1501 cache.inject(move |_, _| {
1502 if counter.fetch_add(1, Ordering::SeqCst) == crash_at {
1503 panic!("killed");
1504 }
1505 Ok(())
1506 });
1507 let crashed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1508 let _ = cache.replace(&parent, &halves);
1509 }))
1510 .is_err();
1511 drop(cache);
1512 let cache =
1513 FileCache::open_with_max_batches(&dir, max_bytes, max_batches).expect("reopen");
1514 assert!(cache.take_evicted().is_empty(), "step {step}: nothing evicted");
1515 let pending = cache.pending();
1516 let whole = pending == [parent.clone()]
1517 && cache.read(&parent).expect("parent").len() == 120;
1518 let split = pending == [a.clone(), b.clone()];
1519 assert!(whole || split, "step {step}: {pending:?}");
1520 let _ = fs::remove_dir_all(&dir);
1521 if !crashed {
1522 assert!(split, "a replace that ran to the end split the batch");
1523 break;
1524 }
1525 step += 1;
1526 }
1527 assert!(
1528 step >= 10,
1529 "every write, sync, rename, delete and publish was crashed at: {step}"
1530 );
1531 }
1532 }
1533
1534 #[test]
1538 fn a_split_failing_after_its_commit_point_is_kept_and_purgeable() {
1539 use std::sync::atomic::{AtomicBool, Ordering};
1540 let (parent, a, b) = (id(1), format!("{}a", id(1)), format!("{}b", id(1)));
1541 let halves = [(a.clone(), b"half a".to_vec()), (b.clone(), b"half b".to_vec())];
1542 for failing in [Step::Publish, Step::SyncDir] {
1543 let dir = temp_dir(&format!("post-commit-{failing:?}"));
1544 let cache = FileCache::open(&dir, 1 << 20).expect("open");
1545 cache.push(&parent, b"parent").expect("push");
1546 let seen = Arc::new(AtomicBool::new(false));
1547 cache.inject(move |step, _| {
1548 if step == Step::Delete {
1549 seen.store(true, Ordering::SeqCst);
1550 }
1551 if step == failing && seen.load(Ordering::SeqCst) {
1552 return Err(io::Error::other("injected"));
1553 }
1554 Ok(())
1555 });
1556 cache.replace(&parent, &halves).expect("committed: not an error");
1557 assert!(!cache.pending().contains(&parent), "{failing:?}: the parent is gone");
1558 cache.inject(|_, _| Ok(())); assert!(cache.clear().is_ok());
1560 assert_eq!(
1561 fs::read_dir(&dir).expect("dir").count(),
1562 0,
1563 "{failing:?}: nothing survives the opt-out"
1564 );
1565
1566 let dir2 = temp_dir(&format!("post-commit-publish-{failing:?}"));
1568 let cache = FileCache::open(&dir2, 1 << 20).expect("open");
1569 cache.push(&parent, b"parent").expect("push");
1570 let flag = Arc::new(AtomicBool::new(true));
1571 let fail = flag.clone();
1572 let seen = Arc::new(AtomicBool::new(false));
1573 cache.inject(move |step, _| {
1574 if step == Step::Delete {
1575 seen.store(true, Ordering::SeqCst);
1576 }
1577 if step == failing && seen.load(Ordering::SeqCst) && fail.load(Ordering::SeqCst) {
1578 return Err(io::Error::other("injected"));
1579 }
1580 Ok(())
1581 });
1582 cache.replace(&parent, &halves).expect("committed");
1583 flag.store(false, Ordering::SeqCst);
1584 cache.push(&id(9), b"next").expect("push");
1585 assert_eq!(cache.pending(), [a.clone(), b.clone(), id(9)], "{failing:?}: published");
1586 let _ = fs::remove_dir_all(&dir);
1587 let _ = fs::remove_dir_all(&dir2);
1588 }
1589 }
1590
1591 #[test]
1595 fn a_second_cache_on_the_directory_never_breaks_a_split_in_progress() {
1596 let dir = temp_dir("two-generations");
1597 let (parent, a, b) = (id(1), format!("{}a", id(1)), format!("{}b", id(1)));
1598 let halves = [(a.clone(), b"half a".to_vec()), (b.clone(), b"half b".to_vec())];
1599 let old = FileCache::open(&dir, 1 << 20).expect("open");
1600 old.push(&parent, b"parent").expect("push");
1601 let other = Arc::new(Mutex::new(None));
1602 let (handle, path) = (other.clone(), dir.clone());
1603 let (waiting_tx, waiting_rx) = std::sync::mpsc::sync_channel(1);
1604 let waiting_rx = Mutex::new(waiting_rx);
1605 old.inject(move |step, _| {
1606 let mut handle = handle.lock().expect("lock");
1608 if step == Step::Delete && handle.is_none() {
1609 let path = path.clone();
1610 let waiting_tx = waiting_tx.clone();
1611 *handle = Some(std::thread::spawn(move || {
1612 COMMIT_WAIT.with(|slot| {
1613 *slot.borrow_mut() = Some(Box::new(move || {
1614 let _ = waiting_tx.try_send(());
1615 }))
1616 });
1617 let new = FileCache::open(&path, 1 << 20).expect("open");
1618 new.push(&id(2), b"new generation").expect("push");
1619 }));
1620 waiting_rx
1622 .lock()
1623 .unwrap()
1624 .recv_timeout(std::time::Duration::from_secs(5))
1625 .map_err(io::Error::other)?;
1626 }
1627 Ok(())
1628 });
1629 old.replace(&parent, &halves).expect("split");
1630 let new = other.lock().expect("lock").take().expect("the new generation ran");
1631 new.join().expect("the new generation succeeded");
1632 let reopened = FileCache::open(&dir, 1 << 20).expect("reopen");
1633 assert_eq!(reopened.pending(), [a, b, id(2)], "every record exactly once");
1634 let _ = fs::remove_dir_all(&dir);
1635 }
1636
1637 #[cfg(unix)]
1640 #[test]
1641 fn a_stuck_journal_is_retried_after_a_write_not_every_listing() {
1642 let dir = temp_dir("stuck-journal");
1643 let cache = FileCache::open(&dir, 1 << 20).expect("open");
1644 let (parent, half) = (id(1), format!("{}a", id(1)));
1645 fs::write(cache.pend(&parent, &half), b"half").expect("journal");
1646 fs::create_dir(cache.path(&half, BATCH_EXT)).expect("blocker");
1648 fs::write(cache.path(&half, BATCH_EXT).join("x"), b"").expect("blocker content");
1649 let before = cache.recoveries.load(Ordering::Relaxed);
1650 for _ in 0..5 {
1651 cache.pending();
1652 }
1653 assert_eq!(cache.recoveries.load(Ordering::Relaxed), before + 1, "tried once");
1654 cache.push(&id(2), b"next").expect("push");
1655 assert_eq!(cache.recoveries.load(Ordering::Relaxed), before + 2, "a write retries");
1656 fs::remove_dir_all(cache.path(&half, BATCH_EXT)).expect("unblock");
1657 cache.push(&id(3), b"next").expect("push");
1658 assert_eq!(cache.pending(), [half, id(2), id(3)], "published once unblocked");
1659 let _ = fs::remove_dir_all(&dir);
1660 }
1661
1662 #[test]
1665 fn a_write_killed_half_way_leaves_no_partial_batch() {
1666 let dir = temp_dir("torn");
1667 let cache = FileCache::open(&dir, 1 << 20).expect("open");
1668 cache.push(&id(1), b"committed").expect("push");
1669 cache.inject(|step, path| {
1670 if step == Step::Write {
1671 fs::write(path, b"half of a batc").expect("partial");
1672 panic!("killed mid-write");
1673 }
1674 Ok(())
1675 });
1676 let killed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1677 let _ = cache.push(&id(2), b"half of a batch that never lands");
1678 }));
1679 assert!(killed.is_err());
1680 drop(cache);
1681 let cache = FileCache::open(&dir, 1 << 20).expect("reopen");
1682 assert_eq!(cache.pending(), [id(1)]);
1683 assert_eq!(cache.read(&id(1)).expect("read"), b"committed");
1684 assert_eq!(fs::read_dir(&dir).expect("dir").count(), 1, "the torn .tmp is gone");
1685 let _ = fs::remove_dir_all(&dir);
1686 }
1687
1688 #[test]
1692 fn durability_failures_are_errors_not_silent_successes() {
1693 for failing in [Step::Sync, Step::SyncDir] {
1694 let dir = temp_dir("sync");
1695 let cache = FileCache::open(&dir, 1 << 20).expect("open");
1696 cache.push(&id(1), b"parent").expect("push");
1697 cache.inject(move |step, _| {
1698 if step == failing {
1699 return Err(io::Error::other("fsync failed"));
1700 }
1701 Ok(())
1702 });
1703 assert!(cache.push(&id(2), b"new").is_err(), "{failing:?}: not committed");
1704 assert_eq!(cache.pending(), [id(1)], "{failing:?}: nothing left of it");
1705 let halves =
1706 [(format!("{}a", id(1)), b"a".to_vec()), (format!("{}b", id(1)), b"b".to_vec())];
1707 assert!(cache.replace(&id(1), &halves).is_err());
1708 assert_eq!(cache.pending(), [id(1)], "{failing:?}: the parent stays whole");
1709 let _ = fs::remove_dir_all(&dir);
1710 }
1711 }
1712
1713 #[cfg(unix)]
1715 #[test]
1716 fn clearing_an_unreadable_directory_fails() {
1717 use std::os::unix::fs::PermissionsExt;
1718 let dir = temp_dir("unreadable");
1719 let cache = FileCache::open(&dir, 1 << 20).expect("open");
1720 cache.push(&id(1), b"batch").expect("push");
1721 fs::set_permissions(&dir, fs::Permissions::from_mode(0o300)).expect("chmod");
1722 assert!(cache.clear().is_err(), "cannot list, cannot claim it is empty");
1723 fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).expect("chmod");
1724 assert_eq!(cache.pending(), [id(1)]);
1725 let _ = fs::remove_dir_all(&dir);
1726 }
1727
1728 #[test]
1731 fn the_newest_batch_stays_when_older_deletions_fail() {
1732 let dir = temp_dir("floor-undeletable");
1733 let cache = FileCache::open(&dir, 25).expect("open");
1734 cache.push(&id(1), &[b'a'; 10]).expect("push");
1735 cache.push(&id(2), &[b'b'; 10]).expect("push");
1736 let newest = cache.path(&id(3), BATCH_EXT);
1737 cache.inject(move |step, path| {
1738 if step == Step::Delete && path != newest {
1739 return Err(io::Error::other("undeletable"));
1740 }
1741 Ok(())
1742 });
1743 let evicted = cache.push(&id(3), &[b'c'; 10]).expect("push");
1744 assert_eq!(evicted, Vec::<String>::new(), "nothing older could go, the newest stays");
1745 assert_eq!(cache.pending(), [id(1), id(2), id(3)]);
1746 let _ = fs::remove_dir_all(&dir);
1747 }
1748
1749 #[test]
1752 fn an_eviction_another_pass_already_made_is_not_counted_twice() {
1753 let dir = temp_dir("evict-twice");
1754 let cache = FileCache::open(&dir, 10).expect("open");
1755 cache.push(&id(1), &[b'a'; 10]).expect("push");
1756 let gone = cache.path(&id(1), BATCH_EXT);
1757 cache.inject(move |step, path| {
1758 if step == Step::Delete && path == gone {
1759 let _ = fs::remove_file(path);
1761 }
1762 Ok(())
1763 });
1764 let evicted = cache.push(&id(2), &[b'b'; 10]).expect("push");
1765 assert_eq!(evicted, Vec::<String>::new(), "the other pass counts it");
1766 assert_eq!(cache.pending(), [id(2)]);
1767 let _ = fs::remove_dir_all(&dir);
1768 }
1769
1770 #[test]
1775 fn an_eviction_another_pass_made_is_not_counted_even_when_the_sync_fails() {
1776 use std::sync::atomic::{AtomicBool, Ordering};
1777 let dir = temp_dir("evict-twice-sync");
1778 let cache = FileCache::open(&dir, 10).expect("open");
1779 cache.push(&id(1), &[b'a'; 10]).expect("push");
1780 let gone = cache.path(&id(1), BATCH_EXT);
1781 let taken = Arc::new(AtomicBool::new(false));
1782 cache.inject({
1783 let taken = taken.clone();
1784 move |step, path| {
1785 if step == Step::Delete && path == gone {
1786 let _ = fs::remove_file(path);
1788 taken.store(true, Ordering::SeqCst);
1789 }
1790 if step == Step::SyncDir && taken.load(Ordering::SeqCst) {
1791 return Err(io::Error::other("injected"));
1792 }
1793 Ok(())
1794 }
1795 });
1796 let evicted = cache.push(&id(2), &[b'b'; 10]).expect("push");
1797 assert_eq!(evicted, Vec::<String>::new(), "the other pass counts it");
1798 assert_eq!(cache.pending(), [id(2)]);
1799 let _ = fs::remove_dir_all(&dir);
1800 }
1801
1802 #[test]
1806 fn creating_the_directory_syncs_its_parent() {
1807 let dir = temp_dir("parent-sync");
1808 SYNCED_PARENT.with(|synced| synced.take());
1809 FileCache::open(&dir, 1 << 20).expect("open");
1810 let parent = dir.parent().map(Path::to_path_buf);
1811 assert_eq!(SYNCED_PARENT.with(|synced| synced.take()), parent, "an absolute path");
1812 FileCache::open(&dir, 1 << 20).expect("open again");
1813 assert_eq!(SYNCED_PARENT.with(|synced| synced.take()), None, "it existed");
1814 let _ = fs::remove_dir_all(&dir);
1815 let relative =
1816 PathBuf::from(format!("lk-telemetry-parent-sync-{}", crate::event::now_unix_nanos()));
1817 FileCache::open(&relative, 1 << 20).expect("open");
1818 let _ = fs::remove_dir_all(&relative);
1819 assert_eq!(
1820 SYNCED_PARENT.with(|synced| synced.take()),
1821 Some(PathBuf::from(".")),
1822 "a bare relative name lives in the current directory"
1823 );
1824 }
1825
1826 #[test]
1829 fn purge_recovery_tolerates_a_directory_another_cache_recreated() {
1830 use std::sync::atomic::{AtomicBool, Ordering};
1831 let dir = temp_dir("purge-race");
1832 let cache = FileCache::open(&dir, 1 << 20).expect("open");
1833 let first = AtomicBool::new(true);
1834 cache.inject(move |step, _| {
1835 if step == Step::Write && first.swap(false, Ordering::SeqCst) {
1836 return Err(io::ErrorKind::NotFound.into());
1837 }
1838 Ok(())
1839 });
1840 cache.push(&id(1), b"x").expect("the recreated directory is used");
1841 assert_eq!(cache.pending(), [id(1)]);
1842 let _ = fs::remove_dir_all(&dir);
1843 }
1844
1845 #[test]
1848 fn a_lowered_file_count_bound_applies_when_the_cache_opens() {
1849 let dir = temp_dir("open-bound");
1850 let cache = FileCache::open(&dir, 1 << 20).expect("open");
1851 for n in 1..=3 {
1852 cache.push(&id(n), b"x").expect("push");
1853 }
1854 drop(cache);
1855 let cache = FileCache::open_with_max_batches(&dir, 1 << 20, 2).expect("reopen");
1856 assert_eq!(cache.take_evicted(), [id(1)], "opening pruned to the requested bound");
1857 assert_eq!(cache.pending(), [id(2), id(3)]);
1858 let _ = fs::remove_dir_all(&dir);
1859 }
1860}