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

nordisk/gunnar

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