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,
})
}