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

nordisk/holger

bench-scenarios/src/lib.rs

//! Shared bench scenarios for the holger constellation.
//!
//! Two consumers call into this lib:
//!   1. `repository-znippy-rust/tests/...` — correctness-flavoured integration
//!      tests (`#[test]`), exercise the same code paths with assertions.
//!   2. `holger/xtask/examples/nornir-bench.rs` — `Bencher` impls that
//!      `nornir bench run holger` drives, results land in the iceberg
//!      `bench_runs` table at `<workspace>/.nornir/warehouse`.
//!
//! Keep this crate **nornir-free**. Persistence is nornir's job; the
//! contract here is "build a workload, return numbers". See
//! `workspace_holger/WORKSPACE.md` (Bench pattern section).
//!
//! ## Fixtures: where, how big, real vs synthetic
//!
//! Three artifact types: **Rust** crates, **Java** jars, **Python** wheels.
//!
//! * **Location** — under the **home directory**, never `/tmp` (it is tmpfs/RAM
//!   here, ~31 GiB) and never the T9 external drive. Override with
//!   `HOLGER_BENCH_DIR`; default `$HOME/holger-bench-data`.
//! * **Size** — driven by a target byte budget (`Scale::target_bytes`), so a
//!   `huge` run builds ~**100 GiB of served data per type** for real throughput
//!   stress. `quick`/`full` keep the old entry-count workloads.
//! * **Freshness** — fixtures are keyed by `(FIXTURE_VERSION, real?, size)` and a
//!   sidecar `.count`; a param/version change regenerates, otherwise an existing
//!   archive is reused (you do NOT want to rewrite 100 GiB every run).
//! * **Real vs synthetic** — **heavy implies real.** On the heavy tiers
//!   (`full`/`huge`) the fixtures default to **real packages** downloaded once
//!   into the cache (crates.io / Maven Central / PyPI), then replicated (re-id'd)
//!   to reach the byte budget — real bytes/compression at scale without pulling
//!   100 GiB of distinct artifacts off public registries. The light tiers
//!   (`quick`/`sane`) stay fast synthetic (`make_fake_*`). `HOLGER_BENCH_REAL=0`
//!   forces synthetic even when heavy; `=1` forces real even on the light tiers.

use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};

use anyhow::{Context, Result};

/// Heavy, fixture-gated Artifactory-vs-Holger comparison scenario — the sibling
/// of [`nexus`] for JFrog Artifactory OSS. Compiled only under the `artifactory`
/// feature so the default build stays light.
#[cfg(feature = "artifactory")]
pub mod artifactory;
/// Audit-log append throughput scenario: drives holger's real
/// `server_lib::audit::FileAuditLog::record` (the durable audit write on the
/// hot path of every fetch/put/delete) in a serial burst, measuring records/sec +
/// on-disk MB/s of the Arrow-IPC encode + write + flush path. Compiled only under
/// the `audit-append` feature.
#[cfg(feature = "audit-append")]
pub mod audit;
/// Heavy, fixture-gated Nexus-vs-Holger comparison scenarios. Compiled only
/// under the `nexus` feature (pulls testcontainers/tokio/reqwest/agent-lib),
/// so the default build of this crate stays light.
#[cfg(feature = "nexus")]
pub mod nexus;
/// Promotion-through-jera throughput scenario: seals a burst of distinct
/// artifacts through the REAL `server_lib::promote::PromotionEngine` with
/// a durable `jera::jobs::JobStore` ledger attached, measuring the seals/sec and
/// MB/s of the content-address + blob-write + exclusive-ref + ledger-write path.
/// Compiled only under the `promote-ledger` feature.
#[cfg(feature = "promote-ledger")]
pub mod promote;
/// Retention/GC planner throughput scenario: runs holger's grouped
/// `server_lib::retention::plan_retention` head-to-head against a naive O(n²)
/// full-scan baseline over a synthetic record corpus (both agree on the reclaim
/// set), measuring records/sec + latency percentiles. Compiled only under the
/// `retention-planner` feature.
#[cfg(feature = "retention-planner")]
pub mod retention;
/// Secure Update Gateway bundle-path scenario: drives the REAL
/// `holger_update_bundle` prepare → approve×2 → finalize → offline verify →
/// atomic import lifecycle and reports the throughput of each stage
/// (`bundle_prepare` MB/s, `bundle_sign_finalize` bundles/sec, `bundle_verify`
/// MB/s, `bundle_import` MB/s). Holger's UNIQUE capability — Artifactory and Nexus
/// have no equivalent, so this benches holger against itself, not a rival. Each
/// arm carries a red-when-broken correctness floor (the bundle verifies, digests
/// match, anti-rollback rejects a replay). Compiled only under the `secure-update`
/// feature (network-free deps: update-bundle + rcgen + chrono + tempfile).
#[cfg(feature = "secure-update")]
pub mod secure_update;
/// Reusable served-over-HTTP driver: stands up a real Holger native-HTTP server
/// ([`served::start_holger_gateway`]) and drives a pure-Rust parallel GET pool
/// ([`served::bombard_http_get`]) against it — the honest, like-for-like
/// transport the competitor comparison (and the future `holger-ds`) use. Shared
/// by both comparison features.
#[cfg(any(feature = "nexus", feature = "artifactory", feature = "served"))]
pub mod served;
/// Write-side stress scenarios — concurrent uploads, sustained mixed
/// read/write over a repository holding thousands of artifacts, and the edge
/// behaviours (a slow client, a client that vanishes mid-upload, a store that
/// cannot be written). Every other arm in this crate is read-heavy; the
/// measured weakness is on the write side, and until this module there was
/// nothing that exercised it under concurrency. Compiled only under the
/// `stress` feature (server-lib + the served HTTP driver + tempfile).
#[cfg(feature = "stress")]
pub mod stress;

/// The HOPS — the artifact crossing from one store to another: the proxy's
/// cache-and-forward and replication. Stinky-11 reported the `Vec<u8>` ->
/// `Bytes` migration FLAT on every existing arm and named the reason: those arms
/// drive `get_file` / `handle_http2_request` in process, where the buffer was
/// already moved rather than cloned, and **the hops the seam pays off on had no
/// bench arm in holger at all**. These are those arms. Compiled only under the
/// `hops` feature (server-lib, for the real proxy and replication engines).
#[cfg(feature = "hops")]
pub mod hops;

/// The gRPC DOOR — the transport every native client, `holger-ui`, the mirror
/// and `MigrationService` actually speak, and the one surface the bench suite
/// had no arm for at all. Four arms over a REAL holger booted from a real RON
/// config: unary `FetchArtifact`, chunked `StreamArtifact` (with the process's
/// resident-set growth beside the throughput), `PutArtifact`, and the same
/// fetch over gRPC-web, which is what the browser console pays. Compiled only
/// under the `grpc-door` feature.
#[cfg(feature = "grpc-door")]
pub mod grpc_door;

/// The REQUEST PATH — W12's yardstick: HTTP PUT+GET of a 1 GiB and a 4 KiB
/// blob, the gRPC stream, and an OCI chunked push, each over the wire against a
/// real gateway, with the server's resident-set growth beside the throughput.
/// Restores the serving-path measurement `cd7636d` left behind. Compiled only
/// under the `dataplane` feature.
#[cfg(feature = "dataplane")]
pub mod dataplane;

