Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-cli/tests/create_throughput.rs

#![cfg(feature = "server")]
//! **Creating repositories by pushing, measured** (2026-08-30).
//!
//! An earlier bench measured the appliance rig against stock git with the same client
//! and the same budget: push-create ×200 sequential 161 ms vs git's 58, and
//! 8 parallel clients 7.6 ops/s vs git's 103 — creates were the one row where
//! gunnar lost, 14× in throughput. This test is that row in-process, on a real
//! disk, with stock git over real ssh, so the ms can be broken down under
//! `RUST_LOG=…=debug` or `strace -f -T` without a VM in the way.
//!
//! **A real disk is this test's own job, not the caller's** (2026-08-30).
//! `TMPDIR` on tmpfs makes every fsync free and the numbers a lie,
//! so the measurement genuinely cannot run there — but until now the test
//! merely REFUSED, and the build machine's `/tmp` is tmpfs, so `cargo test -p gunnar-cli`
//! read RED on the machine the suite is run on unless the caller happened to
//! know to export `TMPDIR`. A red that is really a precondition is a red nobody
//! reads, and three separate reports had to carry the incantation instead.
//! [`scratch_on_a_real_disk`] now FINDS one — `CARGO_TARGET_DIR`, the crate's
//! own `target/`, then `/var/tmp` — and says on stderr which it took. It still
//! fails, by name, when every candidate is a ram disk, and
//! [`refuse_tmpfs`] still runs against the harness's ACTUAL directory
//! afterwards, so a relocation that silently did not take is a red, not a
//! green. `GUNNAR_CREATE_ALLOW_TMPFS=1` still opts into measuring the cpu path
//! only.
//!
//! `GUNNAR_CREATE_N` repositories (default 24) are pushed into existence
//! sequentially, then another `N` by `GUNNAR_CREATE_PAR` (default 8) clients
//! at once. Every push must be accepted and clone back; the timings are
//! printed and the parallel phase must not be slower than the sequential one
//! in wall time per repository — the shape that bench saw (8 clients queueing
//! behind one writer at 1030 ms each) fails this line.

mod common;

use std::path::PathBuf;
use std::sync::Arc;
use std::time::Instant;

use common::{local_repo, tools_present, Harness, HarnessOptions, Seed};
use gunnar_core::HashKind;

fn env_usize(name: &str, default: usize) -> usize {
    std::env::var(name)
        .ok()
        .and_then(|v| v.parse().ok())
        .unwrap_or(default)
}

/// Is `dir` served by a tmpfs mount?
///
/// The longest matching mount point wins, because `/` is a prefix of everything
/// and on many systems `/` itself is a tmpfs while the interesting paths below
/// it are not. Taking the *first* tmpfs whose mount point is a prefix — which is
/// what this used to do — calls every path on such a host a ram disk.
fn on_tmpfs(dir: &std::path::Path) -> bool {
    let dir = dir.to_string_lossy();
    let mut best: Option<(usize, bool)> = None;
    for line in std::fs::read_to_string("/proc/mounts")
        .unwrap_or_default()
        .lines()
    {
        let mut parts = line.split_whitespace();
        let (Some(_), Some(at), Some(fs)) = (parts.next(), parts.next(), parts.next()) else {
            continue;
        };
        let covers = at == "/" || dir == at || dir.starts_with(&format!("{at}/"));
        if covers && best.is_none_or(|(len, _)| at.len() >= len) {
            best = Some((at.len(), fs == "tmpfs"));
        }
    }
    best.is_some_and(|(_, is_tmpfs)| is_tmpfs)
}

