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);
});