/// The intake-chain arms: every step a real package takes from the wire to a
/// download, per package type (owner order 2026-10-23). See `intake.rs`.
#[cfg(feature = "intake")]
pub mod intake;

/// The scan sandbox, measured: the per-package overhead of the container, the
/// image CVE scan and the EICAR refusal through clamd's sandbox. See `sandbox.rs`.
#[cfg(feature = "sandbox")]
pub mod sandbox;
use repository_znippy_maven::MavenRepoZnippy;
use repository_znippy_python::PipRepoZnippy;
use repository_znippy_rust::RustRepoZnippy;
use znippy_compress::{compress_stream, ArchiveEntry};

/// Bump to invalidate every cached fixture (e.g. when a generator changes).
const FIXTURE_VERSION: u32 = 2;

const GIB: u64 = 1024 * 1024 * 1024;

/// Container boot/readiness timeout (seconds) for the JVM competitor servers
/// (Nexus, Artifactory). JVMs are slow to start on a loaded bench host, so the
/// per-server default is overridable with `HOLGER_BENCH_BOOT_SECS`.
#[cfg(any(feature = "nexus", feature = "artifactory"))]
pub fn boot_timeout_secs(default: u64) -> u64 {
    std::env::var("HOLGER_BENCH_BOOT_SECS")
        .ok()
        .and_then(|s| s.trim().parse().ok())
        .unwrap_or(default)
}

/// Publish `ports` on `0.0.0.0` with a host port the runtime picks.
///
/// testcontainers asks for `PublishAllPorts`, and rootless Podman's Docker API
/// then reports each binding with an EMPTY `HostIp` (`{"HostIp": "", "HostPort":
/// "33875"}`, measured on builder-64 2026-10-23). testcontainers 0.27 keeps a
/// binding only when `HostIp` parses as an address (`core/ports.rs`), so
/// `get_host_port_ipv4` answered "does not expose port 8081/tcp" and every
/// Nexus and Artifactory arm skipped on a box that had started the container.
/// An explicit `0.0.0.0` binding is reported back as `0.0.0.0` by Podman and by
/// Docker alike.
#[cfg(any(feature = "nexus", feature = "artifactory"))]
pub fn publish_on_ipv4(hc: &mut testcontainers::bollard::models::HostConfig, ports: &[u16]) {
    use testcontainers::bollard::models::PortBinding;
    let map = hc.port_bindings.get_or_insert_with(Default::default);
    for p in ports {
        map.insert(
            format!("{p}/tcp"),
            Some(vec![PortBinding {
                host_ip: Some("0.0.0.0".into()),
                host_port: Some(String::new()),
            }]),
        );
    }
}

#[cfg(all(test, any(feature = "nexus", feature = "artifactory")))]
mod publish_tests {
    #[test]
    fn every_port_is_bound_on_ipv4_with_a_runtime_chosen_host_port() {
        let mut hc = testcontainers::bollard::models::HostConfig::default();
        super::publish_on_ipv4(&mut hc, &[8081, 8082]);
        let map = hc.port_bindings.unwrap();
        for p in ["8081/tcp", "8082/tcp"] {
            let b = &map[p].as_ref().unwrap()[0];
            assert_eq!(b.host_ip.as_deref(), Some("0.0.0.0"), "{p}");
            assert_eq!(b.host_port.as_deref(), Some(""), "{p}");
        }
    }
}

/// Make a container runtime discoverable to `testcontainers` before the first
/// `image.start()`.
///
/// `testcontainers` talks to a Docker-compatible socket. When `DOCKER_HOST` is
/// unset it defaults to `unix:///var/run/docker.sock`, which does **not** exist on
/// a rootless-Podman box (the socket lives at
/// `$XDG_RUNTIME_DIR/podman/podman.sock`). That mismatch is the first reason the
/// Nexus/Artifactory containers "couldn't be brought up": the bench skipped with
/// an opaque *container start failed* before any image was even pulled.
///
/// This probes — only when `DOCKER_HOST` is unset and no docker socket is present
/// — for the rootless Podman API socket and exports `DOCKER_HOST` so the existing
/// Podman service answers testcontainers. A pre-set `DOCKER_HOST` (or a real
/// docker socket) is always respected. No-op on success of either; safe to call
/// repeatedly. Pure Rust, no shelling.
#[cfg(any(feature = "nexus", feature = "artifactory"))]
pub fn ensure_container_host() {
    use std::path::Path;
    if std::env::var_os("DOCKER_HOST").is_some() {
        return;
    }
    if Path::new("/var/run/docker.sock").exists() || Path::new("/run/docker.sock").exists() {
        return;
    }
    let mut candidates: Vec<String> = Vec::new();
    if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") {
        if !xdg.is_empty() {
            candidates.push(format!("{xdg}/podman/podman.sock"));
        }
    }
    // Fall back to scanning the per-uid runtime dirs (rootless Podman default
    // location) for a live socket.
    if let Ok(entries) = std::fs::read_dir("/run/user") {
        for e in entries.flatten() {
            candidates.push(format!("{}/podman/podman.sock", e.path().display()));
        }
    }
    for c in candidates {
        if Path::new(&c).exists() {
            std::env::set_var("DOCKER_HOST", format!("unix://{c}"));
            return;
        }
    }
}

/// Byte ceiling for **real**-fixture archives on entry-budgeted scales. Real jars/
/// crates/wheels are MB-scale, so `full`'s 500k entries × real size would reach ~TB;
/// real archives stop at this many bytes instead (synthetic KB-scale entries are
/// unaffected). Byte-budgeted scales (`huge`/`HOLGER_BENCH_BYTES`) use their own target.
const REAL_BYTES_CAP: u64 = 8 * GIB;

/// Root dir for all bench fixtures — **home directory**, never `/tmp` (tmpfs) and
/// never the T9 external drive. `HOLGER_BENCH_DIR` overrides; default
/// `$HOME/holger-bench-data`.
pub fn bench_root() -> PathBuf {
    if let Ok(dir) = std::env::var("HOLGER_BENCH_DIR") {
        if !dir.is_empty() {
            return PathBuf::from(dir);
        }
    }
    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
    PathBuf::from(home).join("holger-bench-data")
}

/// Whether to seed from real downloaded packages (crates.io / Maven Central /
/// PyPI), fetched once into the cache then replicated to size.
///
/// **Heavy implies real.** On the heavy tiers (`full`/`huge`) real artifacts are
/// the default — "heavy" means real jars/crates/wheels without having to say so.
/// `HOLGER_BENCH_REAL=0` is the explicit opt-out (force synthetic even when
/// heavy); `HOLGER_BENCH_REAL=1` forces real even on the light tiers.
pub fn real_enabled(scale: &Scale) -> bool {
    match std::env::var("HOLGER_BENCH_REAL") {
        Ok(v) => v != "0" && !v.is_empty(),
        Err(_) => scale.is_heavy(),
    }
}

