Saltar al contenido
Development

Eliminando el copiar y pegar en los jobs de sindicación de blog-manager

Por Victor Da Luz
railsrubyrefactoringdev-logblog-manager

Esto era un seguimiento de un spike anterior, y la premisa era simple: ocho jobs en segundo plano de blog-manager habían derivado en un desorden de copiar y pegar. Cada job que publica algo en Dev.to, Medium o el boletín, más el escáner de blogs de GitHub, carga el mismo esqueleto de transmisión de Turbo. Tres de ellos construyen un cliente de Listmonk con las mismas seis líneas exactas. Y dos de ellos, los jobs de sondeo que revisan si un artículo de Dev.to o una campaña de Listmonk realmente salió, habían divergido en silencio en cómo manejan el rendirse.

Lo que intenté hacer

Extraer las piezas duplicadas a código compartido sin cambiar cómo se comporta nada de eso en el camino feliz, y arreglar el único bug real escondido en la duplicación: el job de sondeo de Dev.to se detiene en silencio después de 12 intentos y deja el post en devto_status: :draft para siempre, sin error y sin forma de reintentar desde la interfaz. El job de sondeo equivalente del boletín hace lo sensato y se marca a sí mismo como fallido con un mensaje. Misma forma, mismas constantes, final distinto.

Lo que construí

Tres piezas. Broadcastable, un concern de job con un solo método, broadcast_record(record), que en vez de codificar a mano una ruta de parcial por job, llama a record.to_partial_path, la misma primitiva que usa el propio render record de Rails, lo cual significa que el concern no puede desincronizarse de la capa de vistas como eventualmente lo harían seis rutas de parcial construidas por separado con strings. PollJob, una clase base de la que ahora heredan los dos jobs de sondeo de estado, posee el loop, el backoff, el tope de intentos y la política de errores, y cada subclase solo responde un puñado de preguntas: si este registro ya está resuelto, cómo se sondea, cómo se ve “fallido” para este registro, qué errores vale la pena reintentar frente a cuáles abandonar de inmediato. Y Syndication::ClientFactory, un módulo con un método por proveedor (devto, listmonk, medium_bridge) para que el código de lectura de credenciales que antes vivía en cinco métodos build_client distintos ahora viva en un solo lugar.

Decisiones que tomé y por qué

El orden de los rescue en PollJob casi me muerde. Devto::Client::AuthError es una subclase de Devto::Client::Error, y originalmente escribí la cláusula rescue de “reintentar en error transitorio” antes que la de “fallar de inmediato en error de autenticación”. En Ruby, las cláusulas rescue se evalúan de arriba hacia abajo, así que la cláusula más amplia Error habría absorbido en silencio cada AuthError y la habría reprogramado 12 veces en lugar de fallar rápido. Lo detecté trazando qué significa realmente “error de autenticación” en tiempo de ejecución, no porque un test lo atrapara, lo cual es un poco inquietante. Reordené para que la clase de error más específica se revise primero.

También hice que el rescue del sondeo no vuelva a lanzar la excepción, a propósito, lo opuesto a lo que hacen los jobs de publicación. Los jobs de publicación vuelven a lanzar para que la maquinaria retry_on/discard_on de ActiveJob se haga cargo. Pero los jobs de sondeo ya corren su propio loop de reprogramación con su propio contador de intentos. Si dejara que un error rescatado también disparara retry_on, tendría dos mecanismos de reintento independientes contando contra el mismo problema, y el tope efectivo de intentos dejaría de tener sentido.

Dejé explícitamente dos cosas fuera de alcance aunque el issue original las mencionara: memoizar AppSetting.current (eso es su propio ticket, y una memoización ingenua habría roto cada test que llama a AppSetting.current.update! en el setup), y renombrar Post#article_entry a algo más claro. Ninguna de las dos necesitaba moverse para que este refactor aterrizara limpio, y ambas habrían ampliado el diff hacia territorio no relacionado.

Lo que me sorprendió

Cuánto se parecían los dos jobs de sondeo, como gemelos, hasta el momento exacto en que dejaron de parecerlo. Mismo MAX_ATTEMPTS = 12. Misma fórmula de backoff, copiada carácter por carácter: 30 * (2**(attempt - 1)) con un tope de 15 minutos. Al comparar ambos lado a lado se ven apenas cuatro líneas que realmente difieren. Pero una de esas cuatro líneas es “qué pasa cuando se abandona”, y esa es exactamente la línea que nadie vuelve a revisar una vez que el primer job se publica y funciona. El segundo job se escribe copiando el primero, y el único lugar donde debía copiarse fielmente es el único lugar donde alguien cambió algo sin querer enviar ninguna señal.

La otra sorpresa fue más pequeña: los tests unitarios de estos jobs verifican cambios de estado y jobs encolados, nunca lo que realmente se renderiza en la transmisión. Un refactor que produjera en silencio la ruta de parcial equivocada pasaría bin/rails test sin ninguna falla y solo se rompería en el navegador. Terminé verificando to_partial_path para los tres tipos de registro en una consola, y después disparando una transmisión real por tipo y confirmando que un mensaje realmente llegara a solid_cable, porque esa es la única forma de ejercitar de verdad la derivación de la que depende el concern.

Lo que sigue

El ticket de seguimiento retoma la memoización de AppSetting y algunos otros ítems de higiene de esquema y código. La interfaz de Dev.to todavía muestra “Publishing disabled” incluso para un post que ahora está correctamente marcado como fallido después de este cambio, lo cual se lee bien (el badge dice ERROR, el texto explica que todavía no hay camino de reintento), pero un botón de reintento real sigue supeditado a verificar la publicación nativa a Dev.to de punta a punta.

Lecturas relacionadas