diff --git a/bench/ddp-load.js b/bench/ddp-load.js new file mode 100644 index 0000000..f8ad565 --- /dev/null +++ b/bench/ddp-load.js @@ -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); +}); diff --git a/imports/startup/server/calcUnionAsync.js b/imports/startup/server/calcUnionAsync.js index ecb7a9c..a24868a 100644 --- a/imports/startup/server/calcUnionAsync.js +++ b/imports/startup/server/calcUnionAsync.js @@ -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; diff --git a/imports/startup/server/subsUnion.js b/imports/startup/server/subsUnion.js index 169cd61..8a2df27 100644 --- a/imports/startup/server/subsUnion.js +++ b/imports/startup/server/subsUnion.js @@ -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()) { diff --git a/imports/startup/server/subsUnionLogic.js b/imports/startup/server/subsUnionLogic.js index 1788737..61d0661 100644 --- a/imports/startup/server/subsUnionLogic.js +++ b/imports/startup/server/subsUnionLogic.js @@ -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; } diff --git a/imports/startup/server/unionTelemetry.js b/imports/startup/server/unionTelemetry.js new file mode 100644 index 0000000..1209440 --- /dev/null +++ b/imports/startup/server/unionTelemetry.js @@ -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; diff --git a/private/workers/unionWorker.js b/private/workers/unionWorker.js index 53abe56..b900739 100644 --- a/private/workers/unionWorker.js +++ b/private/workers/unionWorker.js @@ -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 { diff --git a/test/server/calcUnionAsync.test.js b/test/server/calcUnionAsync.test.js index 9950c4b..9320c90 100644 --- a/test/server/calcUnionAsync.test.js +++ b/test/server/calcUnionAsync.test.js @@ -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.