/// Tunable workload knobs. `entries`-driven (`quick`/`full`) or `target_bytes`-
/// driven (`huge`). Both produce metrics under the **same names**, so regressions
/// are comparable as long as the scale stays constant within a series.
#[derive(Debug, Clone)]
pub struct Scale {
    pub entries: usize,
    pub bench_secs: u64,
    pub threads_mt: usize,
    /// When set, build until this many bytes of artifact data are archived
    /// (overrides `entries`). This is the "served bytes" budget per type.
    pub target_bytes: Option<u64>,
}

/// Available hardware parallelism — the thread count every `*_mt` scenario and
/// `Scale::{sane,quick,full,huge}` scales to. Public so consumers (e.g. the
/// `holger_multicore_sanity` matrix cell) report the same number the workload ran on.
pub fn cores() -> usize {
    std::thread::available_parallelism()
        .map(|n| n.get())
        .unwrap_or(4)
}

/// Total system RAM in bytes, read **purely in Rust** from `/proc/meminfo`
/// (`MemTotal:`) — never by shelling `free`. Returns `None` off Linux or if the
/// file is missing/unparseable, so callers can fall back conservatively.
#[cfg(any(feature = "nexus", feature = "artifactory"))]
pub fn total_ram_bytes() -> Option<u64> {
    let text = std::fs::read_to_string("/proc/meminfo").ok()?;
    for line in text.lines() {
        // Line form: "MemTotal:       48954104 kB"
        if let Some(rest) = line.strip_prefix("MemTotal:") {
            let kb: u64 = rest.split_whitespace().next()?.parse().ok()?;
            return Some(kb.saturating_mul(1024));
        }
    }
    None
}

/// Fair-resourcing knobs for the JVM competitor containers (Nexus, Artifactory).
///
/// ## Why this exists (årsstädning 2026-06-28 fairness fix)
///
/// The release-gate bench bombards **Holger in-process on the full machine**, so
/// letting the Nexus / Artifactory containers fall back to their *default* JVM
/// heap (~512 MB – 1 GB) — or, worse, pinning Nexus to a deliberate `-Xmx512m`
/// (see [`nexus::latency_tail`]'s old config) — made the competitors look
/// artificially slow. That is the core unfairness the audit flagged.
///
/// [`FairResources::detect`] reads THIS machine at runtime (pure Rust:
/// [`cores`] = `available_parallelism`, [`total_ram_bytes`] = `/proc/meminfo`;
/// never shelling `nproc`/`free`) and hands each container:
///   * `--cpus`   = every logical core (matches Holger's `threads_mt = cores()`);
///   * `--memory` = **75 %** of total RAM (host + Holger keep the rest);
///   * a JVM max heap (`-Xmx`) = **50 %** of the container memory + a direct-
///     memory slice — a genuinely *fair* heap, which is the whole point of the
///     comparison. The remaining ~37 % inside the cap feeds metaspace, code
///     cache, thread stacks and OS page cache so the JVM is well-fed, not
///     OOM-killed.
#[cfg(any(feature = "nexus", feature = "artifactory"))]
#[derive(Debug, Clone, Copy)]
pub struct FairResources {
    /// Logical cores granted to the container (`--cpus`).
    pub cores: usize,
    /// Container memory cap in bytes (`--memory`), ~75 % of total RAM.
    pub container_mem_bytes: u64,
    /// JVM max/min heap in MiB (`-Xms`/`-Xmx`), ~50 % of the container cap.
    pub heap_mb: u64,
    /// JVM max direct memory in MiB (`-XX:MaxDirectMemorySize`), ~1/8 of the cap.
    pub direct_mb: u64,
}

#[cfg(any(feature = "nexus", feature = "artifactory"))]
impl FairResources {
    /// Detect from the live machine. Falls back to a modest 8 GiB RAM assumption
    /// if `/proc/meminfo` is unreadable, so we never *over*-promise memory.
    pub fn detect() -> Self {
        let cores = cores();
        let total = total_ram_bytes().unwrap_or(8 * GIB);
        let container_mem_bytes = total / 4 * 3; // 75 %, leave host/Holger headroom
        let heap_mb = (container_mem_bytes / 2 / (1024 * 1024)).max(512);
        let direct_mb = (container_mem_bytes / 8 / (1024 * 1024)).max(256);
        Self {
            cores,
            container_mem_bytes,
            heap_mb,
            direct_mb,
        }
    }

    /// JVM parameters for a fair heap, shared by Nexus (`INSTALL4J_ADD_VM_PARAMS`)
    /// and Artifactory (`EXTRA_JAVA_OPTIONS`). `extra` appends scenario-specific
    /// flags (e.g. GC-pause targets / logging); later flags win in the JVM.
    pub fn jvm_args(&self, extra: &str) -> String {
        let base = format!(
            "-Xms{h}m -Xmx{h}m -XX:MaxDirectMemorySize={d}m -XX:+UseG1GC \
             -XX:MaxGCPauseMillis=200 \
             -Djava.util.concurrent.ForkJoinPool.common.parallelism={c}",
            h = self.heap_mb,
            d = self.direct_mb,
            c = self.cores,
        );
        if extra.is_empty() {
            base
        } else {
            format!("{base} {extra}")
        }
    }
}

impl Scale {
    /// **Sane-speed** workload for the test matrix — a sub-2s multicore sanity
    /// probe, not a measurement. Small entry count + a 1s loop per phase so the
    /// `holger_multicore_sanity` matrix cell runs inside a normal `cargo test`
    /// while still exercising the lock-free read path on every core
    /// (`threads_mt = available_parallelism()`).
    pub fn sane() -> Self {
        Self {
            entries: 3_000,
            bench_secs: 1,
            threads_mt: cores(),
            target_bytes: None,
        }
    }

    /// Fast workload for CI / smoke runs (~seconds total).
    pub fn quick() -> Self {
        Self {
            entries: 5_000,
            bench_secs: 2,
            threads_mt: cores(),
            target_bytes: None,
        }
    }

    /// Production-grade workload (~minutes per scenario).
    pub fn full() -> Self {
        Self {
            entries: 500_000,
            bench_secs: 5,
            threads_mt: cores(),
            target_bytes: None,
        }
    }

    /// **~100 GiB of served data per type** — large-scale ("drift") throughput
    /// stress. Needs a disk-backed `bench_root()` with room for 3×100 GiB.
    pub fn huge() -> Self {
        Self {
            entries: 0,
            bench_secs: 5,
            threads_mt: cores(),
            target_bytes: Some(100 * GIB),
        }
    }

    /// Heavy = production-grade or byte-budget scale (`full`/`huge`) — the tier
    /// where [`real_enabled`] defaults to real downloaded artifacts. `quick`/`sane`
    /// stay synthetic. A `HOLGER_BENCH_BYTES` override (which sets `target_bytes`
    /// on any scale) also counts as heavy.
    pub fn is_heavy(&self) -> bool {
        self.target_bytes.is_some() || self.entries >= Self::full().entries
    }

