zylior
← Blog

Anti duplo envio: tornar idempotente um worker de emailing

Um worker de emailing que crasha a meio de um envio é normal. O que não é normal é um subscritor receber duas vezes a mesma campanha no reinício. Eis os quatro mecanismos que empilhamos em prod na Zylior para que um destinatário receba uma mensagem exatamente uma vez, mesmo quando o processo morre entre dois lotes.

O problema: «pelo menos uma vez» não é «exatamente uma vez»

Um worker faz tick a cada 60 s, reivindica um lote de 20 destinatários, chama o serviço de envio, marca as linhas como enviadas. Se o processo morrer depois da chamada de rede mas antes do `commit`, o lote volta a estado por enviar no reboot. Sem proteção, essas 20 pessoas recebem o email duas vezes. Ao contrário, se marcares `sent` antes da chamada e a chamada falhar, essas 20 não recebem nada. Não podes ter ambas as garantias no mesmo sítio com uma única escrita — tens de as repartir por várias camadas.

Regra de base: um crash nunca deve reler um trabalho já feito como se fosse novo. A idempotência consiste em tornar a repetição de uma operação indistinguível da sua execução única. Obtém-se em quatro pontos: identidade determinista, transição atómica, unicidade em base, bloqueio de lote.

1. Um job_id determinista por destinatário

O primeiro erro é gerar um identificador de job aleatório (`uuid()`) a cada tentativa. Na repetição é um novo id → o serviço de envio vê-o como uma nova mensagem → duplo envio. A chave: derivar o id do par (campanha, subscritor), nunca de um aleatório nem de um timestamp. Mesma entrada, mesmo id, até ao infinito. Do lado do serviço de envio (BullMQ, dedup do SQS, ou a tua fila caseira), este `job_id` serve de chave de deduplicação: empurrar duas vezes `campaign:cmp_42:sub_7` só guarda um. A repetição torna-se inofensiva por construção.

-- 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. Transição de estado atómica (compare-and-set)

Dois ticks concorrentes (ou duas réplicas do worker) podem ver a mesma campanha `approved` no mesmo instante. Se cada um a passar a `sending` e lançar o envio, duplicas tudo. O compare-and-set resolve isso: um único `UPDATE … WHERE status='approved'` ganha, o outro vê `rowCount = 0` e para. O Postgres serializa a escrita sobre a linha — sem necessidade de bloqueio aplicacional. Nunca faças um `SELECT status` e depois um `UPDATE` separado: entre os dois, outro worker passa. A condição de estado tem de viver dentro do `WHERE` do mesmo `UPDATE` — leitura e escrita numa só operação atómica.

-- 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. Unicidade (campanha, subscritor): a rede de segurança em base

As duas primeiras camadas podem sempre ceder a um bug. A restrição de unicidade, essa, nunca mente: uma linha `growth_sends` por par `(campaign_id, subscriber_id)`, ponto final. Constróis a lista de destinatários com um `INSERT … ON CONFLICT DO NOTHING` — relançar a construção após um crash não cria nenhum duplicado, e o worker retoma exatamente onde tinha parado, lendo as linhas ainda `queued`.

4. Bloqueio de lote: FOR UPDATE SKIP LOCKED

Para paralelizar sem se pisarem, cada worker reivindica um lote de linhas `queued` bloqueando-as. `FOR UPDATE` coloca o bloqueio; `SKIP LOCKED` diz «ignora as linhas já tomadas por outro e passa às seguintes». Sem espera, sem deadlock, sem dois workers sobre o mesmo destinatário. É o padrão de fila nativo do 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.
O ponto subtil: marcas `sent` antes da chamada de rede, dentro da transação. Contraintuitivo, mas é o `job_id` determinista que torna esta escolha correta — um crash entre o `commit` e o envio só produz um re-push do mesmo `job_id`, que a fila deduplica. Marcar `sent` depois do envio, esse sim, far-te-ia reenviar um lote inteiro ao mínimo crash pós-entrega. Se o `sendBulk` falhar de vez, repões as linhas em `queued` e pões a campanha em pausa com um motivo — nunca uma perda silenciosa.

À escala, estas quatro camadas transformam um worker frágil num executor retomável: podes matá-lo com `kill -9` a meio de uma campanha de 50 000 destinatários e relançá-lo — retoma as linhas `queued`, ignora as já `sent`, respeita os opt-outs chegados entretanto, e ninguém recebe duas vezes. Nenhuma camada basta por si só: o CAS protege a transição, a unicidade protege a construção, o `SKIP LOCKED` protege a concorrência, e o `job_id` protege a repetição no fim da cadeia. Começa pela restrição de unicidade em base — é a mais barata de pôr e a que te salva quando as outras três têm um bug.

A newsletter

Ao subscreveres aceitas receber a newsletter da Zylior. Cancelamento em 1 clique em cada email.