/// **Point `TMPDIR` at something that fsyncs.**
///
/// Returns the directory the harness will land in. Called BEFORE the harness is
/// built, because `tempfile::tempdir()` reads `TMPDIR` once and there is no
/// second chance afterwards.
///
/// The candidates, in order, and why: `CARGO_TARGET_DIR` (what this box
/// already sets, and it is on md1 by law), the crate's own `target/` (where
/// cargo has just written gigabytes, so it is a disk on any machine that got
/// this far), then `/var/tmp` (POSIX's persistent-across-reboots temp, unlike
/// `/tmp`). The first one that is not a ram disk wins.
fn scratch_on_a_real_disk() -> std::path::PathBuf {
    let default = std::env::temp_dir();
    if std::env::var_os("GUNNAR_CREATE_ALLOW_TMPFS").is_some() || !on_tmpfs(&default) {
        return default;
    }
    let workspace = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .ancestors()
        .nth(2)
        .expect("crates/<pkg> has a workspace root above it")
        .to_path_buf();
    let candidates = [
        std::env::var_os("CARGO_TARGET_DIR").map(std::path::PathBuf::from),
        Some(workspace.join("target")),
        Some(std::path::PathBuf::from("/var/tmp")),
    ];
    for candidate in candidates.into_iter().flatten() {
        let dir = candidate.join("gunnar-create-throughput");
        if std::fs::create_dir_all(&dir).is_err() || on_tmpfs(&dir) {
            continue;
        }
        eprintln!(
            "TMPDIR {} is a ram disk, where fsync is free; measuring in {} instead",
            default.display(),
            dir.display()
        );
        std::env::set_var("TMPDIR", &dir);
        return dir;
    }
    panic!(
        "TMPDIR {} is on tmpfs, where fsync is free, and none of CARGO_TARGET_DIR, \
         {}/target or /var/tmp is on a real disk either: point TMPDIR at one (or set \
         GUNNAR_CREATE_ALLOW_TMPFS=1 to measure the cpu path only)",
        default.display(),
        workspace.display()
    );
}

/// The relocation actually took: the harness's OWN directory is on a real disk.
///
/// Kept as a separate assertion against the path the harness really used, not
/// against the one [`scratch_on_a_real_disk`] returned — a `Harness` that stops
/// honouring `TMPDIR` would otherwise turn this whole file's premise off in
/// silence, and every number it prints would still look like a result.
fn refuse_tmpfs(dir: &std::path::Path) {
    if std::env::var_os("GUNNAR_CREATE_ALLOW_TMPFS").is_some() {
        return;
    }
    assert!(
        !on_tmpfs(dir),
        "the harness landed in {}, which is on tmpfs, where fsync is free — the \
         relocation onto a real disk did not take (or GUNNAR_CREATE_ALLOW_TMPFS=1 to \
         measure the cpu path only)",
        dir.display()
    );
}

/// N working trees, each one commit, all sha1 — the format the first-push door
/// advertises as its prospect (`push_create_spec`, 2026-09-20).
fn working_trees(harness: &Harness, tag: &str, n: usize) -> Vec<PathBuf> {
    (0..n)
        .map(|i| {
            let work = harness.dir.path().join(format!("work-{tag}-{i}"));
            // sha1: the first-push prospect's format since 2026-09-20
            // (`push_create_spec`). A sha256 tree is refused at the
            // advertisement and never reaches the door this bench measures.
            local_repo(harness, &work, HashKind::Sha1, &format!("{tag} {i}\n"));
            work
        })
        .collect()
}

fn median(ms: &mut [f64]) -> f64 {
    ms.sort_by(|a, b| a.partial_cmp(b).expect("no NaN"));
    ms[ms.len() / 2]
}

