Inicio / Artículos

Diseño de un pipeline de notificaciones sin duplicados en SQS, SES y Redis

Cómo un diseño de SQS con dos colas, una solución alternativa de SES a SendGrid y un bloqueo atómico de Redis garantizan que los documentos empresariales lleguen a los destinatarios exactamente una vez, incluso bajo carga.

Editor: REACTAPP.TOP7 min de lectura1509 palabras

Fuentes: How I Built a Notification Engine Handling 5M+ Events

Dos empleados reciben el mismo evento al mismo tiempo. Cada uno revisa el registro de correos electrónicos, no encuentra ningún registro y envía el mensaje. Un proveedor termina recibiendo dos veces la misma orden de compra, y el equipo se entera de ello a través de un ticket de soporte en lugar de mediante la supervisión. Este artículo explica en detalle un motor de notificaciones para una plataforma de cadena de suministro B2B que llegó a manejar más de cinco millones de eventos al mes, así como los tres fallos en la producción que lo moldearon: correos electrónicos perdidos, correos bloqueados durante la generación de PDF y duplicados.

Qué debe ofrecer el motor

En una plataforma SaaS para la cadena de suministro alimentario, los eventos empresariales como crear o editar un pedido, o realizar una operación de inventario, deben generar documentos para las personas adecuadas: órdenes de compra, órdenes de venta, órdenes de entrega, notas de recepción de mercancías e facturas. Por lo tanto, un único evento puede dividirse en varias etapas: generar un PDF, almacenarlo y enviarlo por correo electrónico a varios destinatarios. El mismo motor también envía mensajes de SMS y WhatsApp para algunos otros casos de uso.

Las consecuencias son reales. Una notificación que nunca llega no es un simple error estético; una orden de compra faltante puede retrasar la entrega de existencias a un cliente.

Dos colas: una decide, otra entrega

El diseño se basa en dos colas de Amazon SQS con tareas claramente separadas.

La cola principal es donde se toman las decisiones. Un único consumidor lee cada evento que llega y determina qué implica: qué documento generar, qué canal utilizar y quién debe recibirlo.

La subcola solo se encarga de entregar los mensajes. Los trabajadores de entrega no aplican ninguna regla empresarial; simplemente transfieren los mensajes a los proveedores.

Un mensaje por destinatario

La decisión más importante en este esquema es que cada destinatario recibe su propio mensaje en la subcola. Un pedido que debe llegar a diez personas se convierte en diez mensajes independientes. Si la entrega a una dirección falla, las otras nueve siguen enviándose, y un intento de reenvío se dirige únicamente al destinatario que tuvo problema, en lugar de reenviarlo a todos.

Esta separación también aísla los cambios. Al sustituir por un proveedor o canal nuevo, solo se afecta el aspecto de entrega; el código que decide qué enviar nunca necesita saber cómo se transporta un mensaje.

¿Por qué no una cola por canal?

Tener colas separadas para correo electrónico, SMS y WhatsApp permitiría que cada canal tuviera un rendimiento e infraestructura de gestión de fallos independientes. Con este volumen de tráfico no era necesario, y añadir una cola cuando sea necesario es más económico que operar cuatro desde el principio.

Fallo uno: correos electrónicos que nunca llegaron

Los proveedores de correo electrónico fallan, a veces de forma evidente con un error y otras veces de manera sutil con una respuesta que carece de los campos esperados. Amazon SES es el proveedor principal en este caso, y una respuesta sin un ID de mensaje se considera un fallo al igual que una excepción. En ambos casos, el mismo correo se envía a través de SendGrid en su lugar:

// simplified
try {
  const res = await ses.send(email);
  if (!res.MessageId) throw new Error("No MessageId from SES");
} catch (err) {
  await sendgrid.send(email); // fallback
}

Observe el lanzamiento explícito cuando falta MessageId: dirige los casos ambiguos al mismo camino de respaldo que un error grave. Vale la pena comprender el equilibrio entre estas opciones. Si SES acepta el correo electrónico pero se pierde la respuesta, el mecanismo de respaldo puede entregarlo una segunda vez; por lo tanto, este nivel da prioridad a “entregado al menos una vez” en lugar de “entregado como máximo una vez”. Para documentos comerciales, esto suele ser la opción adecuada, pero es otra razón por la cual es importante el paso de deduplicación descrito a continuación.

Cuando ambos proveedores fallan, el mensaje va a una cola de correos no entregados y se vuelve a intentar después de un intervalo. Además, un mecanismo separado de reenvío se encarga de los correos registrados como no entregados, por lo que existe una vía de recuperación para todo aquello que se haya pasado por alto.

Fallo dos: PDFs que fallan bajo carga

Los correos electrónicos con documentos siguen una regla estricta: nada se envía hasta que existe el PDF. Los destinatarios necesitan el pedido de compra real, no un enlace que genere un error. El consumidor activa una función Lambda de generación de PDF, escribe el archivo en S3 y solo entonces envía el correo electrónico que contiene un enlace hacia él.

La regla es correcta, pero creó una dependencia oculta. Cuando el sistema se ralentizaba y la generación del PDF fallaba, el correo electrónico no mostraba error alguno; simplemente nunca se enviaba, ya que esperaba un archivo que nunca se generaría. La solución fue dar a la generación de PDF sus propias intentos de reintentar, de modo que un fallo temporal en su procesamiento ya no cancelara silenciosamente la notificación posterior.

Fallo tres: el mismo correo, dos veces

Las duplicaciones provenían de tres fuentes distintas, y cada una requería una respuesta diferente:

  • errores de lógica comunes, que se solucionaron como tales;
  • Se realizan intentos de reenvío para mensajes que de hecho ya habían sido entregados;
  • La competencia entre dos consumidores que manejan el mismo evento de forma concurrente.
  • Por qué una verificación en el registro de logs no es suficiente

    Desde el principio, la cadena de procesamiento consultaba el registro de correos electrónicos antes de enviar: si un registro indicaba que el mensaje ya se había enviado, se omitía el intento de reenvío. Esto funciona bien para los intentos secuenciales. Sin embargo, no puede detener la competencia, ya que leer el registro y escribir en él son dos operaciones separadas. Dos procesos pueden leer “no enviado” antes de que cualquiera de ellos escriba, y ambos continuarán. Las colas estándar de SQS entregan los mensajes al menos una vez, por lo que es un comportamiento esperado que el mismo mensaje llegue a dos consumidores, y no un accidente raro.

    Un bloqueo atómico en Redis

    La solución consistió en implementar un bloqueo atómico en Redis antes de la verificación del registro. La clave combina el ID del pedido, la dirección de correo electrónico del destinatario y el tipo de documento, y vence aproximadamente después de cinco minutos:

    // simplified
    const key = `notif:${orderId}:${email}:${docType}`;
    const locked = await redis.set(key, "1", "NX", "EX", 300);
    if (!locked) return; // someone else already has it
    

    SET con la opción NX escribe la clave solo si aún no existe, y EX 300 establece una fecha de vencimiento de 300 segundos en la misma orden. Dado que la verificación de existencia y la escritura se realizan como una sola operación en el servidor Redis, solo un trabajador recibe un resultado verdadero; todos los demás trabajadores ven null y regresan de inmediato. Esto cierra la brecha que dejaba abierta la verificación del registro.

    Algunas características de este enfoque merecen ser tenidas en cuenta:

    • La granularidad de la clave es importante. Incluir el tipo de documento evita que un pedido de compra y una factura del mismo pedido y destinatario sean considerados duplicados.
  • El TTL funciona como una válvula de seguridad. Si un proceso falla después de obtener el bloqueo, la clave expira y se puede intentar nuevamente más tarde. La fecha de vencimiento debe superar con holgura el tiempo que normalmente tarda un envío, y de ser posible también el tiempo de espera de visibilidad de SQS, para que el bloqueo de un proceso lento no caduque mientras sigue enviando datos.
  • El bloqueo evita duplicados concurrentes; el registro persistente sigue capturando aquellos que se generan posteriormente.
  • La misma idea se aplica en las API HTTP a través de las claves de idempotencia; el artículo en claves de idempotencia en los endpoints POST de Node.js aborda ese aspecto.

    A dónde llegó el sistema

    Tras estos cambios, el motor procesa más de cinco millones de eventos al mes gracias a los desencadenantes relacionados con pedidos, inventario y documentos, logrando una tasa de entrega exitosa del 98.9% y una latencia p95 inferior a 1.2 segundos.

    Qué hacer de manera diferente la próxima vez

    • Detección de fallos antes que los clientes. Demasiados problemas aparecían primero como quejas y solo posteriormente en los registros. Las alertas sobre fallos en la entrega y sobre el nivel de la cola de correos no entregados deben incluirse en la primera versión, no en una posterior.
    • Impida que los eventos desaparezcan. Algunos eventos se perdían por razones que nunca se explicaron completamente. Una bandeja de salida transaccional, que registra el evento junto con la operación empresarial y lo transmite posteriormente, hace que los eventos perdidos sean mucho menos frecuentes. El artículo sobre bandejas de salida y entrada explica su funcionamiento.
    • Mantenga la única subcola hasta que sea necesario dividirla. La cola de entrega compartida ha funcionado bien; solo es necesario dividirla si un canal comienza a desfavorecer al resto.

    Lista de verificación para crear la suya

    • Mantenga las decisiones de enrutamiento en un lugar y la entrega en otro.
    • Emita un mensaje por destinatario para que un único fallo no bloquee al resto.
    • No confíe en un solo proveedor, y considere que la falta de un ID de mensaje constituye un fallo.
    • Asigne a los mensajes fallidos una destinación: una cola de correos no entregados con intentos de reenvío.
    • Nunca envíe un correo con documento antes de que este exista, y repita el paso necesario para generarlo.
    • Haga que la deduplicación sea atómica, ya que verificar primero y luego escribir deja un margen de error.
    • Genere alertas ante los fallos para que los clientes no actúen como su sistema de monitoreo.

    Conclusión

    Ninguna de estas protecciones se diseñó desde el principio; cada una se añadió después de que algo fallara en producción, y así es como maduran realmente la mayoría de los pipelines de entrega. La lección no radica tanto en una solución concreta como en el orden de pensamiento: asuma que la entrega ocurrirá al menos una vez, suponga que los proveedores y dependencias fallarán de forma silenciosa, y haga que cada paso sea idempotente o esté protegido por algo atómico.