bench-scenarios/src/artifactory.rs
//! Artifactory-vs-Holger comparison scenario — the heavy, *fixture-gated*
//! sibling of [`crate::nexus`].
//!
//! Mirrors `nexus::nexus_load`: it pushes the **same cached corpus** (crate /
//! jar / wheel znippy archives) through a real JFrog **Artifactory OSS**
//! container, then drives a served-FETCH-over-HTTP head-to-head — the SAME
//! pure-Rust parallel GET pool ([`crate::served::bombard_http_get`]) against both
//! a real Holger HTTP server and Artifactory — producing the paired
//! `holger_served_mbs` + `artifactory_served_mbs` (+ `ops_per_sec`) metrics the
//! README `benches` mashup bolds head-to-head. The competitor upload throughput
//! is kept as a separate `artifactory_upload_mbs` publish-phase number, and the
//! old in-process `get_file()` figure as `holger_inprocess_read_ceiling_mbs` (an
//! internal Holger-vs-Holger regression ceiling, never beside the competitor).
//!
//! ## Fixture gate (NOT `--heavy`)
//!
//! Exactly like the Nexus scenario, the Artifactory testcontainer is a
//! **fixture gate**: any failure to reach a container runtime / boot the server
//! becomes an `is_skip` error which the bench harness records as a SKIP row, so
//! this runs in the normal (non-`--heavy`) pass and no-ops gracefully on a box
//! with no Docker/Podman or where the (heavy) image cannot boot in time.
//!
//! Compiled only under the `artifactory` cargo feature so the heavy deps
//! (`testcontainers`, `tokio`, `reqwest`, `agent-lib`) never reach the light
//! default build of `holger-bench-scenarios`.
use std::time::{Duration, Instant};
use anyhow::Result;
use agent_lib::operations;
use async_trait::async_trait;
use repository_znippy_rust::RustRepoZnippy;
use testcontainers::{
core::{ContainerPort, WaitFor},
runners::AsyncRunner,
ContainerAsync, GenericImage, ImageExt,
};
use traits::{ConnectorTrait, RemoteAsset, RemoteRepository};
use crate::{
build_crate_archive, build_jar_archive, build_pip_archive, served, FairResources, Scale,
};
/// Marker that a scenario was skipped because no container runtime / Artifactory
/// was reachable. Callers stringify this into a `skipped: ...` reason.
const SKIP_PREFIX: &str = "skipped: no Docker/Artifactory";
/// Default admin credentials of the pinned Artifactory OSS image. The same pair
/// gates create/discovery, upload **and** the authenticated head-to-head GETs
/// (OSS rejects anonymous reads with 401, see [`artifactory_load`]).
const ART_USER: &str = "admin";
const ART_PASS: &str = "password";
/// The **historical** Artifactory pin, released 2021-02-21. Kept as its own arm
/// for series continuity only — as a comparison it is not defensible, and that
/// must be said wherever the number is used:
///
/// * 6.x is long end-of-life. The line itself ended at 6.23.42 (2022-03-28), so
/// this tag is **29 patches behind inside its own dead line**, five years old,
/// and one whole major version behind current.
/// * The justification written into this file for not upgrading — *"7.x OSS
/// won't bootstrap in a throwaway container, its Access/router/event
/// microservices crash-loop"* — was **FALSE**. 7.x bundles those services in
/// the single image; JFrog's documented install is one `docker run`, and
/// PlaytikaOSS/testcontainers-spring-boot ships a live `embedded-artifactory`
/// module on 7.161.26. See [`ART_TAG_CURRENT`] for what the real blocker was.
pub const ART_TAG_PINNED: &str = "6.23.13";
/// The **current** Artifactory OSS line (7.161.26). Runs beside
/// [`ART_TAG_PINNED`], never instead of it.
///
/// ## What actually blocked this for years, MEASURED
///
/// The container did exit in under 10 seconds on oden — but not on anything to
/// do with microservices. The log said:
///
/// ```text
/// /opt/jfrog/artifactory/var is running with 2% free storage.
/// Free up space or increase volume size and try again. Exiting
/// ```
///
/// That is a **percentage-based** free-space guard reading podman's graphroot.
/// oden's `/home` was 98 % full — 168 GB free, which is plenty of bytes, but the
/// guard counts percent. It was a housekeeping problem on the bench host, and it
/// had been written down as a property of Artifactory. Moving `/home` to 87 %
/// free (792 GB) is what unblocked this arm.
///
/// ## The format ceiling, which no version fixes
///
/// Artifactory **OSS serves Maven/Gradle/Ivy/SBT and generic repositories, and
/// nothing else** — no Cargo, no PyPI, no npm, in any free JFrog edition, 6.x or
/// 7.x. So this rival can only ever be a maven/generic data point against a
/// Rust-first product. Every use of these numbers must say so.
pub const ART_TAG_CURRENT: &str = "7.161.26";
/// The PostgreSQL image Artifactory 7.x is given as its database.
///
/// ## Why a second container exists at all — MEASURED, and it corrects TWO claims
///
/// The source used to say 7.x "won't bootstrap … Access/router/event
/// microservices crash-loop on missing master.key/join.key". That reason was
/// false. But so was the assumption that removing the free-space blocker would
/// be enough. With `/home` freed to 87 %, the container stopped exiting in ten
/// seconds and instead sat in `Cluster join: Retry 740: Service registry ping
/// failed` for a quarter of an hour. Its own log says exactly why:
///
/// ```text
/// DbTypeNotAllowedException: DB Type derby is not allowed:
/// Cannot start the application with a database other than PostgreSQL.
/// ```
///
/// **Artifactory 7.x refuses to run on the bundled Derby that 6.x used.** So the
/// honest statement is neither "7.x cannot be containerised" nor "7.x is a
/// drop-in tag bump": 7.x is a **two-container arm**. That is a wait strategy
/// and one more image — still not a different major version of the harness — but
/// it is a real, specific requirement, now measured instead of guessed.
const ART_PG_IMAGE: &str = "docker.io/library/postgres";
const ART_PG_TAG: &str = "16-alpine";
const ART_PG_DB: &str = "artifactory";
const ART_PG_USER: &str = "artifactory";
const ART_PG_PASS: &str = "artifactory";
/// True when `tag` is a 7.x (or later) Artifactory that requires PostgreSQL.
fn needs_postgres(tag: &str) -> bool {
!tag.starts_with("6.")
}
/// The HTTP ports to probe for a given Artifactory tag, in preference order.
///
/// 6.x is a single Tomcat on **8081**. 7.x splits the platform: **8082** is the
/// router / JFrog Platform entry point and **8081** still reaches the artifactory
/// service directly. Both accept `admin:password` basic auth against
/// `/artifactory/api/...`, but which one comes up first varies with boot order,
/// so the boot probes them in turn rather than assuming.
fn candidate_ports(tag: &str) -> &'static [u16] {
if tag.starts_with("6.") {
&[8081]
} else {
&[8082, 8081]
}
}
/// Build an `anyhow::Error` whose message starts with `skipped: no
/// Docker/Artifactory` so the bench harness records a SKIP rather than a hard
/// failure.
fn skip(detail: impl std::fmt::Display) -> anyhow::Error {
anyhow::anyhow!("{SKIP_PREFIX} — {detail}")
}
/// True iff `e` is one of our graceful skips (vs a genuine workload failure).
pub fn is_skip(e: &anyhow::Error) -> bool {
e.to_string().starts_with(SKIP_PREFIX)
}
// ── result shape (mirrors `nexus::LoadOutcome`) ─────────────────────────────
/// Outcome of [`artifactory_load`]: the **honest, like-for-like**
/// Holger-vs-Artifactory throughput. Same shape + semantics as
/// [`crate::nexus::LoadOutcome`].
///
/// HEAD-TO-HEAD (both measured the SAME way — concurrent GETs over HTTP):
/// * `holger_served_mbs` — fetched from a real Holger HTTP server.
/// * `artifactory_served_mbs` — fetched from the Artifactory HTTP server.
///
/// `artifactory_upload_mbs` is the publish phase (separate, not the head-to-head);
/// `holger_inprocess_read_ceiling_mbs` is the internal in-process regression
/// ceiling — NEVER placed beside the competitor.
#[derive(Debug, Clone)]
pub struct LoadOutcome {
pub concurrency: usize,
/// Head-to-head: Holger served over HTTP (MB/s).
pub holger_served_mbs: f64,
/// Head-to-head: Artifactory served over HTTP (MB/s).
pub artifactory_served_mbs: f64,
/// Holger served HTTP GET ops/sec (head-to-head transport).
pub ops_per_sec: f64,
/// Head-to-head: Artifactory served HTTP GET ops/sec (same transport).
pub artifactory_ops_per_sec: f64,
/// Head-to-head served-GET latency (ms), measured identically for both sides
/// over the same pure-Rust client pool. Lower is better.
pub holger_p50_ms: f64,
pub holger_p99_ms: f64,
pub holger_p999_ms: f64,
pub holger_mean_ms: f64,
pub artifactory_p50_ms: f64,
pub artifactory_p99_ms: f64,
pub artifactory_p999_ms: f64,
pub artifactory_mean_ms: f64,
/// Number of Artifactory GETs that returned 2xx during the head-to-head. A
/// healthy/serving Artifactory is `> 0`; a `0` here means the rival cell is
/// empty (the bug this scenario guards against) and [`artifactory_load`]
/// turns it into a hard error rather than a misleading ~0 MB/s.
pub artifactory_ok_ops: u64,
/// The pre-seeded Artifactory generic-local repo the corpus was deployed
/// into (OSS cannot create repos over REST — see [`discover_generic_repo`]).
pub artifactory_repo: String,
/// Publish phase only: Artifactory upload throughput (MB/s). Not the head-to-head.
pub artifactory_upload_mbs: f64,
/// Internal Holger-vs-Holger regression ceiling: in-process `get_file()` MB/s.
/// NOT comparable to Artifactory — never place beside the served numbers.
pub holger_inprocess_read_ceiling_mbs: f64,
pub elapsed_ms: f64,
pub holger_files: usize,
/// The Artifactory image tag this row was measured against. Two arms now run
/// (the EOL 6.x pin and the current 7.x line); a served MB/s that does not
/// carry its rival's version is not a comparison.
pub artifactory_tag: String,
/// Seconds from `start()` to an authenticated 200 on `/api/system/ping`.
/// Recorded because the 7.x boot cost is the ONLY real difference from 6.x,
/// and the stale pin was justified by a guess about exactly this number.
pub artifactory_boot_secs: f64,
}
// ── a minimal Artifactory connector ────────────────────────────────────────
//
// Artifactory's generic-repo deploy API is `PUT /artifactory/<repo>/<path>`
// (basic auth). We only need `upload_asset` for the throughput measurement;
// the other `ConnectorTrait` methods are unused by the bench and stubbed.
struct ArtifactoryConnector {
base_url: String,
client: reqwest::Client,
user: String,
pass: String,
}
#[async_trait]
impl ConnectorTrait for ArtifactoryConnector {
fn name(&self) -> &str {
"artifactory"
}
async fn list_repositories(&self) -> Result<Vec<RemoteRepository>> {
Ok(Vec::new())
}
async fn list_assets(&self, _repository: &str) -> Result<Vec<RemoteAsset>> {
Ok(Vec::new())
}
async fn download_asset(&self, _asset: &RemoteAsset) -> Result<Vec<u8>> {
anyhow::bail!("download_asset unused in the Artifactory bench")
}
async fn upload_asset(&self, repository: &str, path: &str, data: &[u8]) -> Result<()> {
let url = format!("{}/artifactory/{}/{}", self.base_url, repository, path);
self.client
.put(&url)
.basic_auth(&self.user, Some(&self.pass))
.body(data.to_vec())
.send()
.await?
.error_for_status()?;
Ok(())
}
}
// ── container helpers (mirror the Nexus boot path) ──────────────────────────
/// Two-phase readiness. Anonymous "HTTP up" is NOT enough: Artifactory 7.x boots
/// the Router first, but the **Access** service (which bootstraps the default
/// `admin` user) warms up after — so an early anonymous 403 on /ping fires while
/// `admin:password` is still rejected. We gate on an **authenticated** 200, which
/// is the only signal that authenticated writes (create-repo, upload) will work.
/// Poll every candidate base URL until one answers an **authenticated** 200 on
/// `/artifactory/api/system/ping`, and return it.
///
/// Anonymous "HTTP up" is NOT enough: Artifactory 7.x boots the Router first and
/// the **Access** service (which bootstraps the default `admin` user) warms up
/// after, so an early anonymous answer fires while `admin:password` is still
/// rejected. The authenticated 200 is the only signal that authenticated writes
/// (repo discovery, upload) will work.
///
/// Several candidates are polled because 7.x exposes the platform on 8082 and
/// the artifactory service on 8081 (see [`candidate_ports`]); whichever is ready
/// first wins, and the winner is what every later request uses.
async fn wait_for_artifactory(
bases: &[String],
user: &str,
pass: &str,
timeout: Duration,
) -> Option<String> {
let client = tls_roots(reqwest::Client::builder())
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let start = Instant::now();
while start.elapsed() < timeout {
for base_url in bases {
if let Ok(resp) = client
.get(format!("{base_url}/artifactory/api/system/ping"))
.basic_auth(user, Some(pass))
.send()
.await
{
if resp.status().is_success() {
return Some(base_url.clone());
}
}
}
tokio::time::sleep(Duration::from_secs(3)).await;
}
None
}
/// Discover a **pre-seeded generic-local** repository to deploy the bench corpus
/// into, and return its key.
///
/// ## Why discover instead of create (THE central Artifactory-OSS fix)
///
/// The original scenario tried `PUT /api/repositories/<key>` to create
/// `crates-raw`/`jars-raw`/`wheels-raw`. Artifactory **OSS** does not expose the
/// repository-management REST API — that PUT returns `400 "This REST API is
/// available only in Artifactory Pro"` (confirmed live + via the `art_probe`
/// instrumentation). The previous code turned that 400 into a **skip**, so the
/// Artifactory leg *always* no-op'd on OSS → the rival cell was forever empty.
///
/// The fix: OSS ships a default **generic LOCAL** repo (`example-repo-local`).
/// We `GET /api/repositories` (which OSS *does* allow), pick a `LOCAL` +
/// `Generic` repo, and deploy the corpus under sub-paths of it
/// (`<repo>/crates-raw/…`). `PUT /artifactory/<repo>/<path>` (the generic deploy
/// API) is fully available in OSS, so uploads + reads work without a Pro license.
async fn discover_generic_repo(
client: &reqwest::Client,
base_url: &str,
user: &str,
pass: &str,
) -> Result<String> {
// Access can flip to ready mid-loop: a 401/403/503 right after the readiness
// gate is a transient warmup race, not a fatal config error. Back off + retry
// within a deadline before giving up.
let deadline = Instant::now() + Duration::from_secs(60);
let repos: Vec<serde_json::Value> = loop {
let resp = client
.get(format!("{base_url}/artifactory/api/repositories"))
.basic_auth(user, Some(pass))
.send()
.await
.map_err(|e| skip(format!("list repositories: {e}")))?;
let s = resp.status().as_u16();
if (s == 401 || s == 403 || s == 503) && Instant::now() < deadline {
tokio::time::sleep(Duration::from_secs(2)).await;
continue;
}
anyhow::ensure!(resp.status().is_success(), "list repositories: status {s}");
break resp
.json()
.await
.map_err(|e| skip(format!("parse repositories: {e}")))?;
};
// Prefer a LOCAL + Generic repo (deployable, layout-free). `type`/`packageType`
// are reported case-insensitively across versions ("LOCAL"/"Generic").
let is_generic_local = |r: &serde_json::Value| {
let t = r.get("type").and_then(|v| v.as_str()).unwrap_or_default();
let p = r
.get("packageType")
.and_then(|v| v.as_str())
.unwrap_or_default();
t.eq_ignore_ascii_case("local") && p.eq_ignore_ascii_case("generic")
};
if let Some(key) = repos
.iter()
.find(|r| is_generic_local(r))
.and_then(|r| r.get("key").and_then(|v| v.as_str()))
{
return Ok(key.to_string());
}
// Fall back to any LOCAL repo (still deployable via the generic PUT path).
if let Some(key) = repos
.iter()
.find(|r| {
r.get("type")
.and_then(|v| v.as_str())
.map(|t| t.eq_ignore_ascii_case("local"))
.unwrap_or(false)
})
.and_then(|r| r.get("key").and_then(|v| v.as_str()))
{
return Ok(key.to_string());
}
Err(skip(
"Artifactory OSS exposes no pre-seeded LOCAL repo to deploy into (repo-creation REST is Pro-only). Seed a generic-local repo (e.g. example-repo-local) into the image.".to_string(),
))
}
/// Start an Artifactory OSS container, bring it to a ready state, discover a
/// deployable generic-local repo, and return the container (kept alive by the
/// caller), the base URL, a connector, and the repo key. Any failure to reach a
/// runtime becomes a graceful skip.
/// Start the PostgreSQL that Artifactory 7.x insists on, on a shared user-defined
/// network, and return the container plus the hostname Artifactory should use.
/// The container is kept alive by the caller for the whole arm.
async fn boot_postgres(network: &str, host_alias: &str) -> Result<ContainerAsync<GenericImage>> {
let image = GenericImage::new(ART_PG_IMAGE, ART_PG_TAG)
.with_exposed_port(ContainerPort::Tcp(5432))
.with_wait_for(WaitFor::message_on_stderr(
"database system is ready to accept connections",
))
.with_env_var("POSTGRES_DB", ART_PG_DB)
.with_env_var("POSTGRES_USER", ART_PG_USER)
.with_env_var("POSTGRES_PASSWORD", ART_PG_PASS)
.with_network(network.to_string())
.with_container_name(host_alias.to_string());
image.start().await.map_err(|e| {
skip(format!(
"PostgreSQL for Artifactory {ART_PG_TAG} failed to start: {e}"
))
})
}
async fn boot_artifactory(
res: &FairResources,
tag: &str,
boot_timeout: Duration,
) -> Result<(
ContainerAsync<GenericImage>,
Option<ContainerAsync<GenericImage>>,
String,
ArtifactoryConnector,
String,
f64,
)> {
// First reason the container "couldn't be brought up": testcontainers looked
// for a docker socket that does not exist on this rootless-Podman host. Point
// it at the live Podman API socket before the first start().
crate::ensure_container_host();
let user = ART_USER.to_string();
let pass = ART_PASS.to_string();
let nano_cpus = res.cores as i64 * 1_000_000_000;
let ports: &'static [u16] = candidate_ports(tag);
let mem_bytes = res.container_mem_bytes as i64;
// CORRECTION, 2026-09-20. The comment that used to sit here said 7.x OSS
// "won't bootstrap in a throwaway container — its Access/router/event
// microservices crash-loop on missing master.key/join.key". That was wrong,
// and it froze this arm on a five-year-old EOL image. 7.x bundles those
// services inside the one image; what actually killed the container in under
// 10 s was a percentage-based free-space guard on `/opt/jfrog/artifactory/var`
// reading a 98 %-full podman graphroot. See [`ART_TAG_CURRENT`].
//
// With that blocker gone, the REAL 7.x requirement became visible and is now
// handled below: it refuses every database except PostgreSQL, so 7.x is a
// two-container arm. See [`ART_PG_IMAGE`].
let boot_start = Instant::now();
let (pg, pg_env): (Option<ContainerAsync<GenericImage>>, Vec<(String, String)>) =
if needs_postgres(tag) {
// A per-run network + hostname so two concurrent bench runs on the
// same box cannot collide on a container name.
let uniq = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let network = format!("holger-bench-art-{uniq}");
let pg_host = format!("holger-bench-pg-{uniq}");
let pg = boot_postgres(&network, &pg_host).await?;
let env = vec![
(
"JF_SHARED_DATABASE_TYPE".to_string(),
"postgresql".to_string(),
),
(
"JF_SHARED_DATABASE_DRIVER".to_string(),
"org.postgresql.Driver".to_string(),
),
(
"JF_SHARED_DATABASE_URL".to_string(),
format!("jdbc:postgresql://{pg_host}:5432/{ART_PG_DB}"),
),
(
"JF_SHARED_DATABASE_USERNAME".to_string(),
ART_PG_USER.to_string(),
),
(
"JF_SHARED_DATABASE_PASSWORD".to_string(),
ART_PG_PASS.to_string(),
),
("JF_SHARED_NETWORK".to_string(), network.clone()),
];
(Some(pg), env)
} else {
(None, Vec::new())
};
let art_network: Option<String> = pg_env
.iter()
.find(|(k, _)| k == "JF_SHARED_NETWORK")
.map(|(_, v)| v.clone());
let mut base_image = GenericImage::new("releases-docker.jfrog.io/jfrog/artifactory-oss", tag)
.with_wait_for(WaitFor::message_on_stdout("Artifactory")); // best-effort; the authed gate below is authoritative
// 6.x listens on 8081 only; 7.x adds the platform router on 8082. Expose
// whichever this tag uses so the readiness probe has something to reach.
for p in candidate_ports(tag) {
base_image = base_image.with_exposed_port(ContainerPort::Tcp(*p));
}
let mut image = base_image
// FAIRNESS: Artifactory's JVM (Tomcat) reads EXTRA_JAVA_OPTIONS; without
// it the OSS image boots a cramped default heap. Give it the same fair
// heap (~50 % of the container cap) Nexus/Holger get, plus all cores +
// ~75 % RAM via the host-config modifier.
.with_env_var("EXTRA_JAVA_OPTIONS", res.jvm_args(""))
.with_host_config_modifier(move |hc| {
hc.nano_cpus = Some(nano_cpus);
hc.memory = Some(mem_bytes);
hc.memory_swap = Some(mem_bytes);
// Rootless Podman reports PublishAllPorts with an empty HostIp,
// which testcontainers drops (see `publish_on_ipv4`).
crate::publish_on_ipv4(hc, ports);
});
// 7.x only: point it at the PostgreSQL container and join the same network,
// so `jdbc:postgresql://<alias>:5432/...` resolves.
for (k, v) in pg_env.iter().filter(|(k, _)| k != "JF_SHARED_NETWORK") {
image = image.with_env_var(k.clone(), v.clone());
}
if let Some(net) = &art_network {
image = image.with_network(net.clone());
}
// The most common skip path: no Docker/Podman socket -> start() errors.
let container = image
.start()
.await
.map_err(|e| skip(format!("container start failed: {e}")))?;
// Perfect instrumentation: stream Artifactory's container logs to a host file.
// For 7.x this log is where the free-space guard's exit message appears, so it
// is the difference between a diagnosis and a guess.
crate::served::capture_container_logs(&container, &format!("artifactory-{tag}"));
let mut bases = Vec::new();
for p in candidate_ports(tag) {
if let Ok(port) = container.get_host_port_ipv4(ContainerPort::Tcp(*p)).await {
bases.push(format!("http://127.0.0.1:{port}"));
}
}
if bases.is_empty() {
return Err(skip(format!("no mapped host port for Artifactory {tag}")));
}
let base_url = match wait_for_artifactory(&bases, &user, &pass, boot_timeout).await {
Some(b) => b,
None => {
return Err(skip(format!(
"Artifactory {tag} did not become ready for authenticated writes within {}s \
(tried {}); its container log is in $HOLGER_BENCH_DIR/artifactory-{tag}-container.log",
boot_timeout.as_secs(),
bases.join(", ")
)))
}
};
let artifactory_boot_secs = boot_start.elapsed().as_secs_f64();
let client = tls_roots(reqwest::Client::builder())
.build()
.expect("reqwest client");
// OSS can't create repos over REST (Pro-only); deploy into a pre-seeded
// generic-local repo discovered from the running server instead.
let repo_key = discover_generic_repo(&client, &base_url, &user, &pass).await?;
let connector = ArtifactoryConnector {
base_url: base_url.clone(),
client,
user,
pass,
};
Ok((
container,
pg,
base_url,
connector,
repo_key,
artifactory_boot_secs,
))
}
// ── scenario: Artifactory load (MB/s + ops/s at a concurrency sweep) ────────
/// Honest, like-for-like Holger-vs-Artifactory throughput — the sibling of
/// [`crate::nexus::nexus_load`]. Upload the corpus to Artifactory (publish phase),
/// stand up a real Holger HTTP server over the same corpus
/// ([`crate::served::start_holger_gateway`]), then drive the SAME pure-Rust
/// parallel GET pool at each over HTTP (the download-heavy head-to-head).
///
/// Returns `holger_served_mbs` + `artifactory_served_mbs` (head-to-head),
/// `artifactory_upload_mbs` (publish phase, separate) and the in-process
/// `holger_inprocess_read_ceiling_mbs` (internal ceiling only). Fixture-gated.
pub async fn artifactory_load(scale: &Scale) -> Result<LoadOutcome> {
artifactory_load_tag(scale, ART_TAG_PINNED).await
}
/// As [`artifactory_load`], but against an explicit Artifactory image `tag`.
/// Two arms run: [`ART_TAG_PINNED`] for `bench_runs` series continuity and
/// [`ART_TAG_CURRENT`] because a five-year-old EOL rival is not a comparison.
/// Every row carries `artifactory_tag` and `artifactory_boot_secs`.
pub async fn artifactory_load_tag(scale: &Scale, tag: &str) -> Result<LoadOutcome> {
let started = Instant::now();
let concurrency = scale.threads_mt;
let bench = Duration::from_secs(scale.bench_secs);
// Build self-contained archives — the SAME corpus the Nexus scenario pushes.
let crate_path = build_crate_archive(scale.entries)?;
let jar_path = build_jar_archive(scale.entries)?;
let pip_path = build_pip_archive(scale.entries)?;
// Sub-path prefixes inside the pre-seeded Artifactory repo (OSS can't create
// repos over REST). `prefix` doubles as both the deploy sub-path and, for the
// crate corpus, the fetch sub-path.
let archives = [
(crate_path.clone(), "crates-raw"),
(jar_path.clone(), "jars-raw"),
(pip_path.clone(), "wheels-raw"),
];
// Boot Artifactory with FAIR resources detected from this machine (all
// cores, ~75 % RAM, ~50 %-of-container heap) — not the cramped JVM default.
// OSS is a heavy boot, so a generous window — a too-slow box degrades to a
// recorded skip, not a fail.
let res = FairResources::detect();
// 7.x is a multi-service platform inside one image and takes minutes, not
// seconds, to reach an authenticated ping. Giving it 6.x's window is how a
// healthy modern rival gets recorded as a skip.
let default_boot = if tag.starts_with("6.") { 300 } else { 900 };
let (container, pg_container, base_url, connector, art_repo, artifactory_boot_secs) =
boot_artifactory(
&res,
tag,
Duration::from_secs(crate::boot_timeout_secs(default_boot)),
)
.await?;
// ── PUBLISH PHASE (separate from the head-to-head): upload the corpus to
// Artifactory and measure its push throughput → `artifactory_upload_mbs`.
// Deploy under `<art_repo>/<prefix>/…` via the generic PUT API (OSS-OK). ──
let mut art_bytes = 0u64;
let art_up_start = Instant::now();
for (path, prefix) in &archives {
let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
let dest = format!("{art_repo}/{prefix}");
operations::push_znippy_to_connector(&connector, path.clone(), &dest)
.await
.map_err(|e| anyhow::anyhow!("push {dest}: {e}"))?;
art_bytes += size;
}
let art_up = art_up_start.elapsed().as_secs_f64().max(1e-6);
let artifactory_upload_mbs = (art_bytes as f64 / 1_048_576.0) / art_up;
// ── Stand up a REAL Holger HTTP server over the same crate corpus. ──
//
// FAIRNESS FIX (årsstädning 2026-06-28, part 2): Holger is exercised over its
// real native HTTP door (`handle_http2_request` via `start_http_gateway` — the
// path stock cargo hits), symmetric with Artifactory's HTTP. The old in-process
// `get_file()` cheat is demoted to `holger_inprocess_read_ceiling_mbs` (a
// Holger-vs-Holger regression ceiling, NOT the competitor number).
let rust_repo: std::sync::Arc<RustRepoZnippy> = std::sync::Arc::new(
RustRepoZnippy::with_archive("rust".into(), crate_path)
.map_err(|e| anyhow::anyhow!("open rust archive: {e:?}"))?,
);
let rust_files = rust_repo.list_files();
anyhow::ensure!(!rust_files.is_empty(), "no Holger files");
let holger_files = rust_files.len();
let repo_backend: std::sync::Arc<dyn traits::RepositoryBackendTrait> =
std::sync::Arc::clone(&rust_repo) as _;
let holger_addr = served::start_holger_gateway(vec![("rust".into(), repo_backend)]).await?;
let holger_base = format!("http://{holger_addr}");
let holger_urls = served::crate_download_urls(&holger_base, "rust", &rust_files);
anyhow::ensure!(!holger_urls.is_empty(), "no Holger crate-download URLs");
// Artifactory fetch URLs: the crate corpus was pushed to `<art_repo>/crates-raw`
// under each archive entry path, so `GET /artifactory/<art_repo>/crates-raw/{path}`
// retrieves it. `rust_files` comes from the SAME archive's `list_files()` that the
// push iterated, so the reconstructed paths match the deployed ones exactly.
let artifactory_urls: Vec<String> = rust_files
.iter()
.map(|f| format!("{base_url}/artifactory/{art_repo}/crates-raw/{f}"))
.collect();
// ── HEAD-TO-HEAD: SAME pure-Rust parallel GET pool against each, over HTTP. ──
// Holger's bench gateway is open-auth; Artifactory OSS rejects anonymous GETs
// with 401, so its leg authenticates (admin:password) — same concurrent-HTTP
// transport, just a constant auth header. Without this every GET 401'd and the
// rival cell read as a misleading ~0 MB/s.
let holger_fetch = served::bombard_http_get(&holger_urls, concurrency, bench);
let artifactory_fetch = served::bombard_http_get_auth(
&artifactory_urls,
Some((ART_USER, ART_PASS)),
concurrency,
bench,
);
let holger_served_mbs = holger_fetch.mbs;
let artifactory_served_mbs = artifactory_fetch.mbs;
let ops_per_sec = holger_fetch.ops_per_sec;
let artifactory_ops_per_sec = artifactory_fetch.ops_per_sec;
// GUARD AGAINST THE EMPTY RIVAL CELL: a booted, healthy Artifactory that served
// zero successful fetches is a real failure (wrong repo/path/auth), NOT a slow
// run and NOT a fixture skip — surface it loudly instead of reporting ~0 MB/s.
anyhow::ensure!(
artifactory_fetch.ok_ops > 0,
"Artifactory served 0/{} GETs successfully from repo '{art_repo}' \
(corpus deployed but not retrievable — check repo/path/auth); refusing to \
report an empty rival cell",
artifactory_fetch.ops
);
// ── INTERNAL CEILING ONLY: in-process `get_file()` — Holger-vs-Holger. ──
let ceiling_repo = std::sync::Arc::clone(&rust_repo);
let ceiling_files = &rust_files;
let holger_inprocess_read_ceiling_mbs =
served::inprocess_read_ceiling(concurrency, bench, move |n, tid| {
let idx = ((n as usize) * 7 + tid * 1337) % ceiling_files.len();
ceiling_repo
.get_file(&ceiling_files[idx])
.map(|d| d.len())
.unwrap_or(0)
});
drop(container);
// 7.x only: the PostgreSQL companion goes down with the arm it served.
drop(pg_container);
Ok(LoadOutcome {
concurrency,
holger_served_mbs,
artifactory_served_mbs,
ops_per_sec,
artifactory_ops_per_sec,
holger_p50_ms: holger_fetch.p50_ms,
holger_p99_ms: holger_fetch.p99_ms,
holger_p999_ms: holger_fetch.p999_ms,
holger_mean_ms: holger_fetch.mean_ms,
artifactory_p50_ms: artifactory_fetch.p50_ms,
artifactory_p99_ms: artifactory_fetch.p99_ms,
artifactory_p999_ms: artifactory_fetch.p999_ms,
artifactory_mean_ms: artifactory_fetch.mean_ms,
artifactory_ok_ops: artifactory_fetch.ok_ops,
artifactory_repo: art_repo,
artifactory_upload_mbs,
holger_inprocess_read_ceiling_mbs,
elapsed_ms: started.elapsed().as_secs_f64() * 1000.0,
holger_files,
artifactory_tag: tag.to_string(),
artifactory_boot_secs,
})
}
/// reqwest 0.13 with reqwest 0.12 `rustls-tls` behaviour (2026-10-02): the ring
/// provider and the bundled Mozilla roots (webpki), not 0.13's aws-lc-rs + OS
/// trust store. Roots added later on the builder are merged in, as in 0.12.
#[allow(dead_code)]
fn tls_roots(b: reqwest::ClientBuilder) -> reqwest::ClientBuilder {
let _ = rustls::crypto::ring::default_provider().install_default();
b.tls_certs_only(
webpki_root_certs::TLS_SERVER_ROOT_CERTS
.iter()
.filter_map(|c| reqwest::Certificate::from_der(c).ok()),
)
}