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.
Relacionado
Proyectos

CactusPlay - Tienda de juegos con una red de colaboradores verificados
Antes: los compradores de la CEI comparaban a mano los precios de PlayStation y Xbox entre vendedores dispersos, y cada pedido lo cumplían los propios fundadores. Después: una tienda con catálogo en vivo y un CRM al que podía sumarse cualquier colaborador verificado con datos de pago turcos para tomar pedidos y ganar con ellos. Resultado: más de 20 000 posiciones por hora, cambios de precio que llegan a la tienda y a Yandex.Market sin intervención humana, y un cumplimiento que ya no depende de un solo par de manos.
Artículos
Los contadores de analítica no pertenecen a los archivos del tema
Un snippet de seguimiento pegado en una plantilla desaparece con la siguiente actualización del tema, compite con la página por el navegador y duplica las cifras cuando alguien lo pega dos veces. Los tres problemas tienen el mismo arreglo.
Que la entrega sea consecuencia del pago, no una tarea que alguien ejecuta
La entrega manual de productos digitales está limitada por el horario laboral y protegida solo por la atención de alguien. Mover la entrega a los eventos del pedido cambia lo que el sistema garantiza, y la API es lo que impide que un segundo canal se convierta en una segunda implementación.
Poner precio a un catálogo contra una tienda que no controlas
Revender significa que tus precios dependen de que otro cambie los suyos. Consultar al cargar la página tumba tu tienda con la suya; una exportación nocturna se queda obsoleta. El camino intermedio es una caché con una estrategia de precio detrás.
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