#!/usr/bin/env node /* * Banco de carga de la unión de suscripciones (fase 12). * * Ejecuta el MISMO worker que corre en producción * (private/workers/unionWorker.js) contra suscripciones sintéticas realistas, y * mide lo que la fase pide: cuánto tarda un recreate completo, cuánto un * incrementalAdd, cuánto se atasca el event loop del proceso padre mientras * tanto, cuánto ocupa el documento resultante frente al límite de 16 MiB de * Mongo, y cuánta RAM se come el proceso. * * No necesita Meteor ni Mongo: la orquestación (cola, fast-path, guardado) ya * está cubierta por test/server/subsUnion.test.js. Lo que aquí interesa es la * parte cara, que es la geometría. * * node bench/union-bench.js # 1000 5000 10000 * node bench/union-bench.js --sizes 500,1000 # otros tamaños * node bench/union-bench.js --steps 64 # círculos menos detallados * node bench/union-bench.js --strategy tree # unión en árbol en vez de en cadena * node bench/union-bench.js --markdown # tabla lista para pegar en la fase */ const path = require('path'); const { Worker } = require('worker_threads'); const { monitorEventLoopDelay } = require('perf_hooks'); const ROOT = path.resolve(__dirname, '..'); const WORKER_PATH = path.join(ROOT, 'private/workers/unionWorker.js'); const TREE_WORKER_PATH = path.join(__dirname, 'treeUnionWorker.js'); const NPM_MODULES = path.join(ROOT, 'node_modules'); const turfPaths = { circle: path.join(NPM_MODULES, '@turf/circle'), union: path.join(NPM_MODULES, '@turf/union'), truncate: path.join(NPM_MODULES, '@turf/truncate') }; const arg = (name, def) => { const i = process.argv.indexOf(`--${name}`); return i === -1 ? def : process.argv[i + 1]; }; const flag = name => process.argv.includes(`--${name}`); const SIZES = String(arg('sizes', '1000,5000,10000')).split(',').map(Number); const STEPS = Number(arg('steps', 144)); const STRATEGY = arg('strategy', 'sequential'); const TIMEOUT_MS = Number(arg('timeout', 20 * 60 * 1000)); // Suscripciones sintéticas con la forma de las reales: la mayoría en la // península, un puñado en Canarias y Baleares, radios de 5 a 100 km. Sembradas // con un PRNG determinista para que dos ejecuciones sean comparables. let seed = 20260801; const rnd = () => { seed = (seed * 1664525 + 1013904223) % 4294967296; return seed / 4294967296; }; const REGIONS_ES = [ { name: 'peninsula', weight: 0.88, lat: [36.0, 43.7], lon: [-9.2, 3.3] }, { name: 'canarias', weight: 0.07, lat: [27.6, 29.4], lon: [-18.2, -13.3] }, { name: 'baleares', weight: 0.05, lat: [38.6, 40.1], lon: [1.2, 4.3] } ]; // El peor caso para el TAMAÑO del documento: suscripciones repartidas por todo // el mundo, que casi no se solapan, así que la unión conserva un polígono por // suscripción en vez de fundirlas. Es el escenario contra el que existe // unionSizeGuard.js, y el que hay que validar bajo carga. const REGIONS_WORLD = [ { name: 'mundo', weight: 1, lat: [-55, 65], lon: [-170, 170] } ]; const REGIONS = arg('spread', 'es') === 'world' ? REGIONS_WORLD : REGIONS_ES; const pickRegion = () => { const r = rnd(); let acc = 0; for (const region of REGIONS) { acc += region.weight; if (r <= acc) return region; } return REGIONS[0]; }; const makeSubs = (n) => { const subs = []; for (let i = 0; i < n; i += 1) { const region = pickRegion(); const lat = region.lat[0] + (rnd() * (region.lat[1] - region.lat[0])); const lon = region.lon[0] + (rnd() * (region.lon[1] - region.lon[0])); // Radios: la mayoría pequeños, con cola larga hasta 100 km. const distance = Math.round(5 + (rnd() ** 2) * 95); subs.push({ location: { lat, lon }, distance }); } return subs; }; const runWorker = (subs, baseUnion, { steps, strategy }) => new Promise((resolve, reject) => { const script = strategy === 'tree' ? TREE_WORKER_PATH : WORKER_PATH; const worker = new Worker(script, { workerData: { subs, baseUnion, turfPaths, steps } }); const timer = setTimeout(() => { worker.terminate(); reject(new Error(`worker sin responder tras ${TIMEOUT_MS} ms`)); }, TIMEOUT_MS); worker.once('message', (msg) => { clearTimeout(timer); worker.terminate(); if (msg.error) reject(new Error(msg.error)); else resolve(msg.union); }); worker.once('error', (err) => { clearTimeout(timer); worker.terminate(); reject(err); }); }); // Mide, mientras corre `fn`: duración, retraso máximo del event loop del PADRE // (que es donde vive DDP: es la métrica que importa para "¿se congela la web?") // y pico de RSS del proceso. const measure = async (fn) => { const loop = monitorEventLoopDelay({ resolution: 10 }); loop.enable(); let peakRss = process.memoryUsage().rss; const sampler = setInterval(() => { const { rss } = process.memoryUsage(); if (rss > peakRss) peakRss = rss; }, 100); // Un "latido" cada 20 ms: si el event loop se bloquea, se notan los saltos. let beats = 0; const beat = setInterval(() => { beats += 1; }, 20); const started = process.hrtime.bigint(); let result; let error = null; try { result = await fn(); } catch (e) { error = e; } const ms = Number(process.hrtime.bigint() - started) / 1e6; clearInterval(sampler); clearInterval(beat); loop.disable(); return { ms, result, error, maxLagMs: loop.max / 1e6, p99LagMs: loop.percentile(99) / 1e6, peakRssMb: peakRss / 1024 / 1024, beats, expectedBeats: Math.floor(ms / 20) }; }; const sizeOf = (union) => Buffer.byteLength(JSON.stringify(union), 'utf8'); const mb = n => (n / 1024 / 1024).toFixed(2); const pointCount = (union) => { if (!union) return 0; const g = union.geometry || union; const polys = g.type === 'MultiPolygon' ? g.coordinates : [g.coordinates]; return polys.reduce((acc, p) => acc + p.reduce((a, r) => a + r.length, 0), 0); }; const main = async () => { const rows = []; console.log(`Estrategia: ${STRATEGY} · steps por círculo: ${STEPS} · timeout: ${TIMEOUT_MS} ms\n`); for (const n of SIZES) { const subs = makeSubs(n); process.stdout.write(`── ${n} suscripciones ─────────────────────────\n`); const full = await measure(() => runWorker(subs, null, { steps: STEPS, strategy: STRATEGY })); if (full.error) { console.log(` recreate: FALLÓ tras ${(full.ms / 1000).toFixed(1)} s — ${full.error.message}\n`); rows.push({ n, full, inc: null, bytes: null, points: null }); continue; } const bytes = sizeOf(full.result); const points = pointCount(full.result); console.log(` recreate : ${(full.ms / 1000).toFixed(1)} s`); console.log(` doc unión : ${mb(bytes)} MB (${points} vértices) — ${((bytes / (16 * 1024 * 1024)) * 100).toFixed(1)}% del límite de Mongo`); console.log(` event loop : max ${full.maxLagMs.toFixed(1)} ms · p99 ${full.p99LagMs.toFixed(1)} ms · latidos ${full.beats}/${full.expectedBeats}`); console.log(` RSS pico : ${full.peakRssMb.toFixed(0)} MB`); // El caso común en producción: una suscripción nueva sobre la unión ya // guardada. Es el camino que decide si "suscribirse" es instantáneo o no. const one = makeSubs(1); const inc = await measure(() => runWorker(one, full.result, { steps: STEPS, strategy: STRATEGY })); if (inc.error) { console.log(` incrementalAdd: FALLÓ — ${inc.error.message}\n`); } else { console.log(` incrementalAdd: ${inc.ms.toFixed(0)} ms · event loop max ${inc.maxLagMs.toFixed(1)} ms\n`); } rows.push({ n, full, inc, bytes, points }); } if (flag('markdown')) { console.log('\n| subs | recreate | incrementalAdd | doc unión | % de 16 MiB | lag máx. del event loop | RSS pico |'); console.log('|---:|---:|---:|---:|---:|---:|---:|'); for (const r of rows) { if (!r.bytes) { console.log(`| ${r.n} | ✗ (${(r.full.ms / 1000).toFixed(0)} s) | — | — | — | — | — |`); continue; } console.log(`| ${r.n} | ${(r.full.ms / 1000).toFixed(1)} s | ${r.inc && !r.inc.error ? `${r.inc.ms.toFixed(0)} ms` : '✗'} | ${mb(r.bytes)} MB | ${((r.bytes / (16 * 1024 * 1024)) * 100).toFixed(1)}% | ${r.full.maxLagMs.toFixed(1)} ms | ${r.full.peakRssMb.toFixed(0)} MB |`); } } }; main().catch((e) => { console.error(e); process.exit(1); });