    /// Resolve from env: `HOLGER_BENCH_SCALE` = `quick` | `full` | `huge`
    /// (default `quick`); `HOLGER_BENCH_BYTES` overrides the byte budget (any
    /// scale becomes byte-driven). `NORNIR_BENCH_QUICK=0` is honoured as `full`
    /// for back-compat.
    pub fn from_env() -> Self {
        let mut s = match std::env::var("HOLGER_BENCH_SCALE").as_deref() {
            Ok("huge") => Self::huge(),
            Ok("full") => Self::full(),
            Ok("quick") => Self::quick(),
            _ => {
                // back-compat: NORNIR_BENCH_QUICK unset/!=0 => quick, =0 => full
                if std::env::var("NORNIR_BENCH_QUICK")
                    .map(|v| v == "0")
                    .unwrap_or(false)
                {
                    Self::full()
                } else {
                    Self::quick()
                }
            }
        };
        if let Ok(b) = std::env::var("HOLGER_BENCH_BYTES") {
            if let Ok(n) = b.trim().parse::<u64>() {
                s.target_bytes = Some(n);
            }
        }
        // Explicit corpus-size override (entry count) for the competitor head-to-head,
        // whose synthetic crate/jar/wheel corpus is entry-budgeted. Lets a HEAVY 3-way
        // sweep pick a large-but-uploadable file count (the JVM rivals ingest via
        // per-file HTTP PUT, so an unbounded `full`=500k corpus is impractical to push).
        if let Ok(e) = std::env::var("HOLGER_BENCH_ENTRIES") {
            if let Ok(n) = e.trim().parse::<usize>() {
                if n > 0 {
                    s.entries = n;
                }
            }
        }
        // HEAVY runs want a longer, steadier measurement window than the 5s the
        // fixed tiers carry: `HOLGER_BENCH_SECS` overrides the per-phase bench
        // duration for every scenario (served head-to-head, latency sampling),
        // so a 3-way heavy sweep can measure over 20–30s of steady traffic.
        if let Ok(sec) = std::env::var("HOLGER_BENCH_SECS") {
            if let Ok(n) = sec.trim().parse::<u64>() {
                if n > 0 {
                    s.bench_secs = n;
                }
            }
        }
        s
    }
}

/// Result of one throughput scenario. `ops_sec` is the headline number both
/// `no_regression` and `nexus_floor` gates compare on.
#[derive(Debug, Clone)]
pub struct Throughput {
    pub ops: u64,
    pub elapsed: Duration,
    pub bytes: u64,
    pub ops_sec: f64,
    pub mb_per_sec: f64,
}

// ──────────────────────────── synthetic builders ────────────────────────────

fn make_fake_crate(name: &str, version: &str, extra_size: usize) -> Vec<u8> {
    let cargo_toml = format!(
        "[package]\nname = \"{}\"\nversion = \"{}\"\nedition = \"2021\"\n",
        name, version
    );
    let lib_rs = format!(
        "//! Crate {n} v{v}\npub fn hello() {{ println!(\"Hello from {n} {v}\"); }}\n{pad}",
        n = name,
        v = version,
        pad = "// padding\n".repeat(extra_size / 12 + 1),
    );
    let mut tar_data = Vec::new();
    {
        let enc = flate2::write::GzEncoder::new(&mut tar_data, flate2::Compression::fast());
        let mut tar = tar::Builder::new(enc);
        let prefix = format!("{}-{}", name, version);
        for (path, body) in [
            (format!("{}/Cargo.toml", prefix), cargo_toml.as_bytes()),
            (format!("{}/src/lib.rs", prefix), lib_rs.as_bytes()),
        ] {
            let mut header = tar::Header::new_gnu();
            header.set_path(&path).unwrap();
            header.set_size(body.len() as u64);
            header.set_mode(0o644);
            header.set_cksum();
            tar.append(&header, body).unwrap();
        }
        tar.finish().unwrap();
    }
    tar_data
}

fn make_fake_jar(group: &str, artifact: &str, version: &str, extra_size: usize) -> Vec<u8> {
    use std::io::Write;
    let mut buf = Vec::new();
    {
        let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
        let options = zip::write::SimpleFileOptions::default()
            .compression_method(zip::CompressionMethod::Deflated);
        zip.start_file("META-INF/MANIFEST.MF", options).unwrap();
        write!(
            zip,
            "Manifest-Version: 1.0\nImplementation-Title: {}\nImplementation-Version: {}\n",
            artifact, version
        )
        .unwrap();
        let class_path = format!(
            "{}/{}/Main.class",
            group.replace('.', "/"),
            artifact.replace('-', "/")
        );
        zip.start_file(&class_path, options).unwrap();
        let mut class_data = vec![0xCA, 0xFE, 0xBA, 0xBE, 0x00, 0x00, 0x00, 0x3D];
        class_data.extend(vec![0u8; extra_size]);
        zip.write_all(&class_data).unwrap();
        let pom_path = format!("META-INF/maven/{}/{}/pom.properties", group, artifact);
        zip.start_file(&pom_path, options).unwrap();
        write!(
            zip,
            "groupId={}\nartifactId={}\nversion={}\n",
            group, artifact, version
        )
        .unwrap();
        zip.finish().unwrap();
    }
    buf
}

fn make_fake_wheel(name: &str, version: &str, extra_size: usize) -> Vec<u8> {
    use std::io::Write;
    let mut buf = Vec::new();
    {
        let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
        let options = zip::write::SimpleFileOptions::default()
            .compression_method(zip::CompressionMethod::Deflated);
        let dist_info = format!("{}-{}.dist-info", name.replace('-', "_"), version);
        zip.start_file(format!("{}/METADATA", dist_info), options)
            .unwrap();
        write!(
            zip,
            "Metadata-Version: 2.1\nName: {}\nVersion: {}\nSummary: Benchmark package\n",
            name, version
        )
        .unwrap();
        zip.start_file(format!("{}/WHEEL", dist_info), options)
            .unwrap();
        write!(
            zip,
            "Wheel-Version: 1.0\nGenerator: holger-bench\nRoot-Is-Purelib: true\nTag: py3-none-any\n"
        )
        .unwrap();
        let module_name = name.replace('-', "_");
        zip.start_file(format!("{}/__init__.py", module_name), options)
            .unwrap();
        let mut py_data = format!(
            "\"\"\"{}=={}\"\"\"\n__version__ = \"{}\"\n",
            name, version, version
        )
        .into_bytes();
        py_data.extend(vec![b'#'; extra_size]);
        zip.write_all(&py_data).unwrap();
        zip.finish().unwrap();
    }
    buf
}

// ──────────────────────────── real-package corpus ────────────────────────────
//
// Downloaded ONCE into `bench_root()/real-cache/<kind>/`, then replicated (under
// synthetic index names) to reach the byte budget. Curated lists are small on
// purpose — the goal is "real bytes at scale", not 100 GiB of distinct pulls.

fn http_get_bytes(url: &str) -> Result<Vec<u8>> {
    let resp = ureq::Agent::config_builder()
        .proxy(None)
        .max_redirects(5)
        .build()
        .new_agent()
        .get(url)
        .call()
        .map_err(|e| anyhow::anyhow!("GET {url}: {e}"))?;
    let mut buf = Vec::new();
    resp.into_body()
        .into_reader()
        .read_to_end(&mut buf)
        .with_context(|| format!("read body {url}"))?;
    Ok(buf)
}

