Skip to main content

par2_rs/
file_cache.rs

1use std::collections::HashSet;
2use std::fs::File;
3use std::io::{self, Read};
4use std::path::{Path, PathBuf};
5use std::sync::atomic::{AtomicUsize, Ordering};
6use std::sync::{Mutex, OnceLock};
7
8const LARGE_FILE_CACHE_ADVICE_MIN_BYTES: u64 = 64 * 1024 * 1024;
9
10/// Multi-pass operations (repair: verify then accumulate then re-verify) read
11/// the same payload more than once. Evicting after each pass forces the next
12/// pass back to physical storage — on network block storage that re-read
13/// dominates the whole operation. While at least one deferral scope is
14/// active, evictions are recorded instead of issued; the last scope to drop
15/// evicts each recorded file once. The drain is advisory and approximate:
16/// it re-opens by path and evicts the whole file, so a range-limited drop
17/// becomes whole-file, and a file renamed, unlinked, or shrunk below the
18/// size gate before the drain is simply not evicted.
19static EVICTION_DEFERRAL_DEPTH: AtomicUsize = AtomicUsize::new(0);
20
21fn deferred_evictions() -> &'static Mutex<HashSet<PathBuf>> {
22    static DEFERRED: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
23    DEFERRED.get_or_init(|| Mutex::new(HashSet::new()))
24}
25
26/// RAII guard deferring page-cache eviction until the outermost scope drops.
27///
28/// Evictions requested after the last scope has dropped are issued
29/// immediately, exactly as if no scope had existed — keep the scope alive
30/// for the whole multi-pass operation. The depth is process-global: scopes
31/// held by concurrent operations combine, and the last one out drains.
32pub struct CacheEvictionDeferral(());
33
34impl CacheEvictionDeferral {
35    /// Open a deferral scope. Callers running a multi-pass operation (an
36    /// external verify-then-repair flow, for example) hold this across every
37    /// pass so intermediate reads stay cached until the operation completes.
38    ///
39    /// The guard must be dropped: leaking it (`mem::forget`, a leaked task
40    /// still holding it) leaves the depth raised and disables the eviction
41    /// discipline for the rest of the process.
42    pub fn acquire() -> Self {
43        EVICTION_DEFERRAL_DEPTH.fetch_add(1, Ordering::SeqCst);
44        Self(())
45    }
46}
47
48impl Drop for CacheEvictionDeferral {
49    fn drop(&mut self) {
50        if EVICTION_DEFERRAL_DEPTH.fetch_sub(1, Ordering::SeqCst) != 1 {
51            return;
52        }
53        let drained: Vec<PathBuf> = {
54            let mut deferred = deferred_evictions()
55                .lock()
56                .unwrap_or_else(std::sync::PoisonError::into_inner);
57            deferred.drain().collect()
58        };
59        for path in drained {
60            evict_path_now(&path);
61        }
62    }
63}
64
65fn eviction_deferred(path: &Path) -> bool {
66    if EVICTION_DEFERRAL_DEPTH.load(Ordering::SeqCst) == 0 {
67        return false;
68    }
69    deferred_evictions()
70        .lock()
71        .unwrap_or_else(std::sync::PoisonError::into_inner)
72        .insert(path.to_path_buf());
73    true
74}
75
76fn evict_path_now(path: &Path) {
77    // The drain runs from a Drop over paths recorded much earlier; a path
78    // replaced by a FIFO would block a plain open forever, and following a
79    // symlink swap would advise the wrong file. Advisory-only, so refuse
80    // both rather than risk hanging after a successful operation — at the
81    // cost of never draining sources whose final component is a symlink
82    // (the immediate, fd-based eviction path does cover those).
83    #[cfg(unix)]
84    let opened = {
85        use std::os::unix::fs::OpenOptionsExt;
86        std::fs::OpenOptions::new()
87            .read(true)
88            .custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW)
89            .open(path)
90    };
91    #[cfg(not(unix))]
92    let opened = File::open(path);
93    let Ok(file) = opened else {
94        return;
95    };
96    let Ok(metadata) = file.metadata() else {
97        return;
98    };
99    if !metadata.is_file() || metadata.len() < LARGE_FILE_CACHE_ADVICE_MIN_BYTES {
100        return;
101    }
102    log_cache_advice(
103        "dontneed",
104        path,
105        raw_cache_advice(&file, 0, metadata.len(), CacheAdvice::DontNeed),
106    );
107}
108
109pub(crate) struct CacheAdvisedReader {
110    file: File,
111    path: PathBuf,
112    file_len: u64,
113    touched: u64,
114}
115
116impl CacheAdvisedReader {
117    pub(crate) fn open(path: &Path) -> io::Result<Self> {
118        let file = File::open(path)?;
119        let file_len = file.metadata()?.len();
120        advise_sequential(&file, path, file_len);
121        Ok(Self {
122            file,
123            path: path.to_path_buf(),
124            file_len,
125            touched: 0,
126        })
127    }
128}
129
130impl Read for CacheAdvisedReader {
131    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
132        let read = self.file.read(buf)?;
133        self.touched = self.touched.saturating_add(read as u64);
134        Ok(read)
135    }
136}
137
138impl Drop for CacheAdvisedReader {
139    fn drop(&mut self) {
140        drop_touched_file_cache(&self.file, &self.path, self.file_len, 0, self.touched);
141    }
142}
143
144pub(crate) fn read_to_vec(path: &Path) -> io::Result<Vec<u8>> {
145    let mut reader = CacheAdvisedReader::open(path)?;
146    let mut data = Vec::new();
147    reader.read_to_end(&mut data)?;
148    Ok(data)
149}
150
151pub(crate) fn advise_sequential(file: &File, path: &Path, len: u64) {
152    advise_range_sequential(file, path, 0, len);
153}
154
155pub(crate) fn advise_range_sequential(file: &File, path: &Path, offset: u64, len: u64) {
156    if len < LARGE_FILE_CACHE_ADVICE_MIN_BYTES {
157        return;
158    }
159    log_cache_advice(
160        "sequential",
161        path,
162        raw_cache_advice(file, offset, len, CacheAdvice::Sequential),
163    );
164}
165
166pub(crate) fn drop_file_cache(file: &File, path: &Path, offset: u64, len: u64) {
167    if len < LARGE_FILE_CACHE_ADVICE_MIN_BYTES {
168        return;
169    }
170    if eviction_deferred(path) {
171        return;
172    }
173    log_cache_advice(
174        "dontneed",
175        path,
176        raw_cache_advice(file, offset, len, CacheAdvice::DontNeed),
177    );
178}
179
180pub(crate) fn drop_touched_file_cache(
181    file: &File,
182    path: &Path,
183    file_len: u64,
184    offset: u64,
185    touched: u64,
186) {
187    if file_len < LARGE_FILE_CACHE_ADVICE_MIN_BYTES || touched == 0 {
188        return;
189    }
190    if eviction_deferred(path) {
191        return;
192    }
193    log_cache_advice(
194        "dontneed",
195        path,
196        raw_cache_advice(file, offset, touched, CacheAdvice::DontNeed),
197    );
198}
199
200pub(crate) fn drop_path_cache(path: &Path) {
201    let Ok(file) = File::open(path) else {
202        return;
203    };
204    let len = file.metadata().ok().map_or(0, |metadata| metadata.len());
205    drop_file_cache(&file, path, 0, len);
206}
207
208fn log_cache_advice(operation: &'static str, path: &Path, result: io::Result<()>) {
209    match result {
210        Ok(()) => tracing::trace!(operation, path = %path.display(), "file cache advice applied"),
211        Err(error) => {
212            tracing::debug!(operation, path = %path.display(), error = %error, "file cache advice failed")
213        }
214    }
215}
216
217#[derive(Clone, Copy)]
218enum CacheAdvice {
219    Sequential,
220    DontNeed,
221}
222
223#[cfg(any(target_os = "linux", target_os = "android"))]
224fn raw_cache_advice(file: &File, offset: u64, len: u64, advice: CacheAdvice) -> io::Result<()> {
225    use std::os::fd::AsRawFd;
226
227    let offset: libc::off_t = offset
228        .try_into()
229        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "cache advice offset overflow"))?;
230    let len: libc::off_t = len
231        .try_into()
232        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "cache advice length overflow"))?;
233    let advice = match advice {
234        CacheAdvice::Sequential => libc::POSIX_FADV_SEQUENTIAL,
235        CacheAdvice::DontNeed => libc::POSIX_FADV_DONTNEED,
236    };
237    let rc = unsafe { libc::posix_fadvise(file.as_raw_fd(), offset, len, advice) };
238    if rc == 0 {
239        Ok(())
240    } else {
241        Err(io::Error::from_raw_os_error(rc))
242    }
243}
244
245#[cfg(not(any(target_os = "linux", target_os = "android")))]
246fn raw_cache_advice(_file: &File, _offset: u64, _len: u64, _advice: CacheAdvice) -> io::Result<()> {
247    Ok(())
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn read_to_vec_preserves_contents() {
256        let temp = tempfile::NamedTempFile::new().unwrap();
257        std::fs::write(temp.path(), b"cache-advised payload").unwrap();
258
259        assert_eq!(read_to_vec(temp.path()).unwrap(), b"cache-advised payload");
260    }
261
262    #[test]
263    fn path_drop_swallows_missing_file() {
264        drop_path_cache(Path::new("/definitely/missing/weaver/par2-cache.bin"));
265    }
266
267    // The deferral depth and deferred set are process-global; these tests
268    // must not overlap with each other.
269    static DEFERRAL_TEST_LOCK: Mutex<()> = Mutex::new(());
270
271    #[test]
272    fn eviction_deferral_records_and_drains() {
273        let _serial = DEFERRAL_TEST_LOCK
274            .lock()
275            .unwrap_or_else(std::sync::PoisonError::into_inner);
276        let temp = tempfile::NamedTempFile::new().unwrap();
277        let file = File::open(temp.path()).unwrap();
278        {
279            let _outer = CacheEvictionDeferral::acquire();
280            let _inner = CacheEvictionDeferral::acquire();
281            assert!(EVICTION_DEFERRAL_DEPTH.load(Ordering::SeqCst) >= 2);
282            // Above the size gate so the deferral path records the file.
283            drop_file_cache(&file, temp.path(), 0, LARGE_FILE_CACHE_ADVICE_MIN_BYTES + 1);
284            assert!(
285                deferred_evictions()
286                    .lock()
287                    .unwrap_or_else(std::sync::PoisonError::into_inner)
288                    .contains(temp.path())
289            );
290        }
291        // The drain only runs when the depth reaches zero; another test in
292        // this binary may hold its own scope right now, so the post-scope
293        // state is only assertable when no scope remains.
294        if EVICTION_DEFERRAL_DEPTH.load(Ordering::SeqCst) == 0 {
295            assert!(
296                !deferred_evictions()
297                    .lock()
298                    .unwrap_or_else(std::sync::PoisonError::into_inner)
299                    .contains(temp.path())
300            );
301        }
302    }
303
304    #[test]
305    fn below_threshold_drops_are_never_deferred() {
306        let _serial = DEFERRAL_TEST_LOCK
307            .lock()
308            .unwrap_or_else(std::sync::PoisonError::into_inner);
309        let temp = tempfile::NamedTempFile::new().unwrap();
310        let file = File::open(temp.path()).unwrap();
311        let _scope = CacheEvictionDeferral::acquire();
312        drop_file_cache(&file, temp.path(), 0, 1024);
313        drop_touched_file_cache(&file, temp.path(), 1024, 0, 1024);
314        assert!(
315            !deferred_evictions()
316                .lock()
317                .unwrap_or_else(std::sync::PoisonError::into_inner)
318                .contains(temp.path())
319        );
320    }
321
322    #[cfg(not(any(target_os = "linux", target_os = "android")))]
323    #[test]
324    fn cache_advice_noops_on_unsupported_platforms() {
325        let temp = tempfile::NamedTempFile::new().unwrap();
326        let file = File::open(temp.path()).unwrap();
327
328        assert!(raw_cache_advice(&file, 0, 0, CacheAdvice::Sequential).is_ok());
329        assert!(raw_cache_advice(&file, 0, 0, CacheAdvice::DontNeed).is_ok());
330    }
331}