Skip to main content

livekit_telemetry/
cache.rs

1// Copyright 2026 LiveKit, Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::{
16    cell::{Cell, RefCell},
17    fs, io,
18    path::{Path, PathBuf},
19    sync::{
20        atomic::{AtomicBool, Ordering},
21        Mutex,
22    },
23};
24
25/// Queue of encoded OTLP batches between the exporter (`Exporter`) and the transport.
26///
27/// The exporter writes every batch here *before* trying the network (write-ahead), uploads
28/// oldest-first, and removes what the collector accepted or rejected. Two implementations ship:
29/// [`MemoryCache`] (default) and [`FileCache`] (survives crashes and restarts); anything else —
30/// a database, a platform store — plugs in through `Telemetry::with_cache`.
31///
32/// Ids are chosen by the exporter as `<unix ns>-<seq>-<records>-<l|t>-<session>-<project key>`.
33/// They hold digits, `a` and `b` (a split's halves), hex or `process`, `l` or `t`, `-`, and a
34/// routing key of lowercase letters, digits, `.`, `-`, `_`, `+`, and `%` followed by two uppercase
35/// hex digits: characters every platform allows in a file name, Windows included. A batch without a
36/// project ends with `-`, because its key is empty. Ids are sortable, so [`pending`](Self::pending)
37/// is a plain sort, and prefixed with the creation time so an implementation can expire old batches
38/// without touching file timestamps.
39/// Implementations bound their own footprint by evicting the oldest batches and report what they
40/// evicted, so every loss is counted. A failed write or delete is an `Err` and is never reported
41/// as done, with one exception: [`FileCache`] counts an eviction once the file is gone, even when
42/// the directory sync after it failed. A failed listing reads as empty, and the next pass tries
43/// again.
44pub trait BatchCache: Send + Sync {
45    /// Store one encoded batch under `id`: once this returns `Ok` the batch is committed (for
46    /// [`FileCache`], on disk). Returns the ids of older batches evicted to make room (the
47    /// exporter counts their events as dropped).
48    fn push(&self, id: &str, body: &[u8]) -> io::Result<Vec<String>>;
49    /// Replace the batch `old` with `new` (a split): all of `new` is committed before `old`
50    /// goes, and nothing is evicted in between, so a crash can duplicate records but never lose
51    /// them. A missing `old` returns [`io::ErrorKind::NotFound`] without storing children;
52    /// an error does not promise that a concurrently evicted parent still exists. Returns the
53    /// ids evicted afterwards, excluding any parent already reported as evicted.
54    fn replace(&self, old: &str, new: &[(String, Vec<u8>)]) -> io::Result<Vec<String>>;
55    /// Ids of stored batches, oldest first.
56    fn pending(&self) -> Vec<String>;
57    /// The body stored under `id`: `ErrorKind::NotFound` when it is gone, another error when it
58    /// exists but cannot be read right now (e.g. a locked device's file protection).
59    fn read(&self, id: &str) -> io::Result<Vec<u8>>;
60    /// Delete one batch; a batch already gone is not an error.
61    fn remove(&self, id: &str) -> io::Result<()>;
62    /// Discard everything (telemetry disabled: nothing may be replayed later); `Err` when
63    /// something could not be deleted.
64    fn clear(&self) -> io::Result<()>;
65    /// Ids evicted before the exporter could see them (over the bounds when the cache
66    /// was opened), handed over once so they are counted as lost.
67    fn take_evicted(&self) -> Vec<String> {
68        Vec::new()
69    }
70}
71
72/// Batches a cache will hold regardless of their size. `max_cache_bytes` is the size policy;
73/// this only stops a long offline stretch at a 1 s cadence from leaving thousands of tiny files
74/// in the directory.
75// ponytail: a flat cap rather than another config knob; make it one if a host ever needs
76// buffering at a scale where the file count matters.
77pub(crate) const MAX_BATCHES: usize = 512;
78
79/// In-memory [`BatchCache`]: batches that could not be uploaded wait for the next attempt,
80/// bounded by `max_bytes` and 512 batches (`MAX_BATCHES`, oldest evicted). The newest batch always
81/// stays, even when it alone is larger than `max_bytes`. Lost with the process.
82// ponytail: a Vec with remove(0) — a handful of small batches, and it shares Vec code the
83// binary already has instead of pulling in VecDeque's ring-buffer instantiations.
84pub struct MemoryCache {
85    batches: Mutex<Vec<(String, Vec<u8>)>>,
86    max_bytes: usize,
87}
88
89impl MemoryCache {
90    /// An empty cache holding at most `max_bytes` of batches, apart from the newest batch, which
91    /// always stays.
92    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    /// Drop the oldest batches until the rest fit; returns their ids. The newest always stays.
104    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
158/// The extension of a committed batch file.
159const BATCH_EXT: &str = "otlp";
160/// The extension of a file being written; a crash leaves it behind, and `FileCache::open` removes
161/// it.
162const TMP_EXT: &str = "tmp";
163
164/// On-disk [`BatchCache`]: one file per encoded batch, so a crash or an offline shutdown loses
165/// nothing and the next launch replays what is left.
166///
167/// Files are written as `.tmp` and renamed into place, so a crash never leaves a half batch
168/// readable; eviction is drop-oldest above `max_bytes` or 512 files (`MAX_BATCHES`), and the
169/// newest batch always stays. Age is the exporter's call (24 h, judged by the timestamp in the id
170/// rather than file metadata — an Apple "required reason" API).
171/// Writes, recovery and clears serialize across instances in this process, including clear's
172/// listing through deletion. A clear does not revoke future writes; its caller owns consent.
173pub struct FileCache {
174    dir: PathBuf,
175    max_bytes: u64,
176    /// File-count bound: [`MAX_BATCHES`] (lowered only by tests).
177    max_batches: usize,
178    /// What opening pruned, until [`BatchCache::take_evicted`] collects it.
179    evicted: Mutex<Vec<String>>,
180    /// Publishing a journaled split failed: listings stop retrying (and logging) until the cache
181    /// writes again.
182    recovery_failed: std::sync::atomic::AtomicBool,
183    /// `recover` runs so far (tests only).
184    #[cfg(test)]
185    recoveries: std::sync::atomic::AtomicUsize,
186    /// Test-only fault points around every storage step (see [`Step`]).
187    #[cfg(test)]
188    fault: Mutex<Option<Fault>>,
189}
190
191/// A storage step: the points a test can fail (return an error) or crash at (panic),
192/// deterministically.
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub(crate) enum Step {
195    /// Creating and writing a `.tmp` (the path is passed).
196    Write,
197    /// `fsync` of a `.tmp`.
198    Sync,
199    /// Renaming a `.tmp` into place, or into a replacement's journal (`.pend`).
200    Rename,
201    /// `fsync` of the directory.
202    SyncDir,
203    /// Deleting a batch (including a replaced one — a split's commit point).
204    Delete,
205    /// Publishing a committed replacement half (`.pend` → batch).
206    Publish,
207    /// Clear has taken its batch listing (tests only).
208    #[cfg(test)]
209    ClearListed,
210}
211
212#[cfg(test)]
213type Fault = Box<dyn Fn(Step, &Path) -> io::Result<()> + Send + Sync>;
214
215/// The extension of a journaled split half: `<parent>@<half>.pend`.
216const JOURNAL_EXT: &str = "pend";
217/// Separates parent and half in a journal file name. Batch ids never contain it (see the id
218/// alphabet on [`BatchCache`]).
219const JOURNAL_SEP: char = '@';
220
221/// Held across a push's write, a split's journaled section and recovery, by every [`FileCache`]
222/// in the process: a reconfigure opens a second cache on the directory while the replaced
223/// pipeline still drains. Its open must never sweep away that pipeline's `.tmp` mid-write, and
224/// its recovery must never see a split half-done — it would delete the journal of a split about
225/// to commit, losing the halves.
226// ponytail: process-wide rather than per directory. Every push and split holds it for a few
227// fsyncs, and it contends only while two pipelines overlap in a reconfigure. One lock per
228// directory if that ever matters.
229static DIR_LOCK: Mutex<()> = Mutex::new(());
230
231/// Lock order: spill admission, file operation, directory journal, instance state. No host
232/// logger runs under these locks: `outside_gate` defers our diagnostics to the outer release.
233/// Reentrant custom-cache calls into spill mutations fail with WouldBlock instead of waiting.
234// ponytail: process-wide serialization; use per-directory barriers if measured contention warrants it.
235static FILE_WRITE_LOCK: Mutex<()> = Mutex::new(());
236static SPILL_WRITE_LOCK: Mutex<()> = Mutex::new(());
237
238thread_local! {
239    /// The current thread owns spill admission; a custom primary must not wait on itself.
240    static SPILL_ACTIVE: Cell<bool> = const { Cell::new(false) };
241}
242
243/// Own spill admission until commit/clear completes, including unwinding custom primaries.
244struct 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
254/// Refuse synchronous reentry before taking the process-wide admission barrier.
255fn 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
267/// Acquire a commit barrier; tests observe actual contention rather than waiting a fixed time.
268fn 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
280/// Serialize a whole file operation, including clear's listing, and release before diagnostics.
281fn 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    /// Open the cache directory (created if missing; its parent must exist), finish or roll back
294    /// a split interrupted by a crash — before anything is evicted — and discard stray or
295    /// half-written files (only here: a replaced pipeline may still be writing its own).
296    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    /// [`open`](Self::open) with the file-count bound in force from the start, so opening-time
301    /// pruning applies it.
302    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    /// Initialize under the file barrier so recovery and pruning cannot cross a clear.
311    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            // Under `DIR_LOCK` too: another cache's push or split in progress has `.tmp` files
327            // here.
328            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    /// A cache with a lower file-count bound (tests only).
342    #[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    /// Fail or crash at storage steps from now on (tests only).
352    #[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    /// Run before a storage step: in tests the injected fault can fail it, otherwise a no-op.
361    #[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    /// A replacement half journaled under its parent: `<parent>@<half>.pend`.
375    fn pend(&self, parent: &str, half: &str) -> PathBuf {
376        self.dir.join(format!("{parent}{JOURNAL_SEP}{half}.{JOURNAL_EXT}"))
377    }
378
379    /// The journal files in the directory.
380    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    /// Write `body` to `tmp` and flush it to the device (`fsync`), so the rename that publishes
389    /// it can never expose a file whose contents are still only in the page cache.
390    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            // iOS may purge the whole Caches subdirectory while the app runs: recreate it once
400            // (another cache's `open` may have recreated it first).
401            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    /// After a committed write: listings may retry a stuck journal again, and the bounds are
415    /// enforced. Eviction trouble is logged and never an error, because the write itself is
416    /// committed (the caller would otherwise keep a second copy in memory); the next write
417    /// enforces the bound again.
418    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    /// Make the directory's entries (renames, deletes) durable. Failures are errors; only a
427    /// platform that cannot sync a directory at all (not Unix) skips it.
428    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    /// The batch ids in the directory, oldest first; an unreadable directory is an error. A
436    /// split committed but not yet published (its publish failed) is published first, so its
437    /// halves are pending — uploaded, purged and counted — like any other batch.
438    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    /// Finish or roll back a split a crash interrupted. Its halves are journaled as
469    /// `<parent>@<half>.pend` before the parent is deleted (the commit point): with the parent
470    /// still there the journal is discarded (the parent is whole); without it the halves are
471    /// published. A rebinding — one half whose id extends its host-less parent's with the project
472    /// the parent lacks — is rolled forward instead: a journal file is complete once it exists (its
473    /// contents are `fsync`ed before the rename), and only it records the project. Either way
474    /// every record is there exactly once. Under [`DIR_LOCK`], so no split is in progress: a
475    /// journal next to its parent is a crash's or a failed rollback's.
476    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                // Parent first: a crash in between leaves a committed journal, published below.
498                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    /// Delete the oldest batches until the rest fit `max_bytes` and the file-count bound (a
506    /// committed split is published by the listing first). The newest batch is never a
507    /// candidate, whatever happened to the older ones, as in [`MemoryCache`], so it gets its
508    /// upload attempt. Returns the ids this pass removed. An id counts once this pass unlinked
509    /// its file, even when the directory sync after the unlink failed: the count follows the
510    /// visible removal and does not promise a delete that survives a crash. A power loss can
511    /// bring the file back, and the next open evicts it again or keeps it for upload, like any
512    /// other batch. A file that was already gone (another cache on the directory, or an
513    /// overlapping pass, evicted it) frees its room but is that pass's eviction, not this one's.
514    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            // Gone, by this pass or another, frees its room. Only the pass that unlinked it
528            // reports it, whatever its directory sync said afterwards. A batch that could not
529            // be deleted still takes its room: keep evicting.
530            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    /// Delete `id`'s file. The flag says whether this call unlinked it (`false`: it was
544    /// already gone, by an earlier unlink whose directory sync failed, or by another cache on
545    /// the directory). The result is the delete's: an error when the file could not be
546    /// deleted or when the directory sync after it failed. The flag is kept apart from the
547    /// result, so a failed sync never turns another pass's unlink into this one's. A gone
548    /// directory (the OS purged it) has nothing left to sync.
549    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    /// The journaled section of [`BatchCache::replace`], under [`DIR_LOCK`]. `Err` only before the
567    /// commit point; a missing `old` returns NotFound. A rebinding has exactly one member: recovery could not
568    /// tell a crashed multi-member rebinding from a finished one.
569    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        // Committed: the records are the halves now, whatever follows. A failure from here leaves
606        // them journaled — published by the next listing (`ids`), purged by an opt-out — so the
607        // split succeeded and must not be read as "the parent is still there".
608        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        // Logged after the lock: a log line may call into the host.
617        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
628/// The durability boundary: a batch is committed once `push` returns `Ok` — its file was written
629/// to a `.tmp`, `fsync`ed, renamed into place and the directory `fsync`ed; if any of that fails
630/// the file is removed and `push` errs (the caller keeps the batch in memory instead). A crash
631/// before that point loses only the batch being written (its `.tmp` is removed at the next
632/// open). Deletes are `fsync`ed the same way. A split (`replace`) is journaled: see
633/// `FileCache::recover`.
634impl 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            // Under `DIR_LOCK`: another cache opening on the directory sweeps stray `.tmp` files.
639            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                // Not committed: nothing of it may stay (ENOSPC leaves a truncated `.tmp`, a failed
646                // directory sync a published file of unknown durability).
647                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    /// Journaled: both halves are written, `fsync`ed and renamed to `<old>@<half>.pend`, the
657    /// directory is `fsync`ed, then `old` is deleted — the commit point — and the halves are
658    /// published. Before the commit point any failure rolls back; an existing `old` stays whole. After it
659    /// the halves are safe in the journal and published by the next listing. Nothing is evicted
660    /// until the replacement is complete. A rebinding (a host-less `old` that a half extends with
661    /// its project) has exactly one member. Otherwise this returns `InvalidInput` and writes
662    /// nothing.
663    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            // Try every file — batches and journaled split halves — then report the first failure,
685            // an unreadable directory included. A directory the OS purged is already empty.
686            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            // A `.tmp` that a failed write could not remove still holds a batch. Under `DIR_LOCK`, so
704            // no push or split is writing one right now.
705            {
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            // Nothing journaled is left to publish.
721            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/// A cache that never refuses a batch: what the primary cannot store (disk full, directory
732/// gone) goes to memory instead, so it still uploads — it just no longer survives the process.
733/// Every spill is counted as `cache_write_errors`.
734#[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    /// Writes check opt-out under the process-wide spill barrier and hold it through primary
743    /// commit or memory fallback. Clear takes the same barrier across generations. The SDK
744    /// revokes and clears every generation, including each generation's private memory spill.
745    revoked: std::sync::Arc<AtomicBool>,
746    /// The primary is failing: the first spill of an episode warns, a successful write ends it.
747    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    /// Hold the write gate; `Err` once the app opted out.
771    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            // Logged after the gate: a log line may call into the host.
795            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    /// A batch spilled to memory is split in memory; one on disk is split on disk or not at all
804    /// — never moved from disk to memory, where a crash would lose it.
805    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    /// Diagnostics held back while this thread holds the spill gate; `None` logs them at once.
847    static HELD_BACK: RefCell<Option<Vec<(log::Level, String)>>> = const { RefCell::new(None) };
848}
849
850/// Log `message` now, or once the spill gate this thread holds is released (see
851/// [`outside_gate`]): a host logger may call back into the cache, and must not find it locked.
852fn 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/// Run `gated`, which takes the spill gate, and log the diagnostics it raised once it returned,
866/// its gate released (into an outer `outside_gate`, if any).
867#[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
886/// Create `dir` (one that exists already is fine) and, when this call created it, make its
887/// entry durable by syncing its parent: a directory's own `fsync` covers the entries inside it,
888/// not the entry that names it.
889// ponytail: `create_dir`, not `create_dir_all` — the recursive variant drags in
890// `Path::components` machinery (~3 KiB) for a parent the host always provides.
891fn create_dir_synced(dir: &Path) -> io::Result<()> {
892    match fs::create_dir(dir) {
893        Ok(()) => {
894            // A bare relative name (`Path::parent` is the empty path) lives in the current
895            // directory: that is the parent to sync.
896            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
909/// A batch file: our extension and a creation stamp in its name. Anything else in the directory
910/// (a half-written `.tmp`, a stray or mangled file) is removed by [`FileCache::open`].
911fn 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
920/// Whether `half` rebinds `parent`: it extends the host-less parent's id (which ends with its
921/// empty host) with the project the parent lacks. A split's halves rename the sequence instead.
922fn is_rebinding(parent: &str, half: &str) -> bool {
923    parent.ends_with('-') && half.len() > parent.len() && half.starts_with(parent)
924}
925
926/// A directory no other test uses. Tests run in parallel threads, and the wall clock repeats
927/// within its resolution (a microsecond on macOS), so the process id and a counter tell them apart:
928/// two tests on one directory would share a cache and delete each other's files.
929#[cfg(test)]
930pub(crate) fn temp_dir(tag: &str) -> PathBuf {
931    temp_dir_at(tag, crate::event::now_unix_nanos())
932}
933
934/// [`temp_dir`] at a given wall-clock time.
935#[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        /// The parent directory the last directory creation on this thread synced.
953        pub(super) static SYNCED_PARENT: std::cell::RefCell<Option<PathBuf>> =
954            const { std::cell::RefCell::new(None) };
955        /// Signals an actual contended commit lock, without a timer/readiness guess.
956        pub(super) static COMMIT_WAIT: RefCell<Option<Box<dyn Fn()>>> = const { RefCell::new(None) };
957    }
958
959    /// Tests in parallel threads ask for directories within one clock tick; each gets its own,
960    /// and asking for one never deletes another test's.
961    #[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    /// Missing parents cannot be resurrected after the eviction was already counted.
973    #[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    /// Hold a real write and distinguish a blocked clear from a stale listing deterministically.
998    #[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    /// A custom primary holds a write after consent admission, before any FileCache gate.
1059    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    /// A different generation's clear waits through consent admission and custom-cache commit.
1095    #[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    /// Error and unwind paths release both barriers and destroy their cache owners.
1146    #[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    /// A custom primary's synchronous reentry fails rather than waiting on its own barrier.
1180    #[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    /// Ids the exporter would mint: a real timestamp (fixed for the test run, so ids stay
1198    /// stable however slow the disk) + seq.
1199    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    /// A second cache opening on the directory (a reconfigure) never sweeps away the `.tmp` of a
1283    /// push still in flight.
1284    #[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                // Give it every chance to sweep the directory mid-push.
1298                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    /// A `.tmp` that a failed write could not remove (the directory went read-only) is deleted by
1309    /// the next `clear` once the directory is writable again: an opt-out leaves no batch bytes.
1310    #[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    /// A warning raised inside a gated write reaches the host logger only once the spill gate is
1336    /// released: a logger that calls back into the cache (a purge, say) does not deadlock.
1337    #[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    /// A primary that fails while its flag is set.
1362    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        // A stray `.tmp` goes at the next open, never mid-run: it may be a replaced pipeline's
1431        // write in progress.
1432        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    /// Days offline at a one-second cadence are tiny batches, not big ones: the byte cap alone
1441    /// would leave thousands of files in the directory.
1442    #[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        // `id` stamps the current second, so keep the ids rather than recomputing them.
1447        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        // Stand-in for ENOSPC: any write into the directory fails.
1474        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    /// A real split, crashed at every one of its storage steps and
1483    /// reopened under byte and file-count pressure, leaves every record exactly once — the
1484    /// parent whole, or both halves — and recovery runs before anything is evicted.
1485    #[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        // Room for the parent and not a byte more, or for the two halves and not a file more: a
1491        // duplicate would force an eviction.
1492        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    /// A split that fails after its commit point (a failed publish, a failed
1535    /// directory sync) has succeeded — its halves are safe in the journal and published by the
1536    /// next listing — and an opt-out deletes the journal too, leaving the directory empty.
1537    #[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(())); // the storage recovers; then the app opts out
1559            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            // Without the opt-out, the next listing publishes the journaled halves.
1567            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    /// A reconfigure opens a second cache on the directory while the replaced
1592    /// pipeline still drains. Its open, pushes and listings must never see that pipeline's split
1593    /// half-done — deleting the journal of a split about to commit would lose both halves.
1594    #[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            // Journaled, parent still there, commit next: the new generation starts now.
1607            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                // The opener actually reached the held commit barrier mid-split.
1621                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    /// A journal that cannot be published is not retried (and logged) by every
1638    /// listing — only again after the cache has written something.
1639    #[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        // The half's name is taken by a non-empty directory: the publishing rename fails.
1647        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    /// A write killed half-way (bytes on disk, no rename) leaves no partial batch
1663    /// and costs no committed one.
1664    #[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    /// A push whose file or directory cannot be made durable is not committed —
1689    /// it errs and leaves nothing behind — and a split that cannot be made durable before its
1690    /// commit point keeps the parent.
1691    #[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    /// An unreadable directory is an error for `clear`, not an empty success.
1714    #[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    /// The retention floor holds even when the older batches cannot be deleted: the newest is
1729    /// never a candidate.
1730    #[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    /// A batch another pruning pass already deleted (a second cache on the directory, or an
1750    /// overlapping push) is not this pass's eviction: the exporter would count it twice.
1751    #[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                // The other pass wins the unlink.
1760                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    /// The compound case: another pass unlinked the batch, and then this pass's directory sync
1771    /// fails. The eviction is still the other pass's, not this one's, whatever the sync said.
1772    /// Control: this pass's own unlink followed by a failed sync still counts
1773    /// (`an_eviction_whose_directory_sync_fails_still_counts`).
1774    #[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                    // The other pass wins the unlink.
1787                    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    /// Creating the cache directory syncs its parent: the directory named by an absolute path,
1803    /// and the current directory for a bare relative name. An existing directory is not
1804    /// created, so nothing is synced for it.
1805    #[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    /// Purge recovery: the first write finds the directory gone, and by the time it recreates
1827    /// it another generation's `open` already did. The recreated directory is used.
1828    #[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    /// A lowered file-count bound is in force while the cache opens, so opening-time pruning
1846    /// (and the crash test's two-file arm) really runs under count pressure.
1847    #[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}