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.
217 lines
8.3 KiB
JavaScript
217 lines
8.3 KiB
JavaScript
#!/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);
|
|
});
|