Un Orquestador de Workflows para Push y Email

Impacto

Un orquestador para push y email, nodo a nodo

Construí un orquestador centralizado que envía push y email a través de los proveedores que conecta cada equipo, y gran parte de la plataforma a su alrededor. La decisión central fue ejecutar cada nodo cuando le toca, porque los datos del suscriptor cambian mientras un workflow espera.

Senior Software Developer

El año pasado me tocó un desafío que parecía pequeño y no lo era. En la agencia donde trabajaba necesitábamos que cualquier desarrollador pudiera integrar toda la capa de notificaciones, push y email, de una manera fácil y sencilla.

Si has hecho esta integración antes, sabes cómo va. Es repetitiva y molesta, y vuelve en cada proyecto con pequeñas diferencias que nadie recuerda. Yo había trabajado con integraciones así, y sabía que el problema no era enviar una notificación. Era repetir la misma integración una y otra vez.

Así que nos propusimos construir una interfaz gráfica donde el desarrollador configurara lo fijo y lo dinámico, como las etiquetas que describen a cada usuario, y además armara workflows automatizados. Lo que importaba era quién los iba a cambiar después. El propio cliente, no nosotros.

Eso pesaba por el tipo de producto. La agencia trabajaba con aplicaciones móviles, y publicar una actualización de una app es bastante más burocrático que hacerlo en web. Una nueva versión, un review de la tienda, la espera. Si cada ajuste a una campaña exigía eso, la plataforma no servía. El workflow tenía que vivir fuera de la app y poder cambiar sin una integración nueva ni un review.

Con ese requisito, mi primera idea de diseño fue la obvia. Tomar un workflow, recorrer el grafo completo una sola vez y crear todos los mensajes que alguna vez se enviarían. Se rompió en cuanto el grafo tuvo una espera y una rama. Una espera puede durar días, y una rama decide su camino con datos del usuario, como sus etiquetas o atributos. Esos valores pueden cambiar en cualquier momento, así que un mensaje planificado el primer día podía llegarle a alguien que ya no correspondía a ese camino.

El motor

Descarté el plan precalculado y construí un motor que ejecuta cada nodo cuando le toca. Cada nodo es una tarea. Al correr, lee el estado actual del suscriptor, aplica solo su propia lógica y crea la tarea siguiente. Eso significaba que el motor nunca podía ser un ciclo grande. Tenía que ser una cadena de pasos pequeños e independientes, cada uno confiando solo en lo que lee en ese momento.

La cadena está hecha de eventos dentro de la base de datos. Iniciar una ejecución inserta una fila, y un trigger de Postgres la expande en una fila por suscriptor, resolviendo los segmentos en personas. Otro trigger lee la versión inmutable del grafo y crea la primera tarea. Cada inserción de una tarea dispara un trigger más, que asocia el tipo de nodo con una Edge Function y la llama por HTTP, con diez segundos de timeout y un token guardado en el vault de la base de datos. La función lee su tarea, hace su único trabajo, la marca e inserta la siguiente, que dispara el siguiente trigger. Los estados suben solos, de la tarea al suscriptor y del suscriptor a la ejecución, así que un suscriptor detenido detiene sus propias tareas y una ejecución se cierra cuando termina su último suscriptor. Ningún proceso guarda el workflow completo en memoria, y nada gira en un ciclo.

El grafo soporta los nodos que necesita una campaña real. Inicio y fin, email y push, una condición, una multicondición que elige el primer caso que coincide o cae a uno por defecto, uniones para fusionar ramas, un nodo de parada que termina antes el camino de un suscriptor, y esperas. Cada tipo tiene su propia función y su propio contrato pequeño, así que un tipo nuevo llega como una función nueva con la misma forma.

El envío es donde el motor se encuentra con el mundo exterior, y el mundo exterior falla. Email y push pasan por colas, un par por proyecto, creadas cuando se crea el proyecto. Un consumidor lee hasta quinientos mensajes por vez y los procesa en paralelo. Cada mensaje tiene tres intentos. Tras un fallo la tarea queda como en reintento y el mensaje reaparece a los treinta segundos, y tras el último intento queda como fallida y se archiva. Los errores que reintentar no arregla, como un dispositivo sin token, fallan de inmediato. En el caso del email, un fallo final igual mueve el recorrido al siguiente nodo, porque un mensaje que no se pudo entregar no debería congelar a una persona a mitad de una campaña.

