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.
Everything the union does lived inside a Meteor.startup callback with no exports:
the queue, the incremental fast path, the worker-failure handling. None of it was
reachable from a test, which is how three separate incidents shipped — a failed
worker stored as an empty union (50ca9cc), turf deps unresolvable from the worker
(3249362/570cb49) and a recompute that blocked the event loop and froze DDP
(b4e5511/fbb746f). subsUnionLogic.js now takes its collaborators as arguments and
subsUnion.js is only the wiring.
The FIXME at the old subsUnion.js:82 goes with it, because it lives inside the
extracted storeUnion: a union over 16 MiB is simply rejected by Mongo, so the map
would freeze at the last union that happened to fit, silently. unionSizeGuard.js
degrades the geometry instead — coordinate precision, then vertex decimation,
then holes, then the smallest polygons — until it fits, and reports what it did
so the setting document records it. The cap is 8 MiB rather than 16: the same
string is pushed to every browser over DDP.
addNoisy no longer mutates the document it is given, so the public pass cannot
leak its fuzzing into the private one.
storeUnion checked `typeof union === 'object'` as its "did this work"
guard, but typeof null is also 'object' — so a worker call that failed
(caught upstream, leaving union as its initial null) looked identical
to a legitimate "zero valid subscriptions" result, and got silently
stored as the string "null" instead of being treated as a failure.
This is exactly what corrupted subs-public-union/subs-private-union on
staging just now, while chasing the missing-turf-deps bug.
storeUnion is now only called once calcUnionAsync has actually
resolved; process() and incrementalAdd() catch failures themselves and
skip storing anything, leaving the previous (still valid) union alone.
A full recreate() over 7k+ subscriptions takes ~20 minutes even in a
worker thread — fine for not blocking the site, but means a new zone
takes ages to show up on the map. Since union only grows when adding a
circle, a new subscription can just be merged into the union already
stored (one turf.union call) instead of rebuilding the whole chain.
changed/removed still trigger a full recreate() (union can't be
"subtracted" from safely), and incrementalAdd falls back to a full
recreate whenever the stored count isn't exactly one behind what's
expected, rather than risk merging into stale state. Both paths now
share one serialized queue (FIFO for adds, coalesced for recomputes)
so they never race on the same stored union.
The previous fix (yielding to the event loop between turf.union calls)
was not enough: once the merged polygon gets complex with thousands of
subscriptions, a single turf.union call can itself take seconds, and
yielding between iterations doesn't help when one iteration alone
blocks that long. Confirmed in staging: the site was still fully
unresponsive (Cloudflare 524, curl hanging 2+ minutes, healthcheck
failing) while a recompute ran.
Validation/decoration (addNoisy/noNoisy, cheap) stays on the main
thread; the actual circle+union chain now runs in a worker_thread
(private/workers/unionWorker.js, plain CommonJS so meteor build copies
it verbatim instead of compiling it) so the main event loop serving
DDP/HTTP is never blocked by it, regardless of how slow any single
turf call gets.
Every subscription add/change/remove recomputed the full geo union
over all 7k+ subscriptions with a synchronous turf.union chain,
freezing the single Node event loop (and thus DDP/HTTP) for minutes.
The same recompute also runs at startup ("Subs union outdated"),
so every restart froze the site too.
calcUnionAsync yields to the event loop periodically during the
union chain, and subsUnion.js now fires recomputes without blocking
Meteor.startup or the observer callbacks, serializing overlapping
triggers instead of stacking them.
Retira import './segfaults' (workaround de crash obsoleto) y añade
leaflet-workaround.js (shims global window/document/navigator para
render de Leaflet en servidor). Ajustes menores en publications de
FalsePositives y subsUnion.