/// Download `url` to `cache` (under `bench_root()/real-cache/...`) once; reuse if
/// already present and non-empty.
fn cached_download(cache: &PathBuf, url: &str) -> Result<Vec<u8>> {
    if let Ok(meta) = std::fs::metadata(cache) {
        if meta.len() > 0 {
            return std::fs::read(cache).with_context(|| format!("read cache {}", cache.display()));
        }
    }
    if let Some(parent) = cache.parent() {
        std::fs::create_dir_all(parent).ok();
    }
    eprintln!("[bench] downloading real fixture: {url}");
    let bytes = http_get_bytes(url)?;
    std::fs::write(cache, &bytes).with_context(|| format!("write cache {}", cache.display()))?;
    Ok(bytes)
}

fn real_crates() -> Result<Vec<Vec<u8>>> {
    // (name, version) — a handful of real, chunky crates.
    let pkgs = [
        ("serde", "1.0.210"),
        ("syn", "2.0.79"),
        ("regex", "1.11.0"),
        ("tokio", "1.40.0"),
        ("clap", "4.5.20"),
    ];
    let dir = bench_root().join("real-cache").join("crates");
    let mut out = Vec::new();
    for (n, v) in pkgs {
        let url = format!("https://static.crates.io/crates/{n}/{n}-{v}.crate");
        let cache = dir.join(format!("{n}-{v}.crate"));
        out.push(cached_download(&cache, &url)?);
    }
    Ok(out)
}

fn real_jars() -> Result<Vec<Vec<u8>>> {
    // (group-path, artifact, version)
    let pkgs = [
        ("com/google/guava", "guava", "33.3.1-jre"),
        ("org/apache/commons", "commons-lang3", "3.17.0"),
        ("com/fasterxml/jackson/core", "jackson-databind", "2.18.0"),
        ("org/slf4j", "slf4j-api", "2.0.16"),
    ];
    let dir = bench_root().join("real-cache").join("jars");
    let mut out = Vec::new();
    for (g, a, v) in pkgs {
        let url = format!("https://repo1.maven.org/maven2/{g}/{a}/{v}/{a}-{v}.jar");
        let cache = dir.join(format!("{a}-{v}.jar"));
        out.push(cached_download(&cache, &url)?);
    }
    Ok(out)
}

fn real_wheels() -> Result<Vec<Vec<u8>>> {
    // (package, version) — resolve the wheel URL via the PyPI JSON API, then
    // download the first bdist_wheel.
    let pkgs = [
        ("requests", "2.32.3"),
        ("rich", "13.9.2"),
        ("packaging", "24.1"),
    ];
    let dir = bench_root().join("real-cache").join("wheels");
    let mut out = Vec::new();
    for (n, v) in pkgs {
        let meta_url = format!("https://pypi.org/pypi/{n}/{v}/json");
        let json: serde_json::Value = ureq::Agent::config_builder()
            .proxy(None)
            .max_redirects(5)
            .build()
            .new_agent()
            .get(&meta_url)
            .call()
            .map_err(|e| anyhow::anyhow!("GET {meta_url}: {e}"))?
            .into_body()
            .read_json()
            .with_context(|| format!("parse pypi json for {n} {v}"))?;
        let urls = json.get("urls").and_then(|u| u.as_array());
        let wheel = urls.and_then(|arr| {
            arr.iter()
                .find(|e| e.get("packagetype").and_then(|p| p.as_str()) == Some("bdist_wheel"))
        });
        let Some(url) = wheel.and_then(|w| w.get("url")).and_then(|u| u.as_str()) else {
            anyhow::bail!("no bdist_wheel for {n} {v}");
        };
        let fname = url.rsplit('/').next().unwrap_or("pkg.whl");
        let cache = dir.join(fname);
        out.push(cached_download(&cache, url)?);
    }
    Ok(out)
}

// ──────────────────────────── archive build ────────────────────────────

/// Build (or reuse) a znippy archive of `label`'s artifacts under `bench_root()`,
/// returning `(archive_path, entry_count)`. Entries are emitted under synthetic
/// **index** names so the read loops can reconstruct paths by index; in `real`
/// mode the *bytes* come from the downloaded corpus (cycled), the names stay
/// synthetic. Generation stops at `scale.target_bytes` (byte budget) or
/// `scale.entries`. Reuses an existing archive keyed by `(version, real, size)`.
fn build_archive<F>(label: &str, scale: &Scale, real: bool, entry: F) -> Result<(PathBuf, usize)>
where
    F: FnMut(usize) -> (String, Vec<u8>),
{
    build_archive_in(&bench_root(), label, scale, real, entry)
}

/// Like [`build_archive`] but into a caller-chosen `root` (the `holger-ds` CLI's
/// `--data` dir) instead of [`bench_root()`]. The whole reuse-keyed build loop is
/// shared; only the root differs.
fn build_archive_in<F>(
    root: &Path,
    label: &str,
    scale: &Scale,
    real: bool,
    mut entry: F,
) -> Result<(PathBuf, usize)>
where
    F: FnMut(usize) -> (String, Vec<u8>),
{
    let dir = root.to_path_buf();
    std::fs::create_dir_all(&dir).with_context(|| format!("create bench dir {}", dir.display()))?;

    let size_tag = match scale.target_bytes {
        Some(b) => format!("{}b", b),
        None => format!("{}e", scale.entries),
    };
    let real_tag = if real { "real" } else { "syn" };
    let base = dir.join(format!(
        "bench_{label}_v{FIXTURE_VERSION}_{real_tag}_{size_tag}"
    ));
    let index_path = base.with_extension("znippy");
    let count_path = base.with_extension("count");

    if index_path.exists() {
        if let Ok(s) = std::fs::read_to_string(&count_path) {
            if let Ok(n) = s.trim().parse::<usize>() {
                return Ok((index_path, n));
            }
        }
    }

    let compressor =
        compress_stream(&base, true).map_err(|e| anyhow::anyhow!("compress_stream: {e:?}"))?;
    let sender = compressor.sender().clone();

    let mut i: usize = 0;
    let mut bytes: u64 = 0;
    let mut next_log = 5 * GIB;
    loop {
        let (relative_path, data) = entry(i);
        bytes += data.len() as u64;
        sender
            .send(ArchiveEntry {
                relative_path,
                data,
                pkg_type: None,
                repo: None,
            })
            .map_err(|e| anyhow::anyhow!("send entry: {e}"))?;
        i += 1;

        match scale.target_bytes {
            Some(target) => {
                if bytes >= next_log {
                    eprintln!(
                        "[bench] {label}: {:.1} / {:.0} GiB ({i} entries)",
                        bytes as f64 / GIB as f64,
                        target as f64 / GIB as f64
                    );
                    next_log += 5 * GIB;
                }
                if bytes >= target {
                    break;
                }
            }
            None => {
                // Real fixtures are MB-scale, so an entry-budgeted heavy scale
                // (`full` = 500k entries) would balloon to ~TB. Cap real archives
                // by bytes so "heavy ⇒ real" (see `real_enabled`) stays bounded;
                // synthetic entries (KB-scale) are unaffected.
                if real && bytes >= REAL_BYTES_CAP {
                    break;
                }
                if i >= scale.entries {
                    break;
                }
            }
        }
    }
    drop(sender);
    compressor
        .finish()
        .map_err(|e| anyhow::anyhow!("compress finish: {e:?}"))?;
    std::fs::write(&count_path, i.to_string()).ok();
    Ok((index_path, i))
}

