zylior
← Blog

Anti doble envío: hacer idempotente un worker de emailing

Un worker de emailing que se cae en mitad de un envío es normal. Lo que no es normal es que un suscriptor reciba dos veces la misma campaña al reiniciar. Estos son los cuatro mecanismos que apilamos en prod en Zylior para que un destinatario reciba un mensaje exactamente una vez, incluso cuando el proceso muere entre dos lotes.

El problema: «al menos una vez» no es «exactamente una vez»

Un worker hace tick cada 60 s, reclama un lote de 20 destinatarios, llama al servicio de envío, marca las filas como enviadas. Si el proceso muere después de la llamada de red pero antes del `commit`, el lote vuelve a estado por enviar al reiniciar. Sin protección, esas 20 personas reciben el email dos veces. A la inversa, si marcas `sent` antes de la llamada y la llamada falla, esas 20 no reciben nada. No puedes tener ambas garantías en el mismo sitio con una sola escritura: hay que repartirlas en varias capas.

Regla de base: un crash nunca debe releer un trabajo ya hecho como si fuera nuevo. La idempotencia consiste en hacer que la repetición de una operación sea indistinguible de su ejecución única. Se consigue en cuatro puntos: identidad determinista, transición atómica, unicidad en base, bloqueo de lote.

1. Un job_id determinista por destinatario

El primer error es generar un identificador de job aleatorio (`uuid()`) en cada intento. Al repetir es un nuevo id → el servicio de envío lo ve como un mensaje nuevo → doble envío. La clave: derivar el id del par (campaña, suscriptor), nunca de un aleatorio ni de un timestamp. Misma entrada, mismo id, hasta el infinito. En el lado del servicio de envío (BullMQ, dedup de SQS, o tu cola casera), este `job_id` sirve de clave de deduplicación: empujar dos veces `campaign:cmp_42:sub_7` solo conserva uno. La repetición se vuelve inofensiva por construcción.

-- 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. Transición de estado atómica (compare-and-set)

Dos ticks concurrentes (o dos réplicas del worker) pueden ver la misma campaña `approved` en el mismo instante. Si cada uno la pasa a `sending` y lanza el envío, lo duplicas todo. El compare-and-set lo resuelve: un solo `UPDATE … WHERE status='approved'` gana, el otro ve `rowCount = 0` y se detiene. Postgres serializa la escritura sobre la fila: no hace falta un bloqueo aplicativo. Nunca hagas un `SELECT status` y luego un `UPDATE` separado: entre ambos, otro worker se cuela. La condición de estado debe vivir dentro del `WHERE` del mismo `UPDATE`: lectura y escritura en una sola operación 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. Unicidad (campaña, suscriptor): la red de seguridad en base

Las dos primeras capas siempre pueden ceder ante un bug. La restricción de unicidad, en cambio, nunca miente: una fila `growth_sends` por par `(campaign_id, subscriber_id)`, y punto. Construyes la lista de destinatarios con un `INSERT … ON CONFLICT DO NOTHING`: relanzar la construcción tras un crash no crea ningún duplicado, y el worker retoma exactamente donde se había detenido leyendo las filas todavía `queued`.

4. Bloqueo de lote: FOR UPDATE SKIP LOCKED

Para paralelizar sin pisarse, cada worker reclama un lote de filas `queued` bloqueándolas. `FOR UPDATE` pone el bloqueo; `SKIP LOCKED` dice «ignora las filas ya tomadas por otro y pasa a las siguientes». Sin espera, sin deadlock, sin dos workers sobre el mismo destinatario. Es el patrón de cola nativo 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.
El punto sutil: marcas `sent` antes de la llamada de red, dentro de la transacción. Contraintuitivo, pero es el `job_id` determinista lo que hace correcta esta decisión: un crash entre el `commit` y el envío solo produce un re-push del mismo `job_id`, que la cola deduplica. Marcar `sent` después del envío, en cambio, te haría reenviar un lote entero ante el menor crash posterior a la entrega. Si el `sendBulk` falla del todo, devuelves las filas a `queued` y pausas la campaña con un motivo — nunca una pérdida silenciosa.

A escala, estas cuatro capas convierten un worker frágil en un ejecutor reanudable: puedes matarlo con `kill -9` en mitad de una campaña de 50 000 destinatarios y relanzarlo — retoma las filas `queued`, ignora las ya `sent`, respeta los opt-outs llegados entretanto, y nadie lo recibe dos veces. Ninguna capa basta por sí sola: el CAS protege la transición, la unicidad protege la construcción, el `SKIP LOCKED` protege la concurrencia, y el `job_id` protege la repetición al final de la cadena. Empieza por la restricción de unicidad en base: es la más barata de poner y la que te salva cuando las otras tres tienen un bug.

La newsletter

Al suscribirte aceptas recibir la newsletter de Zylior. Baja en 1 clic en cada correo.