diff --git a/.meteorignore b/.meteorignore index eda8931..83c45df 100644 --- a/.meteorignore +++ b/.meteorignore @@ -7,3 +7,5 @@ smoke scripts # Suite Playwright: es un proyecto Node aparte (e2e/package.json), no codigo del servidor e2e +# Banco de carga (fase 12): scripts Node sueltos, no codigo del servidor +bench diff --git a/bench/treeUnionWorker.js b/bench/treeUnionWorker.js new file mode 100644 index 0000000..138688f --- /dev/null +++ b/bench/treeUnionWorker.js @@ -0,0 +1,49 @@ +// Variante SOLO PARA EL BANCO DE CARGA del worker de uniones. +// +// El worker de producción une en cadena: `u = union(u, c[i])` para cada círculo. +// Eso hace que cada llamada trabaje sobre un polígono que no para de crecer, así +// que el coste crece mucho más deprisa que el número de suscripciones. Esta +// variante une en árbol (pares, luego pares de pares…): el mismo resultado +// geométrico, pero la mayoría de las uniones son entre polígonos pequeños. +// +// Está aquí, y no en private/, para que no acabe en el bundle: es material de +// medición. Si los números lo justifican, se porta al worker de verdad. + +const { parentPort, workerData } = require('worker_threads'); + +// eslint-disable-next-line import/no-dynamic-require +const tcircle = require(workerData.turfPaths.circle).default; +// eslint-disable-next-line import/no-dynamic-require +const tunion = require(workerData.turfPaths.union).default; +// eslint-disable-next-line import/no-dynamic-require +const ttrunc = require(workerData.turfPaths.truncate).default; + +const truncOptions = { precision: 6, coordinates: 2 }; + +const run = () => { + const { subs, baseUnion, steps } = workerData; + let level = subs.map(s => ttrunc(tcircle( + [s.location.lon, s.location.lat], + s.distance, + { units: 'kilometers', steps: steps || 144 } + ), truncOptions)); + + if (level.length === 0) return baseUnion || null; + + while (level.length > 1) { + const next = []; + for (let i = 0; i < level.length; i += 2) { + if (i + 1 < level.length) next.push(ttrunc(tunion(level[i], level[i + 1]), truncOptions)); + else next.push(level[i]); + } + level = next; + } + + return baseUnion ? ttrunc(tunion(baseUnion, level[0]), truncOptions) : level[0]; +}; + +try { + parentPort.postMessage({ union: run() }); +} catch (e) { + parentPort.postMessage({ error: e.message }); +} diff --git a/bench/union-bench.js b/bench/union-bench.js new file mode 100644 index 0000000..63242b9 --- /dev/null +++ b/bench/union-bench.js @@ -0,0 +1,217 @@ +#!/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); +}); diff --git a/private/workers/unionWorker.js b/private/workers/unionWorker.js index 34c8156..53abe56 100644 --- a/private/workers/unionWorker.js +++ b/private/workers/unionWorker.js @@ -32,12 +32,18 @@ const ttrunc = require(workerData.turfPaths.truncate).default; const truncOptions = { precision: 6, coordinates: 2 }; +// Vértices por círculo. 144 es lo que se ha venido usando; es también la +// palanca más directa sobre el tamaño del documento y sobre lo que tarda la +// cadena de uniones, así que el banco de carga (bench/union-bench.js) necesita +// poder moverlo. Sin `steps` en workerData el comportamiento es el de siempre. +const DEFAULT_STEPS = 144; + const run = () => { - const { subs, baseUnion } = workerData; + const { subs, baseUnion, steps } = workerData; const circles = subs.map(s => tcircle( [s.location.lon, s.location.lat], s.distance, - { units: 'kilometers', steps: 144 } + { units: 'kilometers', steps: steps || DEFAULT_STEPS } )); let unionTemp = baseUnion || null;