fn crate_archive(scale: &Scale, real: bool) -> Result<(PathBuf, usize)> {
    let sizes = [1_000usize, 5_000, 10_000, 50_000, 100_000];
    let corpus = if real { Some(real_crates()?) } else { None };
    build_archive("crates", scale, real, |i| {
        let name = format!("bench-crate-{:06}", i);
        let data = match &corpus {
            Some(c) => c[i % c.len()].clone(),
            None => make_fake_crate(&name, "1.0.0", sizes[i % sizes.len()]),
        };
        (format!("{}-1.0.0.crate", name), data)
    })
}

fn jar_archive(scale: &Scale, real: bool) -> Result<(PathBuf, usize)> {
    let sizes = [5_000usize, 50_000, 200_000];
    let corpus = if real { Some(real_jars()?) } else { None };
    build_archive("jars", scale, real, |i| {
        let group = format!("com.bench.group{}", i % 50);
        let artifact = format!("artifact-{:06}", i);
        let path = format!(
            "{}/{}/2.0.0/{}-2.0.0.jar",
            group.replace('.', "/"),
            artifact,
            artifact
        );
        let data = match &corpus {
            Some(c) => c[i % c.len()].clone(),
            None => make_fake_jar(&group, &artifact, "2.0.0", sizes[i % sizes.len()]),
        };
        (path, data)
    })
}

fn pip_archive(scale: &Scale, real: bool) -> Result<(PathBuf, usize)> {
    let sizes = [2_000usize, 20_000, 100_000];
    let corpus = if real { Some(real_wheels()?) } else { None };
    build_archive("pip", scale, real, |i| {
        let name = format!("bench-pkg-{:06}", i);
        let data = match &corpus {
            Some(c) => c[i % c.len()].clone(),
            None => make_fake_wheel(&name, "1.0.0", sizes[i % sizes.len()]),
        };
        let filename = format!(
            "packages/{}/{}-1.0.0-py3-none-any.whl",
            name,
            name.replace('-', "_")
        );
        (filename, data)
    })
}

// Back-compat helpers for the integration tests (entry-count form).
pub fn build_crate_archive(n: usize) -> Result<PathBuf> {
    Ok(crate_archive(
        &Scale {
            entries: n,
            ..Scale::quick()
        },
        false,
    )?
    .0)
}
pub fn build_jar_archive(n: usize) -> Result<PathBuf> {
    Ok(jar_archive(
        &Scale {
            entries: n,
            ..Scale::quick()
        },
        false,
    )?
    .0)
}
pub fn build_pip_archive(n: usize) -> Result<PathBuf> {
    Ok(pip_archive(
        &Scale {
            entries: n,
            ..Scale::quick()
        },
        false,
    )?
    .0)
}

// ──────────────────────────── holger-ds corpus ────────────────────────────
//
// The `holger-ds` artifact-delivery benchmark drives Holger's served protocol
// over a synthetic **crate** corpus built into a caller-chosen `--data` dir and
// sized by a byte budget (vs the comparison benches' `bench_root()`/`Scale`).
// It reuses the exact synthetic crate generator ([`make_fake_crate`]) and naming
// (`bench-crate-NNNNNN-1.0.0.crate`) the served head-to-head already validates,
// so [`served::crate_download_urls`] resolves the same way.

/// Build (or reuse) a synthetic crate corpus of at least `target_bytes` of
/// artifact data as a znippy archive under `dir`, returning
/// `(archive_path, crate_count)`. Reuses an existing archive of the same budget.
pub fn build_crate_corpus(dir: &Path, target_bytes: u64) -> Result<(PathBuf, usize)> {
    let scale = Scale {
        entries: 0,
        bench_secs: 0,
        threads_mt: 1,
        target_bytes: Some(target_bytes),
    };
    let sizes = [1_000usize, 5_000, 10_000, 50_000, 100_000];
    build_archive_in(dir, "crates", &scale, false, |i| {
        let name = format!("bench-crate-{:06}", i);
        let data = make_fake_crate(&name, "1.0.0", sizes[i % sizes.len()]);
        (format!("{}-1.0.0.crate", name), data)
    })
}

/// Build (or reuse) a synthetic **wheel** corpus of at least `target_bytes` under
/// `dir`, returning `(archive_path, package_count)`. The Python sibling of
/// [`build_crate_corpus`]: lays each wheel at `packages/{name}/{file}` exactly as
/// [`crate::served::run_pip_index_fetch`] / [`PipRepoZnippy`] expect, so the served
/// PEP 503 index resolves. Reuses an existing archive of the same budget.
pub fn build_pip_corpus(dir: &Path, target_bytes: u64) -> Result<(PathBuf, usize)> {
    let scale = Scale {
        entries: 0,
        bench_secs: 0,
        threads_mt: 1,
        target_bytes: Some(target_bytes),
    };
    let sizes = [2_000usize, 20_000, 100_000];
    build_archive_in(dir, "pip", &scale, false, |i| {
        let name = format!("bench-pkg-{:06}", i);
        let data = make_fake_wheel(&name, "1.0.0", sizes[i % sizes.len()]);
        let filename = format!(
            "packages/{}/{}-1.0.0-py3-none-any.whl",
            name,
            name.replace('-', "_")
        );
        (filename, data)
    })
}

/// The wheel corpus for a bench `scale` (byte-budgeted on the heavy tiers,
/// entry-budgeted otherwise) — the same fixture the in-process `pip_wheel_*` arms
/// use, so the served `holger.pip_index_serve` arm and the in-proc arms share one
/// archive. `real` mode seeds real PyPI wheels; see [`real_enabled`].
pub fn pip_corpus(scale: &Scale) -> Result<(PathBuf, usize)> {
    pip_archive(scale, real_enabled(scale))
}

/// Free (available-to-unprivileged) disk space in bytes on the filesystem
/// holding `path`, read **purely in Rust** via `statvfs(3)` (libc) — never by
/// shelling `df`. Mirrors [`total_ram_bytes`]'s pure-Rust RAM detection. Returns
/// `None` if the syscall fails, so callers fall back conservatively.
#[cfg(all(unix, feature = "served"))]
pub fn free_disk_bytes(path: &Path) -> Option<u64> {
    use std::os::unix::ffi::OsStrExt;
    let c = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
    // SAFETY: `statvfs` only reads from `c` and writes a plain-old-data struct;
    // a zeroed `statvfs` is a valid initial value we read back only on success.
    let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
    if unsafe { libc::statvfs(c.as_ptr(), &mut stat) } != 0 {
        return None;
    }
    Some((stat.f_bavail as u64).saturating_mul(stat.f_frsize as u64))
}

/// Non-Unix fallback: no pure-Rust free-disk probe available.
#[cfg(all(not(unix), feature = "served"))]
pub fn free_disk_bytes(_path: &Path) -> Option<u64> {
    None
}

/// Propose a crate-corpus byte budget for `scale` given `free_bytes` available on
/// the data filesystem: `scale × 1 GiB` of artifact data, **clamped to ≤ 25 %** of
/// free space (headroom for the OS, the compressed archive and the page cache),
/// and floored at 16 MiB so a tiny smoke run still has data. `None` free space
/// (detection failed) leaves the want unclamped.
pub fn propose_dataset_bytes(scale: u64, free_bytes: Option<u64>) -> u64 {
    let want = scale.max(1).saturating_mul(GIB);
    let cap = free_bytes.map(|f| f / 4).unwrap_or(want);
    want.min(cap).max(16 * 1024 * 1024)
}

