todos-contra-el-fuego-web/imports/startup/server/calcUnionAsync.js
vjrj 56f53e2c23
All checks were successful
build-image / test (push) Successful in 2m49s
build-image / build (push) Successful in 13m4s
perf(subsUnion): unión en árbol, menos vértices, timeout y cota de memoria
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.
2026-08-01 22:32:37 +02:00

99 lines
4.3 KiB
JavaScript

import path from 'path';
import { Worker } from 'worker_threads';
import { Meteor } from 'meteor/meteor';
const WORKER_PATH = path.resolve(process.cwd(), 'assets/app/workers/unionWorker.js');
// The app's own npm dependencies (installed by `meteor build`) live in
// programs/server/npm/node_modules — a directory that's a SIBLING of
// programs/server/assets, not an ancestor of it. Node's require() only walks
// up parent directories looking for node_modules, so a plain
// require('@turf/circle') from the worker script (a raw asset, not part of
// Meteor's own module system that knows about this layout) can never find it
// and fails with "Cannot find module '@turf/circle'". Resolving the absolute
// path here, where Meteor's own module system already knows how to find it,
// and handing it to the worker sidesteps that entirely.
const NPM_MODULES = path.resolve(process.cwd(), 'npm/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')
};
// A worker that never answers used to hang the promise forever, and with it the
// queue in subsUnionLogic: `busy` stays true and no union is ever computed again
// until the process restarts — silently, because nothing throws. Measured on
// this hardware (bench/union-bench.js), a full recreate of 10.000 subscriptions
// takes ~40 s, so ten minutes is a very wide margin over anything healthy while
// still being a bound.
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
export const timeoutMs = () => (
(Meteor.settings.private && Meteor.settings.private.unionWorkerTimeoutMs) || DEFAULT_TIMEOUT_MS
);
class UnionWorkerTimeout extends Error {
constructor(ms) {
super(`union worker did not answer in ${ms} ms`);
this.name = 'UnionWorkerTimeout';
this.isTimeout = true;
}
}
// Cota de heap del worker. No es para que quepa: es para que un caso patológico
// (miles de suscripciones que no se solapan) muera con ERR_WORKER_OUT_OF_MEMORY
// —que esta capa convierte en un rechazo y deja la unión anterior intacta— en
// vez de que el kernel se lleve por delante el proceso entero de Meteor. Medido:
// 10.000 suscripciones con 64 vértices por círculo son ~421 MB de RSS de todo el
// proceso, así que 1 GB de heap para el worker es holgado.
const DEFAULT_MAX_HEAP_MB = 1024;
const settingsOf = name => (Meteor.settings.private || {})[name];
const runOnce = (subs, baseUnion, ms) => new Promise((resolve, reject) => {
const worker = new Worker(WORKER_PATH, {
workerData: { subs, baseUnion, turfPaths, steps: settingsOf('unionCircleSteps') },
resourceLimits: { maxOldGenerationSizeMb: settingsOf('unionWorkerMaxHeapMb') || DEFAULT_MAX_HEAP_MB }
});
let settled = false;
const finish = (fn, arg) => {
if (settled) return;
settled = true;
clearTimeout(timer);
// terminate() is what actually stops the thread burning a core; without it a
// timed-out union would keep computing forever next to its replacement.
worker.terminate();
fn(arg);
};
const timer = setTimeout(() => finish(reject, new UnionWorkerTimeout(ms)), ms);
worker.once('message', (msg) => {
if (msg.error) finish(reject, new Error(msg.error));
else finish(resolve, msg.union);
});
worker.once('error', err => finish(reject, err));
});
/**
* Circle-union of already-validated, already-decorated subscriptions, run in a
* worker thread so the main event loop (DDP/HTTP) stays free regardless of
* how long any single turf.union call takes. subs must be an array of plain
* { location: { lat, lon }, distance } objects. If baseUnion is given, subs
* are merged into it instead of a union computed from scratch.
*
* Fails after a timeout, and retries once on a genuine error — a failure to
* spawn the thread, or a crash, is usually transient. A TIMEOUT is not retried:
* whatever made it take longer than the bound will still be there, and a second
* attempt only doubles the time the union stays stale.
*/
const calcUnionAsync = async (subs, baseUnion = null) => {
const ms = timeoutMs();
try {
return await runOnce(subs, baseUnion, ms);
} catch (e) {
if (e.isTimeout) throw e;
console.warn('union worker failed, retrying once', e.message);
return runOnce(subs, baseUnion, ms);
}
};
export default calcUnionAsync;