#!/usr/bin/env node /* * Carga de clientes DDP + REST (fase 12). * * Abre N conexiones DDP como las del navegador (WebSocket crudo contra * /websocket, sin librería), cada una suscrita a lo que suscribe el mapa * —`activefiresmyloc` y `activefiresunionmyloc`— y, con todas conectadas, mide * la latencia de ida y vuelta de un método. Es la pregunta de la fase: con * mucha gente mirando el mapa, ¿sigue respondiendo la web? * * En paralelo golpea los endpoints REST que usa la app Flutter. * * node bench/ddp-load.js --clients 50 * node bench/ddp-load.js --clients 200 --seconds 30 * BASE_URL=http://localhost:3100 node bench/ddp-load.js --clients 500 * * Contra un servidor de desarrollo o el compose local. NUNCA contra staging o * producción: son cientos de conexiones y de llamadas. */ const BASE = (process.env.BASE_URL || 'http://localhost:3100').replace(/\/$/, ''); if (/testfuegos|fuegos\.comunes|fires\.comunes/.test(BASE)) { console.error(`Me niego a lanzar carga contra ${BASE}.`); process.exit(1); } const arg = (name, def) => { const i = process.argv.indexOf(`--${name}`); return i === -1 ? def : Number(process.argv[i + 1]); }; const CLIENTS = arg('clients', 50); const SECONDS = arg('seconds', 20); const WS_URL = `${BASE.replace(/^http/, 'ws')}/websocket`; // El mismo recuadro que pide el mapa al abrirse sobre la península. const BOX = [3.3, 43.7, -9.2, 36.0]; const percentile = (values, p) => { if (values.length === 0) return NaN; const sorted = [...values].sort((a, b) => a - b); return sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))]; }; const openClient = (id) => new Promise((resolve, reject) => { const ws = new WebSocket(WS_URL); const state = { id, ws, ready: 0, subs: 0, errors: 0, latencies: [], pending: new Map() }; const timer = setTimeout(() => reject(new Error(`cliente ${id}: sin conectar en 30 s`)), 30000); ws.addEventListener('open', () => { ws.send(JSON.stringify({ msg: 'connect', version: '1', support: ['1'] })); }); ws.addEventListener('message', (event) => { // Meteor manda varios mensajes DDP en frames separados, uno por frame. let m; try { m = JSON.parse(event.data); } catch (e) { return; } if (m.msg === 'connected') { state.connectedAt = Date.now(); ws.send(JSON.stringify({ msg: 'sub', id: `s${id}a`, name: 'activefiresmyloc', params: [...BOX, false] })); ws.send(JSON.stringify({ msg: 'sub', id: `s${id}b`, name: 'activefiresunionmyloc', params: [...BOX, false] })); clearTimeout(timer); resolve(state); } else if (m.msg === 'ready') { state.subs += (m.subs || []).length; if (!state.ready) state.ready = Date.now() - state.connectedAt; } else if (m.msg === 'nosub') { state.errors += 1; } else if (m.msg === 'result') { const started = state.pending.get(m.id); if (started) { state.pending.delete(m.id); state.latencies.push(Date.now() - started); if (m.error) state.errors += 1; } } else if (m.msg === 'ping') { ws.send(JSON.stringify({ msg: 'pong', id: m.id })); } }); ws.addEventListener('error', () => { state.errors += 1; clearTimeout(timer); reject(new Error(`cliente ${id}: error de websocket`)); }); }); let callSeq = 0; const callMethod = (state) => { if (state.ws.readyState !== 1) return; const id = `m${state.id}_${callSeq += 1}`; state.pending.set(id, Date.now()); // Método barato y real: el que usa el cliente para pedir la clave de mapas. state.ws.send(JSON.stringify({ msg: 'method', method: 'getMapKey', params: [], id })); }; const restRound = async () => { const started = Date.now(); const res = await fetch(`${BASE}/api/v1/status/uptime`).catch(() => null); return { ok: !!res && res.ok, ms: Date.now() - started }; }; const main = async () => { console.log(`Abriendo ${CLIENTS} clientes DDP contra ${WS_URL} …`); const started = Date.now(); const results = await Promise.allSettled( Array.from({ length: CLIENTS }, (_, i) => openClient(i)) ); const clients = results.filter(r => r.status === 'fulfilled').map(r => r.value); const failed = results.length - clients.length; console.log(`Conectados ${clients.length}/${CLIENTS} en ${((Date.now() - started) / 1000).toFixed(1)} s${failed ? ` (${failed} fallos)` : ''}`); // Un momento para que se completen las suscripciones antes de medir. await new Promise(r => setTimeout(r, 3000)); const readies = clients.map(c => c.ready).filter(Boolean); console.log(`Suscripciones listas: p50 ${percentile(readies, 50)} ms · p95 ${percentile(readies, 95)} ms · sin ready ${clients.length - readies.length}`); console.log(`Midiendo ${SECONDS} s de llamadas…`); const restLatencies = []; const methodTimer = setInterval(() => clients.forEach(callMethod), 1000); const restTimer = setInterval(async () => { const r = await restRound(); if (r.ok) restLatencies.push(r.ms); }, 200); await new Promise(r => setTimeout(r, SECONDS * 1000)); clearInterval(methodTimer); clearInterval(restTimer); await new Promise(r => setTimeout(r, 2000)); const latencies = clients.flatMap(c => c.latencies); const errors = clients.reduce((a, c) => a + c.errors, 0); const pending = clients.reduce((a, c) => a + c.pending.size, 0); console.log(''); console.log(`Método DDP (getMapKey): ${latencies.length} respuestas · p50 ${percentile(latencies, 50)} ms · p95 ${percentile(latencies, 95)} ms · p99 ${percentile(latencies, 99)} ms · máx ${Math.max(...latencies, 0)} ms`); console.log(`REST status/uptime : ${restLatencies.length} respuestas · p50 ${percentile(restLatencies, 50)} ms · p95 ${percentile(restLatencies, 95)} ms`); console.log(`Errores: ${errors} · llamadas sin responder al cerrar: ${pending}`); clients.forEach(c => c.ws.close()); process.exit(0); }; main().catch((e) => { console.error(e); process.exit(1); });