Skip to main content

krabka_log/
io.rs

1//! Injectable file I/O for every durable write the log makes.
2//!
3//! The active `.log` file keeps the three handle-shaped methods the append
4//! hot path uses. Every other durable write in the crate -- the sparse
5//! indexes, the `.stampindex`, the producer snapshots, the leader-epoch
6//! checkpoint, the compaction swap and the segment deletions retention
7//! performs -- goes through the path-and-target methods below, so a test can
8//! fail exactly one class of file and watch what recovery makes of it. The
9//! readahead hint of the fetch path goes through it too, so a test can see
10//! which ranges a read asks the kernel for, and so do the block reservation
11//! and release of `preallocate` and the `O_DIRECT` handle its active segment
12//! writes through.
13
14#[cfg(any(target_os = "linux", test))]
15use std::iter;
16#[cfg(unix)]
17use std::os::unix::fs::FileExt;
18use std::{
19    fmt::Debug,
20    fs::{self, File},
21    io::{self, ErrorKind, IoSlice, Write},
22    path::Path,
23    sync::Arc,
24};
25#[cfg(target_os = "linux")]
26use {
27    rustix::{
28        fs::{
29            Advice, FallocateFlags, SyncFileRangeFlags, fadvise, fallocate, open, statx,
30            sync_file_range,
31        },
32        io::{Errno, ReadWriteFlags, preadv2},
33    },
34    std::num::NonZeroU64,
35};
36
37/// Which on-disk file class an [`LogIo`] operation touches.
38///
39/// A fault injector matches on this to fail one durable write without
40/// disturbing the rest of the log.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
42pub enum IoTarget {
43    /// A segment's sparse `.index`.
44    OffsetIndex,
45    /// A segment's sparse `.timeindex`.
46    TimeIndex,
47    /// A segment's `.stampindex` sidecar.
48    StampIndex,
49    /// A `<offset>.snapshot` producer-state snapshot, or its `.tmp` staging
50    /// file.
51    ProducerSnapshot,
52    /// The partition's `leader-epoch-checkpoint`, or its `.tmp` staging file.
53    LeaderEpochCheckpoint,
54    /// The partition's `log-start-offset-checkpoint`, or its `.tmp` staging
55    /// file.
56    LogStartOffsetCheckpoint,
57    /// A compaction `.swap` file, or a segment file the swap replaces.
58    CompactionSwap,
59    /// A segment file being renamed to its `.deleted` tombstone or unlinked
60    /// by retention.
61    SegmentDeletion,
62    /// A file outside any partition log that another crate replaces through
63    /// [`write_file_atomic`]. Only the real-file I/O ever writes one.
64    External,
65}
66
67/// The operating-system I/O boundary every durable log write crosses.
68///
69/// The default methods perform real file I/O. Tests can override only the
70/// operation they need to fail, while reads continue to use the segment's
71/// shared `Arc<File>` directly.
72pub trait LogIo: Debug + Send + Sync {
73    /// Write bytes at the active `.log` file's current cursor.
74    ///
75    /// # Errors
76    /// Returns the underlying write error.
77    fn write(&self, file: &File, buf: &[u8]) -> io::Result<usize> { (&*file).write(buf) }
78
79    /// Write byte slices at the active `.log` file's current cursor.
80    ///
81    /// # Errors
82    /// Returns the underlying vectored-write error.
83    fn write_vectored(&self, file: &File, bufs: &[IoSlice<'_>]) -> io::Result<usize> {
84        (&*file).write_vectored(bufs)
85    }
86
87    /// Flush the active `.log` file's data to stable storage.
88    ///
89    /// # Errors
90    /// Returns the underlying data-sync error.
91    fn sync_data(&self, file: &File) -> io::Result<()> { file.sync_data() }
92
93    /// Start the write-out of `len` bytes of `file`'s page-cache data from
94    /// `offset`, without waiting for it and without a journal commit or a
95    /// disk-cache flush. On Linux this is `sync_file_range(2)` with
96    /// `SYNC_FILE_RANGE_WRITE`; elsewhere it is [`Self::sync_data`], which
97    /// does more and blocks. `Log` paces the active segment's dirty data with
98    /// it, so an `fdatasync` of the file, which stalls the appends that run
99    /// while it is in progress, happens only at the roll and on request.
100    ///
101    /// # Errors
102    /// Returns the underlying write-out error.
103    #[cfg(not(target_os = "wasi"))]
104    fn write_back(&self, file: &File, offset: u64, len: u64) -> io::Result<()> {
105        write_back(file, offset, len)
106    }
107
108    /// Write bytes at `file`'s current cursor on behalf of `target`.
109    ///
110    /// Like [`std::io::Write::write`], this may write fewer bytes than asked
111    /// for; [`write_all`] is the loop that finishes the buffer.
112    ///
113    /// # Errors
114    /// Returns the underlying write error.
115    fn write_at(&self, target: IoTarget, file: &File, buf: &[u8]) -> io::Result<usize> {
116        let _ = target;
117        (&*file).write(buf)
118    }
119
120    /// Flush `target`'s file data to stable storage.
121    ///
122    /// # Errors
123    /// Returns the underlying data-sync error.
124    fn sync_file(&self, target: IoTarget, file: &File) -> io::Result<()> {
125        let _ = target;
126        file.sync_data()
127    }
128
129    /// `fsync` a directory so the names created or renamed inside it are
130    /// durable.
131    ///
132    /// # Errors
133    /// Returns the underlying open or sync error.
134    fn sync_dir(&self, dir: &Path) -> io::Result<()> {
135        // Rust's standard directory-open path is supported on Unix, where
136        // syncing the parent is what makes a rename or a fresh name durable.
137        // Windows offers no equivalent through `std` (`File::open` on a
138        // directory fails with `EACCES`), so the call is a no-op there and on
139        // every other non-Unix target, `wasm32-wasip1` included. The file
140        // contents are still synced before every rename.
141        #[cfg(unix)]
142        {
143            File::open(dir)?.sync_all()
144        }
145        #[cfg(not(unix))]
146        {
147            let _ = dir;
148            Ok(())
149        }
150    }
151
152    /// Rename a file on behalf of `target`.
153    ///
154    /// # Errors
155    /// Returns the underlying rename error.
156    fn rename(&self, target: IoTarget, from: &Path, to: &Path) -> io::Result<()> {
157        let _ = target;
158        fs::rename(from, to)
159    }
160
161    /// Unlink a file on behalf of `target`.
162    ///
163    /// # Errors
164    /// Returns the underlying unlink error.
165    fn remove_file(&self, target: IoTarget, path: &Path) -> io::Result<()> {
166        let _ = target;
167        fs::remove_file(path)
168    }
169
170    /// Tell the kernel that a fetch reads `len` bytes of a `.log` file from
171    /// `offset` next.
172    ///
173    /// This is the one read-side method, and it is a hint, not a read: it
174    /// starts `posix_fadvise(POSIX_FADV_WILLNEED)` readahead of the range and
175    /// returns without waiting for it. The real implementation issues the hint
176    /// in 128 KiB calls, because Linux reads only the start of one large
177    /// `WILLNEED` range. A failure changes nothing a reader can
178    /// observe, so it is dropped. The targets without `posix_fadvise` do
179    /// nothing.
180    fn advise_will_need(&self, file: &File, offset: u64, len: u64) {
181        advise_will_need(file, offset, len);
182    }
183
184    /// Drop the page cache's copy of the `len` bytes of a `.log` file from
185    /// `offset`, once the kernel has written them out.
186    ///
187    /// This waits for the range's write-out with `sync_file_range`, then
188    /// gives `posix_fadvise(POSIX_FADV_DONTNEED)`. The kernel frees only
189    /// clean pages, so a drop of a range whose write-out had only been
190    /// started, as the budget's are when the budget is smaller than the
191    /// write-out in flight, would leave its pages cached and the ledger no
192    /// longer counting them; the wait is what makes a drop a drop. On pages
193    /// written out long ago, every drop of a sealed segment, it returns at
194    /// once. No data is lost either way; a later read of the range comes
195    /// from the disk. A failure changes nothing a reader can observe, so it
196    /// is dropped. The targets without these calls do nothing, and
197    /// `wasm32-wasip1`, which has no flush pool, has no caller. See
198    /// `log::page_cache`.
199    #[cfg(not(target_os = "wasi"))]
200    fn drop_cached(&self, file: &File, offset: u64, len: u64) { drop_cached(file, offset, len); }
201
202    /// Whether the page cache holds the byte at `offset` of `file`, asked
203    /// without waiting for the disk. The fetch path asks it before it walks a
204    /// run's batch headers one small read at a time: on bytes that are not in
205    /// the page cache, each of those reads waits for the disk under the log
206    /// lock, and one large read is faster. A host that cannot ask answers
207    /// `None`, which keeps the walk and lets the segment stop probing.
208    #[krabka_macros::sendfile_cfg]
209    fn is_cached(&self, file: &File, offset: u64) -> Option<bool> { is_cached(file, offset) }
210
211    /// Reserve disk blocks for the `len` bytes of a `.log` file from `offset`
212    /// without changing its length, so the appends that later fill the range
213    /// do not allocate.
214    ///
215    /// This is `fallocate(FALLOC_FL_KEEP_SIZE)`. A reservation changes no
216    /// byte a reader can see, so a caller treats a failure as a segment that
217    /// grows as it is written. The targets without it answer `Unsupported`.
218    ///
219    /// # Errors
220    /// Returns the underlying `fallocate` error, or `Unsupported` off Linux.
221    fn reserve(&self, file: &File, offset: u64, len: u64) -> io::Result<()> {
222        reserve(file, offset, len)
223    }
224
225    /// Give back the blocks a [`LogIo::reserve`] left unused past a `.log`
226    /// file's end, which is `len`.
227    ///
228    /// This truncates the file to the length it already has. ext4 and tmpfs
229    /// free every block past the end on a truncate, even one that moves
230    /// nothing, and that is the only way to reach blocks reserved past the
231    /// end: ext4 ignores `FALLOC_FL_PUNCH_HOLE` at or past it. A filesystem
232    /// that keeps them keeps them until retention deletes the segment.
233    ///
234    /// # Errors
235    /// Returns the underlying truncate error.
236    fn release(&self, file: &File, len: u64) -> io::Result<()> { file.set_len(len) }
237
238    /// Open a second handle on a `.log` file that bypasses the page cache
239    /// (`O_DIRECT`), with the alignment every write through it needs.
240    ///
241    /// The alignment comes from `statx(STATX_DIOALIGN)` (Linux 6.1) and from
242    /// nowhere else: a guessed one that is wrong fails the append that first
243    /// uses it, and a failed append takes the log directory offline. A
244    /// kernel or filesystem that does not report one answers `Unsupported`,
245    /// and the segment stays buffered. Both alignments are then raised to the
246    /// filesystem block, up to 4 KiB, so that no write's offset, length or
247    /// buffer falls inside a block the filesystem would have to lock, convert
248    /// or zero apart.
249    ///
250    /// # Errors
251    /// Returns `Unsupported` where `O_DIRECT` is unavailable or its alignment
252    /// unknown, or the underlying `statx` or `open` error.
253    fn open_direct(&self, path: &Path) -> io::Result<DirectFile> { open_direct(path) }
254
255    /// Write `buf` at `offset` through a handle [`LogIo::open_direct`]
256    /// opened. `buf`, `offset` and the length are aligned as that handle
257    /// requires. Like [`std::io::Write::write`], this may write fewer bytes
258    /// than asked for.
259    ///
260    /// # Errors
261    /// Returns the underlying write error.
262    fn write_direct(&self, file: &File, buf: &[u8], offset: u64) -> io::Result<usize> {
263        write_direct(file, buf, offset)
264    }
265
266    /// Whether the direct writes of a segment under this I/O go to the
267    /// shared `io_uring` submitter, which writes the file itself, rather than
268    /// through [`LogIo::write_direct`] on the write threads.
269    ///
270    /// Only the real file I/O answers true. Every other implementation keeps
271    /// the threads, so an override of [`LogIo::write_direct`] sees each write.
272    fn submits_direct_writes(&self) -> bool { false }
273}
274
275/// A `.log` handle that bypasses the page cache, and what it requires of a
276/// write: a buffer address aligned to `mem_align`, and an offset and a length
277/// that are multiples of `block`.
278#[derive(Debug)]
279pub struct DirectFile {
280    /// The `O_DIRECT` handle.
281    pub file: File,
282    /// Alignment of a write's buffer address, in bytes: at least what
283    /// `O_DIRECT` requires, and every write keeps to it.
284    pub mem_align: usize,
285    /// Alignment of a write's offset and length, in bytes: at least what
286    /// `O_DIRECT` requires, and every write keeps to it.
287    pub block: usize,
288}
289
290/// The largest filesystem block [`write_alignment`] raises a direct write's
291/// alignment to. It is the bound on the zeros past the last batch that
292/// `docs/persisted_formats.md` gives for an active `preallocate` segment.
293/// `stx_blksize` is the preferred I/O size, which is the block size on ext4
294/// but can be an XFS stripe width or an NFS `wsize`: those are larger than
295/// any block, and the cap keeps them from growing every write.
296#[cfg(target_os = "linux")]
297const MAX_FS_BLOCK: u32 = 4096;
298
299/// The alignment a direct write keeps for one of the two `O_DIRECT` rules,
300/// whose kernel minimum is `required`: the buffer address, or the file
301/// offset and length. It is `required` raised to the filesystem block
302/// `fs_block`, capped at [`MAX_FS_BLOCK`], when that is a power of two.
303///
304/// The kernel takes buffers and offsets aligned to the device's logical
305/// block, often 512 bytes, but a filesystem allocates and converts whole
306/// blocks of its own. ext4 sends a direct write down its unaligned path when
307/// its offset, its length or its buffer address falls inside a 4 KiB block:
308/// it takes the inode lock exclusively, waits for every other direct write
309/// to the file, and zeroes the rest of a newly allocated partial block with
310/// a write of its own. A segment's appends end anywhere, so with the kernel
311/// minimum almost every write paid that, at about a fifth of the device's
312/// rate. The larger of two powers of two is a multiple of both, so the
313/// result still meets the kernel's rule.
314#[cfg(target_os = "linux")]
315fn write_alignment(required: u32, fs_block: u32) -> u32 {
316    let fs_block = fs_block.min(MAX_FS_BLOCK);
317    if fs_block.is_power_of_two() {
318        required.max(fs_block)
319    } else {
320        required
321    }
322}
323
324#[cfg(target_os = "linux")]
325fn open_direct(path: &Path) -> io::Result<DirectFile> {
326    use rustix::fs::{AtFlags, CWD, Mode, OFlags, StatxFlags};
327
328    let stat = statx(CWD, path, AtFlags::empty(), StatxFlags::DIOALIGN)?;
329    let reported = stat.stx_mask & StatxFlags::DIOALIGN.bits() != 0;
330    // A zero offset alignment is the kernel saying this file cannot be
331    // opened `O_DIRECT` at all.
332    if !reported || stat.stx_dio_offset_align == 0 || stat.stx_dio_mem_align == 0 {
333        return Err(ErrorKind::Unsupported.into());
334    }
335    let to_usize =
336        |align: u32| usize::try_from(align).map_err(|_| io::Error::from(ErrorKind::Unsupported));
337    let fd = open(
338        path,
339        OFlags::RDWR | OFlags::DIRECT | OFlags::CLOEXEC,
340        Mode::empty(),
341    )?;
342    Ok(DirectFile {
343        file: File::from(fd),
344        mem_align: to_usize(write_alignment(stat.stx_dio_mem_align, stat.stx_blksize))?,
345        block: to_usize(write_alignment(stat.stx_dio_offset_align, stat.stx_blksize))?,
346    })
347}
348
349#[cfg(not(target_os = "linux"))]
350fn open_direct(path: &Path) -> io::Result<DirectFile> {
351    let _ = path;
352    Err(ErrorKind::Unsupported.into())
353}
354
355#[cfg(unix)]
356fn write_direct(file: &File, buf: &[u8], offset: u64) -> io::Result<usize> {
357    FileExt::write_at(file, buf, offset)
358}
359
360/// No target without `O_DIRECT` ever opens a handle to write through.
361#[cfg(not(unix))]
362fn write_direct(file: &File, buf: &[u8], offset: u64) -> io::Result<usize> {
363    let _ = (file, buf, offset);
364    Err(ErrorKind::Unsupported.into())
365}
366
367#[cfg(target_os = "linux")]
368fn reserve(file: &File, offset: u64, len: u64) -> io::Result<()> {
369    fallocate(file, FallocateFlags::KEEP_SIZE, offset, len).map_err(Into::into)
370}
371
372#[cfg(not(target_os = "linux"))]
373fn reserve(file: &File, offset: u64, len: u64) -> io::Result<()> {
374    let _ = (file, offset, len);
375    Err(ErrorKind::Unsupported.into())
376}
377
378/// The size of one `POSIX_FADV_WILLNEED` call.
379///
380/// Linux reads at most `max(max_sectors_kb, read_ahead_kb)` of one `WILLNEED`
381/// call and drops the rest of the range. That is about 4 MiB on a local disk
382/// and often less than 1 MiB on a cloud disk, so a hint over a whole window
383/// reads only its start. 128 KiB is the default `read_ahead_kb`, so each call
384/// of this size reads all of its range.
385#[cfg(any(target_os = "linux", test))]
386const WILL_NEED_CHUNK: u64 = 128 * 1024;
387
388/// The `(offset, len)` ranges, each at most [`WILL_NEED_CHUNK`] long, that
389/// cover the `len` bytes from `offset` in order. The range stops at
390/// `u64::MAX` when `offset + len` is larger.
391#[cfg(any(target_os = "linux", test))]
392fn will_need_chunks(offset: u64, len: u64) -> impl Iterator<Item = (u64, u64)> {
393    let end = offset.saturating_add(len);
394    iter::successors(Some(offset), |start| {
395        Some(start.saturating_add(WILL_NEED_CHUNK))
396    })
397    .take_while(move |start| *start < end)
398    .map(move |start| (start, WILL_NEED_CHUNK.min(end - start)))
399}
400
401#[cfg(target_os = "linux")]
402fn advise_will_need(file: &File, offset: u64, len: u64) {
403    for (offset, len) in will_need_chunks(offset, len) {
404        if let Some(len) = NonZeroU64::new(len) {
405            // A refused hint leaves the read exactly as it was without one.
406            let _ = fadvise(file, offset, Some(len), Advice::WillNeed);
407        }
408    }
409}
410
411#[cfg(not(target_os = "linux"))]
412fn advise_will_need(file: &File, offset: u64, len: u64) { let _ = (file, offset, len); }
413
414#[cfg(target_os = "linux")]
415fn write_back(file: &File, offset: u64, len: u64) -> io::Result<()> {
416    sync_file_range(file, offset, len, SyncFileRangeFlags::WRITE)?;
417    Ok(())
418}
419
420#[cfg(not(any(target_os = "linux", target_os = "wasi")))]
421fn write_back(file: &File, offset: u64, len: u64) -> io::Result<()> {
422    let _ = (offset, len);
423    file.sync_data()
424}
425
426#[cfg(target_os = "linux")]
427fn drop_cached(file: &File, offset: u64, len: u64) {
428    if let Some(len) = NonZeroU64::new(len) {
429        // The pages must be clean to go: wait for a write-out still in
430        // flight, and start one for a page that is still dirty. A failure
431        // here leaves the hint to do what it can.
432        let _ = sync_file_range(
433            file,
434            offset,
435            len.get(),
436            SyncFileRangeFlags::WAIT_BEFORE
437                | SyncFileRangeFlags::WRITE
438                | SyncFileRangeFlags::WAIT_AFTER,
439        );
440        // A refused hint leaves the pages as they were.
441        let _ = fadvise(file, offset, Some(len), Advice::DontNeed);
442    }
443}
444
445#[cfg(not(any(target_os = "linux", target_os = "wasi")))]
446fn drop_cached(file: &File, offset: u64, len: u64) { let _ = (file, offset, len); }
447
448/// `preadv2(RWF_NOWAIT)` of one byte: it fails with `EAGAIN` instead of
449/// reading from the disk when the page cache does not hold the byte. Linux
450/// 5.9 and 5.10 can return zero bytes instead (a bug `preadv2(2)` documents);
451/// callers only ask about bytes below the end of the file, so zero bytes also
452/// means not cached.
453#[cfg(target_os = "linux")]
454fn is_cached(file: &File, offset: u64) -> Option<bool> {
455    let mut byte = [0u8; 1];
456    match preadv2(
457        file,
458        &mut [std::io::IoSliceMut::new(&mut byte)],
459        offset,
460        ReadWriteFlags::NOWAIT,
461    ) {
462        Err(Errno::OPNOTSUPP | Errno::NOSYS) => None,
463        Err(Errno::AGAIN) | Ok(0) => Some(false),
464        _ => Some(true),
465    }
466}
467
468#[cfg(not(target_os = "linux"))]
469#[krabka_macros::sendfile_cfg]
470fn is_cached(file: &File, offset: u64) -> Option<bool> {
471    let _ = (file, offset);
472    None
473}
474
475/// Write the whole of `buf` to `file` through `io`, looping over short writes.
476///
477/// A `LogIo` that writes part of the buffer and then fails leaves the file
478/// torn at exactly that boundary, which is the shape a disk-full write has on
479/// a real filesystem.
480///
481/// # Errors
482/// Returns the first error the underlying writes produce, or `WriteZero` when
483/// a write makes no progress.
484pub fn write_all(io: &dyn LogIo, target: IoTarget, file: &File, buf: &[u8]) -> io::Result<()> {
485    write_all_with(buf, |remaining| io.write_at(target, file, remaining))
486}
487
488/// Complete a scalar write, retrying interrupts and rejecting zero progress.
489pub fn write_all_with(
490    mut buf: &[u8],
491    mut write: impl FnMut(&[u8]) -> io::Result<usize>,
492) -> io::Result<()> {
493    while !buf.is_empty() {
494        let written = write_progress(|| write(buf))?;
495        buf = buf.get(written..).ok_or_else(|| {
496            io::Error::new(
497                ErrorKind::InvalidData,
498                "a write reported more bytes than it was given",
499            )
500        })?;
501    }
502    Ok(())
503}
504
505/// Retry an interrupted write and require it to make progress.
506pub fn write_progress(mut write: impl FnMut() -> io::Result<usize>) -> io::Result<usize> {
507    loop {
508        match write() {
509            Ok(0) => return Err(ErrorKind::WriteZero.into()),
510            Err(error) if error.kind() == ErrorKind::Interrupted => {}
511            result => return result,
512        }
513    }
514}
515
516/// Replace `path` with `bytes` atomically and durably through real file I/O:
517/// write `tmp`, sync it, rename it over `path`, then sync the parent
518/// directory.
519///
520/// This is the entry point for files outside a partition log, such
521/// as the controller's `quorum-state` file and its metadata checkpoints, which
522/// have no fault-injection seam of their own.
523///
524/// # Errors
525/// Returns the first create, write, sync or rename error.
526pub fn write_file_atomic(tmp: &Path, path: &Path, bytes: &[u8]) -> io::Result<()> {
527    write_atomic(&FileIo, IoTarget::External, tmp, path, bytes, true)
528}
529
530/// Replace `path` with `bytes` atomically: write them to `tmp`, flush `tmp` to
531/// stable storage, rename it over `path`, and, when `sync_dir` is set, `fsync`
532/// `path`'s parent directory so the rename itself is durable.
533///
534/// Every step goes through `io` on behalf of `target`, so a fault injector can
535/// fail the write, the flush, the rename or the directory sync. A failure
536/// before the rename leaves `path` as it was; `tmp` may be left behind.
537///
538/// # Errors
539/// Returns the first create, write, sync or rename error.
540pub fn write_atomic(
541    io: &dyn LogIo,
542    target: IoTarget,
543    tmp: &Path,
544    path: &Path,
545    bytes: &[u8],
546    sync_dir: bool,
547) -> io::Result<()> {
548    {
549        let file = File::create(tmp)?;
550        write_all(io, target, &file, bytes)?;
551        io.sync_file(target, &file)?;
552    }
553    io.rename(target, tmp, path)?;
554    if sync_dir {
555        let parent = path
556            .parent()
557            .filter(|parent| !parent.as_os_str().is_empty())
558            .unwrap_or_else(|| Path::new("."));
559        io.sync_dir(parent)?;
560    }
561    Ok(())
562}
563
564#[derive(Debug)]
565pub struct FileIo;
566
567impl LogIo for FileIo {
568    fn submits_direct_writes(&self) -> bool { true }
569}
570
571/// The shared handle every structure defaults to before a test installs its
572/// own.
573pub fn file_io() -> Arc<dyn LogIo> { Arc::new(FileIo) }
574
575#[cfg(test)]
576mod tests {
577    use {
578        super::*,
579        assert2::check,
580        rustix::fs::fstatvfs,
581        std::{
582            fs,
583            io::{self, ErrorKind},
584            path::PathBuf,
585            sync::Mutex,
586            vec,
587        },
588    };
589
590    /// A writer that replays a script of results, recording what each call was
591    /// offered. A regular file cannot be persuaded to write short or to make
592    /// no progress on demand, so the loop's two obligations -- resume where the
593    /// last write stopped, and refuse to spin on a writer that writes nothing
594    /// -- are only reachable through one of these.
595    #[derive(Debug)]
596    struct Scripted {
597        script: Mutex<vec::IntoIter<io::Result<usize>>>,
598        seen: Mutex<Vec<Vec<u8>>>,
599    }
600
601    impl Scripted {
602        fn new(script: Vec<io::Result<usize>>) -> Self {
603            Self {
604                script: Mutex::new(script.into_iter()),
605                seen: Mutex::new(Vec::new()),
606            }
607        }
608    }
609
610    impl LogIo for Scripted {
611        fn write_at(&self, _target: IoTarget, _file: &File, buf: &[u8]) -> io::Result<usize> {
612            self.seen.lock().unwrap().push(buf.to_vec());
613            self.script
614                .lock()
615                .unwrap()
616                .next()
617                .unwrap_or(Ok(buf.len()))
618                .map(|written| written.min(buf.len()))
619        }
620    }
621
622    /// A file in a temporary directory.
623    struct ScratchFile {
624        dir: tempfile::TempDir,
625        file: File,
626    }
627
628    fn scratch_file() -> ScratchFile {
629        let dir = tempfile::tempdir().unwrap();
630        let file = File::create(dir.path().join("scratch")).unwrap();
631        ScratchFile { dir, file }
632    }
633
634    /// A state file with contents, and the staging path beside it, in a
635    /// temporary directory.
636    struct ExistingState {
637        dir: tempfile::TempDir,
638        path: PathBuf,
639        tmp: PathBuf,
640    }
641
642    fn existing_state(contents: &[u8]) -> ExistingState {
643        let dir = tempfile::tempdir().unwrap();
644        let path = dir.path().join("state");
645        let tmp = dir.path().join("state.tmp");
646        fs::write(&path, contents).unwrap();
647        ExistingState { dir, path, tmp }
648    }
649
650    #[test]
651    fn will_need_chunks_cover_the_span_in_chunks() {
652        const C: u64 = WILL_NEED_CHUNK;
653        let cases = [
654            ("empty", 4096, 0, vec![]),
655            ("shorter than a chunk", 4096, 100, vec![(4096, 100)]),
656            ("one chunk", 0, C, vec![(0, C)]),
657            ("exact multiple", 10, 3 * C, vec![
658                (10, C),
659                (10 + C, C),
660                (10 + 2 * C, C),
661            ]),
662            ("remainder", 0, 2 * C + 7, vec![(0, C), (C, C), (2 * C, 7)]),
663            ("saturates at u64::MAX", u64::MAX - C - 5, 3 * C, vec![
664                (u64::MAX - C - 5, C),
665                (u64::MAX - 5, 5),
666            ]),
667            ("starts at u64::MAX", u64::MAX, C, vec![]),
668        ];
669        for (name, offset, len, expected) in cases {
670            let chunks: Vec<_> = will_need_chunks(offset, len).collect();
671            check!(chunks == expected, "{name}");
672        }
673    }
674
675    #[test]
676    fn write_atomic_replaces_the_target_and_consumes_the_staging_file() {
677        let ExistingState {
678            dir: _dir,
679            path,
680            tmp,
681        } = existing_state(b"old contents");
682
683        for contents in [b"new".as_slice(), b"newer"] {
684            write_file_atomic(&tmp, &path, contents).unwrap();
685            check!(fs::read(&path).unwrap() == contents.to_vec());
686            check!(!tmp.exists());
687        }
688    }
689
690    #[test]
691    fn write_atomic_leaves_the_target_alone_when_the_staged_write_fails() {
692        let ExistingState {
693            dir: _dir,
694            path,
695            tmp,
696        } = existing_state(b"old");
697
698        let io = Scripted::new(vec![Err(io::Error::from(ErrorKind::StorageFull))]);
699        let error = write_atomic(&io, IoTarget::External, &tmp, &path, b"new", true).unwrap_err();
700        check!(error.kind() == ErrorKind::StorageFull);
701        check!(fs::read(&path).unwrap() == b"old".to_vec());
702    }
703
704    #[test]
705    fn write_all_resumes_short_writes_retries_interruptions_and_stops_on_no_progress() {
706        let ScratchFile { dir: _dir, file } = scratch_file();
707
708        for (script, expected) in [
709            // Three short writes finish a six-byte buffer, each offered only what
710            // the last one left.
711            (vec![Ok(2), Ok(3), Ok(1)], vec![
712                b"abcdef".to_vec(),
713                b"cdef".to_vec(),
714                b"f".to_vec(),
715            ]),
716            // An interrupted write is retried with the same bytes, not skipped past.
717            (
718                vec![Ok(2), Err(io::Error::from(ErrorKind::Interrupted)), Ok(4)],
719                vec![b"abcdef".to_vec(), b"cdef".to_vec(), b"cdef".to_vec()],
720            ),
721        ] {
722            let io = Scripted::new(script);
723            write_all(&io, IoTarget::OffsetIndex, &file, b"abcdef").unwrap();
724            check!(*io.seen.lock().unwrap() == expected);
725        }
726        for (script, expected) in [
727            // A writer that makes no progress is a `WriteZero`, not a spin.
728            (vec![Ok(0)], ErrorKind::WriteZero),
729            // Any other error is returned as it stands.
730            (
731                vec![Ok(1), Err(io::Error::from(ErrorKind::StorageFull))],
732                ErrorKind::StorageFull,
733            ),
734        ] {
735            let io = Scripted::new(script);
736            let error = write_all(&io, IoTarget::OffsetIndex, &file, b"abcdef").unwrap_err();
737            check!(error.kind() == expected);
738        }
739    }
740
741    /// A direct write keeps to the filesystem block as well as to what
742    /// `O_DIRECT` requires, so neither its buffer nor its offset and length
743    /// ever fall inside a block the filesystem allocates. A preferred I/O size
744    /// above 4 KiB counts as 4 KiB, and one that is not a power of two below
745    /// that, or none, leaves the `O_DIRECT` alignment as it is.
746    #[cfg(target_os = "linux")]
747    #[yare::parameterized(
748        ext4_on_a_512_byte_device = { 512, 4096, 4096 },
749        ext4_on_a_4k_device = { 4096, 4096, 4096 },
750        small_filesystem_block = { 512, 1024, 1024 },
751        device_coarser_than_the_filesystem = { 4096, 1024, 4096 },
752        xfs_stripe_width = { 512, 65536, 4096 },
753        nfs_wsize = { 512, 1_048_576, 4096 },
754        stripe_width_not_a_power_of_two = { 512, 196_608, 4096 },
755        small_block_not_a_power_of_two = { 512, 3072, 512 },
756        no_block_reported = { 512, 0, 512 },
757    )]
758    fn a_direct_write_aligns_to_the_filesystem_block(required: u32, fs_block: u32, expected: u32) {
759        check!(write_alignment(required, fs_block) == expected);
760    }
761
762    /// The real `O_DIRECT` handle, where the scratch filesystem offers one,
763    /// asks for buffers and offsets aligned to the filesystem block, up to
764    /// 4 KiB, whatever the device under it takes.
765    #[cfg(target_os = "linux")]
766    #[test]
767    fn a_real_direct_handle_aligns_to_the_filesystem_block() {
768        let ScratchFile { dir, file } = scratch_file();
769        let Ok(direct) = open_direct(&dir.path().join("scratch")) else {
770            return;
771        };
772        let fs_block = usize::try_from(fstatvfs(&file).unwrap().f_bsize.min(4096)).unwrap();
773        for align in [direct.mem_align, direct.block] {
774            check!(align.is_power_of_two());
775            check!(align % fs_block == 0);
776        }
777    }
778}