/** * Stress test for the PDF export backend (/api/generate_pdf). * * Measures how many concurrent PDF exports the server can handle: * - success rate * - latency distribution (avg / p50 / p95 / max) * - throughput (req/min) * * Usage: * node stress/run-stress-test.js --base http://localhost:17861 --concurrency 1,2,4,6,8 --total 20 --profile medium * * Each concurrency level fires `total` requests in waves of `concurrency` * parallel requests. Between levels the server is given a rest period * (--settle-ms) so memory/CPU can drain back down. * * Output: JSON report written to stress/results/. */ const fs = require('fs'); const path = require('path'); const http = require('http'); const { prepareProfile } = require('./payload-generator'); function parseArgs(argv) { const args = { concurrency: '1,2,4,6,8', total: 20, base: 'http://localhost:17861', profile: 'medium', settleMs: 10000, tag: '', timeoutMs: 180000 }; for (let i = 0; i < argv.length; i++) { const m = argv[i].match(/^--([^=]+)(?:=(.*))?$/); if (!m) continue; if (m[2] !== undefined) { args[m[1]] = m[2]; } else if (i + 1 < argv.length && !argv[i + 1].startsWith('--')) { args[m[1]] = argv[++i]; } else { args[m[1]] = true; } } return args; } function percentile(sorted, p) { if (sorted.length === 0) return 0; const idx = Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1); return sorted[idx]; } function sendPdfRequest(baseUrl, requestBody, timeoutMs) { const url = new URL('/api/generate_pdf', baseUrl); const payload = JSON.stringify(requestBody); return new Promise((resolve) => { const started = Date.now(); const options = { hostname: url.hostname, port: url.port || 80, path: url.pathname, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload), }, timeout: timeoutMs, }; const req = http.request(options, (res) => { const chunks = []; res.on('data', (c) => chunks.push(c)); res.on('end', () => { const elapsed = Date.now() - started; const body = Buffer.concat(chunks); if (res.statusCode === 200) { resolve({ ok: true, status: 200, elapsed, pdfBytes: body.length }); } else { resolve({ ok: false, status: res.statusCode, elapsed, error: body.toString().substring(0, 500) }); } }); res.on('error', (e) => { resolve({ ok: false, status: 0, elapsed: Date.now() - started, error: e.message }); }); }); req.on('error', (e) => { resolve({ ok: false, status: 0, elapsed: Date.now() - started, error: e.message }); }); req.on('timeout', () => { req.destroy(new Error(`timeout after ${timeoutMs}ms`)); }); req.write(payload); req.end(); }); } function fireWave(baseUrl, requestBody, concurrency, timeoutMs, onDone) { return new Promise((resolve) => { const results = []; let active = 0; let queued = concurrency; let done = 0; function startOne() { active++; sendPdfRequest(baseUrl, requestBody, timeoutMs).then((r) => { results.push(r); active--; done++; if (onDone) onDone(r); if (queued > 0) { queued--; startOne(); } else if (active === 0) resolve(results); }); } for (let i = 0; i < concurrency && queued > 0; i++) { queued--; startOne(); } }); } function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } async function runConcurrencyLevel(baseUrl, requestBody, concurrency, total, timeoutMs, settleMs, onProgress) { const waves = Math.ceil(total / concurrency); const all = []; const startTs = Date.now(); for (let w = 0; w < waves; w++) { const remaining = total - all.length; const thisWave = Math.min(concurrency, remaining); const waveResults = await fireWave(baseUrl, requestBody, thisWave, timeoutMs, onProgress); all.push(...waveResults); } const durationMs = Date.now() - startTs; await sleep(settleMs); return analyze(all, durationMs); } function analyze(results, durationMs) { const ok = results.filter((r) => r.ok); const fail = results.filter((r) => !r.ok); const latencies = ok.map((r) => r.elapsed).sort((a, b) => a - b); const totalOk = ok.length; const totalFail = fail.length; // Wall-clock throughput: requests actually completed per minute across the level. const rate = durationMs > 0 ? (totalOk / (durationMs / 60000)) : 0; const errorBreakdown = {}; for (const f of fail) { const key = f.status === 500 ? '500' : (f.status === 0 ? 'conn/' + (f.error || 'unknown') : String(f.status)); errorBreakdown[key] = (errorBreakdown[key] || 0) + 1; } return { totalRequests: results.length, success: totalOk, failed: totalFail, successRate: results.length ? (totalOk / results.length) : 0, latencyMs: { avg: latencies.length ? Math.round(latencies.reduce((a, b) => a + b, 0) / latencies.length) : 0, p50: percentile(latencies, 50), p90: percentile(latencies, 90), p95: percentile(latencies, 95), p99: percentile(latencies, 99), max: latencies.length ? latencies[latencies.length - 1] : 0, min: latencies.length ? latencies[0] : 0, }, throughputPerMin: Math.round(rate), errorBreakdown, durationMs, }; } async function main() { const args = parseArgs(process.argv.slice(2)); const baseUrl = args.base.replace(/\/$/, ''); const concurrencyLevels = args.concurrency.split(',').map((n) => parseInt(n, 10)).filter((n) => n > 0); const total = parseInt(args.total, 10); const timeoutMs = parseInt(args.timeoutMs, 10); const settleMs = parseInt(args.settleMs, 10); const profileName = args.profile; const profileDefs = { small: { name: 'small', opts: { textMB: 0.05, images: 0, codeRatio: 0.3 } }, medium: { name: 'medium', opts: { textMB: 0.3, images: 3, codeRatio: 0.35 } }, large: { name: 'large', opts: { textMB: 1.0, images: 10, codeRatio: 0.4 } }, }; const profileDef = profileDefs[profileName]; if (!profileDef) { console.error(`Unknown profile: ${profileName}. Choose from ${Object.keys(profileDefs).join(', ')}`); process.exit(1); } const profile = prepareProfile(profileDef); const requestBody = { ...profile.requestBody, html: profile.html }; console.log(`Profile: ${profileName} html=${profile.htmlSizeMB.toFixed(3)} MB text=${profile.textOnlySizeMB.toFixed(3)} MB images=${profile.imageCount}`); console.log(`Base URL: ${baseUrl}`); console.log(`Concurrency levels: ${concurrencyLevels.join(', ')} total/level: ${total}\n`); const report = { generatedAt: new Date().toISOString(), tag: args.tag, baseUrl, profile: { name: profileName, htmlSizeMB: profile.htmlSizeMB, textOnlySizeMB: profile.textOnlySizeMB, imageCount: profile.imageCount }, concurrencyLevels, totalPerLevel: total, levels: {}, }; const allRequests = []; for (const conc of concurrencyLevels) { process.stdout.write(`\n=== Concurrency ${conc} (${total} requests) ===\n`); const level = await runConcurrencyLevel(baseUrl, requestBody, conc, total, timeoutMs, settleMs, (r) => { allRequests.push(r); process.stdout.write(r.ok ? '.' : 'X'); }); process.stdout.write('\n'); report.levels[String(conc)] = level; const l = level; console.log(` success=${l.success}/${l.totalRequests} (${(l.successRate * 100).toFixed(1)}%) ` + `latency avg=${l.latencyMs.avg}ms p50=${l.latencyMs.p50}ms p95=${l.latencyMs.p95}ms max=${l.latencyMs.max}ms ` + `throughput=${l.throughputPerMin}/min errors=${JSON.stringify(l.errorBreakdown)}`); } // Summary table console.log('\n=== Summary ==='); console.log('Concurrency | Success | Rate% | avg(ms) | p50(ms) | p95(ms) | max(ms) | req/min |'); console.log('------------|---------|--------|---------|---------|---------|---------|---------|'); for (const conc of concurrencyLevels) { const l = report.levels[String(conc)]; console.log(`${String(conc).padEnd(11)}| ${String(l.success).padEnd(8)}| ${(l.successRate * 100).toFixed(1).padEnd(7)}| ${String(l.latencyMs.avg).padEnd(8)}| ${String(l.latencyMs.p50).padEnd(8)}| ${String(l.latencyMs.p95).padEnd(8)}| ${String(l.latencyMs.max).padEnd(8)}| ${String(l.throughputPerMin).padEnd(8)}|`); } const stamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19); const tagSuffix = args.tag ? `-${args.tag}` : ''; const outFile = path.join(__dirname, 'results', `stress-${profileName}${tagSuffix}-${stamp}.json`); fs.writeFileSync(outFile, JSON.stringify(report, null, 2)); console.log(`\nReport saved: ${outFile}`); } main().catch((e) => { console.error(e); process.exit(1); });