← Volver al blog

Deriva la cola, no la guardes: campañas de email reanudables

Publicado el
4 min de lectura
--- vistas

Enviar un correo es fácil. Enviar cuarenta mil es otro problema, y la dificultad no está en el rendimiento. Está en qué ocurre cuando la ejecución se detiene a mitad.

Porque se detendrá. Un ciclo de cron agota su tiempo. PHP llega a su límite de memoria. El relé SMTP empieza a rechazar conexiones. Alguien despliega. En ese momento has enviado un número desconocido de mensajes y tienes que decidir qué hacer a continuación.

Las dos respuestas habituales, y por qué ambas son malas

Reinicia desde el principio y todos los que ya recibieron el mensaje lo reciben otra vez. En una lista de cuarenta mil, eso es un envío duplicado masivo, un pico de bajas y una tasa de quejas por spam que persigue a tu dominio durante meses.

Lleva un cursor: guarda «última fila procesada» y continúa desde ahí. Funciona hasta que la audiencia cambia en mitad de la ejecución. Alguien se da de baja, alguien nuevo entra en el segmento, y el cursor apunta a una posición de una lista que se ha movido bajo sus pies. Te saltas gente, o la repites, y no puedes saber cuál de las dos cosas.

Ambas fallan por el mismo motivo de fondo: tratan el progreso como lo que hay que recordar. El progreso es un hecho sobre la ejecución, y las ejecuciones mueren.

Derivarla en su lugar

El hecho duradero no es «hasta dónde llegamos». Es «a esta persona se le ha enviado esta campaña»: una fila, escrita en el momento del envío, que sobrevive a la muerte de la ejecución que la escribió.

Una vez existe ese registro, la cola pendiente no hace falta guardarla. Es una consulta:

SELECT c.ID AS c_id, c.title, c.content, u.ID AS u_id, u.user_email
FROM   wp_users u
JOIN   mm_user_category     uc  ON u.ID = uc.user_id
JOIN   mm_campaign_category cc  ON uc.category_id = cc.category_id
JOIN   mm_campaigns         c   ON cc.campaign_id = c.ID
LEFT JOIN mm_user_campaign  d   ON u.ID = d.user_id AND c.ID = d.campaign_id
WHERE  d.status IS NULL
  AND  c.is_active = 1
LIMIT  19

El LEFT JOIN más IS NULL es la idea entera: suscriptores sin fila de entrega para esta campaña. Todo lo demás se deriva de ahí.

La recuperación tras una caída sale gratis: el siguiente ciclo hace la misma pregunta y recibe a los que faltan, así que nadie tiene que enterarse de que una ejecución murió. Los envíos duplicados pasan a ser imposibles y no simplemente improbables, porque una persona con fila de entrega no puede seleccionarse y no hay bandera que olvidar poner.

Los cambios de audiencia también se resuelven solos. Quien entre hoy en el segmento aparece en la consulta de mañana, quien salga desaparece, y no hay cursor que invalidar. El estado además queda inspeccionable: «quién no lo ha recibido todavía» es una consulta que cualquiera puede ejecutar y no un contador interno en el que hay que confiar.

El ritmo vive al lado

En el mismo bucle es donde se mantiene el envío dentro de los límites del hosting:

$send_per_time = (int) get_option('mm_send_amount_per_time');   // 19
$sleep         = (int) get_option('mm_send_sleep_time');        // 3 000 000 µs

foreach (UserCampaignPendingDTO::load_pending($campaign_id, $send_per_time) as $pending) {
    usleep($sleep);
    UserCampaignPendingMail::send_pending($pending, true);
}

Un lote fijo por ciclo con una pausa entre mensajes. Código nada llamativo, y funciona solo porque la cola se deriva. Con un cursor, dormir dentro del bucle únicamente amplía la ventana en la que una caída deja al cursor mintiendo sobre dónde estabas.

Lo que cuesta

Cada ciclo ejecuta un join de cinco tablas. En una lista grande eso requiere índices en las tablas pivote y en el estado de entrega, y cuesta más por ciclo que leer un cursor.

La tabla de entregas crece como suscriptores multiplicados por campañas. Ese es el precio de la garantía, y sirve además como tabla de informes, porque pendientes, enviados y fallidos por campaña salen de esas mismas filas.

Un envío que tiene éxito pero no llega a registrarse se reintentará. Esa es la dirección correcta en la que fallar, pero implica que el registro debe escribirse justo después del envío y no acumularse para el final.

La forma general

Cuando necesites saber qué trabajo queda, derívalo de lo ya completado en vez de registrar dónde te detuviste. El trabajo completado es duradero y monótono; la posición dentro de una ejecución no es ninguna de las dos cosas.

Es el mismo razonamiento que hay detrás de los consumidores de mensajes idempotentes, de la conciliación frente a la reproducción de eventos, y del «estado deseado frente a estado actual» en las herramientas de despliegue. En cada caso el sistema deja de depender de recordar lo que hizo y pasa a depender de poder mirar qué es cierto.

Disponible para colaboración por contrato

Estoy disponible para colaborar por contrato. Si tiene una idea de proyecto interesante, reserve una llamada por Calendly.

Agenda una llamada de 30 min