/* eslint-env mocha */ /* eslint-disable func-names, prefer-arrow-callback */ /* eslint-disable import/no-absolute-path */ import fs from 'fs'; import path from 'path'; import { Worker } from 'worker_threads'; import { expect } from 'chai'; 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 // in production: the worker script and the turf packages live in sibling // directories of the built bundle that a bare require() can never find // (3249362), and one attempt at fixing that doubled the programs/server prefix // so nothing resolved at all (570cb49). const subAt = (lat, lon, distance = 10) => ({ location: { lat, lon }, distance }); describe('calcUnionAsync worker plumbing', function () { this.timeout(60000); it('resolves the worker script and the turf packages from the bundle layout', function () { const worker = path.resolve(process.cwd(), 'assets/app/workers/unionWorker.js'); expect(fs.existsSync(worker), `worker script missing at ${worker}`).to.equal(true); ['circle', 'union', 'truncate'].forEach((mod) => { const turf = path.resolve(process.cwd(), 'npm/node_modules/@turf', mod); expect(fs.existsSync(turf), `@turf/${mod} missing at ${turf}`).to.equal(true); // The doubled-prefix regression: this path must NOT be under programs/server // twice, which is what silently produced "Cannot find module" at runtime. expect(turf).to.not.match(/programs\/server\/programs\/server/); }); }); it('computes a union of one circle', async function () { const union = await calcUnionAsync([subAt(43.5, -5.9)]); expect(union).to.be.an('object'); expect(union.geometry.type).to.equal('Polygon'); expect(union.geometry.coordinates[0].length).to.be.above(3); }); it('returns null when there is nothing to unite', async function () { expect(await calcUnionAsync([])).to.equal(null); }); it('merges two overlapping circles into a single polygon', async function () { const union = await calcUnionAsync([subAt(43.5, -5.9, 20), subAt(43.55, -5.9, 20)]); expect(union.geometry.type).to.equal('Polygon'); }); it('keeps two far-apart circles as a MultiPolygon', async function () { const union = await calcUnionAsync([subAt(43.5, -5.9, 10), subAt(28.1, -15.4, 10)]); expect(union.geometry.type).to.equal('MultiPolygon'); expect(union.geometry.coordinates).to.have.lengthOf(2); }); it('merges into a given base union instead of starting over', async function () { const base = await calcUnionAsync([subAt(43.5, -5.9, 10)]); const merged = await calcUnionAsync([subAt(28.1, -15.4, 10)], base); expect(merged.geometry.type).to.equal('MultiPolygon'); expect(merged.geometry.coordinates).to.have.lengthOf(2); }); it('truncates coordinates to 6 decimals so the stored union stays small', async function () { const union = await calcUnionAsync([subAt(43.5, -5.9)]); union.geometry.coordinates[0].forEach(([lon, lat]) => { expect(String(lon).split('.')[1] || '').to.have.length.at.most(6); expect(String(lat).split('.')[1] || '').to.have.length.at.most(6); }); }); // Regression 50ca9cc's other half: the caller must be able to tell a failure // apart from an empty union, so a worker error has to reject, never resolve. it('rejects when the worker throws instead of resolving to null', async function () { let rejected = false; try { // A subscription with no location makes tcircle throw inside the worker. await calcUnionAsync([{ distance: 10 }]); } catch (e) { rejected = true; expect(e).to.be.an('error'); } expect(rejected, 'calcUnionAsync resolved instead of rejecting').to.equal(true); }); it('rejects when the worker script itself cannot be loaded', async function () { let rejected = false; try { await new Promise((resolve, reject) => { const worker = new Worker(path.resolve(process.cwd(), 'assets/app/workers/doesNotExist.js')); worker.once('message', resolve); worker.once('error', reject); }); } catch (e) { rejected = true; } 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. it('leaves the event loop free while it computes', async function () { const subs = []; for (let i = 0; i < 120; i += 1) subs.push(subAt(40 + (i * 0.02), -4 + (i * 0.02), 25)); let ticks = 0; const timer = setInterval(() => { ticks += 1; }, 10); const started = Date.now(); try { await calcUnionAsync(subs); } finally { clearInterval(timer); } const elapsed = Date.now() - started; // If the union ran on the main thread the interval would barely fire at all. // Half the theoretical ticks is a wide margin against a loaded CI runner. expect(ticks, `only ${ticks} timer ticks in ${elapsed}ms`).to.be.at.least(Math.floor(elapsed / 10 / 2)); }); });