Anti-Doppelversand: einen Emailing-Worker idempotent machen
Ein Emailing-Worker, der mitten im Versand abstürzt, ist normal. Nicht normal ist, dass ein Abonnent dieselbe Kampagne beim Neustart zweimal erhält. Hier sind die vier Mechanismen, die wir bei Zylior in der Prod stapeln, damit ein Empfänger eine Nachricht genau einmal bekommt, selbst wenn der Prozess zwischen zwei Batches stirbt.
Das Problem: „mindestens einmal" ist nicht „genau einmal"
Ein Worker tickt alle 60 s, reserviert einen Batch von 20 Empfängern, ruft den Versanddienst auf, markiert die Zeilen als gesendet. Stirbt der Prozess nach dem Netzwerkaufruf, aber vor dem `commit`, wird der Batch beim Reboot wieder zu „zu senden". Ohne Schutzmechanismus erhalten diese 20 Personen die E-Mail zweimal. Umgekehrt: Markierst du `sent` vor dem Aufruf und der Aufruf schlägt fehl, bekommen diese 20 gar nichts. Du kannst nicht beide Garantien an derselben Stelle mit einem einzigen Write haben — du musst sie auf mehrere Schichten verteilen.
1. Eine deterministische job_id pro Empfänger
Der erste Fehler ist, bei jedem Versuch eine zufällige Job-ID (`uuid()`) zu erzeugen. Beim erneuten Abspielen ist es eine neue ID → der Versanddienst sieht sie als neue Nachricht → Doppelversand. Der Schlüssel: die ID aus dem Paar (Kampagne, Abonnent) ableiten, nie aus einem Zufallswert oder einem Timestamp. Gleiche Eingabe, gleiche ID, bis ins Unendliche. Auf der Seite des Versanddiensts (BullMQ, SQS-Dedup oder deine selbstgebaute Queue) dient diese `job_id` als Deduplizierungsschlüssel: `campaign:cmp_42:sub_7` zweimal zu pushen, behält nur einen. Das erneute Abspielen wird per Konstruktion harmlos.
-- job_id = identité stable du couple (campagne, destinataire)
insert into growth_sends(campaign_id, account_id, subscriber_id, email, job_id)
select distinct on (s.email_lower)
$1, $2, s.id, s.email,
'campaign:' || $1 || ':' || s.id -- déterministe, pas de uuid()
from growth_subscribers s
where s.account_id = $2 and s.status = 'confirmed'
order by s.email_lower, s.created_at
on conflict (campaign_id, subscriber_id) do nothing;
2. Atomare Statusübergänge (compare-and-set)
Zwei nebenläufige Ticks (oder zwei Replicas des Workers) können dieselbe Kampagne im selben Moment als `approved` sehen. Wenn jeder sie auf `sending` setzt und den Versand startet, verdoppelst du alles. Compare-and-set löst das: Nur ein einziges `UPDATE … WHERE status='approved'` gewinnt, das andere sieht `rowCount = 0` und stoppt. Postgres serialisiert den Write auf der Zeile — kein Lock auf Anwendungsebene nötig. Mach niemals ein `SELECT status` und dann ein separates `UPDATE`: Dazwischen schlüpft ein anderer Worker durch. Die Zustandsbedingung muss im `WHERE` desselben `UPDATE` leben — Lesen und Schreiben in einer einzigen atomaren Operation.
-- CAS : un seul worker fait basculer la campagne. Les autres voient rowCount=0.
update growth_campaigns
set status='sending', updated_at=now()
where status='approved' -- garde-fou : l'état attendu
and scheduled_for is not null
and scheduled_for <= now()
returning id;
3. Eindeutigkeit (Kampagne, Abonnent): das Sicherheitsnetz in der DB
Die ersten beiden Schichten können einem Bug immer noch nachgeben. Die Unique-Constraint dagegen lügt nie: eine `growth_sends`-Zeile pro Paar `(campaign_id, subscriber_id)`, Punkt. Du baust die Empfängerliste mit einem `INSERT … ON CONFLICT DO NOTHING` — den Aufbau nach einem Crash erneut auszuführen, erzeugt kein Duplikat, und der Worker macht genau da weiter, wo er aufgehört hat, indem er die noch `queued`-Zeilen liest.
- `UNIQUE(campaign_id, subscriber_id)`: unmöglich, zwei Versandzeilen für denselben Abonnenten in einer Kampagne zu haben, egal was vorgelagert passiert.
- `distinct on (email_lower)`: dieselbe E-Mail in mehreren Segmenten = eine einzige Nachricht (Dedup der Adresse, nicht nur des Abonnenten).
- Beim Claim erneut geprüfte Suppression: ein Opt-out, das während des Versands eintrifft, nimmt den Empfänger vor dem Netzwerkaufruf aus dem Batch — die Suppression-Tabelle pro Tenant behält Vorrang.
4. Batch-Lock: FOR UPDATE SKIP LOCKED
Um zu parallelisieren, ohne sich gegenseitig in die Quere zu kommen, reserviert jeder Worker einen Batch `queued`-Zeilen, indem er sie sperrt. `FOR UPDATE` setzt die Sperre; `SKIP LOCKED` sagt „ignoriere die bereits von einem anderen genommenen Zeilen und geh zu den nächsten über". Kein Warten, kein Deadlock, keine zwei Worker auf demselben Empfänger. Das ist Postgres' nativer Queue-Pattern.
begin;
select id, subscriber_id, email, job_id
from growth_sends
where campaign_id = $1 and status='queued'
order by id
limit 20
for update skip locked; -- chaque worker prend un lot DISJOINT
-- on marque 'sent' AVANT l'appel réseau, dans la même transaction :
update growth_sends set status='sent', updated_at=now()
where id = any($lot);
commit;
-- puis seulement : sendBulk(lot). Crash ici => job_id rend le retry sûr.
Im großen Maßstab verwandeln diese vier Schichten einen fragilen Worker in einen wiederaufnehmbaren Executor: Du kannst ihn mit `kill -9` mitten in einer Kampagne mit 50 000 Empfängern abschießen und neu starten — er nimmt die `queued`-Zeilen wieder auf, ignoriert die bereits `sent`, respektiert die zwischenzeitlich eingetroffenen Opt-outs, und niemand bekommt sie zweimal. Keine Schicht reicht allein: Der CAS schützt die Transition, die Unique-Constraint schützt den Aufbau, das `SKIP LOCKED` schützt die Nebenläufigkeit, und die `job_id` schützt das erneute Abspielen am Ende der Kette. Fang mit der Unique-Constraint in der DB an — sie ist die günstigste einzurichten und die, die dich rettet, wenn die anderen drei einen Bug haben.
Der Newsletter
Mit der Anmeldung stimmst du dem Erhalt des Zylior-Newsletters zu. 1-Klick-Abmeldung in jeder E-Mail.