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}