Backfill de la realidad en un rastreador de sindicación
Hace unos días publiqué la interfaz que muestra cuáles de mis posts están en Medium. Después la abrí en producción y decía que ninguno lo estaba. 169 posts, cero entradas de Medium. El modelo de datos estaba bien; a la base de datos de producción simplemente nunca se le había dicho lo que dev ya sabía.
La parte fácil: volver a correr el backfill codificado
El trabajo de reconciliación anterior vivía en rake tasks, no en el historial de la shell de alguien, y eso rindió frutos de inmediato. medium:seed_published y medium:seed_scheduled se publicaron dentro de la imagen de producción, y backfill_medium! solo mejora el estado, nunca lo empeora, así que correrlos contra prod fue un no-evento: 76 posts marcados como publicados, 8 programados, 0 discrepancias de slug. Si alguna vez surge la tentación de hacer un arreglo de datos puntual a mano en una consola, este es el argumento para escribir en su lugar la rake task descartable, el “puntual” corrió dos veces.
En el camino surgió un prerrequisito: producción no tenía clave de API de Postiz, lo que significaba que el job de reconciliación por hora se había estado saltando en silencio desde el despliegue. Pasé la clave desde la base de datos de dev por stdin para que nunca tocara una terminal, argumentos de proceso, ni el historial de la shell.
El reconciliador estaba roto en todos lados y nadie se dio cuenta
El paso cuatro de mi plan era “correr el reconciliador de RSS para obtener las URLs reales”. Se cayó: ArgumentError: not an HTTP URI. El reconciliador construye la URL del feed a partir del campo profile de la integración de Postiz y asumía que era una URL. Postiz devuelve el handle sin más, “vdaluz”. URI.parse("vdaluz") no tiene host, el código construía "vdaluz/feed", y Net::HTTP lo rechazaba. Mismo fallo en dev, lo que significa que el job por hora había estado fallando en todos lados y lo que iba a marcar mis 8 posts programados como publicados cuando salieran al aire no funcionaba en absoluto.
La solución fue pequeña, pero la revisión la mejoró. Mi primera versión mapeaba cualquier profile sin host a un feed de handle de medium.com. La revisión señaló que URI.parse no devuelve host para NINGUNA cadena sin esquema, así que un dominio personalizado guardado como “blog.example.com” se convertiría en silencio en medium.com/feed/@blog.example.com, obtendría un 404, se interpretaría como un feed vacío, y reportaría éxito para siempre. La versión que se publicó solo trata como handles a las cadenas con forma de handle (sin puntos, sin barras), lanza un error con nombre para cualquier cosa no reconocida, y lanza un error ante respuestas que no sean 2xx. Ahora, una suposición equivocada falla ruidosamente en la cola de jobs en vez de deshabilitar la reconciliación en silencio.
Extrayendo URLs de un dashboard renderizado en el cliente
66 de los posts del backfill no tenían URL de Medium, la interfaz mostraba “Published on Medium” sin nada para hacer clic. Medium mató su API, y el RSS solo expone las 10 historias más recientes, así que la lista completa existe en exactamente un solo lugar: el dashboard de historias con sesión iniciada. Guardar esa página da un shell de React de 99KB sin ningún dato de historias. El truco que funcionó:
// on medium.com/me/stories/public, scrolled to the bottom:
copy(document.body.outerHTML);
// then: pbpaste > /tmp/published_dom.html
El DOM renderizado tiene URLs públicas directas para cada historia. Un script de Nokogiri emparejó 76 de 77 historias con posts por título normalizado, los desajustes eran Medium agregando mi nombre a algunos títulos (con un espacio de no separación, por supuesto) y un título que había reescrito desde 2022. La historia 77 es un duplicado exclusivo de Medium que me negué a adivinar. Un script idempotente de runner después, producción tiene enlaces en los 76 posts publicados. Medium responde 403 a cualquier cliente que no sea un navegador, así que verifiqué por procedencia en vez de con curl.
El fallo de CI que era tres fallos disfrazados de uno
Mergear la solución del reconciliador tomó más tiempo que escribirla. La build de CI falló con bundle: command not found, el runner acababa de reubicarse a un contenedor dedicado y se había saltado el paso de configuración del PATH. La solución documentada agregó PATH=...:$PATH al .env del runner, y después de reiniciar el servicio el CI falló más temprano, con tar: command not found. Ahí fue cuando aprendí que el runner de actions no expande variables dentro de .env: los jobs ahora tenían un PATH con una cadena literal de signo de dólar y sin /usr/bin. El mecanismo real es el archivo .path, que contiene el PATH literal completo. Tercer fallo: Errno::ENOENT - mise durante el bundle install, porque el plugin de RubyGems de mise ejecuta mise reshim después de cada instalación de gema, así que el binario de mise también necesita estar en el PATH del job.
Cada solución movía el fallo un paso más adentro del job, que es la barra de progreso más honesta que puede dar el CI. La parte que sigo reaprendiendo: el paso del runbook había “funcionado” antes solo porque nadie había reiniciado el servicio del runner después de editar .env. Una configuración que nunca se recargó es una configuración que nunca se probó.
Lo que sigue
Los 8 posts programados se publican semanalmente hasta fines de agosto; la reconciliación por hora ya corregida debería pasar cada uno a publicado con su URL, sin intervención. Esa es la prueba real de toda esta cadena. La corrección del runbook ya está registrada, y el aprovisionamiento del runner se está codificando en Ansible para que la lección del PATH quede grabada una sola vez en vez de tener que reaprenderse.
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
Proton Mail
Correo electrónico cifrado de extremo a extremo, con arquitectura de acceso cero.
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ónNordPass
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ó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