zylior
← Blog

Anti double-send : rendre un worker d'emailing idempotent

Un worker d'emailing qui crashe au milieu d'un envoi, c'est normal. Ce qui n'est pas normal, c'est qu'un abonné reçoive deux fois la même campagne au redémarrage. Voici les quatre mécanismes qu'on empile en prod chez Zylior pour qu'un destinataire reçoive un message exactement une fois, même quand le process meurt entre deux lots.

Le problème : « au moins une fois » n'est pas « exactement une fois »

Un worker tick toutes les 60 s, claime un lot de 20 destinataires, appelle le service d'envoi, marque les lignes envoyées. Si le process meurt après l'appel réseau mais avant le `commit`, le lot redevient à envoyer au reboot. Sans garde-fou, ces 20 personnes reçoivent l'email deux fois. À l'inverse, si tu marques `sent` avant l'appel et que l'appel échoue, ces 20 ne reçoivent rien. Tu ne peux pas avoir les deux garanties au même endroit avec une seule écriture — il faut les répartir sur plusieurs couches.

Règle de base : un crash ne doit jamais relire un travail déjà fait comme s'il était neuf. L'idempotence, c'est rendre le rejeu d'une opération indistinguable de son exécution unique. On l'obtient en quatre points : identité déterministe, transition atomique, unicité en base, verrou de lot.

1. Un job_id déterministe par destinataire

La première erreur, c'est de générer un identifiant de job aléatoire (`uuid()`) à chaque tentative. Au rejeu, c'est un nouvel id → le service d'envoi le voit comme un nouveau message → double-send. La clé : dériver l'id du couple (campagne, abonné), jamais d'un random ni d'un timestamp. Même entrée, même id, à l'infini. Côté service d'envoi (BullMQ, SQS dedup, ou ta file maison), ce `job_id` sert de clé de déduplication : pousser deux fois `campaign:cmp_42:sub_7` n'en garde qu'un. Le rejeu devient inoffensif par construction.

-- 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. Transition de statut atomique (compare-and-set)

Deux ticks concurrents (ou deux replicas du worker) peuvent voir la même campagne `approved` au même instant. Si chacun la passe en `sending` et lance l'envoi, tu doubles tout. Le compare-and-set règle ça : un seul `UPDATE … WHERE status='approved'` gagne, l'autre voit `rowCount = 0` et s'arrête. Postgres sérialise l'écriture sur la ligne — pas besoin de verrou applicatif. Ne fais jamais un `SELECT status` puis un `UPDATE` séparé : entre les deux, un autre worker passe. La condition d'état doit vivre dans le `WHERE` du même `UPDATE` — lecture et écriture en une seule opération atomique.

-- 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. Unicité (campagne, abonné) : le filet de sécurité en base

Les deux premières couches peuvent toujours céder à un bug. La contrainte d'unicité, elle, ne ment jamais : une ligne `growth_sends` par couple `(campaign_id, subscriber_id)`, point. Tu construis la liste de destinataires avec un `INSERT … ON CONFLICT DO NOTHING` — relancer la construction après un crash ne crée aucun doublon, et le worker reprend exactement où il s'était arrêté en lisant les lignes encore `queued`.

4. Verrou de lot : FOR UPDATE SKIP LOCKED

Pour paralléliser sans se marcher dessus, chaque worker claime un lot de lignes `queued` en les verrouillant. `FOR UPDATE` pose le verrou ; `SKIP LOCKED` dit « ignore les lignes déjà prises par un autre et passe aux suivantes ». Pas d'attente, pas de deadlock, pas deux workers sur le même destinataire. C'est le pattern de file d'attente natif de Postgres.

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.
Le point subtil : on marque `sent` avant l'appel réseau, dans la transaction. Contre-intuitif, mais c'est le `job_id` déterministe qui rend ce choix correct — un crash entre le `commit` et l'envoi ne produit qu'un re-push du même `job_id`, que la file déduplique. Marquer `sent` après l'envoi, lui, te ferait renvoyer tout un lot au moindre crash post-livraison. Si le `sendBulk` échoue franchement, on remet les lignes en `queued` et on met la campagne en pause avec un motif — jamais de perte silencieuse.

À l'échelle, ces quatre couches transforment un worker fragile en exécuteur reprenable : tu peux le tuer `kill -9` au milieu d'une campagne de 50 000 destinataires et le relancer — il reprend les lignes `queued`, ignore celles déjà `sent`, respecte les opt-outs arrivés entre-temps, et personne ne reçoit deux fois. Aucune couche ne suffit seule : le CAS protège la transition, l'unicité protège la construction, le `SKIP LOCKED` protège la concurrence, et le `job_id` protège le rejeu en bout de chaîne. Commence par la contrainte d'unicité en base — c'est la moins chère à poser et celle qui te sauve quand les trois autres ont un bug.

La newsletter

En t’inscrivant, tu acceptes de recevoir la newsletter Zylior. Désinscription en 1 clic dans chaque email.