#[test]
fn push_create_sequential_and_parallel_none_refused() {
    assert!(
        tools_present(),
        "this test requires `git` and `ssh` on PATH"
    );
    // The build machine's own /tmp is tmpfs, so this test read red on the machine
    // the suite runs on and three reports carried TMPDIR= around instead.
    scratch_on_a_real_disk();
    // **The floor is named, because the relocation above chose the disk.**
    // `scratch_on_a_real_disk` moves the data root off tmpfs onto whatever real
    // disk holds the target dir — on the shared build box that is a 3.7 TB
    // array, and the shipped `5%,2GiB` floor there is 186 GiB. MEASURED on dwarf
    // 2026-09-29 with the array at 97 % (139 GiB free): the very first
    // sequential push-create was refused, correctly, `has 139.3 GiB free, under
    // the 186.2 GiB floor` — the gate working, on a verdict about the build
    // box's fill and not about create throughput. This test measures the create
    // path, so it keeps the floor's ABSOLUTE term (2 GiB: still a live gate,
    // `0%,0` is refused by name) and drops the percentage of a disk it did not
    // choose. The percentage rule itself is proven where it is the subject
    // (`gunnar-ssh` headroom, `Admin.Capacity`).
    // `0%,2GiB`: a term of one kind replaces only that kind's default, so a
    // bare `2GiB` would keep the 5 % (MEASURED: it did, 186.2 GiB again).
    let floor = gunnar_ssh::DiskFloor::parse("0%,2GiB").expect("a byte-only floor parses");
    let harness = Arc::new(Harness::start_with_options(
        "unused",
        "unused",
        HashKind::Sha256,
        HarnessOptions {
            seed: Seed::BootstrapAdminOnly,
            disk_floor: Some(floor),
            ..HarnessOptions::default()
        },
    ));
    refuse_tmpfs(harness.dir.path());
    let n = env_usize("GUNNAR_CREATE_N", 24);
    let par = env_usize("GUNNAR_CREATE_PAR", 8).max(1);

    // ── sequential ──
    let trees = working_trees(&harness, "seq", n);
    let mut ms = Vec::with_capacity(n);
    let seq_started = Instant::now();
    for (i, work) in trees.iter().enumerate() {
        let url = harness.url("nordisk", &format!("seq-{i}"));
        let t = Instant::now();
        harness
            .git(work, &["push", "--quiet", &url, "HEAD:refs/heads/main"])
            .expect_ok("a sequential push-create");
        ms.push(t.elapsed().as_secs_f64() * 1000.0);
    }
    let seq_wall = seq_started.elapsed().as_secs_f64();
    let seq_median = median(&mut ms);
    eprintln!(
        "push-create ×{n} sequential: median {seq_median:.1} ms/op, {:.1} ops/s",
        n as f64 / seq_wall
    );

    // ── parallel ──
    let trees = working_trees(&harness, "par", n);
    let par_started = Instant::now();
    let workers: Vec<_> = (0..par)
        .map(|w| {
            let harness = Arc::clone(&harness);
            let mine: Vec<(usize, PathBuf)> = trees
                .iter()
                .enumerate()
                .filter(|(i, _)| i % par == w)
                .map(|(i, p)| (i, p.clone()))
                .collect();
            std::thread::spawn(move || {
                let mut ms = Vec::new();
                let mut refused = Vec::new();
                for (i, work) in mine {
                    let url = harness.url("nordisk", &format!("par-{i}"));
                    let t = Instant::now();
                    let out =
                        harness.git(&work, &["push", "--quiet", &url, "HEAD:refs/heads/main"]);
                    ms.push(t.elapsed().as_secs_f64() * 1000.0);
                    if !out.ok {
                        refused.push(format!("par-{i}: {}", out.stderr.trim()));
                    }
                }
                (ms, refused)
            })
        })
        .collect();
    let mut par_ms = Vec::new();
    let mut refused = Vec::new();
    for w in workers {
        let (ms, bad) = w.join().expect("a client thread");
        par_ms.extend(ms);
        refused.extend(bad);
    }
    let par_wall = par_started.elapsed().as_secs_f64();
    let par_median = median(&mut par_ms);
    eprintln!(
        "push-create ×{n} {par} parallel: median {par_median:.1} ms/op, {:.1} ops/s (sequential {:.1} ops/s)",
        n as f64 / par_wall,
        n as f64 / seq_wall
    );
    assert!(
        refused.is_empty(),
        "{} of {n} parallel push-creates refused:\n{}",
        refused.len(),
        refused.join("\n")
    );

    // Every one of them is real: the catalog has it and it clones back.
    let repos = harness
        .stores
        .catalog()
        .list(Some("nordisk"))
        .expect("list");
    assert_eq!(repos.len(), 2 * n, "every push-create must be catalogued");
    let out = harness.dir.path().join("clone-back");
    harness
        .git(
            harness.dir.path(),
            &[
                "clone",
                "--quiet",
                &harness.url("nordisk", &format!("par-{}", n - 1)),
                "clone-back",
            ],
        )
        .expect_ok("the last parallel create clones back");
    assert_eq!(
        std::fs::read_to_string(out.join("hello.txt")).expect("the cloned file"),
        format!("par {}\n", n - 1)
    );

    // The parallel storm must not be slower per repository than one client
    // alone — that is what "serialised" looks like from the outside.
    assert!(
        par_wall <= seq_wall * 1.1,
        "{par} parallel clients took {par_wall:.2} s for {n} creates; one client alone took {seq_wall:.2} s"
    );
}