#!/usr/bin/env node /** * Read-path load test — P4 verification. * * Exercises the learner-end read surface (registry list/detail + bundle * download — the static, zero-LLM hot path) with bounded concurrency and * reports p50/p95/max latency + throughput. Run against a production-mode * server: * * COURSEWARE_PUBLISH_TOKEN=... npx next start -p 3100 & * node scripts/load-test-reads.mjs --base http://localhost:3100 \ * --courseware e2e-cw-1 --concurrency 20 --requests 400 * * Expected: the read path is pure static file/JSON serving (no LLM, no * generation), so latency should be flat under load; if it is not, the * deployment has hot-path bottlenecks to address (object store, CDN, DB). */ import { performance } from 'node:perf_hooks'; const args = {}; for (let i = 2; i < process.argv.length; i++) { const arg = process.argv[i]; if (arg.startsWith('--')) { const key = arg.slice(2); args[key] = process.argv[i + 1] && !process.argv[i + 1].startsWith('--') ? process.argv[i + 1] : 'true'; if (args[key] !== 'true') i++; } } const BASE = (args.base || 'http://localhost:3000').replace(/\/$/, ''); const COURSEWARE = args.courseware || 'e2e-cw-1'; const CONCURRENCY = Number(args.concurrency || 20); const REQUESTS = Number(args.requests || 400); const RAMP = Number(args.ramp || 1); // 1 = include download, 0 = metadata only const latency = []; let failures = 0; let bytes = 0; const pathOf = (i) => { if (RAMP && i % 5 === 0) return `/api/coursewares/${COURSEWARE}/bundles/1/download`; if (i % 3 === 0) return `/api/coursewares/${COURSEWARE}`; return '/api/coursewares'; }; async function oneRequest(i) { const url = `${BASE}${pathOf(i)}`; const start = performance.now(); try { const response = await fetch(url); const body = await response.arrayBuffer(); const elapsed = performance.now() - start; latency.push(elapsed); bytes += body.byteLength; if (!response.ok) failures++; } catch (error) { failures++; latency.push(performance.now() - start); console.error(`request ${i} failed: ${error.message}`); } } async function main() { console.log(`load test: base=${BASE} courseware=${COURSEWARE} concurrency=${CONCURRENCY} requests=${REQUESTS} ramp=${RAMP}`); const started = performance.now(); let next = 0; const workers = Array.from({ length: CONCURRENCY }, async () => { while (true) { const i = next++; if (i >= REQUESTS) return; await oneRequest(i); } }); await Promise.all(workers); const totalMs = performance.now() - started; const sorted = [...latency].sort((a, b) => a - b); const p = (q) => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * q))]; const avg = sorted.reduce((s, v) => s + v, 0) / sorted.length; const rps = (REQUESTS / totalMs) * 1000; console.log('\n=== read-path load test ==='); console.log(`requests: ${REQUESTS} failures: ${failures}`); console.log(`throughput: ${rps.toFixed(1)} req/s (total ${(totalMs / 1000).toFixed(2)}s)`); console.log(`bytes served: ${(bytes / 1024 / 1024).toFixed(2)} MB`); console.log(`latency p50: ${p(0.5).toFixed(1)}ms p95: ${p(0.95).toFixed(1)}ms p99: ${p(0.99).toFixed(1)}ms max: ${sorted.at(-1).toFixed(1)}ms avg: ${avg.toFixed(1)}ms`); process.exitCode = failures > 0 ? 1 : 0; } main().catch((error) => { console.error(error); process.exitCode = 1; });