Skip to main content

gunnar_server/
disktest.rs

1//! **The start-up disk test**: what /data can actually do, said once on the
2//! screen.
3//!
4//! ```text
5//!  17:40:43  disktest /data  seq w 812 MB/s · r 1.9 GB/s · 4k sync 3100 ops/s  (1.6 s)
6//! ```
7//!
8//! Three numbers, because they are the three a git server lives on: a
9//! sequential write (a pack arriving), a sequential read (a clone leaving),
10//! and 4 KiB overwrites each followed by `fsync` — the loose-object / ref
11//! update pattern, and the number that separates a FAT /data from an xfs one
12//! (an open item; the rig reads this row).
13//!
14//! The write and the 4 KiB legs are plain `std::fs`. The READ leg measures the
15//! device, not the page cache: the file it just wrote is still cached, so the
16//! read reopens it with `O_DIRECT` (one 4 MiB buffer, 4 KiB-aligned,
17//! allocated once for the whole run) and streams it back off the disk. Where
18//! `O_DIRECT` is refused (tmpfs, some FUSE) the fallback is
19//! `posix_fadvise(DONTNEED)` to drop the cached pages, then a buffered read —
20//! and a result that is still implausible (over 5 GB/s) is said to be
21//! `cached` rather than reported as disk speed. On the appliance `/data` is
22//! the mounted xfs over the whole md raid0 set, so the numbers are the
23//! stripe's, not one member disk's. 256 MiB under `<data>/.korp-disktest`,
24//! deleted afterwards. Runs on its own thread the moment `/data` is known to
25//! be mounted and never delays serving: the row arrives when it arrives.
26//!
27//! Never a panic, never a reason PID 1 is unhappy: too little free space is a
28//! `skipped` row, any I/O error is one WARN row, and both leave the file gone.
29
30use std::fs::{File, OpenOptions};
31use std::io::{Read, Seek, SeekFrom, Write};
32use std::os::unix::fs::OpenOptionsExt;
33use std::path::{Path, PathBuf};
34use std::time::{Duration, Instant};
35
36use rustix::fs::{fadvise, Advice, OFlags};
37
38/// The env seam: `GUNNAR_DISKTEST=1` runs it on a developer box regardless
39/// of the PID-1 gate.
40pub const ENV: &str = "GUNNAR_DISKTEST";
41/// The file's directory under the data root; removed when the test is done.
42pub const DIR_NAME: &str = ".korp-disktest";
43/// What the test writes.
44pub const FILE_BYTES: u64 = 256 << 20;
45/// Skip below this much free space: the test must never be the thing that
46/// fills the disk.
47pub const MIN_FREE_BYTES: u64 = 1 << 30;
48/// How long the 4 KiB fsync loop runs.
49pub const SYNC_WINDOW: Duration = Duration::from_millis(500);
50/// A read faster than this did not touch the disk.
51pub const CACHED_BPS: f64 = 5e9;
52/// The write chunk and the read buffer: one buffer of this size, allocated
53/// once per run, aligned for `O_DIRECT`.
54const CHUNK: usize = 4 << 20;
55const BLOCK: usize = 4096;
56/// `O_DIRECT` wants the buffer, the offset and the length aligned to the
57/// logical block; 4 KiB satisfies every disk the appliance runs on.
58const DIRECT_ALIGN: usize = 4096;
59
60/// How the read leg got its bytes.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum ReadHow {
63    /// `O_DIRECT`: the page cache was bypassed, the number is the device's.
64    Direct,
65    /// `O_DIRECT` refused; the cached pages were dropped (`fadvise
66    /// DONTNEED`) and the file read through the cache.
67    Dropped,
68}
69
70impl ReadHow {
71    pub fn as_str(self) -> &'static str {
72        match self {
73            ReadHow::Direct => "direct",
74            ReadHow::Dropped => "dropped",
75        }
76    }
77}
78
79/// What the test measured.
80#[derive(Debug, Clone, PartialEq)]
81pub struct Report {
82    pub seq_write_bps: f64,
83    pub seq_read_bps: f64,
84    /// Whether the read bypassed the page cache or only dropped it first.
85    pub read_how: ReadHow,
86    pub sync_ops_per_s: f64,
87    pub elapsed: Duration,
88    /// **What the rates are made of** (2026-09-28): the bytes written and
89    /// read back, each leg's own clock, and the fsync'd 4 KiB operations the
90    /// sync rate counts — so a reader can check a rate against the run that
91    /// produced it instead of taking a derived number on trust.
92    pub file_bytes: u64,
93    pub write_elapsed: Duration,
94    pub read_elapsed: Duration,
95    pub sync_ops: u64,
96    pub sync_elapsed: Duration,
97}
98
99impl Report {
100    /// `seq w 812 MB/s · r 1.9 GB/s · 4k sync 3100 ops/s  (1.6 s)`; a read
101    /// over [`CACHED_BPS`] reads `r cached`.
102    pub fn row(&self) -> String {
103        let read = if self.seq_read_bps > CACHED_BPS {
104            "cached".to_owned()
105        } else {
106            fmt_rate(self.seq_read_bps)
107        };
108        format!(
109            "seq w {} \u{b7} r {} \u{b7} 4k sync {:.0} ops/s  ({:.1} s)",
110            fmt_rate(self.seq_write_bps),
111            read,
112            self.sync_ops_per_s,
113            self.elapsed.as_secs_f64()
114        )
115    }
116}
117
118/// `812 MB/s` / `1.9 GB/s` (decimal units, as disks are sold).
119pub fn fmt_rate(bps: f64) -> String {
120    if bps >= 1e9 {
121        format!("{:.1} GB/s", bps / 1e9)
122    } else if bps >= 1e6 {
123        format!("{:.0} MB/s", bps / 1e6)
124    } else {
125        format!("{:.0} kB/s", bps / 1e3)
126    }
127}
128
129/// How one run ended.
130#[derive(Debug, Clone, PartialEq)]
131pub enum Outcome {
132    Done(Report),
133    /// Too little free space: `(free, needed)` bytes.
134    Skipped {
135        free: u64,
136        need: u64,
137    },
138    Failed(String),
139}
140
141impl Outcome {
142    /// The row's text; the kind is `disktest` either way, `Failed` warns.
143    pub fn row(&self) -> (String, bool) {
144        match self {
145            Outcome::Done(r) => (r.row(), false),
146            Outcome::Skipped { free, need } => (
147                format!(
148                    "skipped: {} free (< {})",
149                    crate::dashboard::fmt_bytes(*free),
150                    crate::dashboard::fmt_bytes(*need)
151                ),
152                false,
153            ),
154            Outcome::Failed(why) => (format!("failed: {why}"), true),
155        }
156    }
157}
158
159/// The skip rule, pure: `free` bytes available against [`MIN_FREE_BYTES`]
160/// (or `need`).
161pub fn should_skip(free: u64, need: u64) -> bool {
162    free < need
163}
164
165/// Run the test under `data_root` with the production sizes.
166pub fn run(data_root: &Path) -> Outcome {
167    run_sized(data_root, FILE_BYTES, MIN_FREE_BYTES, SYNC_WINDOW)
168}
169
170/// The test at any size — what the unit test calls with a few MiB.
171pub fn run_sized(
172    data_root: &Path,
173    file_bytes: u64,
174    min_free: u64,
175    sync_window: Duration,
176) -> Outcome {
177    let free = match crate::dashboard::probe::disk(data_root) {
178        Some((_, avail)) => avail,
179        None => {
180            return Outcome::Failed(format!(
181                "statfs {}: not a mounted filesystem",
182                data_root.display()
183            ))
184        }
185    };
186    if should_skip(free, min_free) {
187        return Outcome::Skipped {
188            free,
189            need: min_free,
190        };
191    }
192    let dir = data_root.join(DIR_NAME);
193    let path = dir.join("probe");
194    let result = measure(&dir, &path, file_bytes, sync_window);
195    // Leave nothing behind whichever way it went.
196    let _ = std::fs::remove_file(&path);
197    let _ = std::fs::remove_dir(&dir);
198    match result {
199        Ok(report) => Outcome::Done(report),
200        Err(why) => Outcome::Failed(why),
201    }
202}
203
204fn measure(
205    dir: &Path,
206    path: &Path,
207    file_bytes: u64,
208    sync_window: Duration,
209) -> Result<Report, String> {
210    let started = Instant::now();
211    std::fs::create_dir_all(dir).map_err(|e| format!("mkdir {}: {e}", dir.display()))?;
212    // Not zeros: a filesystem or a virtual disk that compresses or
213    // deduplicates would report a write it never made. ONE buffer for the
214    // whole run — the write source, the O_DIRECT read target and the 4 KiB
215    // block all live in it (allocate once, reuse).
216    let mut buf = AlignedBuf::new(CHUNK, DIRECT_ALIGN);
217    buf.fill(&pattern(CHUNK));
218
219    // ── sequential write + fsync ───────────────────────────────────────────
220    let t = Instant::now();
221    let mut f = File::create(path).map_err(|e| format!("create: {e}"))?;
222    let mut left = file_bytes;
223    while left > 0 {
224        let n = (left as usize).min(CHUNK);
225        f.write_all(&buf.as_slice()[..n])
226            .map_err(|e| format!("write: {e}"))?;
227        left -= n as u64;
228    }
229    f.sync_all().map_err(|e| format!("fsync: {e}"))?;
230    drop(f);
231    let write_elapsed = t.elapsed();
232    let seq_write_bps = file_bytes as f64 / write_elapsed.as_secs_f64().max(1e-9);
233
234    // ── sequential read, off the device ───────────────────────────────────
235    let (got, secs, read_how) = read_back(path, &mut buf, true)?;
236    if got != file_bytes {
237        return Err(format!("read back {got} of {file_bytes} bytes"));
238    }
239    let seq_read_bps = got as f64 / secs.max(1e-9);
240
241    // ── 4 KiB random overwrites, fsync each ────────────────────────────────
242    // The read leg may have filled the buffer with what it read — the same
243    // pattern, but restore the block so the leg does not depend on that.
244    let block_src = pattern(BLOCK);
245    buf.as_mut_slice()[..BLOCK].copy_from_slice(&block_src);
246    let mut f = OpenOptions::new()
247        .write(true)
248        .open(path)
249        .map_err(|e| format!("reopen: {e}"))?;
250    let blocks = (file_bytes / BLOCK as u64).max(1);
251    let block = &buf.as_slice()[..BLOCK];
252    let mut rng = started.elapsed().as_nanos() as u64 | 1;
253    let mut ops = 0u64;
254    let t = Instant::now();
255    while t.elapsed() < sync_window {
256        // xorshift64: enough randomness to defeat any write-combining.
257        rng ^= rng << 13;
258        rng ^= rng >> 7;
259        rng ^= rng << 17;
260        let at = (rng % blocks) * BLOCK as u64;
261        f.seek(SeekFrom::Start(at))
262            .map_err(|e| format!("seek: {e}"))?;
263        f.write_all(block).map_err(|e| format!("write 4k: {e}"))?;
264        f.sync_data().map_err(|e| format!("fdatasync: {e}"))?;
265        ops += 1;
266    }
267    let sync_elapsed = t.elapsed();
268    let sync_ops_per_s = ops as f64 / sync_elapsed.as_secs_f64().max(1e-9);
269    drop(f);
270
271    Ok(Report {
272        seq_write_bps,
273        seq_read_bps,
274        read_how,
275        sync_ops_per_s,
276        elapsed: started.elapsed(),
277        file_bytes,
278        write_elapsed,
279        read_elapsed: Duration::from_secs_f64(secs.max(0.0)),
280        sync_ops: ops,
281        sync_elapsed,
282    })
283}
284
285/// Read `path` back sequentially into `buf`, past the page cache: `O_DIRECT`
286/// when `try_direct` and the filesystem takes it, else drop the cached pages
287/// (`fadvise DONTNEED`, after the caller's fsync they are clean) and read
288/// buffered. Returns `(bytes, seconds, how)`; the clock starts after the
289/// open so the number is the transfer, not the open.
290pub fn read_back(
291    path: &Path,
292    buf: &mut AlignedBuf,
293    try_direct: bool,
294) -> Result<(u64, f64, ReadHow), String> {
295    let direct = if try_direct {
296        OpenOptions::new()
297            .read(true)
298            .custom_flags(OFlags::DIRECT.bits() as i32)
299            .open(path)
300            .ok()
301    } else {
302        None
303    };
304    let (mut f, how) = match direct {
305        Some(f) => (f, ReadHow::Direct),
306        None => {
307            let f = File::open(path).map_err(|e| format!("open: {e}"))?;
308            // Best effort: a filesystem that ignores the advice is one whose
309            // "read" is memory anyway, and the > 5 GB/s rule catches it.
310            let _ = fadvise(&f, 0, None, Advice::DontNeed);
311            (f, ReadHow::Dropped)
312        }
313    };
314    let t = Instant::now();
315    let mut got = 0u64;
316    loop {
317        let n = f
318            .read(buf.as_mut_slice())
319            .map_err(|e| format!("read ({}): {e}", how.as_str()))?;
320        if n == 0 {
321            break;
322        }
323        got += n as u64;
324    }
325    Ok((got, t.elapsed().as_secs_f64(), how))
326}
327
328/// A heap buffer whose start is aligned to `align` (a power of two): what
329/// `O_DIRECT` needs and `Vec<u8>` cannot promise. Allocated once per run.
330pub struct AlignedBuf {
331    raw: Vec<u8>,
332    at: usize,
333    len: usize,
334}
335
336impl AlignedBuf {
337    pub fn new(len: usize, align: usize) -> Self {
338        debug_assert!(align.is_power_of_two());
339        let raw = vec![0u8; len + align];
340        let at = raw.as_ptr().align_offset(align);
341        Self { raw, at, len }
342    }
343    pub fn fill(&mut self, src: &[u8]) {
344        let n = src.len().min(self.len);
345        self.as_mut_slice()[..n].copy_from_slice(&src[..n]);
346    }
347    pub fn as_slice(&self) -> &[u8] {
348        &self.raw[self.at..self.at + self.len]
349    }
350    pub fn as_mut_slice(&mut self) -> &mut [u8] {
351        &mut self.raw[self.at..self.at + self.len]
352    }
353}
354
355fn pattern(n: usize) -> Vec<u8> {
356    let mut v = Vec::with_capacity(n);
357    let mut x = 0x9E37_79B9_7F4A_7C15u64;
358    while v.len() < n {
359        x ^= x << 13;
360        x ^= x >> 7;
361        x ^= x << 17;
362        v.extend_from_slice(&x.to_le_bytes());
363    }
364    v.truncate(n);
365    v
366}
367
368/// **The latch.** Until 2026-09-21 the outcome went to the screen and nowhere
369/// else, so nothing but a human reading a console could learn what this box's
370/// disk does. The twin election compares two boxes' disks over gRPC, so the
371/// last outcome is kept here and read by [`crate::capacity::MachineFacts`].
372///
373/// `None` means the test has not finished — the gate was closed, or the
374/// thread is still running. It is never a zero and never an empty `Report`:
375/// `Admin.Capacity` renders it `DISKTEST_NOT_RUN`, which is a different fact
376/// from a disk that measured badly.
377static LAST: std::sync::OnceLock<std::sync::Mutex<Option<Outcome>>> = std::sync::OnceLock::new();
378
379fn latch() -> &'static std::sync::Mutex<Option<Outcome>> {
380    LAST.get_or_init(|| std::sync::Mutex::new(None))
381}
382
383/// Record the outcome of a run. Called once per run, beside [`announce`].
384pub fn record(outcome: &Outcome) {
385    if let Ok(mut slot) = latch().lock() {
386        *slot = Some(outcome.clone());
387    }
388}
389
390/// The last outcome this process measured, or `None` if it has not finished
391/// one. A lookup, never a run: asking this question must never start a disk
392/// test, because the answer a caller wants is what the disk did at start-up.
393pub fn last() -> Option<Outcome> {
394    latch().lock().ok().and_then(|slot| slot.clone())
395}
396
397/// Say the outcome as the ONE tracing line the log tap turns into the
398/// `disktest` row (`dashboard::describe`).
399pub fn announce(data_root: &Path, outcome: &Outcome) {
400    let (text, warn) = outcome.row();
401    let read = match outcome {
402        Outcome::Done(r) => r.read_how.as_str(),
403        _ => "-",
404    };
405    if warn {
406        tracing::warn!(event = "disktest", path = %data_root.display(), read, "{text}");
407    } else {
408        tracing::info!(event = "disktest", path = %data_root.display(), read, "{text}");
409    }
410}
411
412/// Start the test on its own thread when this is the appliance's PID 1 (the
413/// dashboard's gate) or [`ENV`] is set; say nothing otherwise. Returns
414/// whether it started. Serving never waits for it.
415pub fn spawn(data_root: PathBuf) -> bool {
416    let explicit = std::env::var(ENV)
417        .ok()
418        .filter(|v| !v.is_empty() && v != "0");
419    let gated = appliance_pid1::net::is_init() && appliance_pid1::net::booted_by_korp_start();
420    let started = std::time::Instant::now();
421    if explicit.is_none() && !gated {
422        // No disk test, so nothing competes with the store for the disk and
423        // the server is ready from its first bound socket. No window is opened
424        // and none has to be closed.
425        return false;
426    }
427    // **The boot window opens here.** See `crate::readiness`.
428    crate::readiness::begin("the start-up disk test");
429    // A28 M18, stays a thread: one bounded measurement, its panic caught, its result and its start failure answered.
430    let spawned = std::thread::Builder::new()
431        .name("gunnar-disktest".into())
432        .spawn(move || {
433            let outcome = std::panic::catch_unwind(|| run(&data_root))
434                .unwrap_or_else(|_| Outcome::Failed("the disk test panicked".to_owned()));
435            record(&outcome);
436            announce(&data_root, &outcome);
437            // **The boot window closes here**, and only here. For the whole of
438            // this thread's life another thread of this process has been
439            // saturating the same disk a repository's first post-crash open
440            // has to read (`4k sync 4517 ops/s (0.7 s)` on the rig), which is
441            // the measured 0.6-0.7 s of `the repository is unavailable`.
442            crate::readiness::mark_ready("the start-up disk test", started);
443        })
444        .is_ok();
445    if !spawned {
446        // A thread that could not be spawned must not leave the server refusing
447        // every transfer for ever. The failure is already the caller's `false`.
448        crate::readiness::mark_ready("a disk test that could not be spawned", started);
449    }
450    spawned
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456
457    #[test]
458    fn the_row_reads_as_one_line_and_a_page_cache_read_is_called_cached() {
459        let r = Report {
460            seq_write_bps: 812e6,
461            seq_read_bps: 1.9e9,
462            read_how: ReadHow::Direct,
463            sync_ops_per_s: 3100.4,
464            elapsed: Duration::from_millis(1600),
465            file_bytes: 256 << 20,
466            write_elapsed: Duration::from_millis(330),
467            read_elapsed: Duration::from_millis(141),
468            sync_ops: 1550,
469            sync_elapsed: Duration::from_millis(500),
470        };
471        assert_eq!(
472            r.row(),
473            "seq w 812 MB/s \u{b7} r 1.9 GB/s \u{b7} 4k sync 3100 ops/s  (1.6 s)"
474        );
475        let cached = Report {
476            seq_read_bps: 7.2e9,
477            ..r.clone()
478        };
479        assert_eq!(
480            cached.row(),
481            "seq w 812 MB/s \u{b7} r cached \u{b7} 4k sync 3100 ops/s  (1.6 s)"
482        );
483        assert_eq!(fmt_rate(512e3), "512 kB/s");
484        assert!(!Outcome::Done(r).row().1);
485        assert_eq!(
486            Outcome::Skipped {
487                free: 800 << 20,
488                need: 1 << 30
489            }
490            .row(),
491            ("skipped: 800M free (< 1.0G)".to_owned(), false)
492        );
493        assert_eq!(
494            Outcome::Failed("write: No space left on device".into()).row(),
495            ("failed: write: No space left on device".to_owned(), true)
496        );
497    }
498
499    #[test]
500    fn the_skip_rule_is_free_space_below_the_need() {
501        assert!(should_skip((1 << 30) - 1, 1 << 30));
502        assert!(!should_skip(1 << 30, 1 << 30));
503        assert!(!should_skip(u64::MAX, MIN_FREE_BYTES));
504        // On a filesystem with next to nothing free the run says skipped and
505        // touches no file: asking for more than any disk has.
506        let dir = tempfile::tempdir().unwrap();
507        let out = run_sized(dir.path(), 4 << 20, u64::MAX, Duration::from_millis(1));
508        assert!(
509            matches!(out, Outcome::Skipped { need: u64::MAX, .. }),
510            "{out:?}"
511        );
512        assert!(!dir.path().join(DIR_NAME).exists());
513    }
514
515    #[test]
516    fn a_small_run_measures_three_numbers_and_leaves_nothing_behind() {
517        let dir = tempfile::tempdir().unwrap();
518        let out = run_sized(dir.path(), 4 << 20, 0, Duration::from_millis(50));
519        let Outcome::Done(r) = out else {
520            panic!("{out:?}")
521        };
522        assert!(
523            r.seq_write_bps > 0.0 && r.seq_read_bps > 0.0 && r.sync_ops_per_s > 0.0,
524            "{r:?}"
525        );
526        assert!(r.elapsed >= Duration::from_millis(50));
527        assert!(
528            !dir.path().join(DIR_NAME).exists(),
529            "the probe directory is removed"
530        );
531        let text = r.row();
532        assert!(
533            text.starts_with("seq w ") && text.contains(" 4k sync "),
534            "{text}"
535        );
536    }
537
538    /// A fixture file the read leg is pointed at: 4 MiB + a tail that is
539    /// not a whole block, so the O_DIRECT short read at EOF is exercised.
540    fn fixture(dir: &Path) -> (PathBuf, u64) {
541        let path = dir.join("fixture");
542        let body = pattern(4 * (1 << 20) + 1234);
543        std::fs::write(&path, &body).unwrap();
544        File::open(&path).unwrap().sync_all().unwrap();
545        (path, body.len() as u64)
546    }
547
548    #[test]
549    fn the_read_leg_reads_the_fixture_back_off_the_device_with_o_direct() {
550        // A scratch directory on the real disk; `/tmp` is often tmpfs, which
551        // refuses O_DIRECT — pick a real filesystem when one is at hand.
552        let base = std::env::var_os("GUNNAR_DISKTEST_FIXTURE_DIR").map(PathBuf::from);
553        let dir = match base {
554            Some(b) => tempfile::tempdir_in(b).unwrap(),
555            None => tempfile::tempdir().unwrap(),
556        };
557        let (path, len) = fixture(dir.path());
558        let mut buf = AlignedBuf::new(CHUNK, DIRECT_ALIGN);
559        assert_eq!(
560            buf.as_slice().as_ptr() as usize % DIRECT_ALIGN,
561            0,
562            "the buffer is 4 KiB-aligned"
563        );
564        assert_eq!(buf.as_slice().len(), CHUNK);
565        let (got, secs, how) = read_back(&path, &mut buf, true).unwrap();
566        assert_eq!(got, len, "every byte, including the partial last block");
567        assert!(secs > 0.0);
568        // The bytes that landed in the buffer are the file's tail (the last,
569        // short read) — the leg really read, it did not just count.
570        let tail = &pattern(len as usize)[(len as usize) - 1234..];
571        assert_eq!(&buf.as_slice()[..1234], tail);
572        let fs_direct = OpenOptions::new()
573            .read(true)
574            .custom_flags(OFlags::DIRECT.bits() as i32)
575            .open(&path)
576            .is_ok();
577        assert_eq!(
578            how,
579            if fs_direct {
580                ReadHow::Direct
581            } else {
582                ReadHow::Dropped
583            },
584            "O_DIRECT when the filesystem takes it, the fallback when not"
585        );
586    }
587
588    #[test]
589    fn the_read_leg_falls_back_to_dropping_the_cache_and_reading_buffered() {
590        let dir = tempfile::tempdir().unwrap();
591        let (path, len) = fixture(dir.path());
592        let mut buf = AlignedBuf::new(CHUNK, DIRECT_ALIGN);
593        let (got, _, how) = read_back(&path, &mut buf, false).unwrap();
594        assert_eq!(got, len);
595        assert_eq!(how, ReadHow::Dropped);
596        assert!(
597            read_back(Path::new("/nonexistent/korp/fixture"), &mut buf, false).is_err(),
598            "a missing file is an error, not a panic"
599        );
600    }
601
602    #[test]
603    fn a_full_run_names_how_it_read() {
604        let dir = tempfile::tempdir().unwrap();
605        let out = run_sized(dir.path(), 4 << 20, 0, Duration::from_millis(20));
606        let Outcome::Done(r) = out else {
607            panic!("{out:?}")
608        };
609        assert!(matches!(r.read_how, ReadHow::Direct | ReadHow::Dropped));
610        assert!(r.seq_read_bps > 0.0);
611    }
612
613    #[test]
614    fn a_missing_root_is_a_failure_row_not_a_panic() {
615        let out = run_sized(
616            Path::new("/nonexistent/korp"),
617            4 << 20,
618            0,
619            Duration::from_millis(1),
620        );
621        assert!(matches!(out, Outcome::Failed(_)), "{out:?}");
622    }
623}
624
625/// **The repeatability of this test, measured, not assumed.**
626///
627/// The twin election (`private-gunnar-ops`, `cargo xtask elect twin`) refuses
628/// when two boxes' disks differ by less than the noise, so somebody has to
629/// know what the noise IS. This is how it was measured: N production-sized
630/// runs back to back on one idle filesystem, printing the spread of each leg
631/// as a percentage of its own median. `--ignored` because it takes minutes
632/// and writes gigabytes; it is a MEASUREMENT, re-runnable on any box.
633///
634/// ```text
635/// cargo test -p gunnar-server --lib disktest::noise -- --ignored --nocapture
636/// ```
637#[cfg(test)]
638mod noise {
639    use super::*;
640
641    #[test]
642    #[ignore = "a measurement: minutes, and gigabytes written"]
643    fn the_spread_of_ten_runs_on_one_idle_disk() {
644        let runs: usize = std::env::var("GUNNAR_DISKTEST_RUNS")
645            .ok()
646            .and_then(|v| v.parse().ok())
647            .unwrap_or(10);
648        let dir = match std::env::var_os("GUNNAR_DISKTEST_FIXTURE_DIR") {
649            Some(b) => tempfile::tempdir_in(PathBuf::from(b)).unwrap(),
650            None => tempfile::tempdir().unwrap(),
651        };
652        let mut w = Vec::with_capacity(runs);
653        let mut r = Vec::with_capacity(runs);
654        let mut s = Vec::with_capacity(runs);
655        for i in 0..runs {
656            match run_sized(dir.path(), FILE_BYTES, 0, SYNC_WINDOW) {
657                Outcome::Done(rep) => {
658                    println!("run {i:2}  {}", rep.row());
659                    w.push(rep.seq_write_bps);
660                    r.push(rep.seq_read_bps);
661                    s.push(rep.sync_ops_per_s);
662                }
663                other => panic!("run {i}: {other:?}"),
664            }
665        }
666        for (name, mut v) in [("seq write", w), ("seq read", r), ("4k sync", s)] {
667            v.sort_by(|a, b| a.partial_cmp(b).unwrap());
668            let median = v[v.len() / 2];
669            let (lo, hi) = (v[0], v[v.len() - 1]);
670            println!(
671                "{name:<10} median {median:>14.0}  min {lo:>14.0}  max {hi:>14.0}  \
672                 spread {:>6.1}% of median",
673                (hi - lo) / median * 100.0
674            );
675        }
676    }
677}