// ──────────────────────────── bench loops ────────────────────────────

fn bench_loop<F>(duration: Duration, mut f: F) -> Throughput
where
    F: FnMut(u64) -> usize,
{
    for i in 0..100 {
        f(i);
    } // warmup
    let start = Instant::now();
    let mut ops: u64 = 0;
    let mut total_bytes: u64 = 0;
    while start.elapsed() < duration {
        for _ in 0..1000 {
            total_bytes += f(ops) as u64;
            ops += 1;
        }
    }
    let elapsed = start.elapsed();
    let s = elapsed.as_secs_f64();
    Throughput {
        ops,
        elapsed,
        bytes: total_bytes,
        ops_sec: ops as f64 / s,
        mb_per_sec: total_bytes as f64 / s / 1_048_576.0,
    }
}

fn bench_threaded<F>(duration: Duration, threads: usize, f: F) -> Throughput
where
    F: Fn(u64, usize) -> usize + Send + Sync + 'static,
{
    let f = Arc::new(f);
    let barrier = Arc::new(std::sync::Barrier::new(threads));
    let handles: Vec<_> = (0..threads)
        .map(|tid| {
            let f = Arc::clone(&f);
            let barrier = Arc::clone(&barrier);
            std::thread::spawn(move || {
                for i in 0..50 {
                    f(i, tid);
                }
                barrier.wait();
                let start = Instant::now();
                let mut ops: u64 = 0;
                let mut bytes: u64 = 0;
                while start.elapsed() < duration {
                    for _ in 0..500 {
                        bytes += f(ops, tid) as u64;
                        ops += 1;
                    }
                }
                (ops, start.elapsed(), bytes)
            })
        })
        .collect();
    let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
    let total_ops: u64 = results.iter().map(|(o, _, _)| o).sum();
    let total_bytes: u64 = results.iter().map(|(_, _, b)| b).sum();
    let max_elapsed = results.iter().map(|(_, e, _)| *e).max().unwrap();
    let s = max_elapsed.as_secs_f64();
    Throughput {
        ops: total_ops,
        elapsed: max_elapsed,
        bytes: total_bytes,
        ops_sec: total_ops as f64 / s,
        mb_per_sec: total_bytes as f64 / s / 1_048_576.0,
    }
}

// ──────────────────────────── scenario fns ────────────────────────────

pub fn rust_crate_st(scale: &Scale) -> Result<Throughput> {
    let (path, n) = crate_archive(scale, real_enabled(scale))?;
    let repo = Arc::new(
        RustRepoZnippy::with_archive("bench-rust".into(), path)
            .map_err(|e| anyhow::anyhow!("open rust archive: {e:?}"))?,
    );
    Ok(bench_loop(
        Duration::from_secs(scale.bench_secs),
        move |i| {
            let idx = (i as usize) % n;
            let filename = format!("bench-crate-{:06}-1.0.0.crate", idx);
            repo.get_file(&filename).map(|d| d.len()).unwrap_or(0)
        },
    ))
}

pub fn rust_crate_mt(scale: &Scale) -> Result<Throughput> {
    let (path, n) = crate_archive(scale, real_enabled(scale))?;
    let repo = Arc::new(
        RustRepoZnippy::with_archive("bench-rust".into(), path)
            .map_err(|e| anyhow::anyhow!("open rust archive: {e:?}"))?,
    );
    let r = Arc::clone(&repo);
    Ok(bench_threaded(
        Duration::from_secs(scale.bench_secs),
        scale.threads_mt,
        move |i, tid| {
            let idx = ((i as usize) * 7 + tid * 1337) % n;
            let filename = format!("bench-crate-{:06}-1.0.0.crate", idx);
            r.get_file(&filename).map(|d| d.len()).unwrap_or(0)
        },
    ))
}

pub fn java_jar_st(scale: &Scale) -> Result<Throughput> {
    let (path, n) = jar_archive(scale, real_enabled(scale))?;
    let repo = Arc::new(
        MavenRepoZnippy::with_archive("bench-maven".into(), path)
            .map_err(|e| anyhow::anyhow!("open jar archive: {e:?}"))?,
    );
    Ok(bench_loop(
        Duration::from_secs(scale.bench_secs),
        move |i| {
            let idx = (i as usize) % n;
            let group = format!("com.bench.group{}", idx % 50);
            let artifact = format!("artifact-{:06}", idx);
            let path = format!(
                "{}/{}/2.0.0/{}-2.0.0.jar",
                group.replace('.', "/"),
                artifact,
                artifact
            );
            repo.get_file(&path).map(|d| d.len()).unwrap_or(0)
        },
    ))
}

pub fn java_jar_mt(scale: &Scale) -> Result<Throughput> {
    let (path, n) = jar_archive(scale, real_enabled(scale))?;
    let repo = Arc::new(
        MavenRepoZnippy::with_archive("bench-maven".into(), path)
            .map_err(|e| anyhow::anyhow!("open jar archive: {e:?}"))?,
    );
    let r = Arc::clone(&repo);
    Ok(bench_threaded(
        Duration::from_secs(scale.bench_secs),
        scale.threads_mt,
        move |i, tid| {
            let idx = ((i as usize) * 7 + tid * 1337) % n;
            let group = format!("com.bench.group{}", idx % 50);
            let artifact = format!("artifact-{:06}", idx);
            let path = format!(
                "{}/{}/2.0.0/{}-2.0.0.jar",
                group.replace('.', "/"),
                artifact,
                artifact
            );
            r.get_file(&path).map(|d| d.len()).unwrap_or(0)
        },
    ))
}

pub fn pip_wheel_st(scale: &Scale) -> Result<Throughput> {
    let (path, n) = pip_archive(scale, real_enabled(scale))?;
    let repo = Arc::new(
        PipRepoZnippy::with_archive("bench-pip".into(), path)
            .map_err(|e| anyhow::anyhow!("open pip archive: {e:?}"))?,
    );
    Ok(bench_loop(
        Duration::from_secs(scale.bench_secs),
        move |i| {
            let idx = (i as usize) % n;
            let name = format!("bench-pkg-{:06}", idx);
            let filename = format!(
                "packages/{}/{}-1.0.0-py3-none-any.whl",
                name,
                name.replace('-', "_")
            );
            repo.get_file(&filename).map(|d| d.len()).unwrap_or(0)
        },
    ))
}

pub fn pip_wheel_mt(scale: &Scale) -> Result<Throughput> {
    let (path, n) = pip_archive(scale, real_enabled(scale))?;
    let repo = Arc::new(
        PipRepoZnippy::with_archive("bench-pip".into(), path)
            .map_err(|e| anyhow::anyhow!("open pip archive: {e:?}"))?,
    );
    let r = Arc::clone(&repo);
    Ok(bench_threaded(
        Duration::from_secs(scale.bench_secs),
        scale.threads_mt,
        move |i, tid| {
            let idx = ((i as usize) * 7 + tid * 1337) % n;
            let name = format!("bench-pkg-{:06}", idx);
            let filename = format!(
                "packages/{}/{}-1.0.0-py3-none-any.whl",
                name,
                name.replace('-', "_")
            );
            r.get_file(&filename).map(|d| d.len()).unwrap_or(0)
        },
    ))
}

