Haciendo los escaneos del blog asíncronos con Solid Queue (y una trampa de Rails)
Agregué un botón de escaneo a blog-manager hace unas semanas. Al hacer clic, llama a la API de GitHub para cada post del repositorio, actualiza la base de datos y muestra un resumen. Bastante simple. Y funcionaba bien mientras el escaneo se mantenía pequeño.
Después, el escaneo superó las 300 llamadas a la API.
El escaneo empezó a agotar el tiempo de espera. No porque Rails se cayera, el trabajo en realidad terminaba. Pero kamal-proxy tiene un tiempo límite de unos 30 segundos, y 300 llamadas a la API de GitHub tardan cerca de 55 segundos. El navegador recibía un 504. Rails terminaba de todos modos y actualizaba la base de datos, completamente en silencio. No estaba nada bien.
La solución era obvia: moverlo a un trabajo en segundo plano. Solid Queue ya estaba en el Gemfile por defecto de Rails 8.1. Solo que todavía no había escrito mi primer job.
Poniendo todo en marcha
El primer job en cualquier app de Rails establece el patrón para todo lo que sigue, así que dediqué un tiempo a pensar su forma antes de escribir una sola línea.
El job necesitaba hacer tres cosas:
- Registrar el estado en el registro
Blog(idle,running,failed) para que la interfaz pudiera reflejar lo que estaba pasando sin hacer polling a un endpoint - Guardar el resumen del último resultado (
last_scan_summary) para que los usuarios pudieran ver qué había pasado después de que el job terminara - Manejar los errores correctamente: los fallos de autenticación y los 404 debían fallar rápido, y los errores transitorios de GitHub debían reintentarse
Agregué un enum scan_state y una columna de texto last_scan_summary a blogs. El job pone running al inicio, y luego idle (con el resumen del resultado) o failed (con el error) al final.
La trampa
Rails ActiveJob ofrece dos herramientas declarativas: retry_on para fallos transitorios y discard_on para los permanentes. Quería que los errores de autenticación y los 404 se descartaran de inmediato, y que los errores genéricos de GitHub se reintentaran 3 veces.
Escribí el job con discard_on primero, y retry_on después. Mis pruebas del camino de descarte fallaron, el blog se quedaba en el estado :running.
La causa: Rails usa rescue_from por debajo para ambos, y rescue_from usa una pila LIFO. Lo último registrado se revisa primero. Yo había registrado discard_on primero y retry_on después, así que retry_on quedaba en la punta de la pila. Como AuthError hereda de Error, retry_on Github::ContentClient::Error lo capturaba antes de que discard_on llegara a ejecutarse.
La solución: invertir el orden. Definir retry_on primero, y luego discard_on. Ahora discard_on queda en la punta de la pila y captura los errores de autenticación antes de que retry_on los vea.
# CORRECT
retry_on Github::ContentClient::Error, wait: :polynomially_longer, attempts: 3
discard_on Github::ContentClient::AuthError, Github::ContentClient::NotFoundError do |job, error|
job.arguments.first.update!(scan_state: :failed, last_scan_summary: "...")
end
No había visto esto documentado con claridad en ningún lado. Tiene sentido una vez que se sabe que rescue_from es LIFO, pero es fácil pasarlo por alto.
Mission Control
Con los jobs corriendo en segundo plano, quería visibilidad. La gema complementaria de Solid Queue, mission_control-jobs, agrega un panel al estilo Sidekiq en cualquier ruta donde se monte. Lo conecté en /jobs en unos cinco minutos.
Una nota de configuración: definir base_controller_class para usar la autenticación propia de la app no alcanza por sí solo. También hace falta http_basic_auth_enabled = false, o si no ambos mecanismos de autenticación corren y gana HTTP Basic.
Lo que sigue
La interfaz todavía requiere un refresco manual para ver los resultados del job. Esa es la conexión con Turbo Streams.
Lecturas relacionadas
Qué pasa cuando un job transmite y nadie escucha
Cerrando el ciclo de la imagen destacada: frontmatter que solo inserta, una validación que detectó drift real, y un broadcast sin oyentes.
Una corrección de desfase de documentación que no fue tan aburrida como sonaba
Tres puntos de auditoría que se volvieron algo más: una afirmación a medio corregir, un restablecimiento de contraseña muerto en silencio, y un correo de staging que enlazaba a producción.
Un 500 escondido dentro de las rutas aisladas de un engine montado
El dashboard de jobs devolvía un 500 en vez de una página de login: los route helpers sin calificar se resuelven contra el engine, no contra la app. Una línea, más su gemela dormida.
También te podría ser útil
NordPass
Gestor de contraseñas del equipo detrás de NordVPN, con un plan gratuito.
Como afiliado de NordPass, obtengo ingresos por las compras que califican.
Más informacióneSIM Airalo
eSIM de datos local para viajes - sin necesidad de cambiar una SIM física.
Este es mi enlace de referido de Airalo. Obtienes un descuento en tu primer eSIM y yo obtengo crédito de Airalo para el mío.
Más informaciónProton Pass
Gestor de contraseñas centrado en la privacidad, del equipo detrás de Proton Mail.
Como socio de Proton, obtengo ingresos por las compras que califican de los servicios de privacidad y seguridad de Proton (Pass, Mail, VPN, Drive).
Más información