#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" usage() { cat <<'EOF' usage: ./scripts/run_bench_compare.sh Runs the same nostr-bench suite against: 1) Parrhesia (temporary prod relay via run_e2e_suite.sh) 2) strfry (ephemeral instance) — optional, skipped if not in PATH 3) nostr-rs-relay (ephemeral sqlite instance) — optional, skipped if not in PATH Environment: PARRHESIA_BENCH_RUNS Number of comparison runs (default: 2) KEEP_BENCH_LOGS Keep raw logs (1 = keep, default: 0) Benchmark knobs (forwarded to both relays, defaults shown): PARRHESIA_BENCH_CONNECT_COUNT 200 PARRHESIA_BENCH_CONNECT_RATE 100 PARRHESIA_BENCH_ECHO_COUNT 100 PARRHESIA_BENCH_ECHO_RATE 50 PARRHESIA_BENCH_ECHO_SIZE 512 PARRHESIA_BENCH_EVENT_COUNT 100 PARRHESIA_BENCH_EVENT_RATE 50 PARRHESIA_BENCH_REQ_COUNT 100 PARRHESIA_BENCH_REQ_RATE 50 PARRHESIA_BENCH_REQ_LIMIT 10 PARRHESIA_BENCH_KEEPALIVE_SECONDS 5 Example (quick smoke): PARRHESIA_BENCH_RUNS=1 \ PARRHESIA_BENCH_CONNECT_COUNT=20 \ PARRHESIA_BENCH_EVENT_COUNT=20 \ PARRHESIA_BENCH_REQ_COUNT=20 \ ./scripts/run_bench_compare.sh EOF } if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then usage exit 0 fi if ! command -v nostr-bench >/dev/null 2>&1; then echo "nostr-bench not found in PATH. Enter devenv shell first." >&2 exit 1 fi # port_listening PORT — cross-platform check (Darwin: lsof, Linux: ss) port_listening() { local port="$1" if command -v ss >/dev/null 2>&1; then ss -ltn | grep -q ":${port} " else lsof -iTCP:"${port}" -sTCP:LISTEN -P -n >/dev/null 2>&1 fi } HAS_STRFRY=0 if command -v strfry >/dev/null 2>&1; then HAS_STRFRY=1 else echo "strfry not found in PATH — skipping strfry benchmarks" fi HAS_NOSTR_RS=0 if command -v nostr-rs-relay >/dev/null 2>&1; then HAS_NOSTR_RS=1 else echo "nostr-rs-relay not found in PATH — skipping nostr-rs-relay benchmarks" fi RUNS="${PARRHESIA_BENCH_RUNS:-2}" if ! [[ "$RUNS" =~ ^[0-9]+$ ]] || [[ "$RUNS" -lt 1 ]]; then echo "PARRHESIA_BENCH_RUNS must be a positive integer, got: $RUNS" >&2 exit 1 fi PARRHESIA_VERSION="$(grep -E 'version:[[:space:]]+"[^"]+"' mix.exs | head -n 1 | sed -E 's/.*version:[[:space:]]+"([^"]+)".*/\1/')" resolve_strfry_version() { local cli_version local nix_version cli_version="$(strfry --version 2>/dev/null | head -n 1 | tr -d '\r')" # some builds report "strfry no-git-commits" instead of a semver-like version if [[ "$cli_version" =~ [0-9]+\.[0-9]+(\.[0-9]+)? ]]; then printf '%s\n' "$cli_version" return 0 fi nix_version="" if command -v nix >/dev/null 2>&1; then nix_version="$(nix eval --raw --impure --expr 'let pkgs = import {}; in pkgs.strfry.version' 2>/dev/null || true)" fi if [[ -n "$nix_version" ]]; then printf 'strfry %s (nixpkgs)\n' "$nix_version" return 0 fi printf '%s\n' "$cli_version" } STRFRY_VERSION="" if (( HAS_STRFRY )); then STRFRY_VERSION="$(resolve_strfry_version)" fi NOSTR_RS_RELAY_VERSION="" if (( HAS_NOSTR_RS )); then NOSTR_RS_RELAY_VERSION="$(nostr-rs-relay --version 2>/dev/null | head -n 1 | tr -d '\r')" fi NOSTR_BENCH_VERSION="$(nostr-bench --version 2>/dev/null | head -n 1 | tr -d '\r')" export PARRHESIA_BENCH_CONNECT_COUNT="${PARRHESIA_BENCH_CONNECT_COUNT:-200}" export PARRHESIA_BENCH_CONNECT_RATE="${PARRHESIA_BENCH_CONNECT_RATE:-100}" export PARRHESIA_BENCH_ECHO_COUNT="${PARRHESIA_BENCH_ECHO_COUNT:-100}" export PARRHESIA_BENCH_ECHO_RATE="${PARRHESIA_BENCH_ECHO_RATE:-50}" export PARRHESIA_BENCH_ECHO_SIZE="${PARRHESIA_BENCH_ECHO_SIZE:-512}" export PARRHESIA_BENCH_EVENT_COUNT="${PARRHESIA_BENCH_EVENT_COUNT:-100}" export PARRHESIA_BENCH_EVENT_RATE="${PARRHESIA_BENCH_EVENT_RATE:-50}" export PARRHESIA_BENCH_REQ_COUNT="${PARRHESIA_BENCH_REQ_COUNT:-100}" export PARRHESIA_BENCH_REQ_RATE="${PARRHESIA_BENCH_REQ_RATE:-50}" export PARRHESIA_BENCH_REQ_LIMIT="${PARRHESIA_BENCH_REQ_LIMIT:-10}" export PARRHESIA_BENCH_KEEPALIVE_SECONDS="${PARRHESIA_BENCH_KEEPALIVE_SECONDS:-5}" WORK_DIR="$(mktemp -d)" STRFRY_PID="" NOSTR_RS_PID="" cleanup() { if [[ -n "$STRFRY_PID" ]] && kill -0 "$STRFRY_PID" 2>/dev/null; then kill "$STRFRY_PID" 2>/dev/null || true wait "$STRFRY_PID" 2>/dev/null || true fi if [[ -n "$NOSTR_RS_PID" ]] && kill -0 "$NOSTR_RS_PID" 2>/dev/null; then kill "$NOSTR_RS_PID" 2>/dev/null || true wait "$NOSTR_RS_PID" 2>/dev/null || true fi if [[ "${KEEP_BENCH_LOGS:-0}" == "1" ]]; then echo "bench logs kept at: $WORK_DIR" else rm -rf "$WORK_DIR" fi } trap cleanup EXIT INT TERM start_strfry() { local run_index="$1" local port="$2" local strfry_dir="$WORK_DIR/strfry_${run_index}" mkdir -p "$strfry_dir/db" cat >"$strfry_dir/strfry.conf" <"$strfry_dir/strfry.log" 2>&1 & STRFRY_PID=$! for _ in {1..100}; do if port_listening "${port}"; then return 0 fi sleep 0.1 done echo "strfry did not become ready on port ${port}" >&2 tail -n 120 "$strfry_dir/strfry.log" >&2 || true return 1 } stop_strfry() { if [[ -n "$STRFRY_PID" ]] && kill -0 "$STRFRY_PID" 2>/dev/null; then kill "$STRFRY_PID" 2>/dev/null || true wait "$STRFRY_PID" 2>/dev/null || true fi STRFRY_PID="" } start_nostr_rs_relay() { local run_index="$1" local port="$2" local relay_dir="$WORK_DIR/nostr_rs_relay_${run_index}" mkdir -p "$relay_dir/db" cat >"$relay_dir/config.toml" <"$relay_dir/relay.log" 2>&1 & NOSTR_RS_PID=$! for _ in {1..100}; do if port_listening "${port}"; then return 0 fi sleep 0.1 done echo "nostr-rs-relay did not become ready on port ${port}" >&2 tail -n 120 "$relay_dir/relay.log" >&2 || true return 1 } stop_nostr_rs_relay() { if [[ -n "$NOSTR_RS_PID" ]] && kill -0 "$NOSTR_RS_PID" 2>/dev/null; then kill "$NOSTR_RS_PID" 2>/dev/null || true wait "$NOSTR_RS_PID" 2>/dev/null || true fi NOSTR_RS_PID="" } echo "Running ${RUNS} comparison run(s)..." echo "Versions:" echo " parrhesia ${PARRHESIA_VERSION}" if (( HAS_STRFRY )); then echo " ${STRFRY_VERSION}" fi if (( HAS_NOSTR_RS )); then echo " ${NOSTR_RS_RELAY_VERSION}" fi echo " ${NOSTR_BENCH_VERSION}" echo for run in $(seq 1 "$RUNS"); do echo "[run ${run}/${RUNS}] Parrhesia" parrhesia_log="$WORK_DIR/parrhesia_${run}.log" if ! ./scripts/run_nostr_bench.sh all >"$parrhesia_log" 2>&1; then echo "Parrhesia benchmark failed. Log: $parrhesia_log" >&2 tail -n 120 "$parrhesia_log" >&2 || true exit 1 fi if (( HAS_STRFRY )); then echo "[run ${run}/${RUNS}] strfry" strfry_log="$WORK_DIR/strfry_${run}.log" strfry_port=$((49000 + run)) start_strfry "$run" "$strfry_port" if ! STRFRY_BENCH_RELAY_URL="ws://127.0.0.1:${strfry_port}" ./scripts/run_nostr_bench_strfry.sh all >"$strfry_log" 2>&1; then echo "strfry benchmark failed. Log: $strfry_log" >&2 tail -n 120 "$strfry_log" >&2 || true stop_strfry exit 1 fi stop_strfry fi if (( HAS_NOSTR_RS )); then echo "[run ${run}/${RUNS}] nostr-rs-relay" nostr_rs_log="$WORK_DIR/nostr_rs_relay_${run}.log" nostr_rs_port=$((50000 + run)) start_nostr_rs_relay "$run" "$nostr_rs_port" if ! NOSTR_RS_BENCH_RELAY_URL="ws://127.0.0.1:${nostr_rs_port}" ./scripts/run_nostr_bench_nostr_rs_relay.sh all >"$nostr_rs_log" 2>&1; then echo "nostr-rs-relay benchmark failed. Log: $nostr_rs_log" >&2 tail -n 120 "$nostr_rs_log" >&2 || true stop_nostr_rs_relay exit 1 fi stop_nostr_rs_relay fi echo done node - "$WORK_DIR" "$RUNS" "$HAS_STRFRY" "$HAS_NOSTR_RS" <<'NODE' const fs = require("node:fs"); const path = require("node:path"); const workDir = process.argv[2]; const runs = Number(process.argv[3]); const hasStrfry = process.argv[4] === "1"; const hasNostrRs = process.argv[5] === "1"; function parseLog(filePath) { const content = fs.readFileSync(filePath, "utf8"); let section = null; const last = {}; for (const lineRaw of content.split(/\r?\n/)) { const line = lineRaw.trim(); const match = line.match(/^==>\s+nostr-bench\s+(connect|echo|event|req)\s+/); if (match) { section = match[1]; continue; } if (!line.startsWith("{")) continue; try { const parsed = JSON.parse(line); if (section) { last[section] = parsed; } } catch { // ignore non-json lines } } return last; } function runMetrics(parsed) { const connect = parsed.connect?.connect_stats?.success_time ?? {}; const echo = parsed.echo ?? {}; const event = parsed.event ?? {}; const req = parsed.req ?? {}; return { connectAvgMs: Number(connect.avg ?? NaN), connectMaxMs: Number(connect.max ?? NaN), echoTps: Number(echo.tps ?? NaN), echoSizeMiBS: Number(echo.size ?? NaN), eventTps: Number(event.tps ?? NaN), eventSizeMiBS: Number(event.size ?? NaN), reqTps: Number(req.tps ?? NaN), reqSizeMiBS: Number(req.size ?? NaN), }; } function mean(values) { const valid = values.filter((v) => Number.isFinite(v)); if (valid.length === 0) return NaN; return valid.reduce((a, b) => a + b, 0) / valid.length; } function toFixed(value, digits = 2) { return Number.isFinite(value) ? value.toFixed(digits) : "n/a"; } function loadRuns(prefix) { const all = []; for (let i = 1; i <= runs; i += 1) { const file = path.join(workDir, `${prefix}_${i}.log`); all.push(runMetrics(parseLog(file))); } return all; } const parrhesiaRuns = loadRuns("parrhesia"); const strfryRuns = hasStrfry ? loadRuns("strfry") : []; const nostrRsRuns = hasNostrRs ? loadRuns("nostr_rs_relay") : []; const metrics = [ "connectAvgMs", "connectMaxMs", "echoTps", "echoSizeMiBS", "eventTps", "eventSizeMiBS", "reqTps", "reqSizeMiBS", ]; function summarise(allRuns) { const out = {}; for (const m of metrics) { out[m] = mean(allRuns.map((r) => r[m])); } return out; } const summary = { parrhesia: summarise(parrhesiaRuns) }; if (hasStrfry) summary.strfry = summarise(strfryRuns); if (hasNostrRs) summary.nostrRsRelay = summarise(nostrRsRuns); function ratioVsParrhesia(serverKey, metric) { const p = summary.parrhesia[metric]; const other = summary[serverKey]?.[metric]; if (!Number.isFinite(p) || !Number.isFinite(other) || p === 0) return "n/a"; return `${(other / p).toFixed(2)}x`; } const metricLabels = [ ["connect avg latency (ms) ↓", "connectAvgMs"], ["connect max latency (ms) ↓", "connectMaxMs"], ["echo throughput (TPS) ↑", "echoTps"], ["echo throughput (MiB/s) ↑", "echoSizeMiBS"], ["event throughput (TPS) ↑", "eventTps"], ["event throughput (MiB/s) ↑", "eventSizeMiBS"], ["req throughput (TPS) ↑", "reqTps"], ["req throughput (MiB/s) ↑", "reqSizeMiBS"], ]; const headers = ["metric", "parrhesia"]; if (hasStrfry) headers.push("strfry"); if (hasNostrRs) headers.push("nostr-rs-relay"); if (hasStrfry) headers.push("strfry/parrhesia"); if (hasNostrRs) headers.push("nostr-rs/parrhesia"); const rows = metricLabels.map(([label, key]) => { const row = [label, toFixed(summary.parrhesia[key])]; if (hasStrfry) row.push(toFixed(summary.strfry[key])); if (hasNostrRs) row.push(toFixed(summary.nostrRsRelay[key])); if (hasStrfry) row.push(ratioVsParrhesia("strfry", key)); if (hasNostrRs) row.push(ratioVsParrhesia("nostrRsRelay", key)); return row; }); const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length))); function fmtRow(cols) { return cols .map((c, i) => c.padEnd(widths[i])) .join(" "); } console.log("=== Bench comparison (averages) ==="); console.log(fmtRow(headers)); console.log(fmtRow(widths.map((w) => "-".repeat(w)))); for (const row of rows) { console.log(fmtRow(row)); } console.log("\nLegend: ↑ higher is better, ↓ lower is better."); if (hasStrfry || hasNostrRs) { console.log("Ratio columns are server/parrhesia (for ↓ metrics, <1.00x means that server is faster).\n"); } else { console.log(""); } console.log("Run details:"); for (let i = 0; i < runs; i += 1) { const p = parrhesiaRuns[i]; let line = ` run ${i + 1}: ` + `parrhesia(echo_tps=${toFixed(p.echoTps, 0)}, event_tps=${toFixed(p.eventTps, 0)}, req_tps=${toFixed(p.reqTps, 0)}, connect_avg_ms=${toFixed(p.connectAvgMs, 0)})`; if (hasStrfry) { const s = strfryRuns[i]; line += ` | strfry(echo_tps=${toFixed(s.echoTps, 0)}, event_tps=${toFixed(s.eventTps, 0)}, req_tps=${toFixed(s.reqTps, 0)}, connect_avg_ms=${toFixed(s.connectAvgMs, 0)})`; } if (hasNostrRs) { const n = nostrRsRuns[i]; line += ` | nostr-rs-relay(echo_tps=${toFixed(n.echoTps, 0)}, event_tps=${toFixed(n.eventTps, 0)}, req_tps=${toFixed(n.reqTps, 0)}, connect_avg_ms=${toFixed(n.connectAvgMs, 0)})`; } console.log(line); } NODE