#!/usr/bin/env node /** * EventSnap load / stress driver. * * Simulates ~100 guests joining an event and uploading ~1000 images in bursts * (10-20 at a time) spread across a compressed time window, plus a pool of * viewers holding live SSE connections (like phones with the feed open) and one * "diashow" connection (the showcase display). Measures the metrics that matter * for a live event — especially the *pipeline backlog*: how long between an * upload succeeding and its preview being ready (SSE `upload-processed`), which * is exactly "how long until the photo shows up on the diashow". * * This is an HTTP-level driver on purpose: 100 real browsers would bottleneck * the test box, not the server. One real browser watches /diashow separately * (see diashow-watch.mjs). * * METHODOLOGY NOTE — what we change vs. the shipping config: * We ONLY disable rate limits. They are per-IP/per-user anti-abuse guards; a * synthetic test from one IP would trip them in a way real guests (distinct * IPs, phones) never would, so leaving them on would measure the rate limiter * instead of the pipeline. We DELIBERATELY leave compression concurrency, * DB pool size and quotas at their real defaults — validating those is the * whole point. (Runtime can't change compression concurrency anyway; it's a * boot-time env var.) * * Related real-world finding to keep in mind: the default upload_rate_per_hour * is 10, so a real guest uploading a burst of 10-20 would be throttled by the * SHIPPING config too. That's a genuine event-day issue worth its own report, * independent of this pipeline test. * * Usage: * node driver.mjs # full run (100 guests / 1000 imgs / 15 min) * LT_GUESTS=5 LT_IMAGES=50 LT_WINDOW_SEC=60 node driver.mjs # smoke * * Env knobs (all optional; defaults target the full run): * LT_BASE http://localhost:3101 frontend/caddy base URL * LT_GUESTS 100 number of virtual guests * LT_IMAGES 1000 total uploads to perform * LT_WINDOW_SEC 900 spread uploads over this window * LT_BURST_MIN/MAX 10 / 20 images per burst * LT_BURST_CONC 3 parallel uploads within one burst (phone-like) * LT_VIEWERS 20 extra guests holding SSE + polling feed * LT_PHOTOS_DIR /tmp/eventsnap-loadtest/photos * LT_TRUNCATE 1 wipe event data before the run * LT_DRAIN_TIMEOUT_SEC 600 max wait for compression backlog to drain * LT_APP_CONTAINER e2e-app-1 docker container for CPU/mem sampling * LT_DB_CONTAINER e2e-db-1 docker container for psql ground-truth * LT_ADMIN_PW admin-test-pw * LT_KEEP_RATELIMITS 0 set 1 to NOT disable rate limits * LT_OUT_DIR ./results */ import { readFileSync, readdirSync, writeFileSync, mkdirSync, statSync } from 'node:fs'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const execFileAsync = promisify(execFile); const __dirname = dirname(fileURLToPath(import.meta.url)); // ── Config ────────────────────────────────────────────────────────────────── const cfg = { base: process.env.LT_BASE ?? 'http://localhost:3101', guests: int('LT_GUESTS', 100), images: int('LT_IMAGES', 1000), windowSec: int('LT_WINDOW_SEC', 900), burstMin: int('LT_BURST_MIN', 10), burstMax: int('LT_BURST_MAX', 20), burstConc: int('LT_BURST_CONC', 3), viewers: int('LT_VIEWERS', 20), photosDir: process.env.LT_PHOTOS_DIR ?? '/tmp/eventsnap-loadtest/photos', truncate: process.env.LT_TRUNCATE !== '0', drainTimeoutSec: int('LT_DRAIN_TIMEOUT_SEC', 600), appContainer: process.env.LT_APP_CONTAINER ?? 'e2e-app-1', dbContainer: process.env.LT_DB_CONTAINER ?? 'e2e-db-1', adminPw: process.env.LT_ADMIN_PW ?? 'admin-test-pw', keepRateLimits: process.env.LT_KEEP_RATELIMITS === '1', outDir: process.env.LT_OUT_DIR ?? join(__dirname, 'results'), }; function int(name, def) { const v = process.env[name]; return v === undefined ? def : parseInt(v, 10); } const API = `${cfg.base}/api/v1`; const now = () => Date.now(); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const rand = (min, max) => min + Math.floor(Math.random() * (max - min + 1)); const pick = (arr) => arr[Math.floor(Math.random() * arr.length)]; // ── HTTP helpers ────────────────────────────────────────────────────────────── async function api(path, { method = 'GET', token, json, expect } = {}) { const headers = {}; if (token) headers.Authorization = `Bearer ${token}`; if (json !== undefined) headers['Content-Type'] = 'application/json'; const res = await fetch(`${API}${path}`, { method, headers, body: json !== undefined ? JSON.stringify(json) : undefined, }); let body; if (res.status !== 204) { const text = await res.text(); try { body = text.length ? JSON.parse(text) : undefined; } catch { body = text; } } if (expect && !expect.includes(res.status)) { throw new Error(`${method} ${path} → ${res.status}: ${JSON.stringify(body)}`); } return { status: res.status, body }; } const adminLogin = () => api('/admin/login', { method: 'POST', json: { password: cfg.adminPw }, expect: [200] }).then( (r) => r.body.jwt ); const joinGuest = (name) => api('/join', { method: 'POST', json: { display_name: name }, expect: [201] }).then((r) => r.body); const patchConfig = (adminJwt, patch) => api('/admin/config', { method: 'PATCH', token: adminJwt, json: patch, expect: [204] }); const getConfig = (adminJwt) => api('/admin/config', { token: adminJwt }).then((r) => r.body); const truncate = (adminJwt) => api('/admin/__truncate', { method: 'POST', token: adminJwt, expect: [204] }); const CAPTIONS = [ 'Was für ein magischer Tag 💍', 'Der erste Tanz 🕺', 'Prost! 🥂', 'Die Torte 🍰', 'Feuerwerk 🎆', 'Beste Freunde 💕', 'Was für eine Stimmung! 🎉', 'Details 🌸', 'Sonnenuntergang 🌅', 'Tanzfläche brennt 🔥', null, null, null, ]; const TAGS = ['hochzeit', 'liebe', 'party', 'tanzen', 'natur', 'feier', 'freunde', 'dessert']; async function uploadPhoto(jwt, file, buf) { const form = new FormData(); form.append('file', new Blob([buf], { type: 'image/jpeg' }), 'photo.jpg'); const cap = pick(CAPTIONS); if (cap) form.append('caption', cap); form.append('hashtags', `${pick(TAGS)},${pick(TAGS)}`); const t0 = now(); const res = await fetch(`${API}/upload`, { method: 'POST', headers: { Authorization: `Bearer ${jwt}` }, body: form, }); const t1 = now(); let id, errText; if (res.status === 201) { id = (await res.json()).id; } else { errText = (await res.text()).slice(0, 200); } return { status: res.status, id, ms: t1 - t0, endTs: t1, bytes: buf.length, errText }; } // ── SSE client (auto-reconnecting) ──────────────────────────────────────────── class SseClient { constructor(jwt, label, onEvent) { this.jwt = jwt; this.label = label; this.onEvent = onEvent; this.stop = false; this.reconnects = 0; this.resyncs = 0; this.controller = null; } start() { this._loop(); return this; } async _loop() { while (!this.stop) { try { const tkt = await api('/stream/ticket', { method: 'POST', token: this.jwt, expect: [200] }); this.controller = new AbortController(); const res = await fetch(`${API}/stream?ticket=${tkt.body.ticket}`, { headers: { Accept: 'text/event-stream' }, signal: this.controller.signal, }); if (!res.ok || !res.body) throw new Error(`stream ${res.status}`); const reader = res.body.getReader(); const dec = new TextDecoder(); let buf = ''; while (!this.stop) { const { value, done } = await reader.read(); if (done) break; buf += dec.decode(value, { stream: true }); let idx; while ((idx = buf.indexOf('\n\n')) !== -1) { const frame = buf.slice(0, idx); buf = buf.slice(idx + 2); this._parseFrame(frame); } } } catch (e) { if (this.stop) return; this.reconnects++; await sleep(500 + Math.random() * 500); } } } _parseFrame(frame) { let event = 'message'; let data = ''; for (const line of frame.split('\n')) { if (line.startsWith('event:')) event = line.slice(6).trim(); else if (line.startsWith('data:')) data += line.slice(5).trim(); } if (event === 'resync') this.resyncs++; if (!data) return; let payload; try { payload = JSON.parse(data); } catch { payload = data; } this.onEvent(event, payload, this.label); } close() { this.stop = true; try { this.controller?.abort(); } catch { /* noop */ } } } // ── Resource sampler (docker stats + psql ground truth) ─────────────────────── async function sampleResources() { const out = { ts: now() }; try { const { stdout } = await execFileAsync('docker', [ 'stats', '--no-stream', '--format', '{{.Name}}|{{.CPUPerc}}|{{.MemUsage}}', cfg.appContainer, cfg.dbContainer, ]); out.docker = stdout.trim(); } catch (e) { out.dockerErr = String(e).slice(0, 120); } try { const { stdout } = await psql( `select count(*) from pg_stat_activity where datname='eventsnap_test'` ); out.dbConns = parseInt(stdout.trim(), 10); } catch { /* ignore */ } return out; } function psql(sql) { return execFileAsync('docker', [ 'exec', cfg.dbContainer, 'psql', '-U', 'eventsnap_test', '-d', 'eventsnap_test', '-tAc', sql, ]); } async function compressionCounts() { try { const { stdout } = await psql( `select compression_status, count(*) from upload where deleted_at is null group by 1` ); const map = {}; for (const line of stdout.trim().split('\n')) { if (!line) continue; const [status, n] = line.split('|'); map[status] = parseInt(n, 10); } return map; } catch (e) { return { error: String(e).slice(0, 120) }; } } // ── Stats helpers ───────────────────────────────────────────────────────────── function pct(sorted, p) { if (!sorted.length) return null; const i = Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length)); return sorted[i]; } function summarize(nums) { if (!nums.length) return null; const s = [...nums].sort((a, b) => a - b); const sum = s.reduce((a, b) => a + b, 0); return { n: s.length, min: s[0], max: s[s.length - 1], mean: Math.round(sum / s.length), p50: pct(s, 50), p95: pct(s, 95), p99: pct(s, 99), }; } // ── Image pool ──────────────────────────────────────────────────────────────── function loadPool() { let files; try { files = readdirSync(cfg.photosDir).filter((f) => f.endsWith('.jpg')); } catch { files = []; } if (!files.length) { console.error( `\n✗ No images in ${cfg.photosDir}. Run first:\n` + ` LT_PHOTOS_DIR=${cfg.photosDir} python3 ${join(__dirname, 'gen-images.py')}\n` ); process.exit(1); } const pool = files.map((f) => { const p = join(cfg.photosDir, f); return { buf: readFileSync(p), size: statSync(p).size, name: f }; }); return pool; } // ── Burst schedule ──────────────────────────────────────────────────────────── // Build bursts until we reach the image target, assign each to a random guest at // a random time in the window. Naturally gives some guests several bursts and // some none — like a real event. Guests all join before their first burst. function buildSchedule() { const bursts = []; let remaining = cfg.images; while (remaining > 0) { const size = Math.min(remaining, rand(cfg.burstMin, cfg.burstMax)); bursts.push({ guest: rand(0, cfg.guests - 1), atMs: Math.floor(Math.random() * cfg.windowSec * 1000), size, }); remaining -= size; } bursts.sort((a, b) => a.atMs - b.atMs); return bursts; } // ── Main ────────────────────────────────────────────────────────────────────── async function main() { console.log('━'.repeat(72)); console.log('EventSnap load driver'); console.log( ` target: ${cfg.guests} guests, ${cfg.images} images, ` + `bursts ${cfg.burstMin}-${cfg.burstMax}, window ${cfg.windowSec}s, ${cfg.viewers} viewers` ); console.log(` base: ${cfg.base}`); console.log('━'.repeat(72)); const pool = loadPool(); const avgMb = pool.reduce((a, p) => a + p.size, 0) / pool.length / 1e6; console.log( `[pool] ${pool.length} images, avg ${avgMb.toFixed(2)} MB, ` + `projected originals ~${((avgMb * cfg.images) / 1000).toFixed(1)} GB` ); // Preflight: disk headroom try { const { stdout } = await execFileAsync('df', ['-BG', '--output=avail', '/']); console.log(`[preflight] disk avail:${stdout.trim().split('\n').pop().trim()}`); } catch { /* ignore */ } // Admin: reset + config const admin = await adminLogin(); const cfgBefore = await getConfig(admin); if (cfg.truncate) { console.log('[setup] truncating event data…'); await truncate(admin); } const admin2 = await adminLogin(); if (!cfg.keepRateLimits) { console.log('[setup] disabling rate limits (methodology: isolate pipeline, not limiter)'); await patchConfig(admin2, { rate_limits_enabled: 'false', upload_rate_enabled: 'false', feed_rate_enabled: 'false', join_rate_enabled: 'false', recover_rate_enabled: 'false', }); } console.log( `[setup] compression concurrency / quotas left at SHIPPING defaults ` + `(compression_worker_concurrency is a boot env var, not runtime)` ); // Metrics stores const uploads = []; // {status, ms, endTs, bytes, id, guest} const processedAt = new Map(); // upload_id -> ts of upload-processed const uploadEndTs = new Map(); // upload_id -> endTs const errorEvents = []; // upload-error payloads const resources = []; let newUploadEvents = 0; // Join guests (staggered, small concurrency) console.log(`[join] joining ${cfg.guests} guests…`); const accounts = new Array(cfg.guests); const joinConc = 10; for (let i = 0; i < cfg.guests; i += joinConc) { await Promise.all( Array.from({ length: Math.min(joinConc, cfg.guests - i) }, (_, k) => joinGuest(`LoadGuest ${i + k}`).then((a) => (accounts[i + k] = a)) ) ); } console.log('[join] done'); // Live connections: 1 diashow + N viewers const onEvent = (event, payload) => { if (event === 'new-upload') newUploadEvents++; else if (event === 'upload-processed' && payload?.upload_id) { if (!processedAt.has(payload.upload_id)) processedAt.set(payload.upload_id, now()); } else if (event === 'upload-error' && payload?.upload_id) { errorEvents.push(payload); } }; const sseClients = []; sseClients.push(new SseClient(accounts[0].jwt, 'diashow', onEvent).start()); for (let i = 0; i < Math.min(cfg.viewers, cfg.guests); i++) { sseClients.push(new SseClient(accounts[i].jwt, `viewer${i}`, onEvent).start()); } console.log(`[sse] opened ${sseClients.length} live connections (1 diashow + viewers)`); // Viewers also poll the feed periodically (viewing load) let feedPolls = 0; const feedPoller = setInterval(async () => { const g = accounts[rand(0, Math.min(cfg.viewers, cfg.guests) - 1)]; try { await api('/feed', { token: g.jwt }); feedPolls++; } catch { /* ignore */ } }, 1500); // Resource sampler const sampler = setInterval(async () => resources.push(await sampleResources()), 5000); resources.push(await sampleResources()); // Run the burst schedule const schedule = buildSchedule(); console.log( `[run] ${schedule.length} bursts scheduled over ${cfg.windowSec}s ` + `(≈${(cfg.images / (cfg.windowSec / 60)).toFixed(0)} img/min avg). Starting…` ); const startTs = now(); let done = 0; const runBurst = async (burst) => { const jwt = accounts[burst.guest].jwt; const items = Array.from({ length: burst.size }, () => pick(pool)); // upload with phone-like small concurrency inside the burst for (let i = 0; i < items.length; i += cfg.burstConc) { const chunk = items.slice(i, i + cfg.burstConc); const results = await Promise.all(chunk.map((it) => uploadPhoto(jwt, it.name, it.buf))); for (const r of results) { uploads.push({ ...r, guest: burst.guest }); if (r.id) uploadEndTs.set(r.id, r.endTs); done++; } } }; const timers = []; const burstPromises = []; for (const burst of schedule) { timers.push( setTimeout(() => { burstPromises.push(runBurst(burst)); }, burst.atMs) ); } // progress ticker const ticker = setInterval(() => { const elapsed = ((now() - startTs) / 1000).toFixed(0); const ok = uploads.filter((u) => u.status === 201).length; console.log( `[t+${elapsed}s] uploads ${done}/${cfg.images} (ok ${ok}), ` + `processed ${processedAt.size}, new-upload evts ${newUploadEvents}, ` + `feedPolls ${feedPolls}` ); }, 10000); // wait for the window to elapse, then for all scheduled bursts to finish await sleep(cfg.windowSec * 1000 + 500); await Promise.all(burstPromises); clearInterval(ticker); console.log(`[run] all bursts issued. uploaded ${done}, ok ${uploads.filter((u) => u.status === 201).length}`); // Drain: wait for compression backlog to clear (SSE processed ⊇ successful ids) console.log('[drain] waiting for compression backlog to clear…'); const drainStart = now(); const successIds = new Set(uploads.filter((u) => u.id).map((u) => u.id)); let lastLog = 0; let drainReason = 'timeout'; while (now() - drainStart < cfg.drainTimeoutSec * 1000) { // SSE view (what the diashow actually "sees") const pendingSse = [...successIds].filter((id) => !processedAt.has(id)).length; if (pendingSse === 0) { console.log('[drain] backlog cleared (all upload-processed events received)'); drainReason = 'sse-complete'; break; } // DB ground truth — authoritative even if an SSE reconnect dropped events const counts = await compressionCounts(); const dbPending = Object.entries(counts) .filter(([s]) => s !== 'done' && s !== 'error') .reduce((a, [, n]) => a + n, 0); if (!counts.error && dbPending === 0) { console.log(`[drain] backlog cleared per DB (db status: ${JSON.stringify(counts)})`); drainReason = 'db-complete'; break; } if (now() - lastLog > 5000) { console.log( `[drain] pending(sse) ${pendingSse}, pending(db) ${dbPending} — db: ${JSON.stringify(counts)}` ); lastLog = now(); } await sleep(1000); } const drainMs = now() - drainStart; // Stop background work clearInterval(sampler); clearInterval(feedPoller); timers.forEach(clearTimeout); resources.push(await sampleResources()); const finalCounts = await compressionCounts(); await sleep(200); sseClients.forEach((c) => c.close()); // ── Build report ──────────────────────────────────────────────────────────── const byStatus = {}; for (const u of uploads) byStatus[u.status] = (byStatus[u.status] ?? 0) + 1; const okUploads = uploads.filter((u) => u.status === 201); const uploadLatency = summarize(okUploads.map((u) => u.ms)); const pipelineLatency = summarize( [...processedAt.entries()] .filter(([id]) => uploadEndTs.has(id)) .map(([id, ts]) => ts - uploadEndTs.get(id)) ); const totalReconnects = sseClients.reduce((a, c) => a + c.reconnects, 0); const totalResyncs = sseClients.reduce((a, c) => a + c.resyncs, 0); const report = { config: cfg, startedAt: new Date(startTs).toISOString(), durationSec: Math.round((now() - startTs) / 1000), totals: { uploadsAttempted: uploads.length, uploadsOk: okUploads.length, byStatus, newUploadEvents, processedEvents: processedAt.size, uploadErrorEvents: errorEvents.length, feedPolls, }, uploadLatencyMs: uploadLatency, pipelineLatencyMs: pipelineLatency, drain: { ms: drainMs, cleared: drainReason !== 'timeout', reason: drainReason, ssePending: [...successIds].filter((id) => !processedAt.has(id)).length, finalDbCounts: finalCounts, }, sse: { connections: sseClients.length, reconnects: totalReconnects, resyncs: totalResyncs, }, resources, rateLimitsDisabled: !cfg.keepRateLimits, configBefore: cfgBefore, }; mkdirSync(cfg.outDir, { recursive: true }); const stamp = new Date(startTs).toISOString().replace(/[:.]/g, '-'); const outPath = join(cfg.outDir, `run-${stamp}.json`); writeFileSync(outPath, JSON.stringify(report, null, 2)); // ── Print verdict ───────────────────────────────────────────────────────── console.log('\n' + '━'.repeat(72)); console.log('RESULTS'); console.log('━'.repeat(72)); console.log(`uploads: ${okUploads.length}/${uploads.length} ok — byStatus ${JSON.stringify(byStatus)}`); console.log(`upload latency ms: ${JSON.stringify(uploadLatency)}`); console.log(`pipeline latency ms (upload→preview ready): ${JSON.stringify(pipelineLatency)}`); console.log(`backlog drain: ${(drainMs / 1000).toFixed(1)}s, cleared=${report.drain.cleared}`); console.log(`final db compression status: ${JSON.stringify(finalCounts)}`); console.log(`sse: ${sseClients.length} conns, ${totalReconnects} reconnects, ${totalResyncs} resyncs`); console.log(`\nfull report → ${outPath}`); // Heuristic pass/fail flags (validate shipping config) const flags = []; const err5xx = Object.entries(byStatus).filter(([s]) => +s >= 500).reduce((a, [, n]) => a + n, 0); if (err5xx > 0) flags.push(`✗ ${err5xx} server errors (5xx)`); if (byStatus['507']) flags.push(`✗ ${byStatus['507']} quota rejections (507)`); if (byStatus['413']) flags.push(`⚠ ${byStatus['413']} too-large (413)`); if (byStatus['429']) flags.push(`⚠ ${byStatus['429']} rate-limited (429) — unexpected with limits off`); if (!report.drain.cleared) flags.push(`✗ backlog did NOT drain within ${cfg.drainTimeoutSec}s`); if (totalResyncs > sseClients.length) flags.push(`⚠ ${totalResyncs} SSE resyncs (consumer lag)`); if (pipelineLatency && pipelineLatency.p95 > 60000) flags.push(`⚠ pipeline p95 ${(pipelineLatency.p95 / 1000).toFixed(0)}s (diashow lags >1min)`); console.log('\nflags:'); if (flags.length) flags.forEach((f) => console.log(' ' + f)); else console.log(' ✓ no red flags — default config handled the load'); console.log('━'.repeat(72)); } main().catch((e) => { console.error('\n✗ driver failed:', e); process.exit(1); });