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"
);
}