Las esperas son la otra excepción, porque no ocurre nada en el instante en que una espera termina. Un job programado en la base de datos corre cada minuto, busca las esperas cuyo tiempo ya pasó y las completa, devolviéndole el control a la cadena. Las tareas de push tienen una función de validación propia que cierra una tarea cuando cada dispositivo al que apuntaba terminó, con éxito o con fallo.

Las definiciones de workflow se guardan como versiones inmutables del grafo. Una ejecución apunta a una versión, así que editar un workflow en el editor nunca cambia una ejecución que ya está en curso.

El tiempo era más difícil de lo que parecía

Una espera parece el nodo más simple. Esconde una cantidad sorprendente de aritmética de fechas. Una duración es fácil, porque los días y las semanas son múltiplos simples de veinticuatro horas. El tipo difícil es la espera hasta un momento, como la próxima vez que sean las nueve de la mañana, el próximo martes, un día del mes o una fecha fija. Las nueve de la mañana no son un solo instante. Son un instante distinto para cada suscriptor.

Por eso cada suscriptor y cada proyecto lleva una zona horaria IANA, y el motor usa primero la del suscriptor y después la del proyecto. El editor se niega a agregar una espera hasta un momento en un proyecto que no tiene ninguna. Llegar ahí implicó migrar la base de datos de timestamps sin zona a timestamps con zona, tratando las filas existentes como UTC. La conversión de una hora local a un instante prueba los desfases posibles, y tiene que resolver dos casos incómodos. Si el horario de verano salta la hora que pediste, la espera se resuelve al siguiente minuto válido del mismo día. Si la hora ocurre dos veces, toma la primera. El editor necesitaba la misma lógica para mostrar cuándo se reanudaría una espera, así que una copia vive en la consola, y un conjunto de tests fija los casos en el motor.

Condiciones sobre datos que no se quedan quietos

Una condición lee etiquetas, las marcas que describen a cada suscriptor y que la app o el propio workflow pueden agregar y quitar. Varios predicados en un mismo nodo deben cumplirse todos, y el schema rechaza los duplicados. La multicondición cubre lo que una condición simple no puede. Sus casos se evalúan en orden, gana el primero que coincide y todo lo demás toma una rama por defecto, así que una campaña puede dividir una audiencia en tres grupos y volver a juntarlos. Como la comprobación ocurre cuando el suscriptor llega al nodo, una etiqueta agregada por un nodo anterior, o por la app hace una hora, ya cuenta. Las condiciones sobre segmentos las dejé para una segunda versión.

Todo lo que rodea al motor

Todo pertenece a exactamente un proyecto. Los suscriptores, etiquetas, segmentos, workflows, conectores, plantillas y las propias colas llevan el proyecto al que pertenecen, y eso es lo que permite que muchos equipos compartan una plataforma sin verse entre sí.

Cada proyecto conecta su propio proveedor de email, Resend o un servidor SMTP propio, y las credenciales se resuelven a través de la base de datos en el momento del envío. El push sale por Firebase Cloud Messaging con la cuenta de servicio del propio proyecto, guardada cifrada. SMTP fue la parte menos vistosa y la más delicada, porque los hosts bloquean algunos puertos, el puerto 465 exige TLS y el remitente debe pertenecer al dominio del buzón o el proveedor rechaza el mensaje.

El grafo no es la única puerta de entrada. Las apps hablan con una API HTTP pública para registrar dispositivos, identificar suscriptores, etiquetarlos y arrancar workflows, y los servidores pueden enviar push o email en lote. Las difusiones llegan a un segmento, a una etiqueta o a una lista de suscriptores. Un suscriptor puede ser anónimo hasta que inicia sesión, y la plataforma reemplaza el registro anónimo por el identificado. Los segmentos se calculan a partir de reglas sobre etiquetas, así que la pertenencia se recalcula cuando cambian las etiquetas en lugar de subirse. Todo cae en las mismas tablas, así que un solo historial muestra lo que recibió una persona, sin importar el canal ni el origen.

Cada endpoint valida su entrada con un schema, y un único middleware comprueba la clave y su alcance antes de que corra cualquier otra cosa. El contrato se publica como un documento OpenAPI, para que clientes y herramientas puedan leerlo.

La consola es una app en Next.js donde el workflow se dibuja en un grafo. Escribí yo mismo el código del layout, en carriles, para que las ramas de una multicondición se separen y vuelvan a unirse en pantalla. Cada nodo tiene su formulario y su estado de validación, los nodos se pueden copiar por el portapapeles del sistema, y las vistas de ejecución muestran cuántos suscriptores hay en cada nodo, con vistas previas del push y del email tal como se renderizaron. También hay un editor de plantillas de email que rechaza scripts, iframes y manejadores de eventos en línea, y un historial de mensajes por suscriptor.

Alrededor del producto está la plomería propia de la plataforma. Los emails de autenticación salen por un hook que verifica la solicitud y envía plantillas de marca. La facturación viene de Stripe, sincronizada a Postgres para que la consola y la API lean el estado de la suscripción con consultas simples, y las funciones se limitan según el plan. Los emails de ciclo de vida y las alertas a un canal de Discord corren con la misma maquinaria. Un formulario de soporte en la consola abre una issue en el repositorio del equipo con el usuario y el proyecto adjuntos.

La cara pública son tres piezas más. Un sitio de documentación y marketing alimentado desde un studio de contenido, que además publica el documento OpenAPI, un archivo llms.txt generado desde el contenido y los datos estructurados para buscadores. Una página de estado que combina nuestras propias métricas de entrega con los feeds de incidentes de los proveedores de los que dependemos. Y el propio studio de contenido, con schemas para el blog, la documentación y las páginas legales.

Abrirlo a muchos equipos obligó a decidir quién puede hacer qué

Cuando otros equipos fueran a usar la plataforma, el control de acceso dejó de ser un detalle. Construí una capa de gobernanza con roles, permisos y planes. Cada permiso indica si aplica a la consola, a la API o a ambas. Los planes llevan capacidades, límites o funciones, y la consola las lee para limitar pantallas y mostrar el camino de mejora.

Las API keys vinieron en tipos. Una clave pública solo puede identificar dispositivos y suscriptores y gestionar sus etiquetas, así que es seguro incluirla dentro de una app. Una clave secreta pertenece a un servidor y puede enviar mensajes y arrancar workflows. Una clave restringida lleva una lista explícita de alcances dentro del techo de una de esas dos. Las claves se guardan con hash, se muestran una sola vez, y se rotan o se revocan sin tocar las demás.

La parte más grande fue la base de datos. La primera versión ejecutaba sus funciones con privilegios elevados y confiaba en el código que las rodeaba. Las pasé por oleadas a ejecutarse como quien llama, bajo row level security, y revoqué privilegios de ejecución que nadie debería tener. Cada oleada vino con smoke tests que corren contra una base local real, para que las políticas se ejerciten y no solo se lean. Durante la transición, las tablas de membresía antiguas y las nuevas se mantuvieron sincronizadas con un trigger. Todavía existe un esquema de tokens heredado para clientes antiguos, y retirarlo es un trabajo documentado y planificado.

Cinco repositorios, una sola forma de entregar

Todo vive en cinco repositorios separados de un monolito anterior. El backend, la consola, el sitio de marketing y documentación, la página de estado y el studio de contenido. Estandaricé cómo se entregan. La base de datos se describe como schemas declarativos, y las migraciones se generan desde el diff, se revisan y se aplican en CI, sin editarlas nunca a mano en el dashboard. Los git hooks exigen convenciones de commit y corren el type check, la detección de código muerto, los tests y el lint antes de un push, y bloquean los push a producción fuera de lunes a jueves. Los releases se generan desde los commits, y la rama principal se fusiona de vuelta en develop automáticamente.

Resultado

Un desarrollador conecta un proyecto, usa la API o un SDK, y desde ahí las campañas viven en la consola. Quienes las operan pueden cambiar un workflow, publicar una versión nueva y hacer que se aplique a las ejecuciones nuevas sin que nadie publique un release ni espere un review de la tienda. Cada mensaje sale con los datos que el suscriptor tiene en ese momento, por el canal y el proveedor que el equipo eligió. Los nuevos tipos de nodo, los nuevos tipos de clave y los nuevos proveedores llegaron como piezas pequeñas que no reescribieron el resto.

Lo que aprendí

Los pasos guiados por eventos son fáciles de razonar de a uno y difíciles de razonar en conjunto. El costo aparece en la observabilidad, así que terminé construyendo métricas por nodo y una vista de ejecución para ver dónde estaba realmente cada suscriptor.

La segunda lección es que las reglas que dependen de la memoria se desvían. La seguridad que vivía en la base de datos y las convenciones impuestas por hooks y tests aguantaron mejor que cualquier cosa que dependiera de que la gente recordara.