#[cfg(test)]
mod ds_tests {
    use super::*;

    #[test]
    fn propose_clamps_to_quarter_free() {
        // 8 GiB free -> 2 GiB cap; scale 100 (wants 100 GiB) clamps to the cap.
        assert_eq!(propose_dataset_bytes(100, Some(8 * GIB)), 2 * GIB);
        // scale 1 wants 1 GiB, under the cap -> stays 1 GiB.
        assert_eq!(propose_dataset_bytes(1, Some(8 * GIB)), GIB);
        // Tiny free space -> floored at 16 MiB so a smoke run still has data.
        assert_eq!(propose_dataset_bytes(1, Some(1024)), 16 * 1024 * 1024);
        // Detection failed -> use the want as-is (no clamp).
        assert_eq!(propose_dataset_bytes(2, None), 2 * GIB);
    }
}

// ───────────── restored: the request path, and the write path ─────────────
//
// `8aa6159` (2026-05-22) benched `handle_http2_request` beside `get_file`. The
// move into this crate (`cd7636d`, 2026-05-31) kept only `get_file`, so from
// that day until 2026-09-20 NOTHING measured the serving path — the route that
// carries the URL parse and the whole-`Vec` response body — and nothing
// measured the write path at all. Both are restored here, same corpus and same
// `bench_loop`/`bench_threaded` shape as their read twins, under NEW metric
// names: `bench_runs` keys regression history by metric name, so an existing
// name is never reused for a different workload.

use traits::{ArtifactId, RepositoryBackendTrait};

/// The whole serving route for one Rust crate, single thread: URL parse →
/// archive extract → whole-`Vec` response body. Read twin: [`rust_crate_st`],
/// which stops at `get_file` — the difference between the two arms IS the cost
/// of the door.
pub fn rust_crate_http_st(scale: &Scale) -> Result<Throughput> {
    let (path, n) = crate_archive(scale, real_enabled(scale))?;
    let repo = Arc::new(
        RustRepoZnippy::with_archive("bench-rust".into(), path)
            .map_err(|e| anyhow::anyhow!("open rust archive: {e:?}"))?,
    );
    Ok(bench_loop(
        Duration::from_secs(scale.bench_secs),
        move |i| {
            let idx = (i as usize) % n;
            let url = format!("/bench-rust/crates/bench-crate-{:06}/1.0.0/download", idx);
            let (status, _, body) = repo
                .handle_http2_request("GET", &url, &[])
                .expect("znippy-rust http2 route");
            // Loud, not silent: a route that 404s would otherwise bench an empty
            // body at a spectacular ops/s. The argument order of
            // `handle_http2_request(method, suburl, ..)` is the trap here — both are
            // `&str`, swapping them compiles and every route falls to the catch-all.
            assert_eq!(status, 200, "GET {url} answered {status}");
            body.len()
        },
    ))
}

/// All-core twin of [`rust_crate_http_st`]. Threads = `available_parallelism()`.
pub fn rust_crate_http_mt(scale: &Scale) -> Result<Throughput> {
    let (path, n) = crate_archive(scale, real_enabled(scale))?;
    let repo = Arc::new(
        RustRepoZnippy::with_archive("bench-rust".into(), path)
            .map_err(|e| anyhow::anyhow!("open rust archive: {e:?}"))?,
    );
    let r = Arc::clone(&repo);
    Ok(bench_threaded(
        Duration::from_secs(scale.bench_secs),
        scale.threads_mt,
        move |i, tid| {
            let idx = ((i as usize) * 13 + tid * 997) % n;
            let url = format!("/bench-rust/crates/bench-crate-{:06}/1.0.0/download", idx);
            let (status, _, body) = r
                .handle_http2_request("GET", &url, &[])
                .expect("znippy-rust http2 route");
            assert_eq!(status, 200, "GET {url} answered {status}");
            body.len()
        },
    ))
}

/// How many one-at-a-time `put`s the write-path arm performs. **Fixed N, not a
/// fixed duration**: the defect this arm exists to catch is quadratic, so the
/// number that matters is total wall for a known N, and a time-boxed loop would
/// hide it by simply completing fewer puts. `HOLGER_BENCH_PUTS` overrides.
fn put_count(scale: &Scale) -> usize {
    if let Ok(v) = std::env::var("HOLGER_BENCH_PUTS") {
        if let Ok(n) = v.parse::<usize>() {
            if n > 0 {
                return n;
            }
        }
    }
    scale.entries.clamp(200, 2_000)
}

/// The WRITE path of the writable znippy repo: N `cargo publish`-shaped `put`s,
/// one at a time, into one growing `.znippy` — exactly what `/sparring` and the
/// `/cache` primary do on a cache miss. Every payload is built BEFORE the clock
/// starts, so the number is the repo's write cost and not the fixture's.
///
/// `ops_per_sec` here is puts/second. A quadratic append (re-sealing the whole
/// Arrow index per call) shows up as this number FALLING when
/// `HOLGER_BENCH_PUTS` is raised; an O(1) append holds it flat.
pub fn rust_crate_put(scale: &Scale) -> Result<Throughput> {
    let n = put_count(scale);
    let dir = bench_root().join("put-arena");
    if dir.exists() {
        std::fs::remove_dir_all(&dir).ok();
    }
    std::fs::create_dir_all(&dir).context("create put-arena")?;
    let archive = dir.join("put-bench.znippy");

    // Preallocate the whole corpus up front (house doctrine: no allocation on
    // the measured path that can be paid for before it).
    let payloads: Vec<(ArtifactId, Vec<u8>)> = (0..n)
        .map(|i| {
            let name = format!("put-crate-{:06}", i);
            let data = make_fake_crate(&name, "1.0.0", 4_096);
            (
                ArtifactId {
                    namespace: None,
                    name,
                    version: "1.0.0".into(),
                },
                data,
            )
        })
        .collect();

    let repo = RustRepoZnippy::with_writable_archive("bench-put".into(), archive.clone());
    let start = Instant::now();
    let mut bytes: u64 = 0;
    for (id, data) in &payloads {
        repo.put(id, data)
            .with_context(|| format!("put {}", id.name))?;
        bytes += data.len() as u64;
    }
    let elapsed = start.elapsed();

    // ANSWER floor: a write bench that wrote nothing readable is not a
    // measurement. Read one back through the same repo.
    let (status, _, body) = repo
        .handle_http2_request(
            "GET",
            &format!("/bench-put/crates/put-crate-{:06}/1.0.0/download", n - 1),
            &[],
        )
        .context("read back the last put")?;
    anyhow::ensure!(
        status == 200 && !body.is_empty(),
        "write path produced an archive the read path cannot serve: status {status}, {} bytes",
        body.len()
    );
    drop(repo);
    std::fs::remove_dir_all(&dir).ok();

    let s = elapsed.as_secs_f64();
    Ok(Throughput {
        ops: n as u64,
        elapsed,
        bytes,
        ops_sec: n as f64 / s,
        mb_per_sec: bytes as f64 / s / 1_048_576.0,
    })
}