Operaciones globales de tags, y el guard de concurrencia que no lo era
Este llevaba en el backlog desde que se separó de una especificación más grande del editor de posts, hace meses: un índice de tags con conteo de posts, más renombrar/fusionar en todos los posts de un blog. Prioridad baja, marcado “v2”. Lo tomé sobre todo porque parecía autocontenido.
No lo era, de una forma interesante: el propio texto del ticket señalaba una dependencia que resultó no ser real, y la implementación real reveló un vacío genuino en cómo venía razonando sobre la seguridad de los jobs en segundo plano.
La dependencia que no era tal
El ticket decía que los renombrados pasan “por el camino de escritura del editor”. Hay un ticket totalmente separado dedicado justo a eso, un botón de commit interactivo para un solo post, todavía bloqueado. Leído de forma literal, este parecía no poder empezar hasta que aquel se completara.
Leí de verdad para qué sirve ese ticket bloqueado antes de aceptar eso. Es específicamente sobre resolver “¿cambió el archivo en GitHub mientras una persona estaba mirando el editor en medio de una edición?”, necesita un base_file_sha guardado en un registro de borrador precisamente porque una sesión de navegador abarca varias solicitudes y el archivo se podría desviar en el medio. Un renombrado en lote no tiene nada de ese problema. Obtiene el sha actual de un archivo y lo vuelve a escribir dentro de la misma ejecución síncrona del job, sin ninguna brecha de múltiples solicitudes en la que algo se pueda desviar. Los primitivos de escritura que de verdad necesitaba (un reescritor de frontmatter YAML sin pérdida, una escritura a GitHub con chequeo de conflicto basado en sha) ya existían y ya estaban probados funcionando sin ningún modelo de borrador, en la misma forma en que la función de commit de imagen destacada se había lanzado antes.
Entonces el planteamiento del propio ticket estaba un poco equivocado, y tomarlo al pie de la letra habría significado no construir nada (esperando un ticket bloqueado) o reinventar por accidente el alcance de ese ticket dentro de un job en lote que no lo necesitaba. Valió la pena leer veinte minutos extra antes de escribir código.
El diseño que casi lancé mal
Renombrar un tag en N posts significa N escrituras separadas a GitHub. Algunas podían fallar: un sha desactualizado, un problema de red, lo que sea. Diseñé esto como un solo job que recorre cada post afectado, atrapa el fallo de cada post por separado, y sigue adelante, así un archivo malo no bloquea a los otros 39. Al final registra un resumen y continúa.
Esa parte estaba bien. Lo que hice mal: el job nunca volvía a lanzar nada. Un lote donde 2 de 40 posts fallaban se veía, desde afuera, idéntico a un lote donde los 40 tuvieron éxito, una entrada verde normal de “completado” en el dashboard de jobs. El único rastro del fallo era una línea de log que nadie tenía motivo para ir a buscar.
Había decidido explícitamente durante la planificación que un fallo parcial era un compromiso aceptable para v1, siempre que “volver a correr la operación” fuera un camino de recuperación real. La revisión de código señaló, con razón, que un camino de recuperación solo funciona si algo avisa que hace falta. Había construido la mitad de “seguro para volver a correr” y dejado caer en silencio la mitad de “cómo se enteraría alguien”. Se arregló haciendo que el job lance una excepción resumen después del loop si algo falló, sin interrumpir el loop en sí, solo mostrándolo al final para que el job aparezca como fallido/reintentando igual que cualquier otro job de esta app. De regalo vino un reintento automático, que resultó importar más de lo esperado (siguiente sección).
El guard de concurrencia que en realidad no era un guard
Este es el que más me molesta no haber revisado antes. Ahora dos jobs distintos escriben en el mismo repositorio de GitHub de un blog: el job existente de commit de imagen destacada, y este nuevo job de renombrado de tags. Le di a ambos la misma clave limits_concurrency, asumiendo que eso los serializaría entre sí para que nunca compitieran por el mismo archivo.
No lo hace. Solo lo encontré porque una revisión fue y leyó el código fuente real de la gem en vez de confiar en la API de superficie. La clave de concurrencia de Rails en realidad es [group, key].join, y el group por defecto es el nombre de la propia clase del job, salvo que se indique lo contrario. Misma cadena de clave, distintas clases de job, distintos groups, distinta clave final: ningún lock compartido en absoluto. Toda mi suposición estaba mal, y nada en el código lo iba a decir, hace en silencio algo distinto de lo que aparenta hacer.
La red de seguridad real todo este tiempo fue el propio chequeo de conflicto de GitHub sobre el sha del archivo: real, y suficiente para evitar corrupción, pero no lo que había diseñado, y tampoco algo para lo que mi job estuviera preparado para reintentar (esa brecha se cerró con el mismo arreglo de la sección anterior, ya que un conflicto de sha es exactamente el tipo de fallo transitorio que ahora cubre el nuevo reintento). Se arregló con un group compartido explícito entre los dos jobs, para que ahora de verdad compartan un slot por blog, tal como se había pensado originalmente.
Cosas más chicas
Los nombres de tags vienen de frontmatter de texto libre, así que nada impide que uno contenga una barra. Había puesto el nombre del tag directamente en un segmento de la ruta URL para la ruta de renombrado, lo que significaba que toda la página de tags se caía apenas un post tuviera un tag como “ci/cd”, no solo el botón de renombrado de esa fila. Se movió el renombrado a una URL fija con el nombre del tag en el cuerpo de la solicitud en vez de en la ruta, lo que evita toda esa clase de problema de “qué caracteres rompen el enrutamiento”.
También se fusionaron tres copias casi idénticas de la misma lógica de “revisar primero la ruta con prefijo de idioma, y si no, la ruta plana” para buscar archivos. Dos de ellas tenían comentarios que decían literalmente “mirrors X’s version” sin que nadie la hubiera extraído. A la tercera vez sí se hizo.
Lo que sorprendió
Probar esto contra datos reales (saneados, de dev) en vez de solo fixtures dio resultado de inmediato: el contenido real de vdaluz.com tiene 465 tags distintos, incluyendo un duplicado de mayúsculas “Ansible”/“ansible” ahí mismo en vivo, que se convirtió en el caso de prueba natural para el comportamiento de fusión en vez de algo sintético. También sacó a la luz el riesgo de barras en tags de forma empírica en vez de teórica, porque se pudo ver la forma real de los datos antes de comprometerse con un diseño de URL.
Qué sigue
Nada más en cola para este ticket específico. Si una necesidad futura quiere que los tags registrados en la base de datos sigan el desvío de los archivos en vivo de forma más rigurosa, o quiere visibilidad entre pestañas de un renombrado en curso, esas son extensiones sobre esto, no correcciones.
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ó.