This commit is contained in:
2026-05-26 23:22:32 +02:00
parent 842fca2b72
commit 730de334e0
17 changed files with 5380 additions and 0 deletions
Generated
+2293
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
[workspace]
resolver = "2"
members = [
"urus-server",
"axum-server",
]
[profile.release]
lto = "thin"
codegen-units = 1
panic = "unwind"
+26
View File
@@ -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"
+585
View File
@@ -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(())
}
+12
View File
@@ -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
+5
View File
@@ -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"
+13
View File
@@ -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
+30
View File
@@ -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
View File
@@ -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"
Executable
+367
View File
@@ -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
+66
View File
@@ -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"
'';
}
+462
View File
@@ -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 6080% 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 1020%
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.
+35
View File
@@ -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"
+192
View File
@@ -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))
}
+194
View File
@@ -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
}
+234
View File
@@ -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");
}
}
+444
View File
@@ -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())); }
}
}
}
}
}