crates/gunnar-loadbench/src/args.rs
//! **The command line.** The rate cap and the max-in-flight are REQUIRED. There
//! is no default for them anywhere in this crate, because a default is a number
//! somebody forgot to choose, and the thing being loaded is three small rented
//! VMs that other people's work depends on.
//!
//! `--profile conservative` is the one shorthand, and it is not a default: it
//! is a named, documented set of numbers that the run prints back before it
//! sends anything. Naming it is still choosing it.
use std::time::Duration;
/// The conservative profile — what this estate can absorb without anyone
/// noticing. 2 requests per second, 4 in flight, 60 requests per probe: about
/// 30 seconds per probe and well under what one browser tab does opening the
/// console.
pub const CONSERVATIVE: (f64, usize, usize) = (2.0, 4, 60);
#[derive(Debug, Clone)]
pub struct Args {
pub rate_per_s: f64,
pub max_in_flight: usize,
pub requests_per_probe: usize,
pub deadline: Duration,
pub request_timeout: Duration,
pub out: String,
pub baseline: Option<String>,
pub from_upcloud: bool,
/// `--mock-api <loopback base>`: with `--from-upcloud`, read a mock-upcloud
/// instead of the account.
pub mock_api: Option<String>,
/// `--only <name>` restricts the run to one probe.
pub only: Option<String>,
pub vantage: String,
}
pub fn help() -> String {
format!(
"gunnar-loadbench — load/capacity bench for the gunnar.rs estate
READ PATHS ONLY. The front's Stripe key is rk_live_: nothing here can express
a POST, and the safety gate refuses any URL containing a money or state word
before a target list is even built. See src/safety.rs.
USAGE
gunnar-loadbench --rate <rps> --max-in-flight <n> [options]
gunnar-loadbench --profile conservative [options]
REQUIRED (no defaults — this estate is three small VMs)
--rate <rps> requests per second OFFERED, across all probes' workers
--max-in-flight <n> maximum concurrent requests (the worker count)
OPTIONS
--profile conservative declare {r} rps / {f} in flight / {n} requests per probe
--requests <n> requests per probe [default 60]
--deadline <secs> wall clock for the WHOLE run [default 300]
--timeout <secs> per-request budget [default 15]
--out <path> machine-readable summary [default gunnar-loadbench.json]
--baseline <path> compare this run against an earlier summary
--only <name> run one probe by name
--from-upcloud measure from a temporary UpCloud VM instead of here
--mock-api <base> with --from-upcloud: a mock-upcloud on loopback, not the account
-h, --help this
VERDICT
Thresholds are declared in src/verdict.rs, not on this command line.
Exit 0 = PASS, 1 = FAIL, 2 = the bench could not run.",
r = CONSERVATIVE.0,
f = CONSERVATIVE.1,
n = CONSERVATIVE.2
)
}
pub fn parse(argv: &[String]) -> Result<Args, String> {
let mut rate: Option<f64> = None;
let mut in_flight: Option<usize> = None;
let mut requests: Option<usize> = None;
let mut deadline = 300u64;
let mut timeout = 15u64;
let mut out = "gunnar-loadbench.json".to_string();
let mut baseline = None;
let mut from_upcloud = false;
let mut mock_api = None;
let mut only = None;
let mut i = 0;
while i < argv.len() {
let a = argv[i].as_str();
let mut next = |what: &str| -> Result<String, String> {
i += 1;
argv.get(i)
.cloned()
.ok_or_else(|| format!("{what} needs a value"))
};
match a {
"--rate" => {
rate = Some(
next("--rate")?
.parse()
.map_err(|_| "--rate wants a number")?,
)
}
"--max-in-flight" => {
in_flight = Some(
next("--max-in-flight")?
.parse()
.map_err(|_| "--max-in-flight wants an integer")?,
)
}
"--requests" => {
requests = Some(
next("--requests")?
.parse()
.map_err(|_| "--requests wants an integer")?,
)
}
"--deadline" => {
deadline = next("--deadline")?
.parse()
.map_err(|_| "--deadline wants seconds")?
}
"--timeout" => {
timeout = next("--timeout")?
.parse()
.map_err(|_| "--timeout wants seconds")?
}
"--out" => out = next("--out")?,
"--baseline" => baseline = Some(next("--baseline")?),
"--only" => only = Some(next("--only")?),
"--from-upcloud" => from_upcloud = true,
"--mock-api" => mock_api = Some(next("--mock-api")?),
"--profile" => {
let p = next("--profile")?;
if p != "conservative" {
return Err(format!(
"unknown profile {p:?}; the only one is \"conservative\" ({} rps / {} in flight)",
CONSERVATIVE.0, CONSERVATIVE.1
));
}
rate = Some(CONSERVATIVE.0);
in_flight = Some(CONSERVATIVE.1);
requests = requests.or(Some(CONSERVATIVE.2));
}
"-h" | "--help" => return Err("HELP".to_string()),
other => return Err(format!("unknown argument {other:?}")),
}
i += 1;
}
let rate_per_s = rate.ok_or(
"--rate is required. There is no default: this estate is three small VMs. \
Use --profile conservative if you want the safe numbers.",
)?;
let max_in_flight = in_flight.ok_or(
"--max-in-flight is required. There is no default: this estate is three small VMs. \
Use --profile conservative if you want the safe numbers.",
)?;
if !(rate_per_s > 0.0) {
return Err("--rate must be above zero".into());
}
if max_in_flight == 0 {
return Err("--max-in-flight must be at least 1".into());
}
if deadline == 0 {
return Err("--deadline must be above zero: the run has to stop".into());
}
if timeout == 0 {
return Err("--timeout must be above zero".into());
}
Ok(Args {
rate_per_s,
max_in_flight,
requests_per_probe: requests.unwrap_or(60),
deadline: Duration::from_secs(deadline),
request_timeout: Duration::from_secs(timeout),
out,
baseline,
from_upcloud,
mock_api,
only,
vantage: "local".to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn v(s: &[&str]) -> Vec<String> {
s.iter().map(|x| x.to_string()).collect()
}
#[test]
fn the_rate_cap_cannot_be_forgotten() {
let e = parse(&v(&["--max-in-flight", "4"])).unwrap_err();
assert!(e.contains("--rate is required"), "{e}");
}
#[test]
fn the_max_in_flight_cannot_be_forgotten() {
let e = parse(&v(&["--rate", "2"])).unwrap_err();
assert!(e.contains("--max-in-flight is required"), "{e}");
}
#[test]
fn nothing_at_all_is_refused_rather_than_run_with_defaults() {
assert!(parse(&[]).is_err());
}
#[test]
fn the_conservative_profile_declares_both_numbers() {
let a = parse(&v(&["--profile", "conservative"])).unwrap();
assert_eq!(a.rate_per_s, CONSERVATIVE.0);
assert_eq!(a.max_in_flight, CONSERVATIVE.1);
assert_eq!(a.requests_per_probe, CONSERVATIVE.2);
}
#[test]
fn an_unknown_profile_names_the_one_that_exists() {
let e = parse(&v(&["--profile", "brutal"])).unwrap_err();
assert!(e.contains("conservative"), "{e}");
}
#[test]
fn a_zero_deadline_is_refused_because_the_run_has_to_stop() {
let e = parse(&v(&[
"--rate",
"2",
"--max-in-flight",
"4",
"--deadline",
"0",
]))
.unwrap_err();
assert!(e.contains("has to stop"), "{e}");
}
#[test]
fn a_zero_rate_is_refused() {
assert!(parse(&v(&["--rate", "0", "--max-in-flight", "4"])).is_err());
}
#[test]
fn an_unknown_flag_is_named_rather_than_ignored() {
let e = parse(&v(&["--rate", "2", "--max-in-flight", "4", "--yolo"])).unwrap_err();
assert!(e.contains("yolo"), "{e}");
}
#[test]
fn a_full_line_parses() {
let a = parse(&v(&[
"--rate",
"5",
"--max-in-flight",
"8",
"--requests",
"100",
"--deadline",
"60",
"--out",
"/tmp/x.json",
"--only",
"front-health",
]))
.unwrap();
assert_eq!(a.rate_per_s, 5.0);
assert_eq!(a.max_in_flight, 8);
assert_eq!(a.requests_per_probe, 100);
assert_eq!(a.deadline, Duration::from_secs(60));
assert_eq!(a.only.as_deref(), Some("front-health"));
}
}