1use 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 Enter(enter::Args),
27}
28
29mod 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 #[arg(short, long, value_name = "ENVIRONMENT", default_value = "local")]
60 environment: Environment,
61
62 #[arg(short, long, value_name = "PHC_URL")]
64 url: Option<url::Url>,
65
66 #[arg(long, value_name = "PATH", default_value = "yivi_jwt.pem")]
68 yivi_sk: std::path::PathBuf,
69
70 #[arg(long, value_name = "NAME", default_value = "local-yivi")]
73 yivi_server_name: String,
74
75 #[arg(short, long, default_value_t = NonZeroUsize::new(16).unwrap())]
77 concurrency: NonZeroUsize,
78
79 #[arg(short, long, default_value_t = 100)]
81 total: u64,
82
83 #[arg(long, value_enum, default_value_t = Mode::LoginOrRegister)]
85 mode: Mode,
86 }
87
88 const IDENTIFYING_ATTR: &str = "email";
96 const BANNABLE_ATTR: &str = "phone";
97
98 #[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 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 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 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 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 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 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 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 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 fn precompute_disclosures(
281 sprequest: &yivi::ExtendedSessionRequest,
282 creds: &yivi::Credentials<yivi::SigningKey>,
283 total: usize,
284 ) -> Vec<jwt::JWT> {
285 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 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 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 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 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 #[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 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 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 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 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}