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.
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`.
- `UNIQUE(campaign_id, subscriber_id)`: impossível ter duas linhas de envio para o mesmo subscritor numa campanha, aconteça o que acontecer a montante.
- `distinct on (email_lower)`: um mesmo email presente em vários segmentos = uma só mensagem (a dedup de endereço, não apenas de subscritor).
- Supressão revalidada no claim: um opt-out chegado durante o envio retira o destinatário do lote antes da chamada de rede — a tabela de supressão por tenant mantém-se prioritária.
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.
À 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.