todos-contra-el-fuego-web/imports/startup/server/subsUnion.js
vjrj b4e5511cd0 fix(subsUnion): stop blocking DDP on subscription union recompute
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.
2026-07-30 06:36:37 +02:00

152 lines
5.2 KiB
JavaScript

/* eslint-disable import/no-absolute-path */
import { Meteor } from 'meteor/meteor';
import Subscriptions from '/imports/api/Subscriptions/Subscriptions';
import SiteSettings from '/imports/api/SiteSettings/SiteSettings';
import Perlin from 'loms.perlin';
import './leaflet-workaround';
import L from 'leaflet';
import calcUnionAsync from '/imports/startup/server/calcUnionAsync';
import { isMailServerMaster } from '/imports/startup/server/email';
// sudo apt-get install libcairo2-dev libjpeg-dev libgif-dev
Meteor.startup(async () => {
if (!isMailServerMaster) {
console.log('We only process subsUnion in master');
return;
}
const debug = true; // Meteor.isDevelopment;
Perlin.seed(Math.random());
const addNoisy = (osub) => {
const sub = osub;
let lat = Math.round(sub.location.lat * 10) / 10;
let lon = Math.round(sub.location.lon * 10) / 10;
const noiseBase = Perlin.perlin2(lat, lon);
const noise = Math.abs(noiseBase / 3);
lat += noise;
lon += noise;
sub.location.lat = lat;
sub.location.lon = lon;
sub.distance += noiseBase;
return sub;
};
const noNoisy = sub => sub;
const process = async (isPublic) => {
const subscribers = await Subscriptions.find().fetchAsync();
const union = await calcUnionAsync(subscribers, isPublic ? addNoisy : noNoisy);
const bounds = union === null ? null : L.geoJSON(union).getBounds();
const publicl = isPublic ? 'public' : 'private';
const Publicl = publicl.replace(/\b\w/g, l => l.toUpperCase());
if (typeof union === 'object') {
const unionSet = {
$set: {
name: `subs-${publicl}-union`,
value: JSON.stringify(union),
isPublic,
description: `${Publicl} subscriptions union`,
type: 'string'
}
};
const boundsSet = {
$set: {
name: `subs-${publicl}-union-bounds`,
value: JSON.stringify(bounds),
isPublic,
description: `${Publicl} subscriptions union bounds`,
type: 'string'
}
};
const sizeSet = {
$set: {
name: 'subs-union-count',
value: subscribers.length,
isPublic: false,
description: 'Subscriptions count',
type: 'number'
}
};
// FIXME, take care of object size:
// https://stackoverflow.com/questions/10827812/what-is-the-length-maximum-for-a-string-data-type-in-mongodb-used-with-ruby
await SiteSettings.upsertAsync({ name: `subs-${publicl}-union` }, unionSet, { multi: false });
await SiteSettings.upsertAsync({ name: `subs-${publicl}-union-bounds` }, boundsSet, { multi: false });
await SiteSettings.upsertAsync({ name: 'subs-union-count' }, sizeSet, { multi: false });
if (debug) console.log(`${Publicl} subscription union calculated`);
} else {
console.log('Subscription union failed!');
}
};
const recreate = async () => { await process(true); await process(false); };
let recomputeRunning = false;
let recomputePending = false;
// Runs recreate() serially: a recompute already in flight is never overlapped by
// another one, bursts of subscription changes just mark it pending and get a single
// extra pass once the current one finishes.
const runRecreate = async () => {
recomputeRunning = true;
try {
await recreate();
} catch (e) {
console.error('subsUnion recompute failed', e);
} finally {
recomputeRunning = false;
if (recomputePending) {
recomputePending = false;
runRecreate();
}
}
};
// Fire-and-forget on purpose: recreate() can take a while over thousands of
// subscriptions, and callers (Meteor.startup, observeAsync callbacks) must not
// block on it.
const scheduleRecreate = () => {
if (recomputeRunning) {
recomputePending = true;
return;
}
runRecreate();
};
// At startup, we check if it's necessary to calc subscriptions union again
const currentUnion = await SiteSettings.findOneAsync({ name: 'subs-public-union' });
const lastSubs = await Subscriptions.findOneAsync({}, { sort: { updatedAt: -1 } });
const countUnionSubs = await SiteSettings.findOneAsync({ name: 'subs-union-count' });
const countSubs = await Subscriptions.find({}).countAsync();
if (currentUnion && lastSubs) {
const lastUnionUpdated = currentUnion.updatedAt;
const lastSubsUpdated = lastSubs.updatedAt;
if (lastUnionUpdated > lastSubsUpdated || !countUnionSubs || countSubs !== countUnionSubs.value) {
console.log('Subs union outdated');
scheduleRecreate();
} else {
console.log('Subs union up-to-date');
}
}
await Subscriptions.find({ createdAt: { $gt: new Date() } }).observeAsync({
added: function newSubAdded() { // doc) {
if (debug) console.log('Subs added so recreate union');
scheduleRecreate();
}
});
await Subscriptions.find().observeAsync({
changed: function subsChanged() { // updatedDoc, oldDoc) {
if (debug) console.log('Subs changed so recreate union');
scheduleRecreate();
},
removed: function subsRemoved() { // oldDoc) {
if (debug) console.log('Subs removed so recreate union');
scheduleRecreate();
}
});
});