zylior
← Blog

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.

Grundregel: Ein Crash darf eine bereits erledigte Arbeit niemals erneut lesen, als wäre sie neu. Idempotenz heißt, das erneute Abspielen einer Operation von ihrer einmaligen Ausführung ununterscheidbar zu machen. Man erreicht sie über vier Punkte: deterministische Identität, atomare Transition, Unique in der DB, Batch-Lock.

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.

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.
Der subtile Punkt: Du markierst `sent` vor dem Netzwerkaufruf, innerhalb der Transaktion. Kontraintuitiv, aber es ist die deterministische `job_id`, die diese Wahl korrekt macht — ein Crash zwischen dem `commit` und dem Versand erzeugt nur ein erneutes Pushen derselben `job_id`, das die Queue dedupliziert. `sent` dagegen nach dem Versand zu markieren, würde dich beim kleinsten Crash nach der Zustellung einen ganzen Batch erneut versenden lassen. Schlägt das `sendBulk` komplett fehl, setzt du die Zeilen zurück auf `queued` und pausierst die Kampagne mit einem Grund — nie ein stiller Verlust.

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.