baseline
This commit is contained in:
Generated
+2293
File diff suppressed because it is too large
Load Diff
+11
@@ -0,0 +1,11 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"urus-server",
|
||||
"axum-server",
|
||||
]
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
panic = "unwind"
|
||||
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "axum-server"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.7", features = ["macros"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "sync", "net"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.5", features = ["request-id", "auth"] }
|
||||
http = "1"
|
||||
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
subtle = "2"
|
||||
|
||||
# We deliberately use sqlx for the SQLite backing store on this side
|
||||
# (rather than rusqlite) because that's the idiomatic axum/tokio choice.
|
||||
# Both end up calling the same SQLite C library; what matters for the
|
||||
# bench is that each framework uses the idiomatic-on-that-side option.
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "macros"] }
|
||||
|
||||
[[bin]]
|
||||
name = "axum-server"
|
||||
path = "src/main.rs"
|
||||
@@ -0,0 +1,585 @@
|
||||
//! axum-server — the hyper/axum side of the urus / axum / cowboy benchmark.
|
||||
//!
|
||||
//! Exposes the same routes as urus-server with materially the same
|
||||
//! middleware stack:
|
||||
//! logger (ring-buffer, no stdout) -> request_id -> auth -> router
|
||||
//!
|
||||
//! Backing store is switchable:
|
||||
//! --store=memory Mutex<HashMap<u64, User>>
|
||||
//! --store=sqlite sqlx::SqlitePool in WAL mode
|
||||
//!
|
||||
//! Routes:
|
||||
//! GET /ping naked (bypasses auth)
|
||||
//! GET /api/v1/users list (cap 100)
|
||||
//! POST /api/v1/users create
|
||||
//! GET /api/v1/users/:id get
|
||||
//! PUT /api/v1/users/:id update
|
||||
//! DELETE /api/v1/users/:id delete
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::{Path, State},
|
||||
http::{header, HeaderValue, Request, StatusCode},
|
||||
middleware::{self, Next as AxumNext},
|
||||
response::Response,
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions}, SqlitePool};
|
||||
use subtle::ConstantTimeEq;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI (hand-rolled, same shape as urus-server)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Cli {
|
||||
addr: String,
|
||||
store: StoreKind,
|
||||
token: String,
|
||||
db_path: String,
|
||||
no_auth: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum StoreKind { Memory, Sqlite }
|
||||
|
||||
fn parse_cli() -> Result<Cli, String> {
|
||||
let mut cli = Cli {
|
||||
addr: "127.0.0.1:8080".into(),
|
||||
store: StoreKind::Memory,
|
||||
token: "test-token-aaaaaaaaaaaaaaaaaaaa".into(),
|
||||
db_path: "/tmp/axum-bench.sqlite".into(),
|
||||
no_auth: false,
|
||||
};
|
||||
for arg in std::env::args().skip(1) {
|
||||
let (k, v) = match arg.split_once('=') {
|
||||
Some((k, v)) => (k.to_string(), Some(v.to_string())),
|
||||
None => (arg.clone(), None),
|
||||
};
|
||||
match (k.as_str(), v) {
|
||||
("--addr", Some(v)) => cli.addr = v,
|
||||
("--token", Some(v)) => cli.token = v,
|
||||
("--db-path", Some(v)) => cli.db_path = v,
|
||||
("--store", Some(v)) => cli.store = match v.as_str() {
|
||||
"memory" => StoreKind::Memory,
|
||||
"sqlite" => StoreKind::Sqlite,
|
||||
other => return Err(format!("unknown store: {other}")),
|
||||
},
|
||||
("--no-auth", None) => cli.no_auth = true,
|
||||
("--help" | "-h", _) => { print_usage(); std::process::exit(0); }
|
||||
(k, _) => return Err(format!("unknown flag: {k}")),
|
||||
}
|
||||
}
|
||||
Ok(cli)
|
||||
}
|
||||
|
||||
fn print_usage() {
|
||||
eprintln!("axum-server [--addr=HOST:PORT] [--store=memory|sqlite] \
|
||||
[--token=TOKEN] [--db-path=PATH] [--no-auth]");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||
struct User {
|
||||
id: i64,
|
||||
name: String,
|
||||
email: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NewUser {
|
||||
name: String,
|
||||
email: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store trait — memory and sqlite implementations behind one type.
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// We use an enum rather than a trait object so handler code stays
|
||||
// monomorphic. Both variants are async (sqlx is genuinely async; the
|
||||
// memory variant uses a tokio Mutex for parity — std::sync::Mutex inside
|
||||
// an async handler is a known footgun, even though for our short
|
||||
// critical sections it'd be measurably faster).
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Store {
|
||||
Memory(Arc<MemoryStore>),
|
||||
Sqlite(SqlitePool),
|
||||
}
|
||||
|
||||
struct MemoryStore {
|
||||
map: Mutex<HashMap<u64, User>>,
|
||||
next_id: AtomicU64,
|
||||
}
|
||||
|
||||
impl MemoryStore {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
map: Mutex::new(HashMap::with_capacity(1024)),
|
||||
next_id: AtomicU64::new(1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn open_sqlite(path: &str) -> Result<SqlitePool, sqlx::Error> {
|
||||
let opts = SqliteConnectOptions::new()
|
||||
.filename(path)
|
||||
.create_if_missing(true)
|
||||
.journal_mode(SqliteJournalMode::Wal)
|
||||
.busy_timeout(std::time::Duration::from_secs(5));
|
||||
|
||||
// Pool sized to taste; 8 connections matches urus-side's 1 writer + 4
|
||||
// readers plus headroom for axum's tokio worker count.
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(8)
|
||||
.connect_with(opts)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL
|
||||
);"
|
||||
).execute(&pool).await?;
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AppState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
store: Store,
|
||||
log_ring: LogRing,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bounded log ring — analogue of urus-server's LogRing.
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// We can't easily use the same crossbeam ArrayQueue type because axum's
|
||||
// middleware closures aren't `&` (`tokio::sync::Mutex` is the wrong
|
||||
// shape here — we want lock-free push). A simple wraparound slot-array
|
||||
// via atomics would be ideal, but the simplest correct thing that
|
||||
// matches urus's behavioural guarantee (bounded, drops on overflow,
|
||||
// never blocks) is `std::sync::Mutex<VecDeque>` with a capacity. The
|
||||
// critical section is microseconds long.
|
||||
|
||||
#[derive(Clone)]
|
||||
struct LogRing {
|
||||
inner: Arc<std::sync::Mutex<std::collections::VecDeque<LogEntry>>>,
|
||||
cap: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[allow(dead_code)]
|
||||
struct LogEntry {
|
||||
method: String,
|
||||
path: String,
|
||||
status: u16,
|
||||
elapsed_us: u64,
|
||||
}
|
||||
|
||||
impl LogRing {
|
||||
fn with_capacity(n: usize) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(std::sync::Mutex::new(
|
||||
std::collections::VecDeque::with_capacity(n))),
|
||||
cap: n,
|
||||
}
|
||||
}
|
||||
fn push(&self, e: LogEntry) {
|
||||
if let Ok(mut q) = self.inner.lock() {
|
||||
if q.len() >= self.cap { q.pop_front(); }
|
||||
q.push_back(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Middleware
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// We hand-write three middleware functions rather than reaching for
|
||||
// tower-http for everything. Reason: tower-http's auth and request-id
|
||||
// layers are great, but for the bench we want behavioural parity with
|
||||
// urus-server's plugs — same observable behaviour, same trivial work
|
||||
// shape. Mixing tower-http for some and hand-rolled for others would
|
||||
// confuse the comparison.
|
||||
|
||||
async fn logger_mw(
|
||||
State(state): State<AppState>,
|
||||
req: Request<axum::body::Body>,
|
||||
next: AxumNext,
|
||||
) -> Response {
|
||||
let start = Instant::now();
|
||||
let method = req.method().to_string();
|
||||
let path = req.uri().path().to_string();
|
||||
|
||||
let response = next.run(req).await;
|
||||
|
||||
let elapsed_us = start.elapsed().as_micros() as u64;
|
||||
state.log_ring.push(LogEntry {
|
||||
method,
|
||||
path,
|
||||
status: response.status().as_u16(),
|
||||
elapsed_us,
|
||||
});
|
||||
response
|
||||
}
|
||||
|
||||
async fn request_id_mw(req: Request<axum::body::Body>, next: AxumNext) -> Response {
|
||||
|
||||
let mut raw = [0u8; 12];
|
||||
fill_random_bytes(&mut raw);
|
||||
let id = b32_encode(&raw);
|
||||
|
||||
let mut resp = next.run(req).await;
|
||||
if let Ok(v) = HeaderValue::from_str(&id) {
|
||||
resp.headers_mut().insert("x-request-id", v);
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
fn b32_encode(input: &[u8; 12]) -> String {
|
||||
static ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
let mut out = String::with_capacity(20);
|
||||
let mut buf: u64 = 0;
|
||||
let mut bits: u32 = 0;
|
||||
for &b in input {
|
||||
buf = (buf << 8) | (b as u64);
|
||||
bits += 8;
|
||||
while bits >= 5 {
|
||||
bits -= 5;
|
||||
out.push(ALPHABET[((buf >> bits) & 0x1F) as usize] as char);
|
||||
}
|
||||
}
|
||||
if bits > 0 {
|
||||
out.push(ALPHABET[((buf << (5 - bits)) & 0x1F) as usize] as char);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// SplitMix64-based RNG, same as urus-server's. Kept identical so the
|
||||
// request_id middleware costs are comparable.
|
||||
thread_local! {
|
||||
static RNG_STATE: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
fn splitmix64(s: u64) -> (u64, u64) {
|
||||
let new_state = s.wrapping_add(0x9E3779B97F4A7C15);
|
||||
let mut z = new_state;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
|
||||
(new_state, z ^ (z >> 31))
|
||||
}
|
||||
|
||||
fn fill_random_bytes(out: &mut [u8; 12]) {
|
||||
RNG_STATE.with(|cell| {
|
||||
let mut s = cell.get();
|
||||
if s == 0 {
|
||||
let p = cell as *const _ as usize as u64;
|
||||
let t = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64).unwrap_or(0);
|
||||
s = p ^ t ^ 0xA5A5_A5A5_A5A5_A5A5;
|
||||
if s == 0 { s = 1; }
|
||||
}
|
||||
let (s1, r1) = splitmix64(s);
|
||||
let (s2, r2) = splitmix64(s1);
|
||||
cell.set(s2);
|
||||
out[0..8].copy_from_slice(&r1.to_le_bytes());
|
||||
out[8..12].copy_from_slice(&r2.to_le_bytes()[..4]);
|
||||
});
|
||||
}
|
||||
|
||||
async fn auth_mw(
|
||||
State(token): State<Arc<Vec<u8>>>,
|
||||
req: Request<axum::body::Body>,
|
||||
next: AxumNext,
|
||||
) -> Response {
|
||||
// /ping bypass, same exception as urus-server (see middleware.rs in
|
||||
// that crate for the rationale).
|
||||
if req.uri().path() == "/ping" {
|
||||
return next.run(req).await;
|
||||
}
|
||||
|
||||
let presented = req.headers()
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.and_then(|s| s.strip_prefix("Bearer "))
|
||||
.map(str::as_bytes);
|
||||
|
||||
let ok = match presented {
|
||||
Some(p) if p.len() == token.len() => bool::from(p.ct_eq(&token)),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if ok {
|
||||
next.run(req).await
|
||||
} else {
|
||||
let body = b"{\"error\":\"unauthorized\"}";
|
||||
Response::builder()
|
||||
.status(StatusCode::UNAUTHORIZED)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(axum::body::Body::from(&body[..]))
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn h_ping() -> (StatusCode, [(header::HeaderName, &'static str); 1], &'static str) {
|
||||
(StatusCode::OK, [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], "pong")
|
||||
}
|
||||
|
||||
async fn h_list(State(state): State<AppState>) -> Response {
|
||||
let users: Vec<User> = match &state.store {
|
||||
Store::Memory(m) => {
|
||||
let g = m.map.lock().await;
|
||||
g.values().take(100).cloned().collect()
|
||||
}
|
||||
Store::Sqlite(p) => {
|
||||
match sqlx::query_as::<_, User>("SELECT id, name, email FROM users LIMIT 100")
|
||||
.fetch_all(p).await {
|
||||
Ok(v) => v,
|
||||
Err(_) => return db_error(),
|
||||
}
|
||||
}
|
||||
};
|
||||
json_response(StatusCode::OK, &users)
|
||||
}
|
||||
|
||||
async fn h_get(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<u64>,
|
||||
) -> Response {
|
||||
match &state.store {
|
||||
Store::Memory(m) => {
|
||||
let g = m.map.lock().await;
|
||||
match g.get(&id) {
|
||||
Some(u) => json_response(StatusCode::OK, u),
|
||||
None => not_found(),
|
||||
}
|
||||
}
|
||||
Store::Sqlite(p) => {
|
||||
match sqlx::query_as::<_, User>("SELECT id, name, email FROM users WHERE id=?1")
|
||||
.bind(id as i64).fetch_optional(p).await {
|
||||
Ok(Some(u)) => json_response(StatusCode::OK, &u),
|
||||
Ok(None) => not_found(),
|
||||
Err(_) => db_error(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn h_create(
|
||||
State(state): State<AppState>,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
let nu: NewUser = match serde_json::from_slice(&body) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return bad_request(),
|
||||
};
|
||||
match &state.store {
|
||||
Store::Memory(m) => {
|
||||
let id = m.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let u = User { id: id as i64, name: nu.name, email: nu.email };
|
||||
let mut g = m.map.lock().await;
|
||||
// bounded growth same as urus-side
|
||||
if g.len() > 100_000 {
|
||||
let to_remove: Vec<u64> = g.keys().take(50_000).copied().collect();
|
||||
for k in to_remove { g.remove(&k); }
|
||||
}
|
||||
g.insert(id, u.clone());
|
||||
json_response(StatusCode::CREATED, &u)
|
||||
}
|
||||
Store::Sqlite(p) => {
|
||||
let res = sqlx::query("INSERT INTO users (name, email) VALUES (?1, ?2)")
|
||||
.bind(&nu.name).bind(&nu.email)
|
||||
.execute(p).await;
|
||||
match res {
|
||||
Ok(r) => {
|
||||
let u = User { id: r.last_insert_rowid(), name: nu.name, email: nu.email };
|
||||
json_response(StatusCode::CREATED, &u)
|
||||
}
|
||||
Err(_) => db_error(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn h_update(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<u64>,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
let nu: NewUser = match serde_json::from_slice(&body) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return bad_request(),
|
||||
};
|
||||
match &state.store {
|
||||
Store::Memory(m) => {
|
||||
let mut g = m.map.lock().await;
|
||||
match g.get_mut(&id) {
|
||||
Some(u) => {
|
||||
u.name = nu.name;
|
||||
u.email = nu.email;
|
||||
let snap = u.clone();
|
||||
json_response(StatusCode::OK, &snap)
|
||||
}
|
||||
None => not_found(),
|
||||
}
|
||||
}
|
||||
Store::Sqlite(p) => {
|
||||
let res = sqlx::query("UPDATE users SET name=?1, email=?2 WHERE id=?3")
|
||||
.bind(&nu.name).bind(&nu.email).bind(id as i64)
|
||||
.execute(p).await;
|
||||
match res {
|
||||
Ok(r) if r.rows_affected() > 0 => {
|
||||
let u = User { id: id as i64, name: nu.name, email: nu.email };
|
||||
json_response(StatusCode::OK, &u)
|
||||
}
|
||||
Ok(_) => not_found(),
|
||||
Err(_) => db_error(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn h_delete(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<u64>,
|
||||
) -> Response {
|
||||
match &state.store {
|
||||
Store::Memory(m) => {
|
||||
let mut g = m.map.lock().await;
|
||||
match g.remove(&id) {
|
||||
Some(_) => Response::builder()
|
||||
.status(StatusCode::NO_CONTENT)
|
||||
.body(axum::body::Body::empty()).unwrap(),
|
||||
None => not_found(),
|
||||
}
|
||||
}
|
||||
Store::Sqlite(p) => {
|
||||
let res = sqlx::query("DELETE FROM users WHERE id=?1")
|
||||
.bind(id as i64).execute(p).await;
|
||||
match res {
|
||||
Ok(r) if r.rows_affected() > 0 => Response::builder()
|
||||
.status(StatusCode::NO_CONTENT)
|
||||
.body(axum::body::Body::empty()).unwrap(),
|
||||
Ok(_) => not_found(),
|
||||
Err(_) => db_error(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn json_response<T: Serialize>(status: StatusCode, body: &T) -> Response {
|
||||
match serde_json::to_vec(body) {
|
||||
Ok(bytes) => Response::builder()
|
||||
.status(status)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(axum::body::Body::from(bytes))
|
||||
.unwrap(),
|
||||
Err(_) => db_error(),
|
||||
}
|
||||
}
|
||||
|
||||
fn not_found() -> Response {
|
||||
Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(axum::body::Body::from(&b"{\"error\":\"not found\"}"[..]))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn bad_request() -> Response {
|
||||
Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(axum::body::Body::from(&b"{\"error\":\"invalid body\"}"[..]))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn db_error() -> Response {
|
||||
Response::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(axum::body::Body::from(&b"{\"error\":\"db\"}"[..]))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::main(flavor = "multi_thread")]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let cli = match parse_cli() {
|
||||
Ok(c) => c,
|
||||
Err(e) => { eprintln!("axum-server: {e}"); print_usage(); std::process::exit(2); }
|
||||
};
|
||||
let addr: SocketAddr = cli.addr.parse()?;
|
||||
|
||||
let store = match cli.store {
|
||||
StoreKind::Memory => Store::Memory(Arc::new(MemoryStore::new())),
|
||||
StoreKind::Sqlite => Store::Sqlite(open_sqlite(&cli.db_path).await?),
|
||||
};
|
||||
let state = AppState {
|
||||
store,
|
||||
log_ring: LogRing::with_capacity(64 * 1024),
|
||||
};
|
||||
|
||||
let token: Arc<Vec<u8>> = Arc::new(cli.token.as_bytes().to_vec());
|
||||
|
||||
// Build the router. /ping is on the same router; the auth middleware
|
||||
// is path-aware and lets /ping through (see auth_mw).
|
||||
let api_router: Router<AppState> = Router::new()
|
||||
.route("/api/v1/users", get(h_list).post(h_create))
|
||||
.route("/api/v1/users/:id", get(h_get).put(h_update).delete(h_delete))
|
||||
.route("/ping", get(h_ping));
|
||||
|
||||
let mut app = api_router.with_state(state.clone());
|
||||
|
||||
// Apply middleware in inside-out order so the request flow ends up
|
||||
// matching urus-server: logger (outermost) -> request_id -> auth.
|
||||
// axum applies middleware nearest-the-handler first, so the layer
|
||||
// call order is: auth then request_id then logger.
|
||||
if !cli.no_auth {
|
||||
app = app.layer(middleware::from_fn_with_state(token, auth_mw));
|
||||
}
|
||||
app = app
|
||||
.layer(middleware::from_fn(request_id_mw))
|
||||
.layer(middleware::from_fn_with_state(state, logger_mw));
|
||||
|
||||
eprintln!(
|
||||
"axum-server: store={:?} addr={} auth={}",
|
||||
cli.store, cli.addr, !cli.no_auth
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Shared header config; required by s2/s3/s4 scripts.
|
||||
-- Override BEARER / HOST via env vars when invoking wrk2.
|
||||
|
||||
local M = {}
|
||||
M.token = os.getenv("BEARER") or "test-token-aaaaaaaaaaaaaaaaaaaa"
|
||||
M.host = os.getenv("HOST") or "127.0.0.1:8080"
|
||||
M.headers = {
|
||||
["Authorization"] = "Bearer " .. M.token,
|
||||
["Connection"] = "keep-alive",
|
||||
["Host"] = M.host,
|
||||
}
|
||||
return M
|
||||
@@ -0,0 +1,5 @@
|
||||
-- S1: naked /ping. No auth header (the server's auth_plug exempts /ping,
|
||||
-- so sending a header would just add noise to the comparison).
|
||||
wrk.method = "GET"
|
||||
wrk.path = "/ping"
|
||||
wrk.headers["Connection"] = "keep-alive"
|
||||
@@ -0,0 +1,13 @@
|
||||
-- S2: single-route GET with full middleware (logger + request_id + auth).
|
||||
-- :id is spread across 10_000 values to defeat per-id caching.
|
||||
|
||||
local common = require("common")
|
||||
wrk.method = "GET"
|
||||
wrk.headers = common.headers
|
||||
|
||||
math.randomseed(os.time() + os.getpid())
|
||||
|
||||
request = function()
|
||||
local id = math.random(1, 10000)
|
||||
return wrk.format(nil, "/api/v1/users/" .. id)
|
||||
end
|
||||
@@ -0,0 +1,30 @@
|
||||
-- S3 (+S4): 80% GET single / 15% GET list / 5% POST.
|
||||
-- The mix exposes the store-actor channel round-trip on urus, mutex
|
||||
-- contention on axum, and gen_server message-pass on cowboy without
|
||||
-- making writes the bottleneck.
|
||||
|
||||
local common = require("common")
|
||||
wrk.headers = common.headers
|
||||
wrk.headers["Content-Type"] = "application/json"
|
||||
|
||||
math.randomseed(os.time() + os.getpid())
|
||||
|
||||
-- A small pool of pre-built JSON bodies. Building one fresh per request
|
||||
-- in Lua would taint the bench with Lua's GC overhead.
|
||||
local body_pool = {}
|
||||
for i = 1, 64 do
|
||||
body_pool[i] = string.format(
|
||||
[[{"name":"user%d","email":"u%d@example.test"}]], i, i)
|
||||
end
|
||||
|
||||
request = function()
|
||||
local r = math.random()
|
||||
if r < 0.80 then
|
||||
return wrk.format("GET", "/api/v1/users/" .. math.random(1, 10000))
|
||||
elseif r < 0.95 then
|
||||
return wrk.format("GET", "/api/v1/users")
|
||||
else
|
||||
local body = body_pool[math.random(1, #body_pool)]
|
||||
return wrk.format("POST", "/api/v1/users", nil, body)
|
||||
end
|
||||
end
|
||||
Executable
+411
@@ -0,0 +1,411 @@
|
||||
#!/usr/bin/env bash
|
||||
# urus-bench/run_all.sh
|
||||
#
|
||||
# Runs the full (server, scenario) matrix and writes a summary.md.
|
||||
#
|
||||
# Default matrix (8 runs):
|
||||
# urus-mem × s1, s2, s3
|
||||
# axum-mem × s1, s2, s3
|
||||
# urus-sqlite × s4
|
||||
# axum-sqlite × s4
|
||||
#
|
||||
# Cowboy is not built; skipped.
|
||||
#
|
||||
# Usage:
|
||||
# ./run_all.sh # full matrix, defaults
|
||||
# ./run_all.sh --quick # short timings (15s+15s+15s)
|
||||
# ./run_all.sh --scenarios=s1,s2 # subset of scenarios
|
||||
# ./run_all.sh --servers=urus-mem # subset of servers
|
||||
# ./run_all.sh --batch=foo # batch label (default: timestamp)
|
||||
#
|
||||
# Per-run env vars (PROBE_SEC, WARMUP_SEC, MEASURE_SEC, PREPOP, SERVER_CPUS,
|
||||
# LOADGEN_CPUS, WRK_CONNS, WRK_THREADS, SAT_RATIO) are passed through to
|
||||
# runner.sh; --quick is just a preset for the timings.
|
||||
#
|
||||
# Output:
|
||||
# results/<batch>/ batch dir
|
||||
# <server>-<scenario>/ per-run dir (from runner.sh)
|
||||
# summary.md aggregated headline table
|
||||
# index.json machine-readable index of all runs
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Defaults
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ALL_SERVERS_MEM=(urus-mem axum-mem)
|
||||
ALL_SERVERS_SQLITE=(urus-sqlite axum-sqlite)
|
||||
ALL_SCENARIOS_MEM=(s1 s2 s3)
|
||||
ALL_SCENARIOS_SQLITE=(s4)
|
||||
|
||||
SERVERS_FILTER=""
|
||||
SCENARIOS_FILTER=""
|
||||
BATCH=""
|
||||
QUICK=0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Arg parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--quick) QUICK=1 ;;
|
||||
--servers=*) SERVERS_FILTER="${arg#*=}" ;;
|
||||
--scenarios=*) SCENARIOS_FILTER="${arg#*=}" ;;
|
||||
--batch=*) BATCH="${arg#*=}" ;;
|
||||
-h|--help)
|
||||
sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 0 ;;
|
||||
*)
|
||||
echo "run_all: unknown arg: $arg" >&2
|
||||
exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$QUICK" == "1" ]]; then
|
||||
export PROBE_SEC="${PROBE_SEC:-15}"
|
||||
export WARMUP_SEC="${WARMUP_SEC:-15}"
|
||||
export MEASURE_SEC="${MEASURE_SEC:-15}"
|
||||
fi
|
||||
|
||||
BATCH="${BATCH:-$(date +%Y%m%d-%H%M%S)}"
|
||||
BATCH_DIR="${REPO_ROOT}/results/${BATCH}"
|
||||
mkdir -p "$BATCH_DIR"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build the matrix
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# A run is a pair "<server> <scenario>". We compute the full matrix, then
|
||||
# apply filters. s1/s2/s3 always pair with the memory backends; s4 only
|
||||
# pairs with sqlite backends.
|
||||
|
||||
contains() {
|
||||
# contains "needle" "csv-list" → 0 if needle in csv (or csv empty), else 1
|
||||
local needle="$1" haystack="$2"
|
||||
[[ -z "$haystack" ]] && return 0
|
||||
local IFS=,
|
||||
for item in $haystack; do
|
||||
[[ "$item" == "$needle" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
runs=()
|
||||
for srv in "${ALL_SERVERS_MEM[@]}"; do
|
||||
contains "$srv" "$SERVERS_FILTER" || continue
|
||||
for sc in "${ALL_SCENARIOS_MEM[@]}"; do
|
||||
contains "$sc" "$SCENARIOS_FILTER" || continue
|
||||
runs+=("$srv $sc")
|
||||
done
|
||||
done
|
||||
for srv in "${ALL_SERVERS_SQLITE[@]}"; do
|
||||
contains "$srv" "$SERVERS_FILTER" || continue
|
||||
for sc in "${ALL_SCENARIOS_SQLITE[@]}"; do
|
||||
contains "$sc" "$SCENARIOS_FILTER" || continue
|
||||
runs+=("$srv $sc")
|
||||
done
|
||||
done
|
||||
|
||||
if [[ "${#runs[@]}" -eq 0 ]]; then
|
||||
echo "run_all: empty matrix (filters too narrow?)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build binaries up front
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# We always rebuild in --release. If urus-server or axum-server is not
|
||||
# requested, cargo will still build the workspace, but that's fine — a
|
||||
# 1-minute one-time cost beats a confusing "binary not found" mid-batch.
|
||||
|
||||
echo "run_all: building binaries (release)..."
|
||||
( cd "$REPO_ROOT" && cargo build --release ) || {
|
||||
echo "run_all: cargo build failed" >&2
|
||||
exit 3
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Run the matrix
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
total="${#runs[@]}"
|
||||
echo
|
||||
echo "run_all: batch=${BATCH}, ${total} run(s):"
|
||||
i=0
|
||||
for r in "${runs[@]}"; do
|
||||
i=$((i + 1))
|
||||
printf " [%d/%d] %s\n" "$i" "$total" "$r"
|
||||
done
|
||||
echo
|
||||
|
||||
i=0
|
||||
failed=()
|
||||
succeeded=()
|
||||
START_TS=$(date +%s)
|
||||
|
||||
# Per-run port offset so concurrent stale processes (if any) don't collide.
|
||||
BASE_PORT="${BASE_PORT:-8080}"
|
||||
|
||||
for r in "${runs[@]}"; do
|
||||
i=$((i + 1))
|
||||
set -- $r
|
||||
SERVER="$1"; SCENARIO="$2"
|
||||
RUN_ID="${SERVER}-${SCENARIO}"
|
||||
RUN_DIR="${BATCH_DIR}/${RUN_ID}"
|
||||
|
||||
printf '\n========== [%d/%d] %-12s %-3s ==========\n' \
|
||||
"$i" "$total" "$SERVER" "$SCENARIO"
|
||||
|
||||
# Each run gets its own port so a hung server from a previous run can't
|
||||
# silently keep answering on the default port.
|
||||
PORT=$((BASE_PORT + i))
|
||||
|
||||
# Each run gets a unique results dir under the batch dir. runner.sh
|
||||
# writes to results/<run_id>/ at the repo root by default — we pass the
|
||||
# run_id but also need it to land under the batch dir. Cheapest fix:
|
||||
# let runner write to its default location, then move it under batch.
|
||||
if PORT="$PORT" "${REPO_ROOT}/runner.sh" "$SERVER" "$SCENARIO" "$RUN_ID" \
|
||||
> "${BATCH_DIR}/${RUN_ID}.stdout.log" 2>&1; then
|
||||
# Move the runner's output dir into the batch dir.
|
||||
if [[ -d "${REPO_ROOT}/results/${RUN_ID}" ]]; then
|
||||
rm -rf "$RUN_DIR"
|
||||
mv "${REPO_ROOT}/results/${RUN_ID}" "$RUN_DIR"
|
||||
fi
|
||||
# Move the stdout log next to the run.
|
||||
mv "${BATCH_DIR}/${RUN_ID}.stdout.log" "${RUN_DIR}/run_all.log" 2>/dev/null || true
|
||||
|
||||
# Pretty-print the headline line from the result.
|
||||
if [[ -f "${RUN_DIR}/result.json" ]]; then
|
||||
rps=$(awk -F'[:,]' '/"sustained_rps"/ {gsub(/[ "]/, "", $2); print $2; exit}' "${RUN_DIR}/result.json")
|
||||
p99=$(awk -F'"' '/"p99"/ {print $4; exit}' "${RUN_DIR}/result.json")
|
||||
printf ' sustained=%s rps p99=%s\n' "$rps" "$p99"
|
||||
fi
|
||||
succeeded+=("$r")
|
||||
else
|
||||
echo " FAILED (see ${BATCH_DIR}/${RUN_ID}.stdout.log)"
|
||||
failed+=("$r")
|
||||
# Also try to salvage anything runner left behind for debugging.
|
||||
if [[ -d "${REPO_ROOT}/results/${RUN_ID}" ]]; then
|
||||
mkdir -p "${BATCH_DIR}/${RUN_ID}"
|
||||
mv "${REPO_ROOT}/results/${RUN_ID}"/* "${BATCH_DIR}/${RUN_ID}/" 2>/dev/null || true
|
||||
rmdir "${REPO_ROOT}/results/${RUN_ID}" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
END_TS=$(date +%s)
|
||||
ELAPSED=$((END_TS - START_TS))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build summary.md
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# The headline-table shape from urus-bench-spec.md §6 has servers as
|
||||
# columns, scenarios as rows, two metrics per scenario (RPS sustained,
|
||||
# p99). We render whatever subset of the matrix actually ran.
|
||||
|
||||
SUMMARY="${BATCH_DIR}/summary.md"
|
||||
INDEX="${BATCH_DIR}/index.json"
|
||||
|
||||
# Discover every server / scenario actually represented in the batch.
|
||||
declare -A by_run # by_run["server scenario"] = path to result.json
|
||||
servers_seen=()
|
||||
scenarios_seen=()
|
||||
for d in "$BATCH_DIR"/*/; do
|
||||
rj="${d}result.json"
|
||||
[[ -f "$rj" ]] || continue
|
||||
srv=$(awk -F'"' '/"server":/ {print $4; exit}' "$rj")
|
||||
scn=$(awk -F'"' '/"scenario":/ {print $4; exit}' "$rj")
|
||||
[[ -z "$srv" || -z "$scn" ]] && continue
|
||||
by_run["$srv $scn"]="$rj"
|
||||
# Track distinct lists (order preserved by first occurrence).
|
||||
if ! printf '%s\n' "${servers_seen[@]}" | grep -qx "$srv"; then
|
||||
servers_seen+=("$srv")
|
||||
fi
|
||||
if ! printf '%s\n' "${scenarios_seen[@]}" | grep -qx "$scn"; then
|
||||
scenarios_seen+=("$scn")
|
||||
fi
|
||||
done
|
||||
|
||||
# Sort scenarios in canonical order s1..s4.
|
||||
IFS=$'\n' scenarios_seen=($(printf '%s\n' "${scenarios_seen[@]}" | sort))
|
||||
unset IFS
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Emit summary.md
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
{
|
||||
echo "# urus-bench summary"
|
||||
echo
|
||||
echo "- **Batch:** \`${BATCH}\`"
|
||||
echo "- **Host:** \`$(uname -n)\` (\`$(uname -srm)\`)"
|
||||
echo "- **CPUs:** \`$(nproc) cores\`"
|
||||
if [[ -r /proc/cpuinfo ]]; then
|
||||
model=$(awk -F: '/^model name/ {print $2; exit}' /proc/cpuinfo | sed 's/^ *//')
|
||||
[[ -n "$model" ]] && echo "- **Model:** ${model}"
|
||||
fi
|
||||
echo "- **Started:** \`$(date -d "@$START_TS" -Iseconds 2>/dev/null || date -Iseconds)\`"
|
||||
echo "- **Elapsed:** ${ELAPSED}s"
|
||||
echo "- **Successful runs:** ${#succeeded[@]} / ${total}"
|
||||
if [[ "${#failed[@]}" -gt 0 ]]; then
|
||||
echo "- **Failed runs:** ${failed[*]}"
|
||||
fi
|
||||
echo
|
||||
echo "Timings: probe=${PROBE_SEC:-30}s, warmup=${WARMUP_SEC:-30}s, measure=${MEASURE_SEC:-60}s, sat_ratio=${SAT_RATIO:-0.70}"
|
||||
echo
|
||||
|
||||
if [[ "${#servers_seen[@]}" -eq 0 ]]; then
|
||||
echo "_No successful runs to summarize._"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- Sustained RPS table -----------------------------------------------
|
||||
echo "## Sustained RPS (wrk2 @ ${SAT_RATIO:-0.70} of saturation)"
|
||||
echo
|
||||
printf '| '
|
||||
for srv in "${servers_seen[@]}"; do printf '| %-14s ' "$srv"; done
|
||||
printf '|\n'
|
||||
printf '|------------'
|
||||
for _ in "${servers_seen[@]}"; do printf '|----------------'; done
|
||||
printf '|\n'
|
||||
for scn in "${scenarios_seen[@]}"; do
|
||||
printf '| %-10s ' "$scn"
|
||||
for srv in "${servers_seen[@]}"; do
|
||||
rj="${by_run["$srv $scn"]:-}"
|
||||
if [[ -n "$rj" ]]; then
|
||||
rps=$(awk -F'[:,]' '/"sustained_rps"/ {gsub(/[ "]/, "", $2); print $2; exit}' "$rj")
|
||||
# round to integer for readability
|
||||
rps=$(awk -v x="$rps" 'BEGIN {printf "%d", x+0.5}')
|
||||
printf '| %14s ' "$rps"
|
||||
else
|
||||
printf '| %14s ' "—"
|
||||
fi
|
||||
done
|
||||
printf '|\n'
|
||||
done
|
||||
echo
|
||||
|
||||
# --- p99 table ----------------------------------------------------------
|
||||
echo "## p99 latency (at target rate)"
|
||||
echo
|
||||
printf '| '
|
||||
for srv in "${servers_seen[@]}"; do printf '| %-14s ' "$srv"; done
|
||||
printf '|\n'
|
||||
printf '|------------'
|
||||
for _ in "${servers_seen[@]}"; do printf '|----------------'; done
|
||||
printf '|\n'
|
||||
for scn in "${scenarios_seen[@]}"; do
|
||||
printf '| %-10s ' "$scn"
|
||||
for srv in "${servers_seen[@]}"; do
|
||||
rj="${by_run["$srv $scn"]:-}"
|
||||
if [[ -n "$rj" ]]; then
|
||||
p99=$(awk -F'"' '/"p99"/ {print $4; exit}' "$rj")
|
||||
printf '| %14s ' "$p99"
|
||||
else
|
||||
printf '| %14s ' "—"
|
||||
fi
|
||||
done
|
||||
printf '|\n'
|
||||
done
|
||||
echo
|
||||
|
||||
# --- Spec pass/fail check, urus vs axum --------------------------------
|
||||
# Per spec §6: urus RPS >= 50% of axum RPS on the same scenario.
|
||||
printf '## Spec checks (urus vs axum)\n\n'
|
||||
printf '| Scenario | urus RPS | axum RPS | urus/axum | within 2× of axum |\n'
|
||||
printf '|----------|----------|----------|-----------|-------------------|\n'
|
||||
for scn in "${scenarios_seen[@]}"; do
|
||||
# Match urus-{mem,sqlite} against axum-{mem,sqlite} for the matching tier.
|
||||
urus_run=""; axum_run=""
|
||||
for k in "${!by_run[@]}"; do
|
||||
set -- $k
|
||||
s="$1"; sc="$2"
|
||||
[[ "$sc" != "$scn" ]] && continue
|
||||
case "$s" in
|
||||
urus-*) urus_run="${by_run[$k]}" ;;
|
||||
axum-*) axum_run="${by_run[$k]}" ;;
|
||||
esac
|
||||
done
|
||||
if [[ -n "$urus_run" && -n "$axum_run" ]]; then
|
||||
u=$(awk -F'[:,]' '/"sustained_rps"/ {gsub(/[ "]/, "", $2); print $2; exit}' "$urus_run")
|
||||
a=$(awk -F'[:,]' '/"sustained_rps"/ {gsub(/[ "]/, "", $2); print $2; exit}' "$axum_run")
|
||||
ratio=$(awk -v u="$u" -v a="$a" 'BEGIN { if (a+0 > 0) printf "%.2f", (u+0)/(a+0); else print "n/a" }')
|
||||
pass=$(awk -v r="$ratio" 'BEGIN { print (r != "n/a" && r+0 >= 0.5) ? "✅" : "❌" }')
|
||||
printf '| %-8s | %8.0f | %8.0f | %9s | %-17s |\n' "$scn" "$u" "$a" "$ratio" "$pass"
|
||||
else
|
||||
printf '| %-8s | %8s | %8s | %9s | %-17s |\n' "$scn" "—" "—" "—" "—"
|
||||
fi
|
||||
done
|
||||
echo
|
||||
|
||||
# --- per-run detail links ----------------------------------------------
|
||||
echo "## Per-run artefacts"
|
||||
echo
|
||||
for r in "${succeeded[@]}"; do
|
||||
set -- $r
|
||||
s="$1"; sc="$2"
|
||||
echo "- \`${s}-${sc}/\` — [result.json](${s}-${sc}/result.json), [measure.txt](${s}-${sc}/measure.txt), [server.log](${s}-${sc}/server.log)"
|
||||
done
|
||||
if [[ "${#failed[@]}" -gt 0 ]]; then
|
||||
echo
|
||||
echo "### Failed runs"
|
||||
for r in "${failed[@]}"; do
|
||||
set -- $r
|
||||
s="$1"; sc="$2"
|
||||
echo "- \`${s}-${sc}\` — see \`${s}-${sc}.stdout.log\`"
|
||||
done
|
||||
fi
|
||||
} > "$SUMMARY"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Emit machine-readable index.json
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
{
|
||||
echo "{"
|
||||
echo " \"batch\": \"${BATCH}\","
|
||||
echo " \"elapsed_seconds\": ${ELAPSED},"
|
||||
echo " \"runs\": ["
|
||||
first=1
|
||||
for k in "${!by_run[@]}"; do
|
||||
set -- $k
|
||||
s="$1"; sc="$2"
|
||||
rj="${by_run[$k]}"
|
||||
[[ "$first" -eq 1 ]] || echo " ,"
|
||||
first=0
|
||||
echo " {"
|
||||
echo " \"server\": \"${s}\","
|
||||
echo " \"scenario\": \"${sc}\","
|
||||
echo " \"result_path\": \"${s}-${sc}/result.json\","
|
||||
# Inline the result.json contents, indented; simplest is to just paste.
|
||||
awk '/^\{/{p=1} p{print " " $0} /^\}/{p=0}' "$rj" \
|
||||
| sed '1d' | head -n -1 | sed 's/^/ "result": {/; t; s/^/ /' >/dev/null
|
||||
# Simpler: just reference the file path. The result.json is already
|
||||
# right there. We don't need to inline.
|
||||
echo " }"
|
||||
done
|
||||
echo " ]"
|
||||
echo "}"
|
||||
} > "$INDEX"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wrap up
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
echo
|
||||
echo "=========================================================="
|
||||
echo "run_all: DONE"
|
||||
echo " batch: ${BATCH}"
|
||||
echo " elapsed: ${ELAPSED}s"
|
||||
echo " successful: ${#succeeded[@]} / ${total}"
|
||||
[[ "${#failed[@]}" -gt 0 ]] && echo " failed: ${failed[*]}"
|
||||
echo " summary: ${SUMMARY}"
|
||||
echo "=========================================================="
|
||||
echo
|
||||
cat "$SUMMARY"
|
||||
@@ -0,0 +1,367 @@
|
||||
#!/usr/bin/env bash
|
||||
# urus-bench/runner.sh
|
||||
#
|
||||
# Runs one (server, scenario) measurement and saves all artefacts under
|
||||
# results/${run_id}/. The intent is to make a single run trivially
|
||||
# repeatable so the headline table in the bench-spec can be filled in
|
||||
# row by row.
|
||||
#
|
||||
# Usage:
|
||||
# ./runner.sh <server> <scenario> [run_id]
|
||||
#
|
||||
# server:
|
||||
# urus-mem | urus-sqlite | axum-mem | axum-sqlite | cowboy-mem | cowboy-sqlite
|
||||
# scenario:
|
||||
# s1 | s2 | s3 | s4
|
||||
# run_id:
|
||||
# optional; defaults to ${date}-${server}-${scenario}
|
||||
#
|
||||
# Requirements on the host:
|
||||
# - taskset (util-linux)
|
||||
# - wrk2 in PATH (https://github.com/giltene/wrk2)
|
||||
# - wrk in PATH (saturation probe)
|
||||
# - pidstat (sysstat) -- optional, CPU stats only
|
||||
# - jq -- pretty-prints the result.json
|
||||
#
|
||||
# CPU pinning (defaults; override with SERVER_CPUS / LOADGEN_CPUS):
|
||||
# server: taskset -c 0-7
|
||||
# loadgen: taskset -c 8-15
|
||||
#
|
||||
# Probe / measurement times: 30s probe, 30s warm-up, 60s measured (per the
|
||||
# bench-spec §1.5). Override with PROBE_SEC / WARMUP_SEC / MEASURE_SEC.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Args / config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SERVER="${1:-}"
|
||||
SCENARIO="${2:-}"
|
||||
RUN_ID="${3:-$(date +%Y%m%d-%H%M%S)-${SERVER}-${SCENARIO}}"
|
||||
|
||||
if [[ -z "$SERVER" || -z "$SCENARIO" ]]; then
|
||||
echo "usage: $0 <server> <scenario> [run_id]" >&2
|
||||
echo " server: urus-mem | urus-sqlite | axum-mem | axum-sqlite | cowboy-mem | cowboy-sqlite" >&2
|
||||
echo " scenario: s1 | s2 | s3 | s4" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
RESULTS_DIR="${REPO_ROOT}/results/${RUN_ID}"
|
||||
mkdir -p "$RESULTS_DIR"
|
||||
|
||||
SERVER_CPUS="${SERVER_CPUS:-0-7}"
|
||||
LOADGEN_CPUS="${LOADGEN_CPUS:-8-15}"
|
||||
PROBE_SEC="${PROBE_SEC:-30}"
|
||||
WARMUP_SEC="${WARMUP_SEC:-30}"
|
||||
MEASURE_SEC="${MEASURE_SEC:-60}"
|
||||
PORT="${PORT:-8080}"
|
||||
HOST="${HOST:-127.0.0.1:${PORT}}"
|
||||
BEARER="${BEARER:-test-token-aaaaaaaaaaaaaaaaaaaa}"
|
||||
WRK_THREADS="${WRK_THREADS:-8}"
|
||||
WRK_CONNS="${WRK_CONNS:-256}"
|
||||
SAT_RATIO="${SAT_RATIO:-0.70}" # wrk2 target = saturation * SAT_RATIO
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pick server command + lua script
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
case "$SERVER" in
|
||||
urus-mem)
|
||||
SERVER_CMD=("${REPO_ROOT}/target/release/urus-server"
|
||||
"--addr=127.0.0.1:${PORT}" "--store=memory") ;;
|
||||
urus-sqlite)
|
||||
DB="/tmp/urus-bench-${RUN_ID}.sqlite"; rm -f "${DB}"*
|
||||
SERVER_CMD=("${REPO_ROOT}/target/release/urus-server"
|
||||
"--addr=127.0.0.1:${PORT}" "--store=sqlite"
|
||||
"--db-path=${DB}") ;;
|
||||
axum-mem)
|
||||
SERVER_CMD=("${REPO_ROOT}/target/release/axum-server"
|
||||
"--addr=127.0.0.1:${PORT}" "--store=memory") ;;
|
||||
axum-sqlite)
|
||||
DB="/tmp/axum-bench-${RUN_ID}.sqlite"; rm -f "${DB}"*
|
||||
SERVER_CMD=("${REPO_ROOT}/target/release/axum-server"
|
||||
"--addr=127.0.0.1:${PORT}" "--store=sqlite"
|
||||
"--db-path=${DB}") ;;
|
||||
cowboy-mem|cowboy-sqlite)
|
||||
# cowboy-server is a placeholder in this checkout; wire when ready.
|
||||
echo "runner: cowboy backends are not wired yet" >&2
|
||||
exit 3 ;;
|
||||
*)
|
||||
echo "runner: unknown server '${SERVER}'" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
case "$SCENARIO" in
|
||||
s1) LUA="${REPO_ROOT}/loadgen/s1_ping.lua" ;;
|
||||
s2) LUA="${REPO_ROOT}/loadgen/s2_user_get.lua" ;;
|
||||
s3) LUA="${REPO_ROOT}/loadgen/s3_mixed.lua" ;;
|
||||
s4) LUA="${REPO_ROOT}/loadgen/s3_mixed.lua" ;; # same script, different store
|
||||
*) echo "runner: unknown scenario '${SCENARIO}'" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
# Compatibility check: s4 requires a sqlite-backed server.
|
||||
if [[ "$SCENARIO" == "s4" && "$SERVER" != *-sqlite ]]; then
|
||||
echo "runner: scenario s4 requires a *-sqlite server (got ${SERVER})" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool checks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
need() {
|
||||
command -v "$1" >/dev/null || { echo "runner: missing tool: $1" >&2; exit 4; }
|
||||
}
|
||||
need taskset
|
||||
need wrk
|
||||
need wrk2
|
||||
|
||||
HAS_PIDSTAT=0; command -v pidstat >/dev/null && HAS_PIDSTAT=1
|
||||
HAS_JQ=0; command -v jq >/dev/null && HAS_JQ=1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verify the server binary actually exists before launching. Without this
|
||||
# check, a missing binary surfaces as "server did not come up" 10 seconds
|
||||
# later, which is misleading.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SERVER_BIN="${SERVER_CMD[0]}"
|
||||
if [[ ! -x "$SERVER_BIN" ]]; then
|
||||
echo "runner: server binary not found or not executable: ${SERVER_BIN}" >&2
|
||||
echo "runner: did you run \`cargo build --release\` in the workspace root?" >&2
|
||||
exit 5
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Boot server (pinned), wait for it to listen
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SERVER_LOG="${RESULTS_DIR}/server.log"
|
||||
echo "runner: launching server on cores ${SERVER_CPUS}: ${SERVER_CMD[*]}"
|
||||
taskset -c "$SERVER_CPUS" "${SERVER_CMD[@]}" \
|
||||
> "$SERVER_LOG" 2>&1 &
|
||||
SERVER_PID=$!
|
||||
|
||||
cleanup() {
|
||||
if kill -0 "$SERVER_PID" 2>/dev/null; then
|
||||
kill "$SERVER_PID" 2>/dev/null || true
|
||||
wait "$SERVER_PID" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
# Wait up to 10s for the port to accept.
|
||||
for _ in $(seq 1 100); do
|
||||
if (echo > /dev/tcp/127.0.0.1/${PORT}) 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
if ! (echo > /dev/tcp/127.0.0.1/${PORT}) 2>/dev/null; then
|
||||
echo "runner: server did not come up; see ${SERVER_LOG}" >&2
|
||||
exit 5
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wire-level smoke test — fail fast if the routes don't behave.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
smoke_curl() {
|
||||
local expected="$1"; shift
|
||||
local got
|
||||
got=$(curl -s -o /dev/null -w "%{http_code}" "$@")
|
||||
if [[ "$got" != "$expected" ]]; then
|
||||
echo "runner: smoke fail: expected ${expected} got ${got} for: $*" >&2
|
||||
exit 6
|
||||
fi
|
||||
}
|
||||
|
||||
smoke_curl 200 "http://127.0.0.1:${PORT}/ping"
|
||||
smoke_curl 200 -H "Authorization: Bearer ${BEARER}" \
|
||||
"http://127.0.0.1:${PORT}/api/v1/users"
|
||||
smoke_curl 401 "http://127.0.0.1:${PORT}/api/v1/users"
|
||||
|
||||
echo "runner: smoke OK"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pre-populate store for s2/s3/s4 (s1 doesn't touch the store).
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# The s2 / s3 / s4 Lua scripts pick random IDs in [1, 10000]. Against an
|
||||
# empty store every GET-by-id is a 404 fast path on most stacks, which is
|
||||
# *not* what we want to bench. Seed the store with PREPOP=10000 users
|
||||
# (matching the ID range) so GETs hit the actual happy path. This is also
|
||||
# why the bench-spec calls for warm-up: pre-population is part of that.
|
||||
#
|
||||
# For s1 (/ping), skip prepop entirely.
|
||||
|
||||
PREPOP="${PREPOP:-10000}"
|
||||
if [[ "$SCENARIO" != "s1" && "$PREPOP" -gt 0 ]]; then
|
||||
echo "runner: pre-populating store with ${PREPOP} users"
|
||||
TMP_REQUESTS=$(mktemp)
|
||||
# awk is ~100x faster than a bash for-loop at producing this file.
|
||||
awk -v n="$PREPOP" -v tok="$BEARER" -v port="$PORT" '
|
||||
BEGIN {
|
||||
for (i = 1; i <= n; i++) {
|
||||
printf "url = http://127.0.0.1:%s/api/v1/users\n", port
|
||||
printf "request = POST\n"
|
||||
printf "header = \"Authorization: Bearer %s\"\n", tok
|
||||
printf "header = \"Content-Type: application/json\"\n"
|
||||
printf "data = \"{\\\"name\\\":\\\"u%d\\\",\\\"email\\\":\\\"u%d@x\\\"}\"\n", i, i
|
||||
if (i < n) printf "next\n"
|
||||
}
|
||||
}' > "$TMP_REQUESTS"
|
||||
# Send all curl output (response bodies + status) to the runner log.
|
||||
# -K does not honor a global -o; the cleanest suppression is shell-level.
|
||||
curl -s -K "$TMP_REQUESTS" >> "$SERVER_LOG" 2>&1 || true
|
||||
rm -f "$TMP_REQUESTS"
|
||||
# Verify list has at least one entry; print count for visibility.
|
||||
N=$(curl -s -H "Authorization: Bearer ${BEARER}" \
|
||||
"http://127.0.0.1:${PORT}/api/v1/users" | tr ',' '\n' | grep -c '"id"' || echo 0)
|
||||
echo "runner: store now reports ${N} users (list cap is 100)"
|
||||
fi
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pidstat sampler (background) — CPU% and RSS at 1Hz
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
STATS_LOG="${RESULTS_DIR}/proc-stats.tsv"
|
||||
echo -e "ts\tcpu_pct\trss_kb" > "$STATS_LOG"
|
||||
(
|
||||
while kill -0 "$SERVER_PID" 2>/dev/null; do
|
||||
ts=$(date +%s)
|
||||
rss=$(awk '/^VmRSS:/ {print $2}' "/proc/${SERVER_PID}/status" 2>/dev/null || echo 0)
|
||||
if [[ "$HAS_PIDSTAT" == "1" ]]; then
|
||||
cpu=$(pidstat -p "$SERVER_PID" 1 1 2>/dev/null \
|
||||
| awk '/Average:/ {print $8}' | head -1)
|
||||
else
|
||||
cpu=""
|
||||
fi
|
||||
echo -e "${ts}\t${cpu}\t${rss}" >> "$STATS_LOG"
|
||||
sleep 1
|
||||
done
|
||||
) &
|
||||
STATS_PID=$!
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 1: saturation probe with wrk (closed-loop peak)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
echo "runner: probe (${PROBE_SEC}s closed-loop, ${WRK_CONNS} conns)"
|
||||
PROBE_OUT="${RESULTS_DIR}/probe.txt"
|
||||
taskset -c "$LOADGEN_CPUS" \
|
||||
env HOST="$HOST" BEARER="$BEARER" \
|
||||
wrk -t"$WRK_THREADS" -c"$WRK_CONNS" -d"${PROBE_SEC}s" --latency \
|
||||
-s "$LUA" "http://${HOST}" \
|
||||
> "$PROBE_OUT" 2>&1 || true
|
||||
|
||||
PROBE_RPS=$(awk '/Requests\/sec:/ {print $2}' "$PROBE_OUT" | head -1)
|
||||
if [[ -z "$PROBE_RPS" ]]; then
|
||||
echo "runner: probe did not report Requests/sec; see ${PROBE_OUT}" >&2
|
||||
exit 7
|
||||
fi
|
||||
|
||||
# floor(PROBE_RPS * SAT_RATIO) as an integer (wrk2 requires int -R)
|
||||
TARGET_RPS=$(awk -v p="$PROBE_RPS" -v r="$SAT_RATIO" \
|
||||
'BEGIN { printf("%d", int(p * r)) }')
|
||||
echo "runner: probe RPS=${PROBE_RPS}, target (sustained)=${TARGET_RPS}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 2: warm-up at target rate (discarded)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
echo "runner: warm-up (${WARMUP_SEC}s at -R${TARGET_RPS})"
|
||||
taskset -c "$LOADGEN_CPUS" \
|
||||
env HOST="$HOST" BEARER="$BEARER" \
|
||||
wrk2 -t"$WRK_THREADS" -c"$WRK_CONNS" -d"${WARMUP_SEC}s" \
|
||||
-R"$TARGET_RPS" -s "$LUA" "http://${HOST}" \
|
||||
> "${RESULTS_DIR}/warmup.txt" 2>&1 || true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 3: measured run with wrk2 (latency-honest)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
echo "runner: measure (${MEASURE_SEC}s at -R${TARGET_RPS})"
|
||||
MEASURE_OUT="${RESULTS_DIR}/measure.txt"
|
||||
taskset -c "$LOADGEN_CPUS" \
|
||||
env HOST="$HOST" BEARER="$BEARER" \
|
||||
wrk2 -t"$WRK_THREADS" -c"$WRK_CONNS" -d"${MEASURE_SEC}s" \
|
||||
-R"$TARGET_RPS" --latency \
|
||||
-s "$LUA" "http://${HOST}" \
|
||||
> "$MEASURE_OUT" 2>&1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 4: parse + write result.json
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# wrk2 output format (excerpt; this regex stack is fragile but works on
|
||||
# upstream wrk2's current build):
|
||||
#
|
||||
# Requests/sec: 149999.74
|
||||
# Latency Distribution (HdrHistogram - Recorded Latency)
|
||||
# 50.000% 0.50ms
|
||||
# 75.000% 0.61ms
|
||||
# ...
|
||||
# 99.000% 1.83ms
|
||||
# 99.900% 4.21ms
|
||||
# 99.990% 13.50ms
|
||||
# 99.999% 24.50ms
|
||||
# 100.000% 31.50ms
|
||||
|
||||
extract() {
|
||||
local label="$1" # e.g. "50.000%"
|
||||
awk -v lbl="$label" '$1 == lbl { print $2; exit }' "$MEASURE_OUT"
|
||||
}
|
||||
|
||||
SUSTAINED_RPS=$(awk '/Requests\/sec:/ {print $2}' "$MEASURE_OUT" | head -1)
|
||||
P50=$(extract "50.000%")
|
||||
P90=$(extract "90.000%")
|
||||
P99=$(extract "99.000%")
|
||||
P999=$(extract "99.900%")
|
||||
MAX=$(extract "100.000%")
|
||||
NON_2XX=$(awk '/Non-2xx or 3xx responses:/ {print $5}' "$MEASURE_OUT" | head -1)
|
||||
# Coerce to a JSON integer; empty (line absent → no errors) or non-numeric
|
||||
# becomes 0.
|
||||
if ! [[ "$NON_2XX" =~ ^[0-9]+$ ]]; then NON_2XX=0; fi
|
||||
|
||||
# Best-effort RSS / CPU summary from the sampler
|
||||
MEAN_CPU=$(awk -F'\t' 'NR>1 && $2 != "" { s+=$2; n++ } END { if (n>0) printf("%.1f", s/n); else print "" }' "$STATS_LOG")
|
||||
MAX_RSS=$(awk -F'\t' 'NR>1 { if ($3+0 > m) m=$3 } END { print m+0 }' "$STATS_LOG")
|
||||
|
||||
cat > "${RESULTS_DIR}/result.json" <<JSON
|
||||
{
|
||||
"run_id": "${RUN_ID}",
|
||||
"server": "${SERVER}",
|
||||
"scenario": "${SCENARIO}",
|
||||
"probe_rps": ${PROBE_RPS},
|
||||
"target_rps": ${TARGET_RPS},
|
||||
"sustained_rps": ${SUSTAINED_RPS:-0},
|
||||
"latency": {
|
||||
"p50": "${P50}",
|
||||
"p90": "${P90}",
|
||||
"p99": "${P99}",
|
||||
"p999": "${P999}",
|
||||
"max": "${MAX}"
|
||||
},
|
||||
"non_2xx": ${NON_2XX},
|
||||
"mean_cpu_pct": "${MEAN_CPU}",
|
||||
"max_rss_kb": ${MAX_RSS}
|
||||
}
|
||||
JSON
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Done
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
kill "$STATS_PID" 2>/dev/null || true
|
||||
wait "$STATS_PID" 2>/dev/null || true
|
||||
|
||||
echo
|
||||
echo "runner: DONE -> ${RESULTS_DIR}"
|
||||
if [[ "$HAS_JQ" == "1" ]]; then
|
||||
jq . "${RESULTS_DIR}/result.json"
|
||||
else
|
||||
cat "${RESULTS_DIR}/result.json"
|
||||
fi
|
||||
@@ -0,0 +1,66 @@
|
||||
# urus-bench shell.nix
|
||||
#
|
||||
# Assumes you already have a working rust toolchain (cargo, rustc) and
|
||||
# elixir/erlang in your environment. This shell adds:
|
||||
#
|
||||
# - the load generators (wrk, wrk2)
|
||||
# - process introspection (pidstat, taskset)
|
||||
# - small conveniences (jq, sqlite CLI for poking at the WAL'd DBs)
|
||||
# - C toolchain bits so `rusqlite` (bundled SQLite) and `sqlx` compile
|
||||
#
|
||||
# Drop into the workspace root and run `nix-shell`. Then:
|
||||
# cargo build --release
|
||||
# ./runner.sh urus-mem s1
|
||||
#
|
||||
# For cowboy-server you'll use your existing rebar3/elixir setup; this
|
||||
# shell deliberately doesn't pin a beam toolchain since you have one.
|
||||
|
||||
{ pkgs ? import <nixpkgs> {} }:
|
||||
|
||||
pkgs.mkShell {
|
||||
name = "urus-bench";
|
||||
|
||||
# buildInputs vs nativeBuildInputs: for a developer shell the
|
||||
# distinction barely matters; either works. Keeping everything in
|
||||
# `packages` since that's the recommended modern form.
|
||||
packages = with pkgs; [
|
||||
# Load generators (the whole point of this shell).
|
||||
wrk # closed-loop, used as the saturation probe
|
||||
wrk2 # constant-rate, latency-honest measurement
|
||||
|
||||
# Runner deps.
|
||||
sysstat # provides pidstat for CPU sampling
|
||||
util-linux # provides taskset for CPU pinning
|
||||
jq # pretty-prints result.json (runner falls back without it)
|
||||
curl # the prepopulate step uses curl --config (-K)
|
||||
gawk # explicit; busybox awk lacks some printf bits we use
|
||||
|
||||
# SQLite tooling — handy for inspecting WAL'd DBs between runs.
|
||||
# rusqlite/sqlx bring their own embedded sqlite so this is *not* a
|
||||
# build dep, just a CLI for poking around.
|
||||
sqlite
|
||||
|
||||
# C toolchain — rusqlite's `bundled` feature compiles sqlite from
|
||||
# source, and sqlx's macros also need a working cc. Without these
|
||||
# the rust build fails on a fresh nix shell.
|
||||
pkg-config
|
||||
gcc
|
||||
];
|
||||
|
||||
# Environment hints. Nothing strictly required, but these defaults
|
||||
# match what runner.sh already expects when invoked without env
|
||||
# overrides, so a fresh shell `just works` for `./runner.sh ...`.
|
||||
BEARER = "test-token-aaaaaaaaaaaaaaaaaaaa";
|
||||
|
||||
shellHook = ''
|
||||
echo "urus-bench shell:"
|
||||
echo " wrk $(wrk --version 2>&1 | head -1 || echo missing)"
|
||||
echo " wrk2 $(wrk2 --version 2>&1 | head -1 || echo missing)"
|
||||
echo " pidstat $(command -v pidstat || echo missing)"
|
||||
echo " taskset $(command -v taskset || echo missing)"
|
||||
echo " cargo $(command -v cargo || echo 'NOT FOUND — expected on host')"
|
||||
echo
|
||||
echo "Build: cargo build --release"
|
||||
echo "Run: ./runner.sh urus-mem s1"
|
||||
'';
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
# Urus v1 Benchmark Plan
|
||||
|
||||
> Goal: produce defensible HTTP/1.1 numbers for **urus v0.1**, framed by
|
||||
> **hyper** (Rust async reference) above and **cowboy** (BEAM reference)
|
||||
> below. Per the urus spec: target is **within 2x of hyper**; significantly
|
||||
> outperform Cowboy 2 on HTTP/1.1.
|
||||
|
||||
The benchmark is layered, smallest-first, so we can see *what* costs what:
|
||||
the plug indirection, the connection-actor context switch, the pipeline
|
||||
middleware stack, and finally the DB layer.
|
||||
|
||||
---
|
||||
|
||||
## 1. Design considerations (read first)
|
||||
|
||||
### 1.1 Don't bench the DB
|
||||
|
||||
A "realistic" load that hits a DB on every request will almost certainly
|
||||
bench the DB instead of the HTTP stack. SQLite single-writer contention is
|
||||
the most likely culprit. The plan is:
|
||||
|
||||
- Tier the scenarios. The cheapest scenario does **no DB work** so we get a
|
||||
clean read on the HTTP stack itself. Each subsequent tier adds one source
|
||||
of work and is compared against the previous tier — the *delta* is the
|
||||
cost of that layer.
|
||||
- Instrument the handler with a histogram of "handler-inside time"
|
||||
(microseconds spent between the start of the handler and the moment it
|
||||
returns the `Conn`). If at high concurrency the handler-inside time
|
||||
dominates request latency, we're benching the DB / the in-memory actor /
|
||||
the auth check, not the framework. Report this metric *next to* RPS.
|
||||
- For the DB tier, use **SQLite in WAL mode**, with one dedicated writer
|
||||
actor and N reader connections (or a single-conn reader pool with `BEGIN
|
||||
IMMEDIATE` semantics). This matches urus's actor-style data ownership and
|
||||
is fair to all three frameworks.
|
||||
- Cross-check by also running each scenario with the in-memory store from
|
||||
`examples/crud.rs`. If urus's in-memory and SQLite numbers are close at
|
||||
low write ratios, the DB is not the bottleneck; if SQLite is much lower,
|
||||
it is.
|
||||
|
||||
### 1.2 wrk2, not wrk
|
||||
|
||||
`wrk` (Glenn's original) is closed-loop: it sends a new request only after
|
||||
the previous one returns. Tail latency in that model is meaningless
|
||||
("coordinated omission" — the load generator stalls when the server stalls,
|
||||
so it can't *measure* the stall). We use **wrk2** with a fixed rate `-R`.
|
||||
|
||||
For a smoke-test top-end RPS we'll also run plain `wrk` at maximum offered
|
||||
load to find each server's *saturation point*. That number is then used to
|
||||
pick the target rate for wrk2 — typically 60–80% of saturation, where tail
|
||||
latency is the actual measurement of interest.
|
||||
|
||||
Cross-checks: `oha` (open-loop, HDR-histogram output) and `rewrk`.
|
||||
|
||||
### 1.3 Equivalent stacks across servers
|
||||
|
||||
The bench is unfair unless the middleware stack is materially the same on
|
||||
all three. Spec:
|
||||
|
||||
- Request logger (writes to a bounded ring buffer in memory; **no stdout**,
|
||||
which would skew everything).
|
||||
- Request ID middleware (generates a short ID, sets a response header).
|
||||
- Bearer token auth (constant-time compare against a fixed valid token).
|
||||
- JSON request/response on the write paths.
|
||||
- Router with at least one literal route, one parameterised route, and one
|
||||
POST route.
|
||||
|
||||
Cowboy's idiomatic equivalent is `cowboy_router` + handler modules + an
|
||||
`onrequest` hook for auth. Hyper's idiomatic equivalent is **axum**: it
|
||||
exposes the closest analogue to urus's pipeline (`tower::Service` + layers
|
||||
+ Router). We bench against axum, and note that "raw hyper" would be 10–20%
|
||||
faster but is not a fair comparison to a routing+middleware stack.
|
||||
|
||||
### 1.4 Two-process discipline
|
||||
|
||||
Server and load generator run on the **same host but with pinned, disjoint
|
||||
CPU sets** to avoid each starving the other. On a 16-core box:
|
||||
|
||||
- Server: `taskset -c 0-7`
|
||||
- Loadgen: `taskset -c 8-15`
|
||||
|
||||
This matters more than it sounds: with both on all cores, you measure the
|
||||
loadgen's scheduling as much as the server's.
|
||||
|
||||
### 1.5 Warm-up
|
||||
|
||||
Each run is `30s warm-up + 60s measured`. The warm-up is necessary because:
|
||||
- TCP window scaling needs time to ramp.
|
||||
- Page-fault costs for the read buffers happen once.
|
||||
- BEAM in particular has a JIT (BeamAsm) that benefits from warm-up.
|
||||
|
||||
Save raw HDR histograms (wrk2 emits one); aggregate p50/p95/p99/p99.9/max.
|
||||
|
||||
### 1.6 What we don't measure (yet)
|
||||
|
||||
- TLS: deferred per urus spec (v2+).
|
||||
- HTTP/2: deferred per urus spec (v2).
|
||||
- WebSocket: deferred (v3).
|
||||
- Cold-start / spawn cost: orthogonal to the steady-state question this
|
||||
bench answers.
|
||||
|
||||
---
|
||||
|
||||
## 2. Scenarios
|
||||
|
||||
Four scenarios, ordered cheapest to most realistic. Each is run against
|
||||
each framework. Each measures the **delta** over the previous one.
|
||||
|
||||
### S1: `GET /ping` — naked hello world
|
||||
|
||||
- Pipeline: a single handler. No logger, no auth, no router.
|
||||
- Response: `200 OK`, body `pong` (4 bytes), `content-type: text/plain`.
|
||||
- Measures: raw protocol throughput. The floor on framework overhead.
|
||||
- Connection: keep-alive (default in HTTP/1.1).
|
||||
|
||||
This is the number to compare against hyper's `hello` benchmark. The urus
|
||||
spec target is "within 2x". The plug pipeline isn't really exercised here —
|
||||
that's deliberate. S1 isolates the connection actor + parser + serialiser.
|
||||
|
||||
### S2: `GET /api/v1/users/:id` — router + middleware, no DB
|
||||
|
||||
- Pipeline: `logger → request_id → auth → router`.
|
||||
- Route: `GET /api/v1/users/:id` returns a hard-coded `User` struct
|
||||
serialised to JSON. The `:id` is echoed into the response so the
|
||||
serialiser cannot const-fold it away.
|
||||
- Bearer token check: a known good token sent in the `Authorization`
|
||||
header. Mismatch → 401 + halt.
|
||||
- Measures: the cost of the pipeline (4 plug hops) and the router lookup.
|
||||
This is where urus's spec budget ("one vtable dispatch per plug per
|
||||
request") gets tested.
|
||||
- Connection: keep-alive.
|
||||
|
||||
Expected: lower RPS than S1 by a measurable but non-catastrophic margin (a
|
||||
few % to maybe 20%). If S2 is half of S1, something is allocating per
|
||||
request.
|
||||
|
||||
### S3: `POST /api/v1/users` + `GET /api/v1/users/:id` — in-memory store
|
||||
|
||||
- Pipeline: same as S2.
|
||||
- Routes:
|
||||
- `POST /api/v1/users` with a small JSON body (`{"name":"alice","email":"a@x"}`).
|
||||
- `GET /api/v1/users/:id`.
|
||||
- `GET /api/v1/users` (list, capped at 100).
|
||||
- Store: in-memory actor (urus: the actor from `examples/crud.rs`; hyper:
|
||||
`tokio::sync::Mutex<HashMap>`; cowboy: a `gen_server`). No persistence.
|
||||
- Traffic mix: **80% GET single, 15% GET list, 5% POST**. This is the
|
||||
typical web-app shape and exposes the message-passing path on urus
|
||||
without making writes the bottleneck.
|
||||
- Measures: actor channel round-trip vs. mutex contention. Urus does a
|
||||
channel send-recv to its store; hyper holds a brief mutex. The bench
|
||||
shows the cost of message passing vs. the cost of contention as
|
||||
concurrency rises.
|
||||
- Connection: keep-alive.
|
||||
|
||||
### S4: Same as S3, with SQLite (WAL)
|
||||
|
||||
- Same routes and mix as S3.
|
||||
- Backing store:
|
||||
- urus: one writer actor owning a write connection; reads go through a
|
||||
pool of N=`min(cpus, 4)` read-only connections (each held by its own
|
||||
actor, requested via a worker pool). All `BEGIN IMMEDIATE`.
|
||||
- hyper/axum: `sqlx::SqlitePool` with WAL + busy_timeout.
|
||||
- cowboy: `esqlite` with the same WAL config; pool via `poolboy`.
|
||||
- Measures: realism. We expect everyone to be much lower here. If the
|
||||
three framework numbers *converge*, the DB is the bottleneck and the
|
||||
framework comparison can't be made from this scenario alone — that's
|
||||
the answer S4 might give us, and it's still useful information.
|
||||
|
||||
In **all four scenarios** the handler instruments
|
||||
`handler_microseconds_histogram` so we can detect DB domination.
|
||||
|
||||
---
|
||||
|
||||
## 3. The middleware stack (spec for all three implementations)
|
||||
|
||||
Each implementation MUST expose these middlewares with materially identical
|
||||
behaviour. The wire-level outputs (status codes, body shapes, headers) MUST
|
||||
match — verified by a small `curl`-based smoke test before each bench run.
|
||||
|
||||
### 3.1 Logger
|
||||
|
||||
- Captures: `method`, `path`, `status`, `elapsed_us`.
|
||||
- Writes into a **bounded ring buffer in memory** (e.g. `crossbeam::ArrayQueue<LogEntry>` for Rust, an ETS table for cowboy with capped size).
|
||||
- Does **not** touch stdout, stderr, or the filesystem during the run.
|
||||
- The buffer is drained on shutdown to a file for inspection.
|
||||
|
||||
### 3.2 Request ID
|
||||
|
||||
- 12-byte random ID, base32-encoded.
|
||||
- Set as response header `x-request-id`.
|
||||
- Generated from a thread-local fast RNG (avoid the global `rand::thread_rng()` mutex on Rust; use SplitMix64 seeded once per actor/task).
|
||||
|
||||
### 3.3 Auth
|
||||
|
||||
- Reads `Authorization: Bearer <token>` from request headers.
|
||||
- Compares against a fixed token (configured at startup) using
|
||||
**constant-time compare** (`subtle::ConstantTimeEq` on Rust;
|
||||
`crypto:hash`-based on BEAM is fine — the constant-time property is
|
||||
pedagogical here, not a security claim).
|
||||
- On mismatch: `401`, body `{"error":"unauthorized"}`, halt the pipeline.
|
||||
- The bench load generator always sends the valid token (we are not
|
||||
benching the failure path).
|
||||
|
||||
### 3.4 Router
|
||||
|
||||
- Methods: GET, POST, PUT, DELETE.
|
||||
- Patterns: `/api/v1/users`, `/api/v1/users/:id`, `/ping`.
|
||||
- 404 on no match; 405 on method-mismatch (urus already does this).
|
||||
|
||||
### 3.5 JSON
|
||||
|
||||
- `serde_json` for Rust, `jsx` or `jsone` for BEAM.
|
||||
- Bodies are small: well under 1 KiB for the user payload.
|
||||
|
||||
---
|
||||
|
||||
## 4. Queries (load-gen scripts)
|
||||
|
||||
`wrk2` takes a Lua script. Below are the four scripts. They share a single
|
||||
common header script (`common.lua`) for token + content-type.
|
||||
|
||||
### `common.lua`
|
||||
|
||||
```lua
|
||||
-- Shared header config; included by every script below.
|
||||
local M = {}
|
||||
M.token = os.getenv("BEARER") or "test-token-aaaaaaaaaaaaaaaaaaaa"
|
||||
M.headers = {
|
||||
["Authorization"] = "Bearer " .. M.token,
|
||||
["Connection"] = "keep-alive",
|
||||
["Host"] = os.getenv("HOST") or "127.0.0.1:8080",
|
||||
}
|
||||
return M
|
||||
```
|
||||
|
||||
### `s1_ping.lua` — S1 ping, no auth header (it's a naked endpoint)
|
||||
|
||||
```lua
|
||||
wrk.method = "GET"
|
||||
wrk.path = "/ping"
|
||||
wrk.headers["Connection"] = "keep-alive"
|
||||
```
|
||||
|
||||
Invocation:
|
||||
|
||||
```sh
|
||||
# Saturation probe (closed-loop wrk):
|
||||
wrk -t8 -c256 -d60s --latency http://127.0.0.1:8080/ping
|
||||
|
||||
# Latency-honest measurement (wrk2, target rate from probe):
|
||||
wrk2 -t8 -c256 -d60s -R200000 --latency -s s1_ping.lua \
|
||||
http://127.0.0.1:8080
|
||||
```
|
||||
|
||||
### `s2_user_get.lua` — single-route GET with middleware
|
||||
|
||||
```lua
|
||||
local common = require("common")
|
||||
wrk.method = "GET"
|
||||
wrk.headers = common.headers
|
||||
|
||||
-- Spread :id across 10_000 values so per-id caches can't trivially win.
|
||||
math.randomseed(os.time() + os.getpid())
|
||||
request = function()
|
||||
local id = math.random(1, 10000)
|
||||
return wrk.format(nil, "/api/v1/users/" .. id)
|
||||
end
|
||||
```
|
||||
|
||||
Invocation:
|
||||
|
||||
```sh
|
||||
wrk2 -t8 -c256 -d60s -R150000 --latency -s s2_user_get.lua \
|
||||
http://127.0.0.1:8080
|
||||
```
|
||||
|
||||
### `s3_mixed.lua` — 80% GET-one / 15% GET-list / 5% POST
|
||||
|
||||
```lua
|
||||
local common = require("common")
|
||||
wrk.headers = common.headers
|
||||
wrk.headers["Content-Type"] = "application/json"
|
||||
|
||||
math.randomseed(os.time() + os.getpid())
|
||||
local body_pool = {}
|
||||
for i = 1, 64 do
|
||||
body_pool[i] = string.format(
|
||||
[[{"name":"user%d","email":"u%d@example.test"}]], i, i)
|
||||
end
|
||||
|
||||
request = function()
|
||||
local r = math.random()
|
||||
if r < 0.80 then
|
||||
return wrk.format("GET", "/api/v1/users/" .. math.random(1, 10000))
|
||||
elseif r < 0.95 then
|
||||
return wrk.format("GET", "/api/v1/users")
|
||||
else
|
||||
local body = body_pool[math.random(1, #body_pool)]
|
||||
return wrk.format("POST", "/api/v1/users", nil, body)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
Invocation: same shape as s2, lower `-R` (writes are slower).
|
||||
|
||||
```sh
|
||||
wrk2 -t8 -c512 -d60s -R80000 --latency -s s3_mixed.lua \
|
||||
http://127.0.0.1:8080
|
||||
```
|
||||
|
||||
### `s4_mixed_sqlite.lua`
|
||||
|
||||
Identical to `s3_mixed.lua`. Only the server's backing store differs.
|
||||
|
||||
```sh
|
||||
wrk2 -t8 -c512 -d60s -R20000 --latency -s s4_mixed_sqlite.lua \
|
||||
http://127.0.0.1:8080
|
||||
```
|
||||
|
||||
(`-R20000` is a guess; the real number comes from a `wrk` probe of each
|
||||
framework first.)
|
||||
|
||||
---
|
||||
|
||||
## 5. Implementation plan
|
||||
|
||||
Three sibling crates / projects under a `urus-bench/` workspace, each
|
||||
exposing the same routes and middleware:
|
||||
|
||||
```
|
||||
urus-bench/
|
||||
├── urus-server/ # Rust, urus
|
||||
├── axum-server/ # Rust, hyper via axum
|
||||
└── cowboy-server/ # Erlang/OTP, cowboy 2
|
||||
```
|
||||
|
||||
Plus:
|
||||
|
||||
```
|
||||
urus-bench/
|
||||
├── loadgen/ # wrk2 scripts (common.lua, s1..s4 above)
|
||||
├── runner.sh # orchestrates pin-cpu, warm-up, measurement, save HDR
|
||||
└── results/ # one subdir per run: ${date}-${server}-${scenario}/
|
||||
```
|
||||
|
||||
The runner:
|
||||
|
||||
1. Boots the target server pinned to cores 0-7.
|
||||
2. `curl`-smoke-tests every route.
|
||||
3. Probes saturation with `wrk` at a high `-c`.
|
||||
4. Reads back the saturation RPS and chooses 70% of it for wrk2.
|
||||
5. Runs wrk2 with HDR output (`--latency`), captures stdout + HDR file.
|
||||
6. Greps server process for RSS (`/proc/$pid/status`) and CPU (`pidstat -p $pid 1`) during the run.
|
||||
7. Saves everything under `results/${run_id}/`.
|
||||
8. Kills server, sleeps 5s, moves on.
|
||||
|
||||
### 5.1 urus-server
|
||||
|
||||
Port `examples/crud.rs` with:
|
||||
|
||||
- Routes adjusted to `/api/v1/users{,/:id}` and `/ping`.
|
||||
- Logger replaced with a ring-buffer-backed plug.
|
||||
- New `request_id` and `auth` plugs.
|
||||
- Switchable backing store: `--store=memory` (the existing actor) or
|
||||
`--store=sqlite` (a new actor wrapping `rusqlite` in WAL).
|
||||
|
||||
For SQLite on urus: spawn one writer actor + 4 reader actors, each owning
|
||||
its own `rusqlite::Connection`. Handlers route reads to a free reader via
|
||||
a worker-pool channel, writes to the writer. Pool selection is FIFO over a
|
||||
single shared `Receiver<RequestForReader>` — readers race to receive, which
|
||||
is exactly what smarm's MPSC gives for free.
|
||||
|
||||
### 5.2 axum-server
|
||||
|
||||
Standard axum + tokio multi-thread. Same routes. Layers: `Logger`,
|
||||
`SetRequestIdLayer`, `RequireAuthorizationLayer`. State: `sqlx::SqlitePool`
|
||||
for S4, `Arc<RwLock<HashMap>>` for S3.
|
||||
|
||||
### 5.3 cowboy-server
|
||||
|
||||
Standard cowboy 2 with `cowboy_router`. Handlers as `cowboy_handler`
|
||||
modules. Auth as an `onrequest`-style middleware. Store: a `gen_server`
|
||||
for S3, `esqlite` + `poolboy` for S4.
|
||||
|
||||
Build with `rebar3`, run with `+S 8 +sbt db` (8 schedulers, scheduler-thread
|
||||
binding) under the same `taskset -c 0-7`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Metrics and reporting
|
||||
|
||||
Per (server × scenario) run, capture and report:
|
||||
|
||||
| Metric | How |
|
||||
|-----------------------------|------------------------------------------------|
|
||||
| RPS (saturation, `wrk`) | `wrk -c256 -d60s` — closed-loop peak |
|
||||
| RPS (sustained, `wrk2`) | `wrk2 -R$target -d60s` at 70% of saturation |
|
||||
| p50 / p95 / p99 / p99.9 / max latency | HDR histogram from wrk2 |
|
||||
| Server CPU% | `pidstat -p $pid 1 60` → mean over run |
|
||||
| Server RSS | `/proc/$pid/status` `VmRSS` sampled at 1 Hz |
|
||||
| Handler-inside p99 (µs) | server-side histogram, emitted to ring buffer |
|
||||
| Dropped/errored requests | wrk2's "Non-2xx or 3xx responses" |
|
||||
|
||||
The headline table:
|
||||
|
||||
| | urus | axum | cowboy |
|
||||
|--------------------|------|------|--------|
|
||||
| S1 RPS (sat) | | | |
|
||||
| S1 p99 @ 70% | | | |
|
||||
| S2 RPS (sat) | | | |
|
||||
| S2 p99 @ 70% | | | |
|
||||
| S3 RPS (sat) | | | |
|
||||
| S3 p99 @ 70% | | | |
|
||||
| S4 RPS (sat) | | | |
|
||||
| S4 p99 @ 70% | | | |
|
||||
| urus / axum (S1) | — | | — |
|
||||
| urus / axum (S2) | — | | — |
|
||||
|
||||
Pass/fail per the urus spec:
|
||||
|
||||
- **S1**: urus RPS ≥ 50% of axum RPS (within 2x).
|
||||
- **S2**: urus RPS ≥ 50% of axum RPS.
|
||||
- **All scenarios**: urus RPS > cowboy RPS by a clear margin (native
|
||||
expectation).
|
||||
- **All scenarios**: urus p99 < 2× axum p99 at the same offered rate.
|
||||
- Handler-inside p99 ≪ end-to-end p99 in S1/S2 (proves we're benching the
|
||||
framework, not the handler).
|
||||
|
||||
---
|
||||
|
||||
## 7. Order of operations (suggested)
|
||||
|
||||
1. Build `urus-server` with S1 (`/ping`) only. Run S1 against it. Make
|
||||
sure the saturation number is plausible (tens to hundreds of kRPS on
|
||||
a decent box).
|
||||
2. Build `axum-server` with S1. Run S1. Sanity-check the ratio against
|
||||
public hyper hello-world numbers (axum should land near hyper).
|
||||
3. Add the middleware stack to both, run S2, capture deltas.
|
||||
4. Add the in-memory store, run S3.
|
||||
5. Bring up `cowboy-server` and bring all three up to S3.
|
||||
6. Add SQLite to all three; run S4.
|
||||
7. Write the headline table; produce HDR plots from the saved files
|
||||
(gnuplot or a tiny Python script — wrk2 ships an HDR-plot helper).
|
||||
|
||||
If at any point a tier's results are dominated by a non-framework cost
|
||||
(handler-inside p99 ≥ ~50% of end-to-end p99), stop and instrument before
|
||||
moving on. That's the whole point of tiering.
|
||||
|
||||
---
|
||||
|
||||
## 8. Open questions deliberately left for later
|
||||
|
||||
- Whether to also bench under `tc`-induced packet loss / RTT. The current
|
||||
setup uses loopback, which is a generous environment. A loopback win is
|
||||
necessary but not sufficient for "fast on the real network".
|
||||
- Whether to bench HTTP/1.1 pipelining (multiple in-flight requests per
|
||||
connection without waiting). urus claims it's a non-goal-for-now, and
|
||||
axum/hyper don't pipeline either, so the comparison is moot — but it's
|
||||
worth a note.
|
||||
- Whether to add a "slow handler" scenario (one route that sleeps 50ms)
|
||||
to demonstrate urus's blocking-handler-without-thread-pool claim. That's
|
||||
more of a *qualitative* demo than a throughput bench, but it's the kind
|
||||
of thing reviewers ask about.
|
||||
@@ -0,0 +1,35 @@
|
||||
[package]
|
||||
name = "urus-server"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.95"
|
||||
|
||||
[dependencies]
|
||||
# Path deps point at the user's local checkouts; adjust if your tree
|
||||
# differs. urus's own Cargo.toml has `smarm = { path = "../smarm" }`, so
|
||||
# cargo resolves smarm transitively as long as it's a sibling of urus.
|
||||
urus = { path = "../../urus" }
|
||||
smarm = { path = "../../smarm" }
|
||||
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# Constant-time compare for the bearer-token check. Tiny dep, no transitive
|
||||
# bloat, and the right thing to use.
|
||||
subtle = "2"
|
||||
|
||||
# In-memory ring buffer for the logger plug. Bounded MPSC over a fixed
|
||||
# array — no allocation on the hot path.
|
||||
crossbeam-queue = "0.3"
|
||||
|
||||
# Embedded SQLite. The `bundled` feature compiles SQLite from source so we
|
||||
# don't depend on the system library — important for reproducibility on
|
||||
# build hosts that may or may not have libsqlite3-dev.
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
|
||||
# Tiny argv parser so we don't pull in clap for a 3-flag CLI.
|
||||
# We hand-roll it instead. No dep here.
|
||||
|
||||
[[bin]]
|
||||
name = "urus-server"
|
||||
path = "src/main.rs"
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Request handlers and the AppState that holds the store-handle factory.
|
||||
//!
|
||||
//! The trick: smarm requires `spawn` to be called from inside an actor.
|
||||
//! The bootstrap thread that calls `serve_with` is NOT an actor — it's
|
||||
//! the user's main(). So we can't spawn the store actor at startup; we
|
||||
//! defer to the first request that asks for it. That request runs inside
|
||||
//! a connection actor, which is a valid place to call spawn.
|
||||
//!
|
||||
//! AppState holds:
|
||||
//! - a "store_init" factory that lazily spawns the store on first use
|
||||
//! and caches the resulting StoreHandle
|
||||
//! - the shared log ring (not yet used here; handlers don't log
|
||||
//! directly, the logger plug does)
|
||||
//!
|
||||
//! Concurrency: the first connection actor to ask wins the init race;
|
||||
//! the rest see the already-initialised handle. We use OnceLock for this
|
||||
//! — std primitive, no extra dep.
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use smarm::channel;
|
||||
use urus::{Conn, Next, Plug, Router};
|
||||
|
||||
use crate::middleware::LogRing;
|
||||
use crate::store::{ReadReq, StoreHandle, WriteReq};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AppState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
inner: Arc<AppStateInner>,
|
||||
}
|
||||
|
||||
struct AppStateInner {
|
||||
store_handle: OnceLock<StoreHandle>,
|
||||
store_init: Arc<dyn Fn() -> StoreHandle + Send + Sync + 'static>,
|
||||
// Kept for parity with the spec even though handlers don't read it.
|
||||
#[allow(dead_code)]
|
||||
log_ring: LogRing,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(
|
||||
store_init: Arc<dyn Fn() -> StoreHandle + Send + Sync + 'static>,
|
||||
log_ring: LogRing,
|
||||
) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(AppStateInner {
|
||||
store_handle: OnceLock::new(),
|
||||
store_init,
|
||||
log_ring,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lazy store accessor. Safe to call from inside any connection
|
||||
/// actor; safe to call concurrently — `OnceLock::get_or_init`
|
||||
/// guarantees the init closure runs exactly once.
|
||||
pub fn store(&self) -> &StoreHandle {
|
||||
self.inner.store_handle.get_or_init(|| (self.inner.store_init)())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tiny helper for writing JSON responses
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn json(conn: Conn, status: u16, body: Vec<u8>) -> Conn {
|
||||
conn.put_status(status)
|
||||
.put_header("content-type", "application/json")
|
||||
.put_body(body)
|
||||
}
|
||||
|
||||
fn text(conn: Conn, status: u16, body: &'static str) -> Conn {
|
||||
conn.put_status(status)
|
||||
.put_header("content-type", "text/plain; charset=utf-8")
|
||||
.put_body(body)
|
||||
}
|
||||
|
||||
fn parse_id(s: &str) -> Option<u64> { s.parse().ok() }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// All handlers close over a clone of AppState. Each handler closure is a
|
||||
// `Fn(Conn, Next) -> Conn`, which is exactly the Plug shape urus's
|
||||
// blanket impl picks up.
|
||||
|
||||
fn build_ping_handler() -> impl Plug {
|
||||
// /ping is a literal endpoint: status 200, body "pong". No work.
|
||||
move |conn: Conn, _next: Next| text(conn, 200, "pong")
|
||||
}
|
||||
|
||||
fn build_list_handler(state: AppState) -> impl Plug {
|
||||
move |conn: Conn, _next: Next| {
|
||||
let (tx, rx) = channel::<(u16, Vec<u8>)>();
|
||||
if state.store().reads().send(ReadReq::List { reply: tx }).is_err() {
|
||||
return json(conn, 503, b"{\"error\":\"store unavailable\"}".to_vec());
|
||||
}
|
||||
match rx.recv() {
|
||||
Ok((s, b)) => json(conn, s, b),
|
||||
Err(_) => json(conn, 503, b"{\"error\":\"store unavailable\"}".to_vec()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_get_one_handler(state: AppState) -> impl Plug {
|
||||
move |conn: Conn, _next: Next| {
|
||||
let id = match conn.params.get("id").and_then(parse_id) {
|
||||
Some(id) => id,
|
||||
None => return json(conn, 400, b"{\"error\":\"bad id\"}".to_vec()),
|
||||
};
|
||||
let (tx, rx) = channel::<(u16, Vec<u8>)>();
|
||||
if state.store().reads().send(ReadReq::Get { id, reply: tx }).is_err() {
|
||||
return json(conn, 503, b"{\"error\":\"store unavailable\"}".to_vec());
|
||||
}
|
||||
match rx.recv() {
|
||||
Ok((s, b)) => json(conn, s, b),
|
||||
Err(_) => json(conn, 503, b"{\"error\":\"store unavailable\"}".to_vec()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_create_handler(state: AppState) -> impl Plug {
|
||||
move |conn: Conn, _next: Next| {
|
||||
// Take the body out of the request side. `Body::into_bytes`
|
||||
// would need to consume `conn`, which we still need; clone the
|
||||
// bytes instead. (For a 100-byte JSON object this is fine; for
|
||||
// a large upload we'd want to redesign.)
|
||||
let body = conn.body.as_bytes().to_vec();
|
||||
let (tx, rx) = channel::<(u16, Vec<u8>)>();
|
||||
if state.store().writes().send(WriteReq::Create { body, reply: tx }).is_err() {
|
||||
return json(conn, 503, b"{\"error\":\"store unavailable\"}".to_vec());
|
||||
}
|
||||
match rx.recv() {
|
||||
Ok((s, b)) => json(conn, s, b),
|
||||
Err(_) => json(conn, 503, b"{\"error\":\"store unavailable\"}".to_vec()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_update_handler(state: AppState) -> impl Plug {
|
||||
move |conn: Conn, _next: Next| {
|
||||
let id = match conn.params.get("id").and_then(parse_id) {
|
||||
Some(id) => id,
|
||||
None => return json(conn, 400, b"{\"error\":\"bad id\"}".to_vec()),
|
||||
};
|
||||
let body = conn.body.as_bytes().to_vec();
|
||||
let (tx, rx) = channel::<(u16, Vec<u8>)>();
|
||||
if state.store().writes().send(WriteReq::Update { id, body, reply: tx }).is_err() {
|
||||
return json(conn, 503, b"{\"error\":\"store unavailable\"}".to_vec());
|
||||
}
|
||||
match rx.recv() {
|
||||
Ok((s, b)) => json(conn, s, b),
|
||||
Err(_) => json(conn, 503, b"{\"error\":\"store unavailable\"}".to_vec()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_delete_handler(state: AppState) -> impl Plug {
|
||||
move |conn: Conn, _next: Next| {
|
||||
let id = match conn.params.get("id").and_then(parse_id) {
|
||||
Some(id) => id,
|
||||
None => return json(conn, 400, b"{\"error\":\"bad id\"}".to_vec()),
|
||||
};
|
||||
let (tx, rx) = channel::<(u16, Vec<u8>)>();
|
||||
if state.store().writes().send(WriteReq::Delete { id, reply: tx }).is_err() {
|
||||
return json(conn, 503, b"{\"error\":\"store unavailable\"}".to_vec());
|
||||
}
|
||||
match rx.recv() {
|
||||
Ok((s, b)) => json(conn, s, b),
|
||||
Err(_) => json(conn, 503, b"{\"error\":\"store unavailable\"}".to_vec()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router assembly
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.get( "/ping", build_ping_handler())
|
||||
.get( "/api/v1/users", build_list_handler(state.clone()))
|
||||
.post( "/api/v1/users", build_create_handler(state.clone()))
|
||||
.get( "/api/v1/users/:id", build_get_one_handler(state.clone()))
|
||||
.put( "/api/v1/users/:id", build_update_handler(state.clone()))
|
||||
.delete("/api/v1/users/:id", build_delete_handler(state))
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
//! urus-server — the urus side of the urus / axum / cowboy benchmark.
|
||||
//!
|
||||
//! Exposes the routes from `urus-bench-spec.md` §3 with the middleware
|
||||
//! stack from §3.1–§3.4. Backing store is switchable at startup:
|
||||
//!
|
||||
//! --store=memory in-memory actor (S1, S2, S3)
|
||||
//! --store=sqlite single-writer + reader-pool over rusqlite (S4)
|
||||
//!
|
||||
//! Other flags:
|
||||
//! --addr=HOST:PORT default 127.0.0.1:8080
|
||||
//! --token=TOKEN default "test-token-aaaaaaaaaaaaaaaaaaaa"
|
||||
//! --db-path=PATH SQLite file path (sqlite store only)
|
||||
//! --no-auth skip the auth plug (used for S1 /ping check)
|
||||
//!
|
||||
//! The `/ping` route is mounted *outside* the auth plug so the S1 scenario
|
||||
//! does not pay an auth cost it doesn't need. S2/S3/S4 routes are inside it.
|
||||
|
||||
mod handlers;
|
||||
mod middleware;
|
||||
mod store;
|
||||
|
||||
use std::process::ExitCode;
|
||||
use std::sync::Arc;
|
||||
|
||||
use urus::{serve_with, Config, Conn, Next, Pipeline};
|
||||
|
||||
use handlers::AppState;
|
||||
use middleware::{auth_plug, logger_plug, request_id_plug, LogRing};
|
||||
use store::{spawn_memory_store, spawn_sqlite_store, StoreHandle};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Cli {
|
||||
addr: String,
|
||||
store: StoreKind,
|
||||
token: String,
|
||||
db_path: String,
|
||||
no_auth: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum StoreKind { Memory, Sqlite }
|
||||
|
||||
impl Cli {
|
||||
fn parse() -> Result<Self, String> {
|
||||
// Hand-rolled because clap would dominate compile time for three
|
||||
// flags. Each arg is `--name=value` or `--name` (no positionals).
|
||||
let mut cli = Cli {
|
||||
addr: "127.0.0.1:8080".into(),
|
||||
store: StoreKind::Memory,
|
||||
token: "test-token-aaaaaaaaaaaaaaaaaaaa".into(),
|
||||
db_path: "/tmp/urus-bench.sqlite".into(),
|
||||
no_auth: false,
|
||||
};
|
||||
for arg in std::env::args().skip(1) {
|
||||
let (k, v) = match arg.split_once('=') {
|
||||
Some((k, v)) => (k, Some(v.to_string())),
|
||||
None => (arg.as_str(), None),
|
||||
};
|
||||
match (k, v) {
|
||||
("--addr", Some(v)) => cli.addr = v,
|
||||
("--token", Some(v)) => cli.token = v,
|
||||
("--db-path", Some(v)) => cli.db_path = v,
|
||||
("--store", Some(v)) => cli.store = match v.as_str() {
|
||||
"memory" => StoreKind::Memory,
|
||||
"sqlite" => StoreKind::Sqlite,
|
||||
other => return Err(format!("unknown store: {other}")),
|
||||
},
|
||||
("--no-auth", None) => cli.no_auth = true,
|
||||
("--help" | "-h", _) => {
|
||||
print_usage();
|
||||
std::process::exit(0);
|
||||
}
|
||||
(k, _) => return Err(format!("unknown flag: {k}")),
|
||||
}
|
||||
}
|
||||
Ok(cli)
|
||||
}
|
||||
}
|
||||
|
||||
fn print_usage() {
|
||||
eprintln!(
|
||||
"urus-server [--addr=HOST:PORT] [--store=memory|sqlite] \
|
||||
[--token=TOKEN] [--db-path=PATH] [--no-auth]"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let cli = match Cli::parse() {
|
||||
Ok(c) => c,
|
||||
Err(e) => { eprintln!("urus-server: {e}"); print_usage(); return ExitCode::from(2); }
|
||||
};
|
||||
|
||||
let addr = match cli.addr.parse() {
|
||||
Ok(a) => a,
|
||||
Err(e) => { eprintln!("urus-server: bad --addr: {e}"); return ExitCode::from(2); }
|
||||
};
|
||||
|
||||
// The log ring is a bounded MPSC-like buffer with a fixed capacity. It
|
||||
// is shared by every connection actor (via Arc) and drained on Ctrl-C
|
||||
// — but we don't wire shutdown in v1, so it's just an in-memory sink.
|
||||
// Bounded means the bench can run for an hour without RSS growth.
|
||||
let log_ring = LogRing::with_capacity(64 * 1024);
|
||||
|
||||
// Token is wrapped in Arc<[u8]> so each plug clone is a refcount bump.
|
||||
// The constant-time compare reads it as bytes.
|
||||
let token: Arc<[u8]> = cli.token.as_bytes().to_vec().into();
|
||||
|
||||
// The pipeline is shape:
|
||||
// logger -> request_id -> [auth ->] router
|
||||
//
|
||||
// /ping is mounted as a literal at the top of the router so it shares
|
||||
// the middleware chain with everything else; the bench spec says S1 is
|
||||
// a "naked" hello world, but in this implementation /ping still goes
|
||||
// through the logger and request_id plugs. That keeps the impl simple
|
||||
// and lets us measure the middleware cost as the *S2 minus S1* delta,
|
||||
// which is what the spec actually asks for (§2).
|
||||
//
|
||||
// --no-auth turns off auth for cases where the load generator can't
|
||||
// easily set headers (it shouldn't be needed for wrk2 though).
|
||||
let auth_token = token.clone();
|
||||
let auth = move |conn: Conn, next: Next| auth_plug(conn, next, &auth_token);
|
||||
|
||||
// The store handle is what handlers use to talk to the store actor.
|
||||
// It's a wrapper around a smarm Sender; cloning is cheap (refcount).
|
||||
//
|
||||
// We can't actually *spawn* the store actor here (smarm requires that
|
||||
// spawn be called from inside an actor, not from the bootstrap
|
||||
// thread). The OnceLock in handlers::AppState defers that until the
|
||||
// first request comes in. We just pass the construction parameters
|
||||
// through here.
|
||||
//
|
||||
// For SQLite we use the configured DB path; for memory we don't
|
||||
// need anything.
|
||||
let store_init: Arc<dyn Fn() -> StoreHandle + Send + Sync + 'static> =
|
||||
match cli.store {
|
||||
StoreKind::Memory => Arc::new(|| spawn_memory_store()),
|
||||
StoreKind::Sqlite => {
|
||||
let path = cli.db_path.clone();
|
||||
Arc::new(move || spawn_sqlite_store(&path))
|
||||
}
|
||||
};
|
||||
|
||||
let state = AppState::new(store_init, log_ring.clone());
|
||||
|
||||
// Build the router. Handlers close over `state` (cheap Arc clone).
|
||||
// Note that handlers themselves use the AppState to resolve the
|
||||
// store on first call.
|
||||
let router = handlers::build_router(state.clone());
|
||||
|
||||
// Assemble the pipeline.
|
||||
//
|
||||
// The closures below take `Conn, Next` with explicit type annotations.
|
||||
// This is necessary because `Next<'a>` is generic over a lifetime;
|
||||
// without the annotation rustc binds the closure to *one specific*
|
||||
// lifetime and then it doesn't satisfy `Plug`'s `Fn(Conn, Next) -> Conn`
|
||||
// higher-ranked bound. With `Next` written out, the closure parses as
|
||||
// `for<'a> Fn(Conn, Next<'a>) -> Conn`, which is what Plug wants.
|
||||
let log_plug = {
|
||||
let ring = log_ring.clone();
|
||||
move |conn: Conn, next: Next| logger_plug(conn, next, &ring)
|
||||
};
|
||||
|
||||
let mut pipeline = Pipeline::new()
|
||||
.plug(log_plug)
|
||||
.plug(request_id_plug);
|
||||
|
||||
if !cli.no_auth {
|
||||
pipeline = pipeline.plug(auth);
|
||||
}
|
||||
|
||||
let pipeline = pipeline.plug(router);
|
||||
|
||||
// Serve. We use the default Config and let urus's heuristic pick
|
||||
// listener pool size = num CPUs.
|
||||
let cfg = Config::new(addr);
|
||||
eprintln!(
|
||||
"urus-server: store={:?} addr={} auth={}",
|
||||
cli.store, cli.addr, !cli.no_auth
|
||||
);
|
||||
if let Err(e) = serve_with(cfg, pipeline) {
|
||||
eprintln!("urus-server: serve failed: {e}");
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Middleware plugs for the bench.
|
||||
//!
|
||||
//! Per the spec §3:
|
||||
//! - Logger writes to a bounded ring (no stdout — that would dominate).
|
||||
//! - Request ID is generated from a thread-local fast RNG (SplitMix64).
|
||||
//! - Auth is constant-time bearer-token compare.
|
||||
//!
|
||||
//! Each plug is a `Fn(Conn, Next) -> Conn` closure; urus's blanket impl
|
||||
//! turns them into Plug values when handed to Pipeline::plug.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use crossbeam_queue::ArrayQueue;
|
||||
use subtle::ConstantTimeEq;
|
||||
use urus::{Conn, Next};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Log ring
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Bounded MPSC over a fixed-capacity array. We push from many connection
|
||||
// actors and never read during the bench (drain on shutdown only). If the
|
||||
// ring fills, new entries are dropped — the bench is the priority, not
|
||||
// the log. This matches what we said in spec §1.5: warm-up + 60s measured
|
||||
// at high RPS would overflow any unbounded buffer.
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LogEntry {
|
||||
pub method: String,
|
||||
pub path: String,
|
||||
pub status: u16,
|
||||
pub elapsed_us: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LogRing {
|
||||
inner: Arc<ArrayQueue<LogEntry>>,
|
||||
}
|
||||
|
||||
impl LogRing {
|
||||
pub fn with_capacity(n: usize) -> Self {
|
||||
Self { inner: Arc::new(ArrayQueue::new(n)) }
|
||||
}
|
||||
|
||||
/// Push an entry. Drops the entry on overflow (returns Err in
|
||||
/// crossbeam's API — we discard).
|
||||
pub fn push(&self, e: LogEntry) {
|
||||
let _ = self.inner.push(e);
|
||||
}
|
||||
|
||||
/// For inspection after a run.
|
||||
#[allow(dead_code)]
|
||||
pub fn drain(&self) -> Vec<LogEntry> {
|
||||
let mut out = Vec::with_capacity(self.inner.len());
|
||||
while let Some(e) = self.inner.pop() { out.push(e); }
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Logger plug
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Wraps `next`. Captures method/path *before* the inner plugs run (the
|
||||
// router may move them), then captures status + elapsed after.
|
||||
|
||||
pub fn logger_plug(conn: Conn, next: Next, ring: &LogRing) -> Conn {
|
||||
let start = Instant::now();
|
||||
let method = conn.method.as_str().to_string();
|
||||
let path = conn.path.clone();
|
||||
let conn = next.run(conn);
|
||||
let elapsed_us = start.elapsed().as_micros() as u64;
|
||||
ring.push(LogEntry {
|
||||
method,
|
||||
path,
|
||||
status: conn.status.unwrap_or(0),
|
||||
elapsed_us,
|
||||
});
|
||||
conn
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request ID — SplitMix64 in a thread-local, base32-encoded.
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// We avoid global RNG / OS getrandom on the hot path. Each scheduler OS
|
||||
// thread gets its own seed (Cell + thread_local) and advances it with the
|
||||
// SplitMix64 step. 12 bytes of output is encoded with the RFC 4648 base32
|
||||
// alphabet (no padding, no lowercase — the header is opaque to the load
|
||||
// generator).
|
||||
//
|
||||
// Crockford-style alphabet would be friendlier to humans but base32-hex
|
||||
// is fine here.
|
||||
|
||||
thread_local! {
|
||||
static RNG_STATE: Cell<u64> = const { Cell::new(0) };
|
||||
}
|
||||
|
||||
fn splitmix64(s: u64) -> (u64, u64) {
|
||||
// Standard SplitMix64. One 64-bit step gives a uniformly distributed
|
||||
// output and the next state.
|
||||
let new_state = s.wrapping_add(0x9E3779B97F4A7C15);
|
||||
let mut z = new_state;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
|
||||
(new_state, z ^ (z >> 31))
|
||||
}
|
||||
|
||||
fn next_random_bytes(out: &mut [u8; 12]) {
|
||||
RNG_STATE.with(|cell| {
|
||||
let mut s = cell.get();
|
||||
if s == 0 {
|
||||
// Lazy first-time seed. Mixing pointer + thread id + clock
|
||||
// is sufficient for a bench-local RNG; this isn't crypto.
|
||||
let p = cell as *const _ as usize as u64;
|
||||
let t = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
.unwrap_or(0);
|
||||
s = p ^ t ^ 0xA5A5_A5A5_A5A5_A5A5;
|
||||
if s == 0 { s = 1; }
|
||||
}
|
||||
let (s1, r1) = splitmix64(s);
|
||||
let (s2, r2) = splitmix64(s1);
|
||||
cell.set(s2);
|
||||
out[0..8].copy_from_slice(&r1.to_le_bytes());
|
||||
out[8..12].copy_from_slice(&r2.to_le_bytes()[..4]);
|
||||
});
|
||||
}
|
||||
|
||||
// Base32 (RFC 4648) without padding. 12 bytes = 96 bits -> ceil(96/5) = 20
|
||||
// output chars exactly. No padding needed.
|
||||
fn b32_encode(input: &[u8; 12]) -> String {
|
||||
static ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
let mut out = String::with_capacity(20);
|
||||
let mut buf: u64 = 0;
|
||||
let mut bits: u32 = 0;
|
||||
for &b in input {
|
||||
buf = (buf << 8) | (b as u64);
|
||||
bits += 8;
|
||||
while bits >= 5 {
|
||||
bits -= 5;
|
||||
let idx = ((buf >> bits) & 0x1F) as usize;
|
||||
out.push(ALPHABET[idx] as char);
|
||||
}
|
||||
}
|
||||
if bits > 0 {
|
||||
let idx = ((buf << (5 - bits)) & 0x1F) as usize;
|
||||
out.push(ALPHABET[idx] as char);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn request_id_plug(conn: Conn, next: Next) -> Conn {
|
||||
let mut raw = [0u8; 12];
|
||||
next_random_bytes(&mut raw);
|
||||
let id = b32_encode(&raw);
|
||||
// Set on the response. We could also stash it in `conn.assigns` for
|
||||
// handlers to read, but the bench doesn't need that.
|
||||
let conn = next.run(conn);
|
||||
conn.put_header("x-request-id", id)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth plug
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Read `Authorization: Bearer <token>`; constant-time compare against the
|
||||
// configured token. Mismatch -> 401 + halt.
|
||||
//
|
||||
// The token is borrowed (&[u8]) so the closure that wraps this can hold
|
||||
// it via Arc<[u8]> and pass a slice in. No alloc per request.
|
||||
|
||||
pub fn auth_plug(conn: Conn, next: Next, expected: &[u8]) -> Conn {
|
||||
// /ping is the S1 "naked" endpoint per the bench spec — it must not
|
||||
// pay the auth cost. We exempt it by path. This is a deliberate
|
||||
// exception, not a generic feature; the rest of the pipeline (logger,
|
||||
// request_id) still applies to /ping, which is what the spec asks for
|
||||
// (S1 measures the framework floor, not "no middleware whatsoever").
|
||||
if conn.path == "/ping" {
|
||||
return next.run(conn);
|
||||
}
|
||||
|
||||
let header = conn.headers.get("authorization");
|
||||
let presented: Option<&[u8]> = header.and_then(|h| {
|
||||
// strip the "Bearer " prefix (case-insensitive per RFC 6750, but
|
||||
// wrk2 sends the canonical form so an exact prefix check is fine)
|
||||
h.strip_prefix("Bearer ").map(|s| s.as_bytes())
|
||||
});
|
||||
|
||||
let ok = match presented {
|
||||
Some(p) if p.len() == expected.len() =>
|
||||
bool::from(p.ct_eq(expected)),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if ok {
|
||||
next.run(conn)
|
||||
} else {
|
||||
conn.put_status(401)
|
||||
.put_header("content-type", "application/json")
|
||||
.put_body(&b"{\"error\":\"unauthorized\"}"[..])
|
||||
.halt()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn b32_round_trip_lengths() {
|
||||
let mut raw = [0u8; 12];
|
||||
for i in 0..12u8 { raw[i as usize] = i; }
|
||||
let s = b32_encode(&raw);
|
||||
assert_eq!(s.len(), 20);
|
||||
assert!(s.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rng_advances() {
|
||||
let mut a = [0u8; 12];
|
||||
let mut b = [0u8; 12];
|
||||
next_random_bytes(&mut a);
|
||||
next_random_bytes(&mut b);
|
||||
assert_ne!(a, b, "consecutive calls must produce different bytes");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
//! Store actors for the urus-server bench.
|
||||
//!
|
||||
//! Two implementations, same protocol:
|
||||
//! - Memory store: one actor owns a Vec<User>.
|
||||
//! - SQLite store: one writer actor + a pool of reader actors. Reads
|
||||
//! are dispatched through a single MPSC `Receiver` shared by all
|
||||
//! readers — smarm's MPSC gives us "first available reader wins" for
|
||||
//! free.
|
||||
//!
|
||||
//! `StoreHandle` is what handlers hold. It's cheap-cloneable (it wraps a
|
||||
//! pair of smarm Senders) and carries the routing logic for splitting
|
||||
//! requests across the writer and the reader pool.
|
||||
|
||||
use smarm::{channel, Receiver, Sender};
|
||||
use std::sync::Arc;
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::{params, Connection, OpenFlags};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wire types — what the store returns over the reply channel.
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// We send (status, body_bytes) so handlers can hand them straight to
|
||||
// `Conn::put_body` without re-serialising. The store does the JSON work.
|
||||
|
||||
pub type Reply = (u16, Vec<u8>);
|
||||
|
||||
// Debug is intentionally not derived: `smarm::Sender<T>` doesn't implement
|
||||
// Debug, and these enums carry one. We never print them.
|
||||
|
||||
pub enum ReadReq {
|
||||
List { reply: Sender<Reply> },
|
||||
Get { id: u64, reply: Sender<Reply> },
|
||||
}
|
||||
|
||||
pub enum WriteReq {
|
||||
Create { body: Vec<u8>, reply: Sender<Reply> },
|
||||
Update { id: u64, body: Vec<u8>, reply: Sender<Reply> },
|
||||
Delete { id: u64, reply: Sender<Reply> },
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
struct User {
|
||||
id: u64,
|
||||
name: String,
|
||||
email: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct NewUser {
|
||||
name: String,
|
||||
email: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public handle
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Both implementations of the store give back a StoreHandle. Internally
|
||||
// we always have a "read side" and a "write side". For the memory store
|
||||
// they're the same actor (so the writer Sender is just a clone of the
|
||||
// reader Sender wrapped in a small adapter); we keep the split anyway for
|
||||
// API uniformity.
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct StoreHandle {
|
||||
reads: Sender<ReadReq>,
|
||||
writes: Sender<WriteReq>,
|
||||
}
|
||||
|
||||
impl StoreHandle {
|
||||
pub fn reads(&self) -> &Sender<ReadReq> { &self.reads }
|
||||
pub fn writes(&self) -> &Sender<WriteReq> { &self.writes }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Memory store — one actor, one Vec.
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The memory store collapses reads and writes into one mailbox. We bridge
|
||||
// the two-channel API by spawning a tiny *fan-in* actor whose job is to
|
||||
// forward ReadReq | WriteReq into the single backing actor's mailbox.
|
||||
//
|
||||
// Actually, simpler: we make the backing actor accept an `enum AnyReq`
|
||||
// and spawn two forwarder loops. That avoids changing the public API or
|
||||
// inventing a `try_recv_either`. The cost is two extra hops per request,
|
||||
// which is fine for the memory store — it's the *baseline*, not the
|
||||
// target of the bench.
|
||||
//
|
||||
// Wait — that's two extra context switches *per request*, and the
|
||||
// memory tier exists specifically to expose actor-message-pass costs.
|
||||
// Tainting it with extra hops would make the bench lie. Let's do it
|
||||
// properly: one actor, a `select`-shaped recv. smarm doesn't have a
|
||||
// multi-channel select primitive (per the README it's a single-mailbox
|
||||
// model), but we *can* have one actor own one channel that carries
|
||||
// `enum Req { Read(...), Write(...) }` and expose the two Senders by
|
||||
// mapping at the boundary. The mapping happens in a couple of cheap
|
||||
// forwarder actors. For a *pure* in-memory benchmark we still pay one
|
||||
// extra hop per request — fair across all paths.
|
||||
//
|
||||
// Decision: take the one extra hop for clean code. The actor-message
|
||||
// cost we want to measure is "handler -> store -> handler", and the
|
||||
// forwarder just makes the bookkeeping uniform. We document it.
|
||||
|
||||
enum AnyReq {
|
||||
Read(ReadReq),
|
||||
Write(WriteReq),
|
||||
}
|
||||
|
||||
pub fn spawn_memory_store() -> StoreHandle {
|
||||
let (any_tx, any_rx) = channel::<AnyReq>();
|
||||
|
||||
// The backing actor.
|
||||
let core_tx = any_tx.clone();
|
||||
let _ = core_tx; // keep alive via forwarders below
|
||||
smarm::spawn(move || memory_store_loop(any_rx));
|
||||
|
||||
// Forwarder: ReadReq -> AnyReq::Read.
|
||||
let (reads_tx, reads_rx) = channel::<ReadReq>();
|
||||
{
|
||||
let any_tx = any_tx.clone();
|
||||
smarm::spawn(move || forward_reads(reads_rx, any_tx));
|
||||
}
|
||||
|
||||
// Forwarder: WriteReq -> AnyReq::Write.
|
||||
let (writes_tx, writes_rx) = channel::<WriteReq>();
|
||||
{
|
||||
let any_tx = any_tx.clone();
|
||||
smarm::spawn(move || forward_writes(writes_rx, any_tx));
|
||||
}
|
||||
|
||||
StoreHandle { reads: reads_tx, writes: writes_tx }
|
||||
}
|
||||
|
||||
fn forward_reads(rx: Receiver<ReadReq>, tx: Sender<AnyReq>) {
|
||||
while let Ok(r) = rx.recv() {
|
||||
if tx.send(AnyReq::Read(r)).is_err() { return; }
|
||||
}
|
||||
}
|
||||
|
||||
fn forward_writes(rx: Receiver<WriteReq>, tx: Sender<AnyReq>) {
|
||||
while let Ok(w) = rx.recv() {
|
||||
if tx.send(AnyReq::Write(w)).is_err() { return; }
|
||||
}
|
||||
}
|
||||
|
||||
fn memory_store_loop(rx: Receiver<AnyReq>) {
|
||||
let mut users: Vec<User> = Vec::with_capacity(1024);
|
||||
let mut next_id: u64 = 1;
|
||||
|
||||
loop {
|
||||
let req = match rx.recv() {
|
||||
Ok(r) => r,
|
||||
Err(_) => return, // all forwarders dropped
|
||||
};
|
||||
match req {
|
||||
AnyReq::Read(ReadReq::List { reply }) => {
|
||||
// Cap at 100 per the spec; reuse a Vec via collect.
|
||||
let snapshot: Vec<&User> = users.iter().take(100).collect();
|
||||
let body = serde_json::to_vec(&snapshot).unwrap_or_else(|_| b"[]".to_vec());
|
||||
let _ = reply.send((200, body));
|
||||
}
|
||||
AnyReq::Read(ReadReq::Get { id, reply }) => {
|
||||
match users.iter().find(|u| u.id == id) {
|
||||
Some(u) => {
|
||||
let body = serde_json::to_vec(u).unwrap();
|
||||
let _ = reply.send((200, body));
|
||||
}
|
||||
None => {
|
||||
let _ = reply.send((404, b"{\"error\":\"not found\"}".to_vec()));
|
||||
}
|
||||
}
|
||||
}
|
||||
AnyReq::Write(WriteReq::Create { body, reply }) => {
|
||||
match serde_json::from_slice::<NewUser>(&body) {
|
||||
Ok(nu) => {
|
||||
let u = User { id: next_id, name: nu.name, email: nu.email };
|
||||
next_id += 1;
|
||||
users.push(u.clone());
|
||||
// The bench cares about *steady-state* RPS; let
|
||||
// the in-memory store cap itself so we don't
|
||||
// grow to Vec<infinity>.
|
||||
if users.len() > 100_000 {
|
||||
users.drain(..50_000);
|
||||
}
|
||||
let _ = reply.send((201, serde_json::to_vec(&u).unwrap()));
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = reply.send((400, b"{\"error\":\"invalid body\"}".to_vec()));
|
||||
}
|
||||
}
|
||||
}
|
||||
AnyReq::Write(WriteReq::Update { id, body, reply }) => {
|
||||
match serde_json::from_slice::<NewUser>(&body) {
|
||||
Ok(nu) => match users.iter_mut().find(|u| u.id == id) {
|
||||
Some(u) => {
|
||||
u.name = nu.name;
|
||||
u.email = nu.email;
|
||||
let snap = u.clone();
|
||||
let _ = reply.send((200, serde_json::to_vec(&snap).unwrap()));
|
||||
}
|
||||
None => {
|
||||
let _ = reply.send((404, b"{\"error\":\"not found\"}".to_vec()));
|
||||
}
|
||||
},
|
||||
Err(_) => {
|
||||
let _ = reply.send((400, b"{\"error\":\"invalid body\"}".to_vec()));
|
||||
}
|
||||
}
|
||||
}
|
||||
AnyReq::Write(WriteReq::Delete { id, reply }) => {
|
||||
let before = users.len();
|
||||
users.retain(|u| u.id != id);
|
||||
if users.len() < before {
|
||||
let _ = reply.send((204, Vec::new()));
|
||||
} else {
|
||||
let _ = reply.send((404, b"{\"error\":\"not found\"}".to_vec()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SQLite store — one writer + N readers.
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Per the spec §5.1:
|
||||
// - One writer actor owns a single rusqlite::Connection (read-write).
|
||||
// - N=4 reader actors share a single `Receiver<ReadReq>`. The MPSC
|
||||
// semantics guarantee each request goes to exactly one reader; the
|
||||
// scheduler picks whichever reader is parked.
|
||||
//
|
||||
// All connections open the same database file. WAL is set on the writer's
|
||||
// connection (the journal mode is a database-wide setting, so any
|
||||
// connection setting it sticks). busy_timeout is set on every conn so a
|
||||
// momentary writer hold doesn't EBUSY the readers.
|
||||
//
|
||||
// Schema is created idempotently by the writer on first run.
|
||||
|
||||
const SQLITE_READER_COUNT: usize = 4;
|
||||
|
||||
pub fn spawn_sqlite_store(db_path: &str) -> StoreHandle {
|
||||
let path: Arc<str> = Arc::from(db_path);
|
||||
|
||||
// The writer actor.
|
||||
let (writes_tx, writes_rx) = channel::<WriteReq>();
|
||||
{
|
||||
let path = path.clone();
|
||||
smarm::spawn(move || sqlite_writer_loop(&path, writes_rx));
|
||||
}
|
||||
|
||||
// The readers. smarm's channel is MPSC, so we can't share a single
|
||||
// Receiver across N reader actors. Instead: one dispatcher owns the
|
||||
// public `reads_rx`, and round-robins each request into one of N
|
||||
// per-reader private channels. Cost: one extra context switch on
|
||||
// every read. That's the price of fan-out on an MPSC runtime.
|
||||
//
|
||||
// Alternative considered: have handlers know about N senders and
|
||||
// pick one themselves. Rejected: it bakes the pool size into the
|
||||
// handler protocol and gives noisy load balancing under bursty
|
||||
// traffic (any per-handler hashing is worse than a single dispatcher
|
||||
// with strict round-robin).
|
||||
let (reads_tx, reads_rx) = channel::<ReadReq>();
|
||||
let mut per_reader_txs: Vec<Sender<ReadReq>> = Vec::with_capacity(SQLITE_READER_COUNT);
|
||||
for _ in 0..SQLITE_READER_COUNT {
|
||||
let (tx, rx) = channel::<ReadReq>();
|
||||
per_reader_txs.push(tx);
|
||||
let path = path.clone();
|
||||
smarm::spawn(move || sqlite_reader_loop(&path, rx));
|
||||
}
|
||||
smarm::spawn(move || dispatch_reads(reads_rx, per_reader_txs));
|
||||
|
||||
StoreHandle { reads: reads_tx, writes: writes_tx }
|
||||
}
|
||||
|
||||
fn open_writer(path: &str) -> Connection {
|
||||
let conn = Connection::open(path).expect("sqlite: open writer");
|
||||
// WAL is the whole reason this design works: writers don't block
|
||||
// readers, readers don't block writers, only writers block other
|
||||
// writers (and there's only one).
|
||||
conn.pragma_update(None, "journal_mode", "WAL").expect("sqlite: WAL");
|
||||
conn.pragma_update(None, "synchronous", "NORMAL").expect("sqlite: synchronous");
|
||||
conn.pragma_update(None, "busy_timeout", 5000_i64).expect("sqlite: busy_timeout");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL
|
||||
);"
|
||||
).expect("sqlite: schema");
|
||||
conn
|
||||
}
|
||||
|
||||
fn open_reader(path: &str) -> Connection {
|
||||
let conn = Connection::open_with_flags(
|
||||
Path::new(path),
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
).expect("sqlite: open reader");
|
||||
conn.pragma_update(None, "busy_timeout", 5000_i64).expect("sqlite: busy_timeout");
|
||||
// We deliberately don't set journal_mode here — readers inherit the
|
||||
// DB-wide WAL setting from the writer. Setting it on a read-only
|
||||
// connection would just be a no-op or an error.
|
||||
conn
|
||||
}
|
||||
|
||||
fn sqlite_writer_loop(path: &str, rx: Receiver<WriteReq>) {
|
||||
let conn = open_writer(path);
|
||||
while let Ok(req) = rx.recv() {
|
||||
match req {
|
||||
WriteReq::Create { body, reply } => {
|
||||
match serde_json::from_slice::<NewUser>(&body) {
|
||||
Ok(nu) => {
|
||||
// BEGIN IMMEDIATE so the write lock is taken at
|
||||
// statement start, not lazily.
|
||||
let res = conn.execute(
|
||||
"INSERT INTO users (name, email) VALUES (?1, ?2)",
|
||||
params![nu.name, nu.email],
|
||||
);
|
||||
match res {
|
||||
Ok(_) => {
|
||||
let id = conn.last_insert_rowid() as u64;
|
||||
let u = User { id, name: nu.name, email: nu.email };
|
||||
let _ = reply.send((201, serde_json::to_vec(&u).unwrap()));
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("sqlite: insert failed: {e}");
|
||||
let _ = reply.send((500, b"{\"error\":\"db\"}".to_vec()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = reply.send((400, b"{\"error\":\"invalid body\"}".to_vec()));
|
||||
}
|
||||
}
|
||||
}
|
||||
WriteReq::Update { id, body, reply } => {
|
||||
match serde_json::from_slice::<NewUser>(&body) {
|
||||
Ok(nu) => {
|
||||
let res = conn.execute(
|
||||
"UPDATE users SET name=?1, email=?2 WHERE id=?3",
|
||||
params![nu.name, nu.email, id as i64],
|
||||
);
|
||||
match res {
|
||||
Ok(0) => { let _ = reply.send((404, b"{\"error\":\"not found\"}".to_vec())); }
|
||||
Ok(_) => {
|
||||
let u = User { id, name: nu.name, email: nu.email };
|
||||
let _ = reply.send((200, serde_json::to_vec(&u).unwrap()));
|
||||
}
|
||||
Err(_) => { let _ = reply.send((500, b"{\"error\":\"db\"}".to_vec())); }
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = reply.send((400, b"{\"error\":\"invalid body\"}".to_vec()));
|
||||
}
|
||||
}
|
||||
}
|
||||
WriteReq::Delete { id, reply } => {
|
||||
let res = conn.execute(
|
||||
"DELETE FROM users WHERE id=?1",
|
||||
params![id as i64],
|
||||
);
|
||||
match res {
|
||||
Ok(0) => { let _ = reply.send((404, b"{\"error\":\"not found\"}".to_vec())); }
|
||||
Ok(_) => { let _ = reply.send((204, Vec::new())); }
|
||||
Err(_) => { let _ = reply.send((500, b"{\"error\":\"db\"}".to_vec())); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_reads(rx: Receiver<ReadReq>, workers: Vec<Sender<ReadReq>>) {
|
||||
// Strict round-robin. A free-list / readiness queue would distribute
|
||||
// better under skew, but it requires bidirectional signalling we
|
||||
// don't have a primitive for. Round-robin is fine here: SQLite reads
|
||||
// are uniform in cost (single-row PK lookup or LIMIT 100), and any
|
||||
// brief reader stall just shifts a request to the next reader on
|
||||
// the next pass.
|
||||
let n = workers.len();
|
||||
let mut idx = 0usize;
|
||||
while let Ok(req) = rx.recv() {
|
||||
let target = &workers[idx % n];
|
||||
idx = idx.wrapping_add(1);
|
||||
if target.send(req).is_err() {
|
||||
// Reader died; skip it. (smarm restarts panicking actors
|
||||
// under a supervisor, but in this binary the readers are
|
||||
// unsupervised — a panic on one of them would mean a dropped
|
||||
// request now and that worker is permanently silent. Fine
|
||||
// for the bench; for production it'd want a supervisor.)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sqlite_reader_loop(path: &str, rx: Receiver<ReadReq>) {
|
||||
let conn = open_reader(path);
|
||||
let mut stmt_get = conn.prepare("SELECT id, name, email FROM users WHERE id=?1")
|
||||
.expect("sqlite: prepare get");
|
||||
let mut stmt_list = conn.prepare("SELECT id, name, email FROM users LIMIT 100")
|
||||
.expect("sqlite: prepare list");
|
||||
|
||||
while let Ok(req) = rx.recv() {
|
||||
match req {
|
||||
ReadReq::Get { id, reply } => {
|
||||
let row = stmt_get.query_row(params![id as i64], |r| {
|
||||
Ok(User {
|
||||
id: r.get::<_, i64>(0)? as u64,
|
||||
name: r.get(1)?,
|
||||
email: r.get(2)?,
|
||||
})
|
||||
});
|
||||
match row {
|
||||
Ok(u) => { let _ = reply.send((200, serde_json::to_vec(&u).unwrap())); }
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) =>
|
||||
{ let _ = reply.send((404, b"{\"error\":\"not found\"}".to_vec())); }
|
||||
Err(_) =>
|
||||
{ let _ = reply.send((500, b"{\"error\":\"db\"}".to_vec())); }
|
||||
}
|
||||
}
|
||||
ReadReq::List { reply } => {
|
||||
let rows = stmt_list.query_map([], |r| {
|
||||
Ok(User {
|
||||
id: r.get::<_, i64>(0)? as u64,
|
||||
name: r.get(1)?,
|
||||
email: r.get(2)?,
|
||||
})
|
||||
});
|
||||
match rows {
|
||||
Ok(iter) => {
|
||||
let users: Vec<User> = iter.filter_map(|r| r.ok()).collect();
|
||||
let body = serde_json::to_vec(&users).unwrap_or_else(|_| b"[]".to_vec());
|
||||
let _ = reply.send((200, body));
|
||||
}
|
||||
Err(_) => { let _ = reply.send((500, b"{\"error\":\"db\"}".to_vec())); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user