Cuatro endurecimientos salidos del banco de carga (bench/union-bench.js), cada uno con su número: 1. Unión en ÁRBOL en vez de en cadena. La cadena unía siempre contra un polígono acumulado que no paraba de crecer. Con 1.000 suscripciones repartidas por el mundo —el peor caso, círculos que no se solapan— eran 238 s; en árbol son 3,6 s, con un documento idéntico. Con datos realistas (España) 10.000 suscripciones bajan de 39,3 s a 23,5 s. test/server/calcUnionAsync.test.js comprueba la equivalencia contra una referencia calculada en cadena. 2. 64 vértices por círculo en vez de 144, configurable con `private.unionCircleSteps`. Con 10.000 suscripciones: 23,5 s → 10,0 s y el pico de RSS de 777 MB → 421 MB. En pantalla un círculo de 64 lados sigue siendo un círculo, y la capa de zonas es un adorno difuso; el host de despliegue tiene 5,9 GB para todo el stack. 3. Timeout del worker (10 min por defecto, `private.unionWorkerTimeoutMs`). Un worker que no contestaba dejaba la promesa colgada y con ella la cola entera: `busy` se quedaba en true y no se volvía a calcular ninguna unión hasta reiniciar, sin que nada fallase. Se reintenta una vez ante un error real, nunca ante un timeout. 4. Cota de heap del worker (1 GB, `private.unionWorkerMaxHeapMb`), para que un caso patológico muera con ERR_WORKER_OUT_OF_MEMORY —que esta capa convierte en un rechazo, dejando la unión anterior intacta— en vez de que el kernel se lleve por delante el proceso de Meteor. Además unionTelemetry.js manda a GlitchTip las uniones que pasan de un minuto y las que hay que degradar por tamaño, y registra duración/tamaño de cada una. El único aviso de que la unión iba mal era, hasta ahora, que el mapa se quedaba viejo. Smoke REST byte-idéntico. 90 tests en verde.
96 lines
4.7 KiB
JavaScript
96 lines
4.7 KiB
JavaScript
// Plain CommonJS worker_threads entry point, NOT part of the Meteor imports
|
|
// graph (lives under private/ so `meteor build` copies it verbatim into
|
|
// programs/server/assets/app/workers/unionWorker.js instead of compiling it).
|
|
//
|
|
// Runs the CPU-heavy part of the subscriptions geo-union (circle generation +
|
|
// sequential turf.union chain) off the main thread. A single tunion() call
|
|
// against an already-complex merged polygon can itself take seconds once
|
|
// there are thousands of subscriptions — yielding between iterations on the
|
|
// main thread doesn't help when one iteration alone blocks that long, so this
|
|
// needs a real worker thread, not just cooperative scheduling.
|
|
//
|
|
// workerData.subs must already be validated (location.lat/lon/distance
|
|
// present) and decorated (addNoisy/noNoisy already applied) by the caller.
|
|
// workerData.baseUnion, if given, is an already-computed union GeoJSON that
|
|
// subs gets merged into (the incremental-add fast path) instead of starting
|
|
// from scratch — for a single new subscription this is one tunion() call
|
|
// instead of redoing the whole chain over every subscription again.
|
|
// workerData.turfPaths gives absolute paths to @turf/circle, @turf/union and
|
|
// @turf/truncate — this file lives under programs/server/assets/, while the
|
|
// app's own npm deps live in the sibling programs/server/npm/node_modules,
|
|
// which plain require('@turf/circle') can never find by walking up parent
|
|
// directories. The caller resolves the paths (it's Meteor-compiled code that
|
|
// knows how to find them) and hands them over instead.
|
|
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 };
|
|
|
|
// Vértices por círculo: la palanca más directa sobre lo que tarda la unión y
|
|
// sobre la memoria que se come. Medido con 10.000 suscripciones
|
|
// (bench/union-bench.js): 144 → 23,5 s y 777 MB de pico; 64 → 10,0 s y 421 MB;
|
|
// 32 → 4,7 s y 267 MB. Con 64 un círculo sigue siendo un círculo en pantalla
|
|
// (la capa de zonas es un adorno difuso, no una geometría que nadie mida) y el
|
|
// host de despliegue solo tiene 5,9 GB para todo el stack, así que 64 es el
|
|
// valor por defecto. Se puede volver a 144 con
|
|
// `Meteor.settings.private.unionCircleSteps`.
|
|
const DEFAULT_STEPS = 64;
|
|
|
|
// Unión en ÁRBOL, no en cadena.
|
|
//
|
|
// Antes esto era `u = union(u, circulo[i])` en bucle: cada llamada trabajaba
|
|
// contra un polígono acumulado que no paraba de crecer, así que el coste subía
|
|
// mucho más deprisa que el número de suscripciones. Uniendo por pares (y luego
|
|
// pares de pares) la mayoría de las uniones son entre polígonos pequeños y solo
|
|
// las últimas son caras. El resultado geométrico es el mismo — la unión es
|
|
// asociativa y conmutativa — y así lo comprueba test/server/calcUnionAsync.test.js
|
|
// contra una referencia calculada en cadena.
|
|
//
|
|
// Medido en bench/union-bench.js con 1.000 suscripciones repartidas por el mundo
|
|
// (el peor caso, círculos que casi no se solapan): 238 s en cadena frente a
|
|
// 3,6 s en árbol, con un documento idéntico. Con datos realistas (España) la
|
|
// mejora es menor porque los círculos se funden pronto, pero va en la misma
|
|
// dirección.
|
|
const run = () => {
|
|
const { subs, baseUnion, steps } = workerData;
|
|
if (subs.length === 0) return baseUnion || null;
|
|
|
|
const circleAt = (i) => tcircle(
|
|
[subs[i].location.lon, subs[i].location.lat],
|
|
subs[i].distance,
|
|
{ units: 'kilometers', steps: steps || DEFAULT_STEPS }
|
|
);
|
|
|
|
// Primer nivel: los círculos se generan AL VUELO y se unen de dos en dos, en
|
|
// vez de materializar los N y luego unirlos. Con 10.000 suscripciones eso es
|
|
// la diferencia entre un pico de ~1 GB y uno de la mitad, y el host de
|
|
// despliegue tiene 5,9 GB para todo el stack.
|
|
let level = [];
|
|
for (let i = 0; i < subs.length; i += 2) {
|
|
if (i + 1 < subs.length) level.push(ttrunc(tunion(circleAt(i), circleAt(i + 1)), truncOptions));
|
|
else level.push(ttrunc(circleAt(i), truncOptions));
|
|
}
|
|
|
|
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 });
|
|
}
|