Skip to main content

max / makenotwork

3.5 KB · 95 lines History Blame Raw
1 //! A stand-in Multithreaded, so the forum-membership screens can be put under
2 //! load without a real instance.
3 //!
4 //! The two described forum screens (`src/quasi/forum_memberships.rs`) are the
5 //! only ones whose work is an outbound HTTP call rather than a database round
6 //! trip, and quasi's router is sync: the described version holds a
7 //! BLOCKING-POOL thread for that whole call, where the Askama version pays the
8 //! same latency on a runtime worker. Every runtime number about the description
9 //! layer so far came from sub-millisecond sqlx queries, so nothing has measured
10 //! that regime.
11 //!
12 //! What makes it measurable is that the upstream latency becomes the variable.
13 //! This stub sleeps `LOAD_MT_LATENCY_MS` before answering, so a run can be
14 //! repeated at 0ms, 50ms, 500ms and 5s and the question becomes the threshold at
15 //! which occupancy starts to cost something, rather than a pass/fail against
16 //! whatever a real instance happened to be doing that afternoon.
17 //!
18 //! It sleeps on the runtime rather than blocking, which is the point: the stub
19 //! must not itself consume the pool being measured.
20
21 use axum::Router;
22 use axum::extract::Path;
23 use axum::routing::get;
24 use std::time::Duration;
25
26 /// A running stub, alive until it is dropped.
27 pub(super) struct MtStub {
28 /// What to hand `IntegrationsConfig::mt_base_url`.
29 base_url: String,
30 server: tokio::task::JoinHandle<()>,
31 }
32
33 impl MtStub {
34 /// Bind on an ephemeral port and start answering.
35 ///
36 /// `latency` is slept before every answer. `memberships` is how many rows
37 /// come back, which decides how much describing the screen has to do once
38 /// the call lands.
39 pub(super) async fn start(latency: Duration, memberships: usize) -> Self {
40 let app = Router::new().route(
41 "/api/user/{user_id}/summary",
42 get(move |Path(_user_id): Path<String>| async move {
43 if !latency.is_zero() {
44 tokio::time::sleep(latency).await;
45 }
46 axum::Json(summary(memberships))
47 }),
48 );
49
50 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
51 .await
52 .expect("MT stub could not bind");
53 let addr = listener.local_addr().expect("MT stub has no address");
54
55 let server = tokio::spawn(async move {
56 // Ignore the result: the runner drops the stub at the end of the
57 // run and a shutdown error there is not a finding.
58 let _ = axum::serve(listener, app).await;
59 });
60
61 MtStub {
62 base_url: format!("http://{addr}"),
63 server,
64 }
65 }
66
67 pub(super) fn base_url(&self) -> String {
68 self.base_url.clone()
69 }
70 }
71
72 impl Drop for MtStub {
73 fn drop(&mut self) {
74 self.server.abort();
75 }
76 }
77
78 /// The shape `fetch` in `src/quasi/forum_memberships.rs` reads, and the same
79 /// shape the Askama handler in `routes::pages::public::landing` reads. Both
80 /// sides of the comparison parse this, so a change to it moves both together.
81 fn summary(memberships: usize) -> serde_json::Value {
82 let rows: Vec<serde_json::Value> = (0..memberships)
83 .map(|i| {
84 serde_json::json!({
85 "community_slug": format!("community-{i}"),
86 "community_name": format!("Community {i}"),
87 "role": if i == 0 { "Moderator" } else { "Member" },
88 "post_count": 40 + i as i64,
89 "joined_at": "2026-03-14T09:00:00Z",
90 })
91 })
92 .collect();
93 serde_json::json!({ "memberships": rows })
94 }
95