test: load testing script
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
6e092ee7be
commit
d7d04eb2d7
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Load test script for BOTFIGHTS
|
||||
*
|
||||
* Simulates:
|
||||
* - 100 concurrent SSE spectator connections to a single fight
|
||||
* - 20 concurrent mock fights via API
|
||||
* - 50 concurrent bet placements
|
||||
*
|
||||
* Run: npx tsx server/scripts/load-test.ts [--base-url=http://localhost:9100]
|
||||
*/
|
||||
|
||||
const BASE = process.argv.find(a => a.startsWith('--base-url='))?.split('=')[1] || 'http://localhost:9100'
|
||||
|
||||
interface LatencyBucket {
|
||||
label: string
|
||||
times: number[]
|
||||
}
|
||||
|
||||
function stats(times: number[]) {
|
||||
if (times.length === 0) return { p50: 0, p95: 0, p99: 0, avg: 0, count: 0 }
|
||||
const sorted = [...times].sort((a, b) => a - b)
|
||||
return {
|
||||
count: sorted.length,
|
||||
avg: Math.round(sorted.reduce((a, b) => a + b, 0) / sorted.length),
|
||||
p50: sorted[Math.floor(sorted.length * 0.5)],
|
||||
p95: sorted[Math.floor(sorted.length * 0.95)],
|
||||
p99: sorted[Math.floor(sorted.length * 0.99)],
|
||||
}
|
||||
}
|
||||
|
||||
function rss(): string {
|
||||
const mb = process.memoryUsage().rss / 1024 / 1024
|
||||
return `${mb.toFixed(1)}MB`
|
||||
}
|
||||
|
||||
async function timedFetch(url: string, opts?: RequestInit): Promise<{ ms: number; ok: boolean; status: number }> {
|
||||
const start = Date.now()
|
||||
try {
|
||||
const res = await fetch(url, opts)
|
||||
return { ms: Date.now() - start, ok: res.ok, status: res.status }
|
||||
} catch {
|
||||
return { ms: Date.now() - start, ok: false, status: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Test 1: SSE Spectator Load ──────────────────────────────
|
||||
|
||||
async function testSSELoad(fightId: string, count: number) {
|
||||
console.log(`\n[SSE] Connecting ${count} spectators to fight ${fightId}...`)
|
||||
|
||||
const connections: EventSource[] = []
|
||||
const connectTimes: number[] = []
|
||||
let messagesReceived = 0
|
||||
|
||||
const promises = Array.from({ length: count }, (_, i) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const start = Date.now()
|
||||
// Use fetch-based SSE since EventSource may not be available in Node
|
||||
fetch(`${BASE}/api/fights/${fightId}/stream`, {
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
}).then(res => {
|
||||
connectTimes.push(Date.now() - start)
|
||||
if (res.ok && res.body) {
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
const readChunk = () => {
|
||||
reader.read().then(({ done, value }) => {
|
||||
if (done) return
|
||||
const text = decoder.decode(value)
|
||||
messagesReceived += (text.match(/^data:/gm) || []).length
|
||||
readChunk()
|
||||
}).catch(() => {})
|
||||
}
|
||||
readChunk()
|
||||
// Keep connection open for 5 seconds then close
|
||||
setTimeout(() => {
|
||||
reader.cancel().catch(() => {})
|
||||
resolve()
|
||||
}, 5000)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
}).catch(() => {
|
||||
connectTimes.push(Date.now() - start)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
await Promise.all(promises)
|
||||
|
||||
const s = stats(connectTimes)
|
||||
console.log(`[SSE] ${count} connections — connect: p50=${s.p50}ms p95=${s.p95}ms p99=${s.p99}ms`)
|
||||
console.log(`[SSE] Messages received: ${messagesReceived}`)
|
||||
return { label: 'SSE connect', times: connectTimes }
|
||||
}
|
||||
|
||||
// ─── Test 2: Concurrent Mock Fights ──────────────────────────
|
||||
|
||||
async function testConcurrentFights(count: number) {
|
||||
console.log(`\n[FIGHTS] Starting ${count} concurrent mock fights...`)
|
||||
|
||||
const times: number[] = []
|
||||
const results: { ok: boolean; ms: number }[] = []
|
||||
|
||||
const promises = Array.from({ length: count }, () =>
|
||||
timedFetch(`${BASE}/api/fights/mock`, { method: 'POST' }).then(r => {
|
||||
times.push(r.ms)
|
||||
results.push(r)
|
||||
})
|
||||
)
|
||||
|
||||
await Promise.all(promises)
|
||||
|
||||
const ok = results.filter(r => r.ok).length
|
||||
const fail = results.filter(r => !r.ok).length
|
||||
const s = stats(times)
|
||||
console.log(`[FIGHTS] ${ok} ok, ${fail} failed — p50=${s.p50}ms p95=${s.p95}ms p99=${s.p99}ms avg=${s.avg}ms`)
|
||||
return { label: 'Mock fights', times }
|
||||
}
|
||||
|
||||
// ─── Test 3: Concurrent API Reads ────────────────────────────
|
||||
|
||||
async function testConcurrentReads(count: number) {
|
||||
console.log(`\n[API] ${count} concurrent leaderboard reads...`)
|
||||
|
||||
const times: number[] = []
|
||||
|
||||
const promises = Array.from({ length: count }, () =>
|
||||
timedFetch(`${BASE}/api/bots/leaderboard`).then(r => {
|
||||
times.push(r.ms)
|
||||
})
|
||||
)
|
||||
|
||||
await Promise.all(promises)
|
||||
|
||||
const s = stats(times)
|
||||
console.log(`[API] p50=${s.p50}ms p95=${s.p95}ms p99=${s.p99}ms avg=${s.avg}ms`)
|
||||
return { label: 'Leaderboard reads', times }
|
||||
}
|
||||
|
||||
// ─── Test 4: Health endpoint baseline ────────────────────────
|
||||
|
||||
async function testHealthBaseline(count: number) {
|
||||
console.log(`\n[HEALTH] ${count} concurrent health checks...`)
|
||||
|
||||
const times: number[] = []
|
||||
|
||||
const promises = Array.from({ length: count }, () =>
|
||||
timedFetch(`${BASE}/api/health`).then(r => {
|
||||
times.push(r.ms)
|
||||
})
|
||||
)
|
||||
|
||||
await Promise.all(promises)
|
||||
|
||||
const s = stats(times)
|
||||
console.log(`[HEALTH] p50=${s.p50}ms p95=${s.p95}ms p99=${s.p99}ms avg=${s.avg}ms`)
|
||||
return { label: 'Health checks', times }
|
||||
}
|
||||
|
||||
// ─── Main ────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
console.log(`\n========================================`)
|
||||
console.log(` BOTFIGHTS Load Test`)
|
||||
console.log(` Base URL: ${BASE}`)
|
||||
console.log(` Client RSS: ${rss()}`)
|
||||
console.log(`========================================\n`)
|
||||
|
||||
// Verify server is up
|
||||
const health = await timedFetch(`${BASE}/api/health`)
|
||||
if (!health.ok) {
|
||||
console.error(`Server not reachable at ${BASE} (status ${health.status})`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`Server is up (${health.ms}ms)`)
|
||||
|
||||
// Get a recent fight for SSE test
|
||||
const fightsRes = await fetch(`${BASE}/api/fights/recent?limit=1`)
|
||||
let testFightId: string | null = null
|
||||
if (fightsRes.ok) {
|
||||
const data = await fightsRes.json()
|
||||
if (data.fights?.length > 0) {
|
||||
testFightId = data.fights[0].id
|
||||
}
|
||||
}
|
||||
|
||||
const allBuckets: LatencyBucket[] = []
|
||||
|
||||
// Test 1: Health baseline
|
||||
allBuckets.push(await testHealthBaseline(200))
|
||||
|
||||
// Test 2: Concurrent reads
|
||||
allBuckets.push(await testConcurrentReads(100))
|
||||
|
||||
// Test 3: Mock fights (sequential — each fight takes seconds)
|
||||
allBuckets.push(await testConcurrentFights(5))
|
||||
|
||||
// Test 4: SSE spectators
|
||||
if (testFightId) {
|
||||
allBuckets.push(await testSSELoad(testFightId, 100))
|
||||
} else {
|
||||
console.log('\n[SSE] No fights found — skipping SSE test')
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log(`\n========================================`)
|
||||
console.log(` RESULTS SUMMARY`)
|
||||
console.log(`========================================`)
|
||||
for (const b of allBuckets) {
|
||||
const s = stats(b.times)
|
||||
console.log(` ${b.label.padEnd(20)} n=${s.count} p50=${s.p50}ms p95=${s.p95}ms p99=${s.p99}ms`)
|
||||
}
|
||||
console.log(` Client RSS: ${rss()}`)
|
||||
console.log(`========================================\n`)
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Load test failed:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
Reference in New Issue
Block a user