From 072e76aaedd5c00f5f0869885b6a496b874ec308 Mon Sep 17 00:00:00 2001 From: vjrj Date: Sat, 1 Aug 2026 21:51:27 +0200 Subject: [PATCH] =?UTF-8?q?bench(union):=20banco=20de=20carga=20de=20la=20?= =?UTF-8?q?uni=C3=B3n=20de=20suscripciones?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ejecuta el worker de producción contra suscripciones sintéticas realistas (península, Canarias, Baleares; radios de 5 a 100 km) y mide lo que la fase 12 pide: duración del recreate completo y del incrementalAdd, retraso máximo del event loop del proceso padre —que es donde vive DDP y por tanto la pregunta real de "¿se congela la web?"—, tamaño del documento frente al límite de 16 MiB y pico de RSS. `--spread world` reparte las suscripciones por todo el mundo: es el peor caso para el tamaño, porque casi no se solapan y la unión conserva un polígono por suscripción en vez de fundirlos. treeUnionWorker.js es una variante solo del banco (une en árbol en vez de en cadena) para poder comparar estrategias con números antes de tocar el worker de verdad. El worker de producción solo cambia en que ahora acepta `steps` por workerData, con el mismo 144 de siempre por defecto. --- .meteorignore | 2 + bench/treeUnionWorker.js | 49 ++++++++ bench/union-bench.js | 217 +++++++++++++++++++++++++++++++++ private/workers/unionWorker.js | 10 +- 4 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 bench/treeUnionWorker.js create mode 100644 bench/union-bench.js 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;