perf(subsUnion): unión en árbol, menos vértices, timeout y cota de memoria
All checks were successful
build-image / test (push) Successful in 2m49s
build-image / build (push) Successful in 13m4s

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.
This commit is contained in:
vjrj 2026-08-01 22:32:37 +02:00
parent 072e76aaed
commit 56f53e2c23
7 changed files with 479 additions and 39 deletions

156
bench/ddp-load.js Normal file
View file

@ -0,0 +1,156 @@
#!/usr/bin/env node
/*
* Carga de clientes DDP + REST (fase 12).
*
* Abre N conexiones DDP como las del navegador (WebSocket crudo contra
* /websocket, sin librería), cada una suscrita a lo que suscribe el mapa
* `activefiresmyloc` y `activefiresunionmyloc` y, con todas conectadas, mide
* la latencia de ida y vuelta de un método. Es la pregunta de la fase: con
* mucha gente mirando el mapa, ¿sigue respondiendo la web?
*
* En paralelo golpea los endpoints REST que usa la app Flutter.
*
* node bench/ddp-load.js --clients 50
* node bench/ddp-load.js --clients 200 --seconds 30
* BASE_URL=http://localhost:3100 node bench/ddp-load.js --clients 500
*
* Contra un servidor de desarrollo o el compose local. NUNCA contra staging o
* producción: son cientos de conexiones y de llamadas.
*/
const BASE = (process.env.BASE_URL || 'http://localhost:3100').replace(/\/$/, '');
if (/testfuegos|fuegos\.comunes|fires\.comunes/.test(BASE)) {
console.error(`Me niego a lanzar carga contra ${BASE}.`);
process.exit(1);
}
const arg = (name, def) => {
const i = process.argv.indexOf(`--${name}`);
return i === -1 ? def : Number(process.argv[i + 1]);
};
const CLIENTS = arg('clients', 50);
const SECONDS = arg('seconds', 20);
const WS_URL = `${BASE.replace(/^http/, 'ws')}/websocket`;
// El mismo recuadro que pide el mapa al abrirse sobre la península.
const BOX = [3.3, 43.7, -9.2, 36.0];
const percentile = (values, p) => {
if (values.length === 0) return NaN;
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))];
};
const openClient = (id) => new Promise((resolve, reject) => {
const ws = new WebSocket(WS_URL);
const state = {
id, ws, ready: 0, subs: 0, errors: 0, latencies: [], pending: new Map()
};
const timer = setTimeout(() => reject(new Error(`cliente ${id}: sin conectar en 30 s`)), 30000);
ws.addEventListener('open', () => {
ws.send(JSON.stringify({ msg: 'connect', version: '1', support: ['1'] }));
});
ws.addEventListener('message', (event) => {
// Meteor manda varios mensajes DDP en frames separados, uno por frame.
let m;
try { m = JSON.parse(event.data); } catch (e) { return; }
if (m.msg === 'connected') {
state.connectedAt = Date.now();
ws.send(JSON.stringify({
msg: 'sub', id: `s${id}a`, name: 'activefiresmyloc', params: [...BOX, false]
}));
ws.send(JSON.stringify({
msg: 'sub', id: `s${id}b`, name: 'activefiresunionmyloc', params: [...BOX, false]
}));
clearTimeout(timer);
resolve(state);
} else if (m.msg === 'ready') {
state.subs += (m.subs || []).length;
if (!state.ready) state.ready = Date.now() - state.connectedAt;
} else if (m.msg === 'nosub') {
state.errors += 1;
} else if (m.msg === 'result') {
const started = state.pending.get(m.id);
if (started) {
state.pending.delete(m.id);
state.latencies.push(Date.now() - started);
if (m.error) state.errors += 1;
}
} else if (m.msg === 'ping') {
ws.send(JSON.stringify({ msg: 'pong', id: m.id }));
}
});
ws.addEventListener('error', () => {
state.errors += 1;
clearTimeout(timer);
reject(new Error(`cliente ${id}: error de websocket`));
});
});
let callSeq = 0;
const callMethod = (state) => {
if (state.ws.readyState !== 1) return;
const id = `m${state.id}_${callSeq += 1}`;
state.pending.set(id, Date.now());
// Método barato y real: el que usa el cliente para pedir la clave de mapas.
state.ws.send(JSON.stringify({
msg: 'method', method: 'getMapKey', params: [], id
}));
};
const restRound = async () => {
const started = Date.now();
const res = await fetch(`${BASE}/api/v1/status/uptime`).catch(() => null);
return { ok: !!res && res.ok, ms: Date.now() - started };
};
const main = async () => {
console.log(`Abriendo ${CLIENTS} clientes DDP contra ${WS_URL}`);
const started = Date.now();
const results = await Promise.allSettled(
Array.from({ length: CLIENTS }, (_, i) => openClient(i))
);
const clients = results.filter(r => r.status === 'fulfilled').map(r => r.value);
const failed = results.length - clients.length;
console.log(`Conectados ${clients.length}/${CLIENTS} en ${((Date.now() - started) / 1000).toFixed(1)} s${failed ? ` (${failed} fallos)` : ''}`);
// Un momento para que se completen las suscripciones antes de medir.
await new Promise(r => setTimeout(r, 3000));
const readies = clients.map(c => c.ready).filter(Boolean);
console.log(`Suscripciones listas: p50 ${percentile(readies, 50)} ms · p95 ${percentile(readies, 95)} ms · sin ready ${clients.length - readies.length}`);
console.log(`Midiendo ${SECONDS} s de llamadas…`);
const restLatencies = [];
const methodTimer = setInterval(() => clients.forEach(callMethod), 1000);
const restTimer = setInterval(async () => {
const r = await restRound();
if (r.ok) restLatencies.push(r.ms);
}, 200);
await new Promise(r => setTimeout(r, SECONDS * 1000));
clearInterval(methodTimer);
clearInterval(restTimer);
await new Promise(r => setTimeout(r, 2000));
const latencies = clients.flatMap(c => c.latencies);
const errors = clients.reduce((a, c) => a + c.errors, 0);
const pending = clients.reduce((a, c) => a + c.pending.size, 0);
console.log('');
console.log(`Método DDP (getMapKey): ${latencies.length} respuestas · p50 ${percentile(latencies, 50)} ms · p95 ${percentile(latencies, 95)} ms · p99 ${percentile(latencies, 99)} ms · máx ${Math.max(...latencies, 0)} ms`);
console.log(`REST status/uptime : ${restLatencies.length} respuestas · p50 ${percentile(restLatencies, 50)} ms · p95 ${percentile(restLatencies, 95)} ms`);
console.log(`Errores: ${errors} · llamadas sin responder al cerrar: ${pending}`);
clients.forEach(c => c.ws.close());
process.exit(0);
};
main().catch((e) => {
console.error(e);
process.exit(1);
});

View file

@ -1,5 +1,6 @@
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');
@ -19,27 +20,80 @@ const turfPaths = {
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 = (subs, baseUnion = null) => new Promise((resolve, reject) => {
const worker = new Worker(WORKER_PATH, { workerData: { subs, baseUnion, turfPaths } });
worker.once('message', (msg) => {
worker.terminate();
if (msg.error) {
reject(new Error(msg.error));
} else {
resolve(msg.union);
}
});
worker.once('error', (err) => {
worker.terminate();
reject(err);
});
});
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;

View file

@ -9,6 +9,7 @@ import SiteSettings from '/imports/api/SiteSettings/SiteSettings';
import calcUnionAsync from '/imports/startup/server/calcUnionAsync';
import unionBounds from '/imports/startup/server/unionBounds';
import createSubsUnion from '/imports/startup/server/subsUnionLogic';
import unionTelemetry from '/imports/startup/server/unionTelemetry';
import { isMailServerMaster } from '/imports/startup/server/email';
Meteor.startup(async () => {
@ -21,7 +22,8 @@ Meteor.startup(async () => {
calcUnion: calcUnionAsync,
SiteSettings,
Subscriptions,
boundsOf: unionBounds
boundsOf: unionBounds,
telemetry: unionTelemetry
});
if (await subsUnion.isUnionOutdated()) {

View file

@ -32,6 +32,8 @@ export const isValidSub = (osub, log = console) => {
* @param {number} [deps.maxUnionBytes]
* @param {number} [deps.perlinSeed] fixed seed makes the public "noise" reproducible in tests
*/
const noTelemetry = { computed: () => {}, failed: () => {} };
const createSubsUnion = ({
calcUnion,
SiteSettings,
@ -40,7 +42,8 @@ const createSubsUnion = ({
maxUnionBytes = MAX_UNION_BYTES,
perlinSeed = Math.random(),
debug = true,
log = console
log = console,
telemetry = noTelemetry
}) => {
Perlin.seed(perlinSeed);
@ -112,18 +115,28 @@ const createSubsUnion = ({
await SiteSettings.upsertAsync({ name: `subs-${publicl}-union-bounds` }, boundsSet, { multi: false });
await SiteSettings.upsertAsync({ name: 'subs-union-count' }, sizeSet, { multi: false });
if (debug) log.log(`${Publicl} subscription union calculated`);
return { degraded };
return { degraded, bytes: Buffer.byteLength(json, 'utf8') };
};
const recomputeOne = async (isPublic) => {
const subscribers = await Subscriptions.find().fetchAsync();
const decorated = subscribers.filter(s => isValidSub(s, log)).map(decoratorFor(isPublic));
const started = Date.now();
try {
const union = await calcUnion(decorated);
await storeUnion(isPublic, union, subscribers.length);
const { degraded, bytes } = await storeUnion(isPublic, union, subscribers.length);
telemetry.computed({
kind: 'recreate',
isPublic,
subs: decorated.length,
ms: Date.now() - started,
bytes,
degraded: degraded ? degraded.steps.join(' -> ') : null
});
} catch (e) {
log.error('subsUnion worker failed, union left unchanged', e);
telemetry.failed(e, { kind: 'recreate', isPublic, subs: decorated.length, ms: Date.now() - started });
}
};
@ -172,13 +185,23 @@ const createSubsUnion = ({
const current = await SiteSettings.findOneAsync({ name: `subs-${publicl}-union` });
const baseUnion = current && current.value ? JSON.parse(current.value) : null;
const started = Date.now();
try {
// eslint-disable-next-line no-await-in-loop
const union = await calcUnion([decorated], baseUnion);
// eslint-disable-next-line no-await-in-loop
await storeUnion(isPublic, union, countSubs);
const { degraded, bytes } = await storeUnion(isPublic, union, countSubs);
telemetry.computed({
kind: 'incrementalAdd',
isPublic,
subs: countSubs,
ms: Date.now() - started,
bytes,
degraded: degraded ? degraded.steps.join(' -> ') : null
});
} catch (e) {
log.error('subsUnion incremental worker failed, falling back to a full recreate', e);
telemetry.failed(e, { kind: 'incrementalAdd', isPublic, subs: countSubs, ms: Date.now() - started });
recomputePending = true;
return;
}

View file

@ -0,0 +1,51 @@
/* eslint-disable import/no-absolute-path */
// Telemetría de la unión de suscripciones (fase 12).
//
// Hasta ahora la única señal de que la unión iba mal era que el mapa se quedaba
// viejo: los fallos del worker se registraban con console.error y ahí morían. Lo
// que se manda a GlitchTip es deliberadamente poco: un aviso cuando una unión
// tarda más de lo que el banco de carga considera sano, otro cuando hay que
// degradar la geometría por tamaño, y los fallos. Las uniones normales solo
// dejan una línea de log.
import * as Sentry from '@sentry/node';
import { Meteor } from 'meteor/meteor';
import ravenLogger from '/imports/startup/server/ravenLogger';
// Medido en bench/union-bench.js: 10.000 suscripciones repartidas por España son
// ~40 s. Pasar del minuto significa o mucha más gente, o una distribución que no
// se solapa (el caso caro), o algo que se ha torcido. En cualquiera de los tres
// casos queremos enterarnos.
const DEFAULT_SLOW_MS = 60 * 1000;
const slowMs = () => (
(Meteor.settings.private && Meteor.settings.private.unionSlowMs) || DEFAULT_SLOW_MS
);
const unionTelemetry = {
computed({ kind, isPublic, subs, ms, bytes, degraded }) {
const scope = isPublic ? 'public' : 'private';
const line = `subsUnion ${kind} ${scope}: ${subs} subs, ${Math.round(ms)} ms, ${bytes} bytes${degraded ? ` (degradada: ${degraded})` : ''}`;
console.log(line);
if (ms > slowMs()) {
Sentry.captureMessage(`subsUnion lenta: ${Math.round(ms / 1000)} s con ${subs} suscripciones`, {
level: 'warning',
extra: { kind, scope, subs, ms, bytes }
});
}
if (degraded) {
Sentry.captureMessage(`subsUnion degradada por tamaño: ${degraded}`, {
level: 'warning',
extra: { kind, scope, subs, ms, bytes, degraded }
});
}
},
failed(error, context) {
ravenLogger.log(error, context);
}
};
export default unionTelemetry;

View file

@ -32,30 +32,61 @@ 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;
// 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;
const circles = subs.map(s => tcircle(
[s.location.lon, s.location.lat],
s.distance,
{ units: 'kilometers', steps: steps || DEFAULT_STEPS }
));
if (subs.length === 0) return baseUnion || null;
let unionTemp = baseUnion || null;
let start = 0;
if (!unionTemp && circles.length > 0) {
unionTemp = ttrunc(circles[0], truncOptions);
start = 1;
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));
}
for (let i = start; i < circles.length; i += 1) {
unionTemp = ttrunc(tunion(unionTemp, circles[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 unionTemp;
return baseUnion ? ttrunc(tunion(baseUnion, level[0]), truncOptions) : level[0];
};
try {

View file

@ -6,7 +6,11 @@ import fs from 'fs';
import path from 'path';
import { Worker } from 'worker_threads';
import { expect } from 'chai';
import calcUnionAsync from '/imports/startup/server/calcUnionAsync';
import { Meteor } from 'meteor/meteor';
import tcircle from '@turf/circle';
import tunion from '@turf/union';
import ttruncate from '@turf/truncate';
import calcUnionAsync, { timeoutMs } from '/imports/startup/server/calcUnionAsync';
// These run the real worker thread against the real @turf modules. The point is
// not the geometry (turf is not ours to test) but the plumbing that broke twice
@ -99,6 +103,125 @@ describe('calcUnionAsync worker plumbing', function () {
expect(rejected).to.equal(true);
});
// The worker unites in a tree instead of in a chain (fase 12: 238 s -> 3,6 s on
// the pathological case). The union is associative and commutative, so the
// result must match what the old chain produced — computed here with the same
// turf the worker uses, in the original order.
describe('tree merge equivalence', function () {
// La referencia se calcula aquí con 144 vértices, así que el worker tiene que
// usar los mismos: con distinto número de vértices los polígonos son
// distintos por definición, y no habría nada que comparar.
let originalSteps;
beforeEach(function () {
originalSteps = Meteor.settings.private.unionCircleSteps;
Meteor.settings.private.unionCircleSteps = 144;
});
afterEach(function () {
Meteor.settings.private.unionCircleSteps = originalSteps;
});
const bboxOf = (union) => {
const g = union.geometry || union;
const polys = g.type === 'MultiPolygon' ? g.coordinates : [g.coordinates];
let minLon = Infinity; let minLat = Infinity; let maxLon = -Infinity; let maxLat = -Infinity;
polys.forEach(poly => poly.forEach(ring => ring.forEach(([lon, lat]) => {
if (lon < minLon) minLon = lon;
if (lon > maxLon) maxLon = lon;
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
})));
return [minLon, minLat, maxLon, maxLat];
};
const sequentialReference = (subs) => {
const circles = subs.map(s => tcircle(
[s.location.lon, s.location.lat], s.distance, { units: 'kilometers', steps: 144 }
));
let acc = ttruncate(circles[0], { precision: 6, coordinates: 2 });
for (let i = 1; i < circles.length; i += 1) {
acc = ttruncate(tunion(acc, circles[i]), { precision: 6, coordinates: 2 });
}
return acc;
};
it('gives the same geometry as the old chain for overlapping circles', async function () {
const subs = [];
for (let i = 0; i < 12; i += 1) subs.push(subAt(40 + (i * 0.15), -4 + (i * 0.1), 25));
const fromWorker = await calcUnionAsync(subs);
const reference = sequentialReference(subs);
expect(fromWorker.geometry.type).to.equal(reference.geometry.type);
bboxOf(fromWorker).forEach((v, i) => {
expect(v).to.be.closeTo(bboxOf(reference)[i], 1e-6);
});
});
it('gives the same geometry for circles that do not touch each other', async function () {
const subs = [subAt(43.5, -5.9, 10), subAt(40.4, -3.7, 10), subAt(28.1, -15.4, 10), subAt(39.6, 2.9, 10)];
const fromWorker = await calcUnionAsync(subs);
const reference = sequentialReference(subs);
expect(fromWorker.geometry.type).to.equal('MultiPolygon');
expect(fromWorker.geometry.coordinates).to.have.lengthOf(reference.geometry.coordinates.length);
bboxOf(fromWorker).forEach((v, i) => {
expect(v).to.be.closeTo(bboxOf(reference)[i], 1e-6);
});
});
it('merges into a base union with the same result either way', async function () {
const base = await calcUnionAsync([subAt(43.5, -5.9, 20)]);
const merged = await calcUnionAsync([subAt(43.6, -5.9, 20), subAt(43.7, -5.9, 20)], base);
const reference = sequentialReference([subAt(43.5, -5.9, 20), subAt(43.6, -5.9, 20), subAt(43.7, -5.9, 20)]);
bboxOf(merged).forEach((v, i) => {
expect(v).to.be.closeTo(bboxOf(reference)[i], 1e-4);
});
});
});
// A worker that never answers used to hang the promise forever, taking the
// whole queue with it (busy stays true and no union is ever computed again).
describe('timeout', function () {
let original;
beforeEach(function () {
original = Meteor.settings.private.unionWorkerTimeoutMs;
});
afterEach(function () {
Meteor.settings.private.unionWorkerTimeoutMs = original;
});
it('rejects instead of hanging when the worker takes too long', async function () {
// 1 ms: not even spawning the thread fits in that.
Meteor.settings.private.unionWorkerTimeoutMs = 1;
let error = null;
try {
await calcUnionAsync([subAt(43.5, -5.9)]);
} catch (e) {
error = e;
}
expect(error, 'calcUnionAsync resolved instead of timing out').to.not.equal(null);
expect(error.isTimeout).to.equal(true);
expect(error.message).to.match(/did not answer/);
});
it('uses the configured bound, and a generous default when there is none', async function () {
delete Meteor.settings.private.unionWorkerTimeoutMs;
expect(timeoutMs()).to.equal(10 * 60 * 1000);
Meteor.settings.private.unionWorkerTimeoutMs = 1234;
expect(timeoutMs()).to.equal(1234);
});
it('still resolves normally when the bound is generous', async function () {
Meteor.settings.private.unionWorkerTimeoutMs = 60000;
const union = await calcUnionAsync([subAt(43.5, -5.9)]);
expect(union.geometry.type).to.equal('Polygon');
});
});
// The whole reason the worker exists (b4e5511 / fbb746f): a union big enough to
// take real CPU time must not stop the event loop from running timers, because
// that is what froze DDP and disconnected every browser.