From 4205012a90f9f57a2ae275630f1537007f03344a Mon Sep 17 00:00:00 2001 From: Jemiiah Date: Fri, 20 Feb 2026 13:02:46 -0800 Subject: [PATCH] feat: add k6 load testing suite for performance benchmarks Adds 4 load test scenarios covering API baseline latency, 1000 concurrent WebSocket connections, 100 simultaneous predictions burst, and AMM high-frequency trading at 200 tx/sec. Includes shared config, auth helpers, orchestrator script, npm scripts, and baseline metrics template. Co-Authored-By: Claude Opus 4.6 --- backend/load-tests/LOAD_TEST_RESULTS.md | 206 ++++++++++++ backend/load-tests/config.js | 54 ++++ backend/load-tests/helpers/auth.js | 89 +++++ backend/load-tests/results/.gitignore | 2 + backend/load-tests/results/.gitkeep | 1 + backend/load-tests/run-all.sh | 131 ++++++++ .../scenarios/amm-high-frequency.js | 306 ++++++++++++++++++ backend/load-tests/scenarios/api-baseline.js | 193 +++++++++++ .../load-tests/scenarios/predictions-burst.js | 268 +++++++++++++++ .../scenarios/websocket-connections.js | 179 ++++++++++ backend/package.json | 7 +- 11 files changed, 1435 insertions(+), 1 deletion(-) create mode 100644 backend/load-tests/LOAD_TEST_RESULTS.md create mode 100644 backend/load-tests/config.js create mode 100644 backend/load-tests/helpers/auth.js create mode 100644 backend/load-tests/results/.gitignore create mode 100644 backend/load-tests/results/.gitkeep create mode 100755 backend/load-tests/run-all.sh create mode 100644 backend/load-tests/scenarios/amm-high-frequency.js create mode 100644 backend/load-tests/scenarios/api-baseline.js create mode 100644 backend/load-tests/scenarios/predictions-burst.js create mode 100644 backend/load-tests/scenarios/websocket-connections.js diff --git a/backend/load-tests/LOAD_TEST_RESULTS.md b/backend/load-tests/LOAD_TEST_RESULTS.md new file mode 100644 index 0000000..9895d5d --- /dev/null +++ b/backend/load-tests/LOAD_TEST_RESULTS.md @@ -0,0 +1,206 @@ +# BoxMeOut Stella — Load Test Results & Baseline Metrics + +## Overview + +This document tracks performance baselines for the BoxMeOut Stella prediction market platform. Run load tests regularly against staging and before production deploys. + +## Prerequisites + +```bash +# Install k6 +brew install k6 # macOS +sudo apt install k6 # Ubuntu/Debian + +# Start the backend server +cd backend && npm run dev +``` + +## Running Tests + +```bash +cd backend/load-tests + +# Run all scenarios +./run-all.sh + +# Run a specific scenario +./run-all.sh --scenario baseline +./run-all.sh --scenario websocket +./run-all.sh --scenario predictions +./run-all.sh --scenario amm + +# Override target +./run-all.sh --base-url http://staging.boxmeout.io:3000 + +# Run individual k6 scripts directly +k6 run scenarios/api-baseline.js +k6 run -e MARKET_ID=abc123 scenarios/predictions-burst.js +``` + +## Test Scenarios + +### 1. API Baseline (`scenarios/api-baseline.js`) + +Measures core API latency under moderate load (50 concurrent users). + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/health` | GET | Basic health check | +| `/health/detailed` | GET | Service-level health | +| `/api/markets` | GET | List all markets | +| `/api/markets/:id` | GET | Single market detail | +| `/api/auth/challenge` | POST | Auth nonce request | +| `/metrics` | GET | Prometheus metrics | + +**Thresholds:** +- `p(50)` < 200ms +- `p(95)` < 500ms +- `p(99)` < 1000ms +- Error rate < 5% + +### 2. WebSocket Connections (`scenarios/websocket-connections.js`) + +Ramps to 1000 concurrent WebSocket connections and sustains for 2 minutes. + +| Phase | Duration | Target Connections | +|-------|----------|--------------------| +| Ramp 1 | 30s | 100 | +| Ramp 2 | 30s | 500 | +| Ramp 3 | 30s | 1000 | +| Sustain | 2m | 1000 | +| Ramp down | 30s | 0 | + +**Thresholds:** +- Connection time `p(95)` < 2000ms +- Message latency `p(95)` < 500ms +- Error rate < 10% + +### 3. Predictions Burst (`scenarios/predictions-burst.js`) + +100 users simultaneously submit predictions on a single market, then sustained load. + +| Phase | VUs | Duration | Description | +|-------|-----|----------|-------------| +| Burst | 100 | instant | All 100 commit at once | +| Sustained | 20→100 | 3m45s | Continuous prediction flow | + +**Tested Operations:** +- `POST /api/markets/:id/predict` — Commit prediction +- `POST /api/predictions/:id/reveal` — Reveal prediction +- `POST /api/markets/:id/buy-shares` — Buy YES/NO shares +- `POST /api/markets/:id/sell-shares` — Sell shares + +**Thresholds:** +- Commit `p(50)` < 500ms, `p(95)` < 2000ms +- Reveal `p(50)` < 500ms, `p(95)` < 2000ms +- Buy shares `p(50)` < 300ms, `p(95)` < 1000ms +- Error rate < 15% (blockchain operations may timeout) + +### 4. AMM High-Frequency Trading (`scenarios/amm-high-frequency.js`) + +Simulates high-frequency trading against the AMM with up to 200 trades/second. + +| Phase | Duration | Rate | Description | +|-------|----------|------|-------------| +| Warmup | 30s | 5 VUs | Establish baseline prices | +| Ramp | 30s | 10 tx/s | Light trading | +| Ramp | 30s | 50 tx/s | Medium frequency | +| Stress | 1m | 100 tx/s | High frequency | +| Peak | 1m | 200 tx/s | Maximum stress | +| Cooldown | 45s | 50→0 tx/s | Wind down | + +**Trade Distribution:** +- 40% Buy shares +- 30% Sell shares +- 20% Read pool state +- 10% Add liquidity + +**Concurrent readers:** 20 VUs continuously reading pool state during trading. + +**Thresholds:** +- Buy/Sell `p(50)` < 300ms, `p(95)` < 1500ms +- Pool state read `p(50)` < 100ms, `p(95)` < 300ms +- Trade error rate < 20% + +--- + +## Baseline Metrics Template + +Fill in after first test run: + +### Environment +- **Date:** YYYY-MM-DD +- **Server:** (e.g., MacBook M2, EC2 t3.medium) +- **Node.js:** (version) +- **PostgreSQL:** (version) +- **Redis:** (version) +- **Network:** (local / staging / production) + +### API Baseline Results + +| Endpoint | p50 | p95 | p99 | Max | Error Rate | +|----------|-----|-----|-----|-----|------------| +| `GET /health` | —ms | —ms | —ms | —ms | —% | +| `GET /api/markets` | —ms | —ms | —ms | —ms | —% | +| `GET /api/markets/:id` | —ms | —ms | —ms | —ms | —% | +| `POST /api/auth/challenge` | —ms | —ms | —ms | —ms | —% | +| `GET /metrics` | —ms | —ms | —ms | —ms | —% | + +### WebSocket Results + +| Metric | Value | +|--------|-------| +| Peak concurrent connections | — | +| Connection time (p95) | —ms | +| Message latency (p95) | —ms | +| Connection error rate | —% | +| Messages received | — | + +### Predictions Burst Results + +| Operation | p50 | p95 | p99 | Success Rate | +|-----------|-----|-----|-----|--------------| +| Commit prediction | —ms | —ms | —ms | —% | +| Reveal prediction | —ms | —ms | —ms | —% | +| Buy shares | —ms | —ms | —ms | —% | +| Sell shares | —ms | —ms | —ms | —% | + +### AMM High-Frequency Results + +| Metric | Value | +|--------|-------| +| Total trades executed | — | +| Peak throughput (tx/sec) | — | +| Buy latency (p95) | —ms | +| Sell latency (p95) | —ms | +| Pool state read (p95) | —ms | +| Max slippage observed | —% | +| Trade error rate | —% | + +--- + +## Interpreting Results + +### Green (Pass) +- All percentile thresholds met +- Error rates within bounds +- No connection drops during sustained phase + +### Yellow (Warning) +- p99 exceeding thresholds but p95 passing +- Error rate 5-15% +- Sporadic WebSocket disconnects + +### Red (Fail) +- p95 thresholds breached +- Error rate > 15% +- WebSocket connections unable to sustain target +- AMM pool state inconsistency detected + +## Common Bottlenecks + +1. **Database connection pool exhaustion** — Increase Prisma pool size +2. **Redis connection limits** — Scale Redis or use connection pooling +3. **Blockchain RPC rate limiting** — Queue transactions, use batch calls +4. **Node.js event loop blocking** — Profile with `--prof`, move crypto to worker threads +5. **WebSocket memory** — Each connection ~50KB; 1000 connections = ~50MB baseline diff --git a/backend/load-tests/config.js b/backend/load-tests/config.js new file mode 100644 index 0000000..f38c824 --- /dev/null +++ b/backend/load-tests/config.js @@ -0,0 +1,54 @@ +// Load test shared configuration for BoxMeOut Stella +// Usage: import { CONFIG, THRESHOLDS } from './config.js'; + +export const CONFIG = { + // Target server + BASE_URL: __ENV.BASE_URL || 'http://localhost:3000', + WS_URL: __ENV.WS_URL || 'ws://localhost:3000', + + // Auth + ADMIN_PUBLIC_KEY: __ENV.ADMIN_PUBLIC_KEY || 'GCTEST000000000000000000000000000000000000000000000000000', + + // Test market ID (set via env or use default) + MARKET_ID: __ENV.MARKET_ID || 'test-market-1', + + // Timing + RAMP_UP_DURATION: '30s', + STEADY_STATE_DURATION: '2m', + RAMP_DOWN_DURATION: '15s', + + // Rate limit aware — the API has 100 req/min per IP + API_RATE_LIMIT: 100, +}; + +// Shared k6 thresholds for pass/fail criteria +export const THRESHOLDS = { + // HTTP request duration targets + http_req_duration: [ + 'p(50)<200', // p50 under 200ms + 'p(95)<500', // p95 under 500ms + 'p(99)<1000', // p99 under 1s + ], + // HTTP request failure rate + http_req_failed: [ + 'rate<0.05', // Less than 5% failure rate + ], + // Custom metric thresholds (defined per-scenario) +}; + +// Common HTTP params +export const HEADERS = { + 'Content-Type': 'application/json', + Accept: 'application/json', +}; + +// Generate a fake Stellar public key for load testing +export function generatePublicKey(vuId) { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + let key = 'G'; + const seed = `${vuId}-${Date.now()}`; + for (let i = 0; i < 55; i++) { + key += chars[(vuId * 7 + i * 13) % chars.length]; + } + return key; +} diff --git a/backend/load-tests/helpers/auth.js b/backend/load-tests/helpers/auth.js new file mode 100644 index 0000000..cb199b3 --- /dev/null +++ b/backend/load-tests/helpers/auth.js @@ -0,0 +1,89 @@ +// Authentication helper for load tests +// Handles the challenge-sign-verify flow for generating JWT tokens +import http from 'k6/http'; +import { check } from 'k6'; +import { CONFIG, HEADERS } from '../config.js'; + +// Request a challenge nonce for a given public key +export function requestChallenge(publicKey) { + const res = http.post( + `${CONFIG.BASE_URL}/api/auth/challenge`, + JSON.stringify({ publicKey }), + { headers: HEADERS, tags: { name: 'auth_challenge' } } + ); + + const success = check(res, { + 'challenge: status 200': (r) => r.status === 200, + 'challenge: has nonce': (r) => { + try { + const body = JSON.parse(r.body); + return !!body.nonce || !!(body.data && body.data.nonce); + } catch { + return false; + } + }, + }); + + if (!success) { + return null; + } + + try { + const body = JSON.parse(res.body); + return body.data || body; + } catch { + return null; + } +} + +// Login with a pre-signed payload (for load testing, we skip real signing) +// In a real load test, you'd use a pool of pre-generated tokens +export function login(publicKey, nonce, signature) { + const res = http.post( + `${CONFIG.BASE_URL}/api/auth/login`, + JSON.stringify({ publicKey, nonce, signature }), + { headers: HEADERS, tags: { name: 'auth_login' } } + ); + + const success = check(res, { + 'login: status 200': (r) => r.status === 200, + }); + + if (!success) { + return null; + } + + try { + const body = JSON.parse(res.body); + return body.data || body; + } catch { + return null; + } +} + +// Build authorized headers from a token +export function authHeaders(token) { + return { + ...HEADERS, + Authorization: `Bearer ${token}`, + }; +} + +// Refresh an access token +export function refreshToken(refreshToken) { + const res = http.post( + `${CONFIG.BASE_URL}/api/auth/refresh`, + JSON.stringify({ refreshToken }), + { headers: HEADERS, tags: { name: 'auth_refresh' } } + ); + + if (res.status === 200) { + try { + const body = JSON.parse(res.body); + return body.data || body; + } catch { + return null; + } + } + return null; +} diff --git a/backend/load-tests/results/.gitignore b/backend/load-tests/results/.gitignore new file mode 100644 index 0000000..0827618 --- /dev/null +++ b/backend/load-tests/results/.gitignore @@ -0,0 +1,2 @@ +*.json +!.gitignore diff --git a/backend/load-tests/results/.gitkeep b/backend/load-tests/results/.gitkeep new file mode 100644 index 0000000..61109af --- /dev/null +++ b/backend/load-tests/results/.gitkeep @@ -0,0 +1 @@ +results/*.json diff --git a/backend/load-tests/run-all.sh b/backend/load-tests/run-all.sh new file mode 100755 index 0000000..bb753da --- /dev/null +++ b/backend/load-tests/run-all.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# ============================================================================= +# BoxMeOut Stella — Run All Load Tests +# ============================================================================= +# Usage: ./run-all.sh [--base-url http://localhost:3000] [--scenario ] +# +# Prerequisites: +# brew install k6 (macOS) +# sudo apt install k6 (Linux) +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +RESULTS_DIR="${SCRIPT_DIR}/results" +BASE_URL="${BASE_URL:-http://localhost:3000}" +WS_URL="${WS_URL:-ws://localhost:3000}" +MARKET_ID="${MARKET_ID:-test-market-1}" +SCENARIO="${1:-all}" + +# Parse flags +while [[ $# -gt 0 ]]; do + case $1 in + --base-url) BASE_URL="$2"; shift 2;; + --ws-url) WS_URL="$2"; shift 2;; + --market-id) MARKET_ID="$2"; shift 2;; + --scenario) SCENARIO="$2"; shift 2;; + *) shift;; + esac +done + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +echo -e "${BLUE}╔════════════════════════════════════════════════╗${NC}" +echo -e "${BLUE}║ BoxMeOut Stella — Load Testing Suite ║${NC}" +echo -e "${BLUE}╠════════════════════════════════════════════════╣${NC}" +echo -e "${BLUE}║ Target: ${NC}${BASE_URL}" +echo -e "${BLUE}║ WS: ${NC}${WS_URL}" +echo -e "${BLUE}║ Market: ${NC}${MARKET_ID}" +echo -e "${BLUE}║ Scenario:${NC} ${SCENARIO}" +echo -e "${BLUE}╚════════════════════════════════════════════════╝${NC}" +echo "" + +# Check k6 is installed +if ! command -v k6 &> /dev/null; then + echo -e "${RED}Error: k6 is not installed.${NC}" + echo "Install with: brew install k6 (macOS) or see https://k6.io/docs/get-started/installation/" + exit 1 +fi + +# Check server is reachable +echo -e "${YELLOW}Checking server health...${NC}" +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "${BASE_URL}/health" 2>/dev/null || echo "000") +if [ "$HTTP_CODE" != "200" ]; then + echo -e "${RED}Warning: Server at ${BASE_URL}/health returned HTTP ${HTTP_CODE}${NC}" + echo -e "${YELLOW}Tests may fail. Make sure the server is running.${NC}" + echo "" +fi + +# Create results directory +mkdir -p "${RESULTS_DIR}" + +# Export environment variables for k6 +export BASE_URL WS_URL MARKET_ID + +run_test() { + local name="$1" + local script="$2" + + echo -e "\n${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${GREEN}Running: ${name}${NC}" + echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + + local start_time + start_time=$(date +%s) + + if k6 run \ + -e BASE_URL="${BASE_URL}" \ + -e WS_URL="${WS_URL}" \ + -e MARKET_ID="${MARKET_ID}" \ + "${SCRIPT_DIR}/${script}"; then + local end_time + end_time=$(date +%s) + local duration=$((end_time - start_time)) + echo -e "${GREEN}✓ ${name} completed in ${duration}s${NC}" + else + echo -e "${RED}✗ ${name} failed${NC}" + fi +} + +case "${SCENARIO}" in + all) + run_test "API Baseline" "scenarios/api-baseline.js" + run_test "WebSocket Connections (1000)" "scenarios/websocket-connections.js" + run_test "Predictions Burst (100)" "scenarios/predictions-burst.js" + run_test "AMM High-Frequency Trading" "scenarios/amm-high-frequency.js" + ;; + baseline) + run_test "API Baseline" "scenarios/api-baseline.js" + ;; + websocket) + run_test "WebSocket Connections (1000)" "scenarios/websocket-connections.js" + ;; + predictions) + run_test "Predictions Burst (100)" "scenarios/predictions-burst.js" + ;; + amm) + run_test "AMM High-Frequency Trading" "scenarios/amm-high-frequency.js" + ;; + *) + echo -e "${RED}Unknown scenario: ${SCENARIO}${NC}" + echo "Available: all, baseline, websocket, predictions, amm" + exit 1 + ;; +esac + +echo -e "\n${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +echo -e "${GREEN}All tests complete. Results in: ${RESULTS_DIR}/${NC}" +echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + +# Print summary if result files exist +if ls "${RESULTS_DIR}"/*.json 1>/dev/null 2>&1; then + echo -e "\n${YELLOW}Result files:${NC}" + for f in "${RESULTS_DIR}"/*.json; do + echo " - $(basename "$f")" + done +fi diff --git a/backend/load-tests/scenarios/amm-high-frequency.js b/backend/load-tests/scenarios/amm-high-frequency.js new file mode 100644 index 0000000..e3197ff --- /dev/null +++ b/backend/load-tests/scenarios/amm-high-frequency.js @@ -0,0 +1,306 @@ +// ============================================================================= +// AMM High-Frequency Trading Load Test +// ============================================================================= +// Simulates high-frequency trading against the Automated Market Maker. +// Tests pool state consistency, price slippage under load, and throughput. +// +// Run: k6 run scenarios/amm-high-frequency.js +// Env: BASE_URL, MARKET_ID, AUTH_TOKENS +// ============================================================================= +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; +import { Trend, Rate, Counter, Gauge } from 'k6/metrics'; +import { CONFIG, HEADERS } from '../config.js'; + +// Custom metrics +const buyLatency = new Trend('amm_buy_duration', true); +const sellLatency = new Trend('amm_sell_duration', true); +const poolStateLatency = new Trend('amm_pool_state_duration', true); +const createPoolLatency = new Trend('amm_create_pool_duration', true); +const addLiquidityLatency = new Trend('amm_add_liquidity_duration', true); +const tradeErrors = new Rate('amm_trade_error_rate'); +const tradesExecuted = new Counter('amm_trades_executed'); +const totalVolume = new Counter('amm_total_volume'); +const slippageGauge = new Gauge('amm_max_slippage'); + +// Pre-generated auth tokens +const authTokens = __ENV.AUTH_TOKENS + ? __ENV.AUTH_TOKENS.split(',') + : []; + +export const options = { + scenarios: { + // Phase 1: Warm-up — light trading to establish baseline prices + warmup: { + executor: 'constant-vus', + vus: 5, + duration: '30s', + gracefulStop: '5s', + }, + // Phase 2: Ramp — increasing trade frequency + ramp_trading: { + executor: 'ramping-arrival-rate', + startRate: 10, // 10 trades/sec + timeUnit: '1s', + preAllocatedVUs: 100, + maxVUs: 200, + stages: [ + { duration: '30s', target: 10 }, // 10 trades/sec + { duration: '30s', target: 50 }, // 50 trades/sec + { duration: '1m', target: 100 }, // 100 trades/sec + { duration: '1m', target: 200 }, // 200 trades/sec (stress) + { duration: '30s', target: 50 }, // Cool down + { duration: '15s', target: 0 }, // Wind down + ], + startTime: '35s', // Start after warmup + }, + // Phase 3: Pool state reads — concurrent reads during heavy trading + pool_state_readers: { + executor: 'constant-vus', + vus: 20, + duration: '3m', + startTime: '35s', + gracefulStop: '10s', + }, + }, + thresholds: { + amm_buy_duration: [ + 'p(50)<300', + 'p(95)<1500', + 'p(99)<5000', + ], + amm_sell_duration: [ + 'p(50)<300', + 'p(95)<1500', + 'p(99)<5000', + ], + amm_pool_state_duration: [ + 'p(50)<100', + 'p(95)<300', + 'p(99)<1000', + ], + amm_trade_error_rate: ['rate<0.20'], // Allow 20% for blockchain contention + }, +}; + +function getAuthHeaders(vuId) { + if (authTokens.length > 0) { + const token = authTokens[vuId % authTokens.length]; + return { ...HEADERS, Authorization: `Bearer ${token}` }; + } + return HEADERS; +} + +export default function () { + const vuId = __VU; + const scenario = __ENV.scenario || exec.scenario.name; + const marketId = CONFIG.MARKET_ID; + const headers = getAuthHeaders(vuId); + + // Pool state readers scenario — only read pool state + if (__ENV.__ITER !== undefined && vuId > 180) { + readPoolState(marketId, headers); + sleep(0.5); + return; + } + + // Trading scenarios — execute trades + group('amm_trading', function () { + // Randomly choose: buy YES, buy NO, sell YES, sell NO + const action = Math.random(); + const outcome = Math.random() > 0.5 ? 'YES' : 'NO'; + + if (action < 0.4) { + // 40% chance: Buy shares + buyShares(marketId, outcome, headers); + } else if (action < 0.7) { + // 30% chance: Sell shares + sellShares(marketId, outcome, headers); + } else if (action < 0.9) { + // 20% chance: Read pool state + readPoolState(marketId, headers); + } else { + // 10% chance: Add liquidity + addLiquidity(marketId, headers); + } + }); + + // High frequency: minimal sleep between trades + sleep(Math.random() * 0.5); // 0-500ms between trades +} + +function buyShares(marketId, outcome, headers) { + const shares = Math.floor(Math.random() * 100) + 1; // 1-100 shares + const maxPrice = outcome === 'YES' ? 0.95 : 0.95; // Slippage protection + + const payload = JSON.stringify({ + outcome, + shares, + maxPrice, + }); + + const res = http.post( + `${CONFIG.BASE_URL}/api/markets/${marketId}/buy-shares`, + payload, + { headers, tags: { name: 'amm_buy' }, timeout: '30s' } + ); + + buyLatency.add(res.timings.duration); + + const ok = check(res, { + 'amm buy: status 200/201': (r) => r.status === 200 || r.status === 201, + 'amm buy: has trade data': (r) => { + try { + const body = JSON.parse(r.body); + return !!(body.data || body.trade || body.txHash); + } catch { + return false; + } + }, + }); + + if (ok) { + tradesExecuted.add(1); + totalVolume.add(shares); + + // Check for slippage + try { + const body = JSON.parse(res.body); + const data = body.data || body; + if (data.slippage) { + slippageGauge.add(data.slippage); + } + } catch { /* ignore */ } + } else { + tradeErrors.add(true); + } +} + +function sellShares(marketId, outcome, headers) { + const shares = Math.floor(Math.random() * 50) + 1; // 1-50 shares + const minPrice = 0.05; // Slippage protection + + const payload = JSON.stringify({ + outcome, + shares, + minPrice, + }); + + const res = http.post( + `${CONFIG.BASE_URL}/api/markets/${marketId}/sell-shares`, + payload, + { headers, tags: { name: 'amm_sell' }, timeout: '30s' } + ); + + sellLatency.add(res.timings.duration); + + const ok = check(res, { + 'amm sell: status 200/201': (r) => r.status === 200 || r.status === 201, + }); + + if (ok) { + tradesExecuted.add(1); + totalVolume.add(shares); + } else { + tradeErrors.add(true); + } +} + +function readPoolState(marketId, headers) { + const res = http.get( + `${CONFIG.BASE_URL}/api/markets/${marketId}`, + { headers, tags: { name: 'amm_pool_state' } } + ); + + poolStateLatency.add(res.timings.duration); + + check(res, { + 'pool state: status 200/404': (r) => r.status === 200 || r.status === 404, + 'pool state: has reserves': (r) => { + try { + const body = JSON.parse(r.body); + const data = body.data || body; + return !!(data.reserves || data.odds || data.liquidity); + } catch { + return false; + } + }, + }); +} + +function addLiquidity(marketId, headers) { + const amount = Math.floor(Math.random() * 1000) + 100; // 100-1100 USDC + + const payload = JSON.stringify({ + amountUsdc: amount, + }); + + const res = http.post( + `${CONFIG.BASE_URL}/api/markets/${marketId}/add-liquidity`, + payload, + { headers, tags: { name: 'amm_add_liquidity' }, timeout: '30s' } + ); + + addLiquidityLatency.add(res.timings.duration); + + const ok = check(res, { + 'add liquidity: status 200/201': (r) => r.status === 200 || r.status === 201, + }); + + if (ok) { + totalVolume.add(amount); + } else { + tradeErrors.add(true); + } +} + +// Need to import exec for scenario name detection +import exec from 'k6/execution'; + +export function handleSummary(data) { + const summary = { + timestamp: new Date().toISOString(), + scenario: 'amm-high-frequency', + metrics: {}, + }; + + const metricNames = [ + 'amm_buy_duration', + 'amm_sell_duration', + 'amm_pool_state_duration', + 'amm_add_liquidity_duration', + 'http_req_duration', + ]; + + for (const name of metricNames) { + if (data.metrics[name]) { + const m = data.metrics[name].values; + summary.metrics[name] = { + min: Math.round(m.min * 100) / 100, + avg: Math.round(m.avg * 100) / 100, + med: Math.round(m.med * 100) / 100, + p90: Math.round(m['p(90)'] * 100) / 100, + p95: Math.round(m['p(95)'] * 100) / 100, + p99: Math.round(m['p(99)'] * 100) / 100, + max: Math.round(m.max * 100) / 100, + }; + } + } + + if (data.metrics.amm_trades_executed) { + summary.metrics.trades_executed = data.metrics.amm_trades_executed.values.count; + } + if (data.metrics.amm_total_volume) { + summary.metrics.total_volume = data.metrics.amm_total_volume.values.count; + } + if (data.metrics.amm_trade_error_rate) { + summary.metrics.error_rate = data.metrics.amm_trade_error_rate.values.rate; + } + if (data.metrics.amm_max_slippage) { + summary.metrics.max_slippage = data.metrics.amm_max_slippage.values.max; + } + + return { + 'results/amm-high-frequency.json': JSON.stringify(summary, null, 2), + }; +} diff --git a/backend/load-tests/scenarios/api-baseline.js b/backend/load-tests/scenarios/api-baseline.js new file mode 100644 index 0000000..3007e74 --- /dev/null +++ b/backend/load-tests/scenarios/api-baseline.js @@ -0,0 +1,193 @@ +// ============================================================================= +// API Baseline Performance Test +// ============================================================================= +// Measures p50, p95, p99 latency for core API endpoints under normal load. +// Run: k6 run scenarios/api-baseline.js +// ============================================================================= +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Trend, Rate, Counter } from 'k6/metrics'; +import { CONFIG, HEADERS, THRESHOLDS } from '../config.js'; + +// Custom metrics +const healthLatency = new Trend('health_check_duration', true); +const marketsListLatency = new Trend('markets_list_duration', true); +const marketDetailLatency = new Trend('market_detail_duration', true); +const challengeLatency = new Trend('auth_challenge_duration', true); +const metricsLatency = new Trend('prometheus_metrics_duration', true); +const errorRate = new Rate('error_rate'); +const requestCount = new Counter('total_requests'); + +export const options = { + scenarios: { + // Ramp up to 50 VUs, sustain, then ramp down + baseline: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '30s', target: 10 }, // Warm up + { duration: '30s', target: 50 }, // Ramp to target + { duration: '2m', target: 50 }, // Steady state + { duration: '15s', target: 0 }, // Ramp down + ], + gracefulRampDown: '10s', + }, + }, + thresholds: { + http_req_duration: THRESHOLDS.http_req_duration, + http_req_failed: THRESHOLDS.http_req_failed, + health_check_duration: ['p(95)<100'], + markets_list_duration: ['p(95)<500'], + auth_challenge_duration: ['p(95)<300'], + error_rate: ['rate<0.05'], + }, +}; + +export default function () { + // 1. Health check + { + const res = http.get(`${CONFIG.BASE_URL}/health`, { + tags: { name: 'health' }, + }); + healthLatency.add(res.timings.duration); + const ok = check(res, { + 'health: status 200': (r) => r.status === 200, + 'health: body has status': (r) => { + try { return JSON.parse(r.body).status === 'healthy'; } catch { return false; } + }, + }); + errorRate.add(!ok); + requestCount.add(1); + } + + sleep(0.5); + + // 2. Detailed health check + { + const res = http.get(`${CONFIG.BASE_URL}/health/detailed`, { + tags: { name: 'health_detailed' }, + }); + const ok = check(res, { + 'health/detailed: status 200': (r) => r.status === 200, + }); + errorRate.add(!ok); + requestCount.add(1); + } + + sleep(0.5); + + // 3. List markets + { + const res = http.get(`${CONFIG.BASE_URL}/api/markets`, { + headers: HEADERS, + tags: { name: 'markets_list' }, + }); + marketsListLatency.add(res.timings.duration); + const ok = check(res, { + 'markets list: status 200 or 404': (r) => r.status === 200 || r.status === 404, + }); + errorRate.add(!ok); + requestCount.add(1); + } + + sleep(0.5); + + // 4. Get single market (may 404 in test env — that's ok) + { + const res = http.get(`${CONFIG.BASE_URL}/api/markets/${CONFIG.MARKET_ID}`, { + headers: HEADERS, + tags: { name: 'market_detail' }, + }); + marketDetailLatency.add(res.timings.duration); + const ok = check(res, { + 'market detail: status 200 or 404': (r) => r.status === 200 || r.status === 404, + }); + errorRate.add(!ok); + requestCount.add(1); + } + + sleep(0.5); + + // 5. Auth challenge request + { + const publicKey = `G${'A'.repeat(55)}`.substring(0, 56); + const res = http.post( + `${CONFIG.BASE_URL}/api/auth/challenge`, + JSON.stringify({ publicKey }), + { headers: HEADERS, tags: { name: 'auth_challenge' } } + ); + challengeLatency.add(res.timings.duration); + const ok = check(res, { + 'challenge: status 200 or 429': (r) => r.status === 200 || r.status === 429, + }); + errorRate.add(!ok && res.status !== 429); + requestCount.add(1); + } + + sleep(0.5); + + // 6. Prometheus metrics endpoint + { + const res = http.get(`${CONFIG.BASE_URL}/metrics`, { + tags: { name: 'prometheus_metrics' }, + }); + metricsLatency.add(res.timings.duration); + const ok = check(res, { + 'metrics: status 200': (r) => r.status === 200, + }); + errorRate.add(!ok); + requestCount.add(1); + } + + sleep(1); +} + +export function handleSummary(data) { + const summary = { + timestamp: new Date().toISOString(), + scenario: 'api-baseline', + metrics: {}, + }; + + const metricNames = [ + 'http_req_duration', + 'health_check_duration', + 'markets_list_duration', + 'market_detail_duration', + 'auth_challenge_duration', + 'prometheus_metrics_duration', + ]; + + for (const name of metricNames) { + if (data.metrics[name]) { + const m = data.metrics[name].values; + summary.metrics[name] = { + min: Math.round(m.min * 100) / 100, + avg: Math.round(m.avg * 100) / 100, + med: Math.round(m.med * 100) / 100, + p90: Math.round(m['p(90)'] * 100) / 100, + p95: Math.round(m['p(95)'] * 100) / 100, + p99: Math.round(m['p(99)'] * 100) / 100, + max: Math.round(m.max * 100) / 100, + }; + } + } + + if (data.metrics.error_rate) { + summary.metrics.error_rate = data.metrics.error_rate.values.rate; + } + if (data.metrics.total_requests) { + summary.metrics.total_requests = data.metrics.total_requests.values.count; + } + + return { + 'results/api-baseline.json': JSON.stringify(summary, null, 2), + stdout: textSummary(data, { indent: ' ', enableColors: true }), + }; +} + +// Inline text summary (k6 doesn't export this by default in all envs) +function textSummary(data, opts) { + // k6 will print its built-in summary; this is a fallback + return ''; +} diff --git a/backend/load-tests/scenarios/predictions-burst.js b/backend/load-tests/scenarios/predictions-burst.js new file mode 100644 index 0000000..faf1d1f --- /dev/null +++ b/backend/load-tests/scenarios/predictions-burst.js @@ -0,0 +1,268 @@ +// ============================================================================= +// Predictions Burst Test — 100 Simultaneous Predictions on One Market +// ============================================================================= +// Simulates 100 users submitting predictions on a single market concurrently. +// Tests database write contention, blockchain queuing, and commit-reveal flow. +// +// Run: k6 run scenarios/predictions-burst.js +// Env: BASE_URL, MARKET_ID, AUTH_TOKENS (comma-separated pre-generated tokens) +// ============================================================================= +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; +import { Trend, Rate, Counter } from 'k6/metrics'; +import { SharedArray } from 'k6/data'; +import { CONFIG, HEADERS, generatePublicKey } from '../config.js'; + +// Custom metrics +const commitLatency = new Trend('prediction_commit_duration', true); +const revealLatency = new Trend('prediction_reveal_duration', true); +const buySharesLatency = new Trend('buy_shares_duration', true); +const sellSharesLatency = new Trend('sell_shares_duration', true); +const predictionErrors = new Rate('prediction_error_rate'); +const commitsSucceeded = new Counter('commits_succeeded'); +const commitsFailed = new Counter('commits_failed'); +const revealsSucceeded = new Counter('reveals_succeeded'); +const revealsFailed = new Counter('reveals_failed'); +const sharesTraded = new Counter('shares_traded'); + +// Pre-generated auth tokens for load testing +// In production testing, generate these via a setup script +const authTokens = __ENV.AUTH_TOKENS + ? __ENV.AUTH_TOKENS.split(',') + : []; + +export const options = { + scenarios: { + // Burst: All 100 VUs start at once + prediction_burst: { + executor: 'shared-iterations', + vus: 100, + iterations: 100, + maxDuration: '2m', + }, + // Sustained: Continuous prediction load + prediction_sustained: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '15s', target: 20 }, + { duration: '30s', target: 50 }, + { duration: '1m', target: 100 }, + { duration: '2m', target: 100 }, + { duration: '15s', target: 0 }, + ], + startTime: '2m30s', // Start after burst completes + gracefulRampDown: '10s', + }, + }, + thresholds: { + prediction_commit_duration: [ + 'p(50)<500', + 'p(95)<2000', + 'p(99)<5000', + ], + prediction_reveal_duration: [ + 'p(50)<500', + 'p(95)<2000', + 'p(99)<5000', + ], + buy_shares_duration: [ + 'p(50)<300', + 'p(95)<1000', + 'p(99)<3000', + ], + prediction_error_rate: ['rate<0.15'], // Allow higher error rate due to blockchain + }, +}; + +function getAuthHeaders(vuId) { + if (authTokens.length > 0) { + const token = authTokens[vuId % authTokens.length]; + return { ...HEADERS, Authorization: `Bearer ${token}` }; + } + // Fallback: attempt without auth (will get 401, measured as errors) + return HEADERS; +} + +export default function () { + const vuId = __VU; + const iterationId = __ITER; + const marketId = CONFIG.MARKET_ID; + const headers = getAuthHeaders(vuId); + + group('commit-reveal prediction flow', function () { + // Step 1: Commit a prediction + let commitmentId = null; + + { + const outcome = Math.random() > 0.5 ? 'YES' : 'NO'; + const amount = Math.floor(Math.random() * 100) + 10; // 10-110 USDC + + const payload = JSON.stringify({ + outcome, + amountUsdc: amount, + }); + + const res = http.post( + `${CONFIG.BASE_URL}/api/markets/${marketId}/predict`, + payload, + { headers, tags: { name: 'prediction_commit' }, timeout: '30s' } + ); + + commitLatency.add(res.timings.duration); + + const ok = check(res, { + 'commit: status 200 or 201': (r) => r.status === 200 || r.status === 201, + 'commit: has commitment ID': (r) => { + try { + const body = JSON.parse(r.body); + const data = body.data || body; + commitmentId = data.commitmentId || data.predictionId || data.id; + return !!commitmentId; + } catch { + return false; + } + }, + }); + + if (ok) { + commitsSucceeded.add(1); + } else { + commitsFailed.add(1); + predictionErrors.add(true); + } + } + + sleep(1); + + // Step 2: Reveal the prediction + if (commitmentId) { + const res = http.post( + `${CONFIG.BASE_URL}/api/predictions/${commitmentId}/reveal`, + JSON.stringify({}), + { headers, tags: { name: 'prediction_reveal' }, timeout: '30s' } + ); + + revealLatency.add(res.timings.duration); + + const ok = check(res, { + 'reveal: status 200': (r) => r.status === 200, + }); + + if (ok) { + revealsSucceeded.add(1); + } else { + revealsFailed.add(1); + predictionErrors.add(true); + } + } + + sleep(0.5); + }); + + group('share trading', function () { + // Step 3: Buy shares + { + const outcome = Math.random() > 0.5 ? 'YES' : 'NO'; + const shares = Math.floor(Math.random() * 50) + 1; + + const res = http.post( + `${CONFIG.BASE_URL}/api/markets/${marketId}/buy-shares`, + JSON.stringify({ outcome, shares }), + { headers, tags: { name: 'buy_shares' }, timeout: '15s' } + ); + + buySharesLatency.add(res.timings.duration); + + const ok = check(res, { + 'buy shares: status 200 or 201': (r) => r.status === 200 || r.status === 201, + }); + + if (ok) { + sharesTraded.add(1); + } else { + predictionErrors.add(true); + } + } + + sleep(0.5); + + // Step 4: Sell shares + { + const outcome = Math.random() > 0.5 ? 'YES' : 'NO'; + const shares = Math.floor(Math.random() * 10) + 1; + + const res = http.post( + `${CONFIG.BASE_URL}/api/markets/${marketId}/sell-shares`, + JSON.stringify({ outcome, shares }), + { headers, tags: { name: 'sell_shares' }, timeout: '15s' } + ); + + sellSharesLatency.add(res.timings.duration); + + const ok = check(res, { + 'sell shares: status 200 or 201': (r) => r.status === 200 || r.status === 201, + }); + + if (ok) { + sharesTraded.add(1); + } + } + }); + + sleep(1); +} + +export function handleSummary(data) { + const summary = { + timestamp: new Date().toISOString(), + scenario: 'predictions-burst', + metrics: {}, + }; + + const metricNames = [ + 'prediction_commit_duration', + 'prediction_reveal_duration', + 'buy_shares_duration', + 'sell_shares_duration', + 'http_req_duration', + ]; + + for (const name of metricNames) { + if (data.metrics[name]) { + const m = data.metrics[name].values; + summary.metrics[name] = { + min: Math.round(m.min * 100) / 100, + avg: Math.round(m.avg * 100) / 100, + med: Math.round(m.med * 100) / 100, + p90: Math.round(m['p(90)'] * 100) / 100, + p95: Math.round(m['p(95)'] * 100) / 100, + p99: Math.round(m['p(99)'] * 100) / 100, + max: Math.round(m.max * 100) / 100, + }; + } + } + + if (data.metrics.commits_succeeded) { + summary.metrics.commits_succeeded = data.metrics.commits_succeeded.values.count; + } + if (data.metrics.commits_failed) { + summary.metrics.commits_failed = data.metrics.commits_failed.values.count; + } + if (data.metrics.reveals_succeeded) { + summary.metrics.reveals_succeeded = data.metrics.reveals_succeeded.values.count; + } + if (data.metrics.reveals_failed) { + summary.metrics.reveals_failed = data.metrics.reveals_failed.values.count; + } + if (data.metrics.shares_traded) { + summary.metrics.shares_traded = data.metrics.shares_traded.values.count; + } + if (data.metrics.prediction_error_rate) { + summary.metrics.error_rate = data.metrics.prediction_error_rate.values.rate; + } + + return { + 'results/predictions-burst.json': JSON.stringify(summary, null, 2), + }; +} diff --git a/backend/load-tests/scenarios/websocket-connections.js b/backend/load-tests/scenarios/websocket-connections.js new file mode 100644 index 0000000..2494fae --- /dev/null +++ b/backend/load-tests/scenarios/websocket-connections.js @@ -0,0 +1,179 @@ +// ============================================================================= +// WebSocket Connection Stress Test — 1000 Concurrent Connections +// ============================================================================= +// Tests the system's ability to handle 1000 concurrent WebSocket connections. +// Each VU opens a WebSocket, subscribes to a market, and listens for events. +// +// Run: k6 run scenarios/websocket-connections.js +// Env: BASE_URL, WS_URL, MARKET_ID +// ============================================================================= +import { check, sleep } from 'k6'; +import ws from 'k6/ws'; +import { Trend, Rate, Counter, Gauge } from 'k6/metrics'; +import { CONFIG, generatePublicKey } from '../config.js'; + +// Custom metrics +const wsConnectDuration = new Trend('ws_connect_duration', true); +const wsMessageLatency = new Trend('ws_message_latency', true); +const wsConnections = new Gauge('ws_active_connections'); +const wsErrors = new Rate('ws_error_rate'); +const wsMessagesReceived = new Counter('ws_messages_received'); +const wsConnectionsFailed = new Counter('ws_connections_failed'); +const wsConnectionsOpened = new Counter('ws_connections_opened'); + +export const options = { + scenarios: { + websocket_ramp: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '30s', target: 100 }, // Phase 1: 100 connections + { duration: '30s', target: 500 }, // Phase 2: 500 connections + { duration: '30s', target: 1000 }, // Phase 3: 1000 connections + { duration: '2m', target: 1000 }, // Sustain 1000 connections + { duration: '30s', target: 0 }, // Ramp down + ], + gracefulRampDown: '15s', + }, + }, + thresholds: { + ws_connect_duration: ['p(95)<2000'], // Connection established under 2s + ws_error_rate: ['rate<0.10'], // Less than 10% connection errors + ws_message_latency: ['p(95)<500'], // Messages received under 500ms + }, +}; + +export default function () { + const vuId = __VU; + const marketId = CONFIG.MARKET_ID; + const wsUrl = `${CONFIG.WS_URL}/markets?marketId=${marketId}&vu=${vuId}`; + + const connectStart = Date.now(); + + const res = ws.connect(wsUrl, null, function (socket) { + const connectDuration = Date.now() - connectStart; + wsConnectDuration.add(connectDuration); + wsConnectionsOpened.add(1); + wsConnections.add(1); + + // Track time of last ping for heartbeat + let lastPing = Date.now(); + + socket.on('open', function () { + check(null, { 'ws: connection opened': () => true }); + + // Subscribe to market updates + socket.send(JSON.stringify({ + type: 'subscribe_market', + marketId: marketId, + })); + + // Send periodic heartbeats (every 25s, server expects 30s) + socket.setInterval(function () { + socket.send(JSON.stringify({ type: 'ping', timestamp: Date.now() })); + lastPing = Date.now(); + }, 25000); + }); + + socket.on('message', function (msg) { + wsMessagesReceived.add(1); + + try { + const data = JSON.parse(msg); + + // Measure latency for server-initiated messages + if (data.timestamp) { + const latency = Date.now() - data.timestamp; + wsMessageLatency.add(latency); + } + + // Handle pong responses + if (data.type === 'pong') { + const roundTrip = Date.now() - lastPing; + wsMessageLatency.add(roundTrip); + } + + check(data, { + 'ws: message has type': (d) => !!d.type, + }); + } catch { + // Binary or non-JSON message + } + }); + + socket.on('error', function (e) { + wsErrors.add(true); + wsConnectionsFailed.add(1); + }); + + socket.on('close', function () { + wsConnections.add(-1); + }); + + // Keep connection alive for the duration of the test iteration + // Each VU will hold its connection for ~60s then reconnect + socket.setTimeout(function () { + // Unsubscribe before closing + socket.send(JSON.stringify({ + type: 'unsubscribe_market', + marketId: marketId, + })); + socket.close(); + }, 60000); + }); + + // If connection failed immediately + if (res === null || (res && res.status !== 101)) { + wsErrors.add(true); + wsConnectionsFailed.add(1); + } + + // Brief pause before reconnecting + sleep(2); +} + +export function handleSummary(data) { + const summary = { + timestamp: new Date().toISOString(), + scenario: 'websocket-connections', + target_connections: 1000, + metrics: {}, + }; + + const metricNames = [ + 'ws_connect_duration', + 'ws_message_latency', + ]; + + for (const name of metricNames) { + if (data.metrics[name]) { + const m = data.metrics[name].values; + summary.metrics[name] = { + min: Math.round(m.min * 100) / 100, + avg: Math.round(m.avg * 100) / 100, + med: Math.round(m.med * 100) / 100, + p90: Math.round(m['p(90)'] * 100) / 100, + p95: Math.round(m['p(95)'] * 100) / 100, + p99: Math.round(m['p(99)'] * 100) / 100, + max: Math.round(m.max * 100) / 100, + }; + } + } + + if (data.metrics.ws_active_connections) { + summary.metrics.peak_connections = data.metrics.ws_active_connections.values.max; + } + if (data.metrics.ws_connections_opened) { + summary.metrics.total_connections = data.metrics.ws_connections_opened.values.count; + } + if (data.metrics.ws_connections_failed) { + summary.metrics.failed_connections = data.metrics.ws_connections_failed.values.count; + } + if (data.metrics.ws_error_rate) { + summary.metrics.error_rate = data.metrics.ws_error_rate.values.rate; + } + + return { + 'results/websocket-connections.json': JSON.stringify(summary, null, 2), + }; +} diff --git a/backend/package.json b/backend/package.json index 546fa58..9a69f17 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,7 +18,12 @@ "lint": "eslint src/**/*.ts", "lint:fix": "eslint src/**/*.ts --fix", "format": "prettier --write src/**/*.ts", - "format:check": "prettier --check src/**/*.ts" + "format:check": "prettier --check src/**/*.ts", + "loadtest": "cd load-tests && ./run-all.sh", + "loadtest:baseline": "cd load-tests && ./run-all.sh --scenario baseline", + "loadtest:websocket": "cd load-tests && ./run-all.sh --scenario websocket", + "loadtest:predictions": "cd load-tests && ./run-all.sh --scenario predictions", + "loadtest:amm": "cd load-tests && ./run-all.sh --scenario amm" }, "keywords": [ "stellar",