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
El bug de normalización que solo aparece con etiquetas hechas de nada
Un normalizador basado en strip se topa con una etiqueta de puro signo de puntuación: string vacío como clave de hash, sustitución de etiqueta equivocada, y un autocompletado que hace match con todo. Tres síntomas, una sola causa raíz.
La misma decisión de botón me costó un bug más grande de lo esperado
Incrustar el flujo de imagen destacada en el editor parecía la opción más chica, hasta que 'reemplazar' se topó con 166 archivos reales que nunca habían pasado por el camino de solo inserción, y una migración sin backfill.
El botón de commit del editor es un botón de deploy
Confirmar un borrador a main despliega el blog automáticamente. En cuanto eso quedó claro, sync vs. async dejó de ser una cuestión de estilo, más el caso especial de afiliado heredado que un validador nuevo casi rompió.