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.
212 lines
8.2 KiB
JavaScript
212 lines
8.2 KiB
JavaScript
// Keeps the stored subscriptions-union GeoJSON below a size that Mongo (and the
|
|
// DDP connection that ships it to every client) can actually take.
|
|
//
|
|
// The union of every subscription circle is stored as a JSON string inside a
|
|
// single `siteSettings` document. A BSON document is capped at 16 MiB, so past
|
|
// some number of subscriptions the upsert would simply start failing and the map
|
|
// would silently freeze at the last union that fit — which is exactly the kind of
|
|
// scale bug nobody notices until production. On top of the hard Mongo limit, that
|
|
// same string is published to browsers, so multi-megabyte unions are already a
|
|
// problem well before 16 MiB.
|
|
//
|
|
// The chosen fix is progressive degradation instead of failure: the geometry is
|
|
// coarsened, in visually-cheapest-first order, until it fits. The zone union is a
|
|
// decorative overlay ("roughly where people are watching"), never a source of
|
|
// truth for alerts, so losing precision is acceptable; losing the whole overlay is
|
|
// not. Every degradation is reported back so the caller can store it alongside the
|
|
// union and ops can see it happening.
|
|
//
|
|
// The ladder, in order:
|
|
// 1. coordinate precision 6 -> 5 -> 4 -> 3 decimals (~0.1 m -> ~100 m), dropping
|
|
// points that collapse onto their neighbour after rounding.
|
|
// 2. point decimation, keeping every k-th vertex of each ring (circles are
|
|
// generated with 144 steps, so there is a lot of slack here).
|
|
// 3. dropping holes (inner rings) — visually the least missed.
|
|
// 4. dropping the smallest polygons of the MultiPolygon until it fits.
|
|
// If even a single polygon does not fit, null is returned rather than storing
|
|
// something Mongo will reject.
|
|
|
|
// 16 MiB is Mongo's hard limit for the whole document; this cap is deliberately
|
|
// well below it because the same value travels over DDP to every connected
|
|
// browser. Raise it only together with a plan for the client side.
|
|
export const MAX_UNION_BYTES = 8 * 1024 * 1024;
|
|
|
|
const MIN_RING_POINTS = 12;
|
|
const DECIMATION_FACTORS = [2, 3, 4, 6, 8];
|
|
const PRECISIONS = [5, 4, 3];
|
|
|
|
export const byteLength = json => Buffer.byteLength(json, 'utf8');
|
|
|
|
const geometryOf = union => (union && union.type === 'Feature' ? union.geometry : union);
|
|
|
|
// Both Polygon and MultiPolygon, seen as a list of polygons (a polygon being a
|
|
// list of rings, the first one the outer one).
|
|
const polygonsOf = (geometry) => {
|
|
if (!geometry || !geometry.coordinates) return [];
|
|
return geometry.type === 'MultiPolygon' ? geometry.coordinates : [geometry.coordinates];
|
|
};
|
|
|
|
const withPolygons = (union, polygons) => {
|
|
const geometry = geometryOf(union);
|
|
const coordinates = geometry.type === 'MultiPolygon' ? polygons : polygons[0];
|
|
const newGeometry = { ...geometry, coordinates };
|
|
return union.type === 'Feature' ? { ...union, geometry: newGeometry } : newGeometry;
|
|
};
|
|
|
|
// A ring is only a ring if it closes and has at least three distinct corners.
|
|
const closeRing = (ring) => {
|
|
if (ring.length < 3) return null;
|
|
const [firstLon, firstLat] = ring[0];
|
|
const [lastLon, lastLat] = ring[ring.length - 1];
|
|
const closed = firstLon === lastLon && firstLat === lastLat ? ring : [...ring, [firstLon, firstLat]];
|
|
return closed.length >= 4 ? closed : null;
|
|
};
|
|
|
|
const roundRing = (ring, factor) => {
|
|
const out = [];
|
|
for (let i = 0; i < ring.length; i += 1) {
|
|
const lon = Math.round(ring[i][0] * factor) / factor;
|
|
const lat = Math.round(ring[i][1] * factor) / factor;
|
|
const prev = out[out.length - 1];
|
|
if (!prev || prev[0] !== lon || prev[1] !== lat) out.push([lon, lat]);
|
|
}
|
|
return closeRing(out);
|
|
};
|
|
|
|
const decimateRing = (ring, keepEvery) => {
|
|
if (ring.length <= MIN_RING_POINTS) return ring;
|
|
const out = [];
|
|
for (let i = 0; i < ring.length - 1; i += keepEvery) out.push(ring[i]);
|
|
return closeRing(out) || ring;
|
|
};
|
|
|
|
// Rings that survive a transformation keep the polygon alive; a polygon whose
|
|
// outer ring collapsed is dropped entirely (its holes are meaningless then).
|
|
const mapPolygons = (polygons, mapRing) => polygons
|
|
.map(rings => rings.map(mapRing).filter(Boolean))
|
|
.filter(rings => rings.length > 0);
|
|
|
|
const ringBboxArea = (ring) => {
|
|
let minLon = Infinity;
|
|
let minLat = Infinity;
|
|
let maxLon = -Infinity;
|
|
let maxLat = -Infinity;
|
|
for (let i = 0; i < ring.length; i += 1) {
|
|
if (ring[i][0] < minLon) [minLon] = ring[i];
|
|
if (ring[i][0] > maxLon) [maxLon] = ring[i];
|
|
if (ring[i][1] < minLat) [, minLat] = ring[i];
|
|
if (ring[i][1] > maxLat) [, maxLat] = ring[i];
|
|
}
|
|
return (maxLon - minLon) * (maxLat - minLat);
|
|
};
|
|
|
|
/**
|
|
* Returns the largest-fidelity version of `union` whose JSON fits in `maxBytes`,
|
|
* together with that JSON (callers always need the string anyway, and
|
|
* re-stringifying a multi-megabyte object on the main thread is not free).
|
|
*
|
|
* @returns {{ union: object|null, json: string, bytes: number,
|
|
* degraded: null | { originalBytes: number, steps: string[] } }}
|
|
*/
|
|
const limitUnionSize = (union, maxBytes = MAX_UNION_BYTES) => {
|
|
let json = JSON.stringify(union === undefined ? null : union);
|
|
let bytes = byteLength(json);
|
|
if (union === null || union === undefined || bytes <= maxBytes) {
|
|
return {
|
|
union: union === undefined ? null : union, json, bytes, degraded: null
|
|
};
|
|
}
|
|
|
|
const originalBytes = bytes;
|
|
const steps = [];
|
|
let current = union;
|
|
|
|
const attempt = (step, transform) => {
|
|
const next = transform();
|
|
if (!next) return false;
|
|
current = next;
|
|
steps.push(step);
|
|
json = JSON.stringify(current);
|
|
bytes = byteLength(json);
|
|
return bytes <= maxBytes;
|
|
};
|
|
|
|
const done = () => ({
|
|
union: current, json, bytes, degraded: { originalBytes, steps }
|
|
});
|
|
|
|
for (let i = 0; i < PRECISIONS.length; i += 1) {
|
|
const precision = PRECISIONS[i];
|
|
const factor = 10 ** precision;
|
|
const fits = attempt(`precision:${precision}`, () => {
|
|
const polygons = mapPolygons(polygonsOf(geometryOf(current)), ring => roundRing(ring, factor));
|
|
return polygons.length > 0 ? withPolygons(current, polygons) : null;
|
|
});
|
|
if (fits) return done();
|
|
}
|
|
|
|
for (let i = 0; i < DECIMATION_FACTORS.length; i += 1) {
|
|
const keepEvery = DECIMATION_FACTORS[i];
|
|
const fits = attempt(`decimate:${keepEvery}`, () => {
|
|
const polygons = mapPolygons(polygonsOf(geometryOf(union)), ring => decimateRing(
|
|
roundRing(ring, 10 ** PRECISIONS[PRECISIONS.length - 1]) || ring,
|
|
keepEvery
|
|
));
|
|
return polygons.length > 0 ? withPolygons(current, polygons) : null;
|
|
});
|
|
if (fits) return done();
|
|
}
|
|
|
|
const fitsWithoutHoles = attempt('holes', () => {
|
|
const polygons = polygonsOf(geometryOf(current)).map(rings => [rings[0]]);
|
|
return polygons.length > 0 ? withPolygons(current, polygons) : null;
|
|
});
|
|
if (fitsWithoutHoles) return done();
|
|
|
|
// Last resort: drop the smallest polygons first, so what remains is the part of
|
|
// the map a user is most likely to be looking at. Binary search for how many
|
|
// survive — trying one fewer at a time means re-serializing a multi-megabyte
|
|
// object hundreds of times, which is precisely the kind of main-thread stall
|
|
// this whole area is trying to get rid of.
|
|
const ordered = polygonsOf(geometryOf(current))
|
|
.map(rings => ({ rings, area: ringBboxArea(rings[0]) }))
|
|
.sort((a, b) => b.area - a.area)
|
|
.map(p => p.rings);
|
|
|
|
const fitsWith = (keep) => {
|
|
const candidate = withPolygons(current, ordered.slice(0, keep));
|
|
const candidateJson = JSON.stringify(candidate);
|
|
return byteLength(candidateJson) <= maxBytes ? { candidate, candidateJson } : null;
|
|
};
|
|
|
|
let low = 1;
|
|
let high = ordered.length - 1;
|
|
let best = null;
|
|
while (low <= high) {
|
|
const mid = Math.floor((low + high) / 2);
|
|
const fit = fitsWith(mid);
|
|
if (fit) {
|
|
best = { keep: mid, ...fit };
|
|
low = mid + 1;
|
|
} else {
|
|
high = mid - 1;
|
|
}
|
|
}
|
|
if (best) {
|
|
current = best.candidate;
|
|
json = best.candidateJson;
|
|
bytes = byteLength(json);
|
|
steps.push(`polygons:${best.keep}`);
|
|
return done();
|
|
}
|
|
|
|
// A single polygon that still does not fit means something is very wrong
|
|
// upstream; storing nothing beats an upsert that Mongo rejects on every run.
|
|
steps.push('dropped');
|
|
current = null;
|
|
json = 'null';
|
|
bytes = byteLength(json);
|
|
return done();
|
|
};
|
|
|
|
export default limitUnionSize;
|