Skip to main content

pubhubs/cli/
stress.rs

1//! Load/stress testing: `cargo run stress <subcommand>`.
2//!
3//! These commands hammer a running PubHubs deployment to surface performance problems.  They forge
4//! the Yivi disclosure step locally (see [`enter`]), so no Yivi app/server is involved — they only
5//! need the Yivi server's signing key, which the auth server trusts.
6
7use anyhow::Result;
8
9#[derive(clap::Args, Debug)]
10pub struct StressArgs {
11    #[command(subcommand)]
12    command: Commands,
13}
14
15impl StressArgs {
16    pub fn run(self, _spec: &mut clap::Command) -> Result<()> {
17        match self.command {
18            Commands::Enter(args) => args.run(),
19        }
20    }
21}
22
23#[derive(clap::Subcommand, Debug)]
24enum Commands {
25    /// Hammer PHC's enter (login/registration) endpoint.
26    Enter(enter::Args),
27}
28
29/// `cargo run stress enter`: run many login/registration flows against PHC.
30///
31/// Each iteration runs the real auth + enter flow — [`AuthStartEP`](crate::api::auths::AuthStartEP),
32/// [`AuthCompleteEP`](crate::api::auths::AuthCompleteEP), [`EnterEP`](crate::api::phc::user::EnterEP) —
33/// but forges the Yivi disclosure that normally comes from the user's Yivi app: we mint the
34/// [`SessionResult`] the Yivi server would have returned and sign it with the Yivi server signing key
35/// (the auth server only checks that signature, so a locally-signed one is accepted).  The `EnterEP`
36/// call is what reads and writes PHC's object_store, so this stresses that store under realistic load.
37///
38/// [`SessionResult`]: crate::servers::yivi::SessionResult
39mod enter {
40    use std::num::NonZeroUsize;
41    use std::time::{Duration, Instant};
42
43    use anyhow::{Context as _, Result};
44    use base64ct::{Base64UrlUnpadded, Encoding as _};
45    use futures::stream::StreamExt as _;
46
47    use crate::api;
48    use crate::attr;
49    use crate::cli::common::{self, Environment};
50    use crate::client;
51    use crate::handle;
52    use crate::misc::jwt;
53    use crate::servers::yivi;
54
55    #[derive(clap::Args, Debug)]
56    pub(super) struct Args {
57        /// Stress this PubHubs environment.  Defaults to `local` so you can't accidentally hammer a
58        /// real deployment.
59        #[arg(short, long, value_name = "ENVIRONMENT", default_value = "local")]
60        environment: Environment,
61
62        /// Contact PHC at this url, overriding `--environment`.
63        #[arg(short, long, value_name = "PHC_URL")]
64        url: Option<url::Url>,
65
66        /// Yivi server signing key (PKCS#8 PEM) used to forge accepted disclosures.
67        #[arg(long, value_name = "PATH", default_value = "yivi_jwt.pem")]
68        yivi_sk: std::path::PathBuf,
69
70        /// Issuer (`iss`) the forged disclosure claims; must equal the auth server's configured Yivi
71        /// server name (`auths.yivi.server_name`; `local-yivi` for the local dev stack).
72        #[arg(long, value_name = "NAME", default_value = "local-yivi")]
73        yivi_server_name: String,
74
75        /// Number of enter flows kept in flight at once.
76        #[arg(short, long, default_value_t = NonZeroUsize::new(16).unwrap())]
77        concurrency: NonZeroUsize,
78
79        /// Total number of enter flows to run.
80        #[arg(short, long, default_value_t = 100)]
81        total: u64,
82
83        /// Whether each flow registers a new account, logs into an existing one, or either.
84        #[arg(long, value_enum, default_value_t = Mode::LoginOrRegister)]
85        mode: Mode,
86    }
87
88    // The attribute types each enter flow discloses: an identifying one and a bannable one.
89    //
90    // These are constants rather than command-line flags on purpose.  Which attributes are used makes
91    // no difference to what this command measures — load on PHC's object_store — and supporting an
92    // arbitrary attribute would mean teaching `forged_enter` how to generate a valid value for it.
93    // The first must be an identifying attribute; the second must be *bannable* (phone is), otherwise
94    // PHC rejects the registration with `NoBannableAttribute`.
95    const IDENTIFYING_ATTR: &str = "email";
96    const BANNABLE_ATTR: &str = "phone";
97
98    /// What an enter flow does once it has its attributes.  `Login` expects the account to already
99    /// exist, so run a `Register` pass with the same `--total` first: identities are deterministic in
100    /// the iteration index, so the same range logs back in.
101    #[derive(clap::ValueEnum, Debug, Clone, Copy)]
102    enum Mode {
103        Register,
104        Login,
105        LoginOrRegister,
106    }
107
108    impl From<Mode> for api::phc::user::EnterMode {
109        fn from(mode: Mode) -> Self {
110            match mode {
111                Mode::Register => api::phc::user::EnterMode::Register,
112                Mode::Login => api::phc::user::EnterMode::Login,
113                Mode::LoginOrRegister => api::phc::user::EnterMode::LoginOrRegister,
114            }
115        }
116    }
117
118    impl Args {
119        pub(super) fn run(self) -> Result<()> {
120            env_logger::init();
121
122            // The HTTP client (awc) is single-threaded, so drive everything on one thread; the
123            // concurrency below is many in-flight futures on that thread, which is plenty for IO load.
124            tokio::runtime::Builder::new_current_thread()
125                .enable_all()
126                .build()?
127                .block_on(tokio::task::LocalSet::new().run_until(self.run_async()))
128        }
129
130        fn url(&self) -> std::borrow::Cow<'_, url::Url> {
131            common::phc_url(self.environment, &self.url)
132        }
133
134        async fn run_async(self) -> Result<()> {
135            let client = client::Client::builder().agent(client::Agent::Cli).finish();
136
137            let url = self.url();
138
139            let constellation = client
140                .get_constellation(url.as_ref())
141                .await
142                .context("fetching the constellation from PHC")?
143                .into_constellation()
144                .context("PHC is not done with discovery yet (no constellation)")?;
145
146            // Load the Yivi server signing key and wrap it as the credentials whose `name` the auth
147            // server checks against its configured Yivi server name.
148            let pem = std::fs::read_to_string(&self.yivi_sk)
149                .with_context(|| format!("reading Yivi signing key {}", self.yivi_sk.display()))?;
150            let key: yivi::SigningKey = serde_json::from_value(serde_json::json!({ "rs256": pem }))
151                .context("parsing Yivi signing key (expected a PKCS#8 PEM)")?;
152            let creds = yivi::Credentials {
153                name: self.yivi_server_name.clone(),
154                key,
155            };
156
157            let mode: api::phc::user::EnterMode = self.mode.into();
158            let total = self.total as usize;
159
160            // Learn the disclosure session request once (identical for every flow), then precompute
161            // and sign one disclosure per identity.  RSA signing is the heavy part and would otherwise
162            // bottleneck this single-threaded load generator instead of the server, so we do it up
163            // front (untimed) and spread it across all cores.
164            let sprequest = fetch_sprequest(&client, &constellation).await?;
165            log::info!("precomputing {total} signed disclosures (RSA) across all cores…");
166            let precompute_start = Instant::now();
167            let disclosures = precompute_disclosures(&sprequest, &creds, total);
168            log::info!("…precomputed in {:.1?}", precompute_start.elapsed());
169
170            log::info!(
171                "stressing enter at {url} — {total} flows, {} in flight, mode {:?}",
172                self.concurrency,
173                self.mode,
174            );
175
176            // The AuthStart request is identical for every flow (a pure function of constants); build
177            // it once here, not inside each timed flow.
178            let auth_start_req = auth_start_req();
179
180            let wall = Instant::now();
181            let results: Vec<Result<(Timing, Outcome)>> = futures::stream::iter(disclosures)
182                .map(|disclosure| {
183                    enter_with_disclosure(
184                        &client,
185                        &constellation,
186                        &auth_start_req,
187                        disclosure,
188                        mode,
189                    )
190                })
191                .buffer_unordered(self.concurrency.get())
192                .collect()
193                .await;
194            let wall = wall.elapsed();
195
196            summarize(&results, wall, self.total);
197            Ok(())
198        }
199    }
200
201    /// The result of one enter flow, for the summary.
202    enum Outcome {
203        Registered,
204        LoggedIn,
205        AttributeAlreadyTaken,
206        Other(String),
207    }
208
209    impl Outcome {
210        fn of(resp: &api::phc::user::EnterResp) -> Self {
211            match resp {
212                api::phc::user::EnterResp::Entered {
213                    new_account: true, ..
214                } => Outcome::Registered,
215                api::phc::user::EnterResp::Entered {
216                    new_account: false, ..
217                } => Outcome::LoggedIn,
218                api::phc::user::EnterResp::AttributeAlreadyTaken { .. } => {
219                    Outcome::AttributeAlreadyTaken
220                }
221                other => Outcome::Other(format!("{other:?}")),
222            }
223        }
224    }
225
226    /// Builds the `AuthStartReq` every flow uses: disclose the identifying + bannable attribute.
227    fn auth_start_req() -> api::auths::AuthStartReq {
228        let identifying_type: handle::Handle =
229            IDENTIFYING_ATTR.parse().expect("a valid attribute handle");
230        let bannable_type: handle::Handle =
231            BANNABLE_ATTR.parse().expect("a valid attribute handle");
232        api::auths::AuthStartReq {
233            source: attr::Source::Yivi,
234            attr_types: vec![identifying_type, bannable_type],
235            attr_type_choices: Default::default(),
236            yivi_chained_session: false,
237            yivi_chained_session_drip: false,
238        }
239    }
240
241    /// The value identity `i` discloses for attribute `ati`.  Deterministic in `i`, so a `register`
242    /// pass then a `login` pass over the same range line up.
243    fn value_for(ati: &yivi::AttributeTypeIdentifier, i: usize) -> String {
244        let id = ati.as_str();
245        if id.contains("email") {
246            format!("stress+{i}@example.com")
247        } else if id.contains("mobilenumber") || id.contains("phone") {
248            format!("+1555{i:07}")
249        } else {
250            format!("stress-{i}")
251        }
252    }
253
254    /// One `AuthStart` to learn the disclosure session request — the Yivi attribute identifiers the
255    /// auth server asks for.  Identical for every flow, so fetch it once and build every forged
256    /// disclosure from it.
257    async fn fetch_sprequest(
258        client: &client::Client,
259        constellation: &crate::servers::Constellation,
260    ) -> Result<yivi::ExtendedSessionRequest> {
261        let api::auths::AuthStartResp::Success { task, .. } = client
262            .query_with_retry::<api::auths::AuthStartEP, _, _>(
263                &constellation.auths_url,
264                &auth_start_req(),
265            )
266            .await
267            .context("auth start (to learn the disclosure request)")?
268        else {
269            anyhow::bail!("auth start did not succeed");
270        };
271        let api::auths::AuthTask::Yivi {
272            disclosure_request, ..
273        } = task;
274        sprequest_unverified(&disclosure_request)
275    }
276
277    /// Signs one disclosure per identity, in parallel across all cores.  RSA signing dominates a
278    /// single thread (it's what made the load generator the bottleneck), so it happens here — up
279    /// front and untimed — rather than inside the measured loop.
280    fn precompute_disclosures(
281        sprequest: &yivi::ExtendedSessionRequest,
282        creds: &yivi::Credentials<yivi::SigningKey>,
283        total: usize,
284    ) -> Vec<jwt::JWT> {
285        // The auth server doesn't check the disclosure's expiry (see
286        // `yivi::SessionResult::open_signed`), but sign with a generous validity regardless.
287        const VALIDITY: Duration = Duration::from_secs(3600);
288
289        let threads = std::thread::available_parallelism()
290            .map(|n| n.get())
291            .unwrap_or(4);
292        let chunk = total.div_ceil(threads).max(1);
293
294        let mut out: Vec<Option<jwt::JWT>> = (0..total).map(|_| None).collect();
295        std::thread::scope(|scope| {
296            for (c, slot) in out.chunks_mut(chunk).enumerate() {
297                scope.spawn(move || {
298                    for (l, cell) in slot.iter_mut().enumerate() {
299                        let i = c * chunk + l;
300                        *cell = Some(
301                            sprequest
302                                .mock_disclosure_response(|ati| value_for(ati, i))
303                                .sign(creds, VALIDITY)
304                                .expect("signing a precomputed disclosure"),
305                        );
306                    }
307                });
308            }
309        });
310        out.into_iter()
311            .map(|disclosure| disclosure.expect("every disclosure slot was filled"))
312            .collect()
313    }
314
315    /// One timed enter flow using a precomputed disclosure: `AuthStart` (for a fresh sealed state) →
316    /// `AuthComplete` → `EnterEP`.  No signing happens here, so the timing reflects the server's work.
317    async fn enter_with_disclosure(
318        client: &client::Client,
319        constellation: &crate::servers::Constellation,
320        auth_start_req: &api::auths::AuthStartReq,
321        disclosure: jwt::JWT,
322        mode: api::phc::user::EnterMode,
323    ) -> Result<(Timing, Outcome)> {
324        // AuthStart: a fresh sealed state to pair with the (reusable) precomputed disclosure.
325        let t = Instant::now();
326        let api::auths::AuthStartResp::Success { state, .. } = client
327            .query_with_retry::<api::auths::AuthStartEP, _, _>(
328                &constellation.auths_url,
329                auth_start_req,
330            )
331            .await
332            .context("auth start")?
333        else {
334            anyhow::bail!("auth start did not succeed");
335        };
336        let auth_start = t.elapsed();
337
338        // AuthComplete: hand over the precomputed disclosure, get signed attributes back.
339        let t = Instant::now();
340        let api::auths::AuthCompleteResp::Success { mut attrs } = client
341            .query_with_retry::<api::auths::AuthCompleteEP, _, _>(
342                &constellation.auths_url,
343                &api::auths::AuthCompleteReq {
344                    state,
345                    proof: api::auths::AuthProof::Yivi { disclosure },
346                },
347            )
348            .await
349            .context("auth complete")?
350        else {
351            anyhow::bail!("auth complete did not succeed");
352        };
353        let auth_complete = t.elapsed();
354
355        // Enter PHC with those attributes — the call that reads/writes the object_store.
356        let Some((id_type, identifying_attr)) = attrs.shift_remove_index(0) else {
357            anyhow::bail!("auth complete returned no attributes");
358        };
359        anyhow::ensure!(
360            id_type.as_str() == IDENTIFYING_ATTR,
361            "auth complete returned unexpected identifying attribute type {id_type}, expected {IDENTIFYING_ATTR}",
362        );
363        let add_attrs: Vec<_> = attrs.values().cloned().collect();
364
365        // Keep `..Default::default()` so a future `EnterReq` field doesn't break this call site.
366        #[allow(clippy::needless_update)]
367        let enter_req = api::phc::user::EnterReq {
368            identifying_attr: Some(identifying_attr),
369            mode,
370            add_attrs,
371            register_only_with_unique_attrs: false,
372            ..Default::default()
373        };
374
375        let t = Instant::now();
376        let enter_resp = client
377            .query_with_retry::<api::phc::user::EnterEP, _, _>(&constellation.phc_url, &enter_req)
378            .await
379            .context("enter")?;
380        let enter = t.elapsed();
381
382        Ok((
383            Timing {
384                auth_start,
385                auth_complete,
386                enter,
387            },
388            Outcome::of(&enter_resp),
389        ))
390    }
391
392    /// Per-stage wall-clock of one enter flow.
393    struct Timing {
394        auth_start: Duration,
395        auth_complete: Duration,
396        enter: Duration,
397    }
398
399    impl Timing {
400        fn total(&self) -> Duration {
401            self.auth_start + self.auth_complete + self.enter
402        }
403    }
404
405    /// Reads the `sprequest` (disclosure session request) out of the auth server's
406    /// `disclosure_request` JWT *without verifying its signature*: we made no claim to authenticate
407    /// it — we only need to learn which Yivi attribute identifiers it asks for, to forge a matching
408    /// response.
409    fn sprequest_unverified(disclosure_request: &jwt::JWT) -> Result<yivi::ExtendedSessionRequest> {
410        #[derive(serde::Deserialize)]
411        struct DisclosureRequestClaims {
412            sprequest: yivi::ExtendedSessionRequest,
413        }
414
415        let payload = disclosure_request
416            .as_str()
417            .split('.')
418            .nth(1)
419            .context("disclosure_request JWT has no payload segment")?;
420        let bytes = Base64UrlUnpadded::decode_vec(payload)
421            .context("disclosure_request JWT payload is not valid base64url")?;
422        let claims: DisclosureRequestClaims =
423            serde_json::from_slice(&bytes).context("decoding disclosure_request claims")?;
424        Ok(claims.sprequest)
425    }
426
427    /// Prints outcome counts, throughput, and per-stage latency percentiles.  The per-stage split is
428    /// the point: `auth-complete` is the auth server's crypto (verify the disclosure, sign the
429    /// attributes), while `enter` is PHC's object_store reads/writes (plus its pseudonym crypto).
430    fn summarize(results: &[Result<(Timing, Outcome)>], wall: Duration, total: u64) {
431        let mut registered = 0u64;
432        let mut logged_in = 0u64;
433        let mut taken = 0u64;
434        let mut other: std::collections::BTreeMap<String, u64> = Default::default();
435        let mut errors: std::collections::BTreeMap<String, u64> = Default::default();
436        let mut starts: Vec<Duration> = Vec::new();
437        let mut completes: Vec<Duration> = Vec::new();
438        let mut enters: Vec<Duration> = Vec::new();
439        let mut totals: Vec<Duration> = Vec::new();
440
441        for result in results {
442            match result {
443                Ok((timing, outcome)) => {
444                    starts.push(timing.auth_start);
445                    completes.push(timing.auth_complete);
446                    enters.push(timing.enter);
447                    totals.push(timing.total());
448                    match outcome {
449                        Outcome::Registered => registered += 1,
450                        Outcome::LoggedIn => logged_in += 1,
451                        Outcome::AttributeAlreadyTaken => taken += 1,
452                        Outcome::Other(msg) => *other.entry(msg.clone()).or_default() += 1,
453                    }
454                }
455                Err(err) => *errors.entry(format!("{err:#}")).or_default() += 1,
456            }
457        }
458
459        println!("\n=== stress enter summary ===");
460        println!("iterations:  {total}");
461        println!("wall time:   {wall:.2?}");
462        println!(
463            "throughput:  {:.1} attempted enters/s",
464            total as f64 / wall.as_secs_f64().max(f64::MIN_POSITIVE)
465        );
466        println!("registered:  {registered}");
467        println!("logged in:   {logged_in}");
468        println!("attr taken:  {taken}");
469        for (msg, count) in &other {
470            println!("other:       {count}× {msg}");
471        }
472        let error_total: u64 = errors.values().sum();
473        println!("errors:      {error_total}");
474        for (msg, count) in &errors {
475            println!("  {count}× {msg}");
476        }
477
478        println!("per-stage latency (p50 / p95 / max):");
479        stage("auth-start    (auths)", &mut starts);
480        stage("auth-complete (auths, crypto)", &mut completes);
481        stage("enter         (phc, object_store)", &mut enters);
482        stage("total", &mut totals);
483    }
484
485    /// Sorts `samples` and prints one `name: p50 / p95 / max` line.
486    fn stage(name: &str, samples: &mut [Duration]) {
487        if samples.is_empty() {
488            return;
489        }
490        samples.sort_unstable();
491        let pct = |p: f64| {
492            let idx = ((p / 100.0) * (samples.len() as f64 - 1.0)).round() as usize;
493            samples[idx.min(samples.len() - 1)]
494        };
495        println!(
496            "  {name}: {:.1?} / {:.1?} / {:.1?}",
497            pct(50.0),
498            pct(95.0),
499            samples.last().unwrap(),
500        );
501    }
502}