Ir al contenido

Blog ·

Dos rondas, un mismo número de campo

Dos ramas añadieron una variante al mismo mensaje protobuf el mismo día y las dos eligieron el mismo número. Casi nada de eso falla de forma ruidosa.

Por Provibr

Dos trabajos corrían en paralelo sobre el mismo repositorio. Uno añadía la capacidad de preguntarle a un hipervisor qué fue de una tarea en segundo plano. El otro añadía la capacidad de reinstalar un contenedor. Los dos necesitaban una variante nueva en el mismo mensaje protobuf, los dos miraron el fichero, los dos vieron que el número más alto en uso era el treinta y tres, y los dos escribieron treinta y cuatro.

Ninguno fue descuidado. Cada uno leyó el fichero correctamente en el momento en que se ramificó. La colisión existe solo en un tercer estado que ninguno de los dos llegó a compilar: la fusión.

Dónde aparece la colisiónQué ocurre
Las dos ramas por separadoNada. Cada una es correcta frente a su propio punto de partida
La fusión, en el fichero de esquemaUn conflicto y, si se conservan las dos, un error duro del compilador
La fusión, en el código generado que está en el repositorioUn conflicto y, si se conservan las dos, absolutamente nada
El comprobador de compatibilidadVerde en las dos ramas, porque compara cada una con su propia línea base
Un agente y una pasarela desplegados que no coincidenNunca se llegó hasta aquí: un comando que llegaría vacío, del que se informa a capas de distancia de la causa

Lo que lleva realmente la línea

El nombre de un campo protobuf es una comodidad para las personas. Lo que va por la conexión es el número, desplazado tres bits a la izquierda y con un indicador de tipo en los bits bajos, de modo que el campo treinta y cuatro con una carga útil precedida de su longitud se escribe como la secuencia de bytes correspondiente a doscientos setenta y cuatro. Al renombrar un campo no cambia nada de lo que va por la conexión binaria. Al cambiar su número, todo lector desplegado está descodificando ahora otra cosa con la misma clave.

Por eso un número, una vez que ha salido en una versión publicada, queda congelado. No es una regla de estilo. Es el único identificador que comparten los dos extremos.

Lo que dicen las herramientas, y lo que no

Git se dio cuenta. El conflicto apareció en el fichero de esquema y en el TypeScript generado que está en el repositorio, las dos veces como adiciones solapadas en el mismo sitio. La resolución tentadora es la que parece evidentemente correcta para dos funcionalidades independientes: conservar las dos.

En el fichero de esquema eso se detecta. Compilarlo produce una negativa rotunda que nombra el mensaje y el campo que ya ocupa el número, cosa que se midió en lugar de suponerse: un mensaje de prueba con dos variantes, las dos en el treinta y cuatro, se rechaza con una línea, una columna y una sugerencia del siguiente número libre.

El TypeScript generado es otra historia, y es la razón por la que esto merece dejarse por escrito. Ese fichero está en el repositorio, porque el trabajo diario no debería exigir tener instaladas las herramientas del esquema. El coste de esa elección es que participa en las fusiones como cualquier otro fichero fuente, y es código corriente. Una rama duplicada en una sentencia switch es TypeScript legal. Compilar un fichero pequeño con dos etiquetas case idénticas, las dos con la comprobación de etiqueta del campo treinta y cuatro, no produce ningún diagnóstico y termina limpiamente. A la segunda rama sencillamente no se llega nunca.

Así que un fichero generado fusionado a mano da un codificador que escribe la misma etiqueta para dos mensajes distintos y un descodificador que la resuelve siempre hacia el que aparezca primero. Nada falla al compilar, nada falla al arrancar y nada falla con el primer mensaje. Una de las dos funcionalidades está sencillamente mal, de una manera que solo se manifiesta en la máquina que hay al otro extremo de la conexión.

Por qué la comprobación de compatibilidad estaba en verde

El esquema está vigilado por un comprobador de cambios rompedores, y cumplió con su trabajo. Compara un esquema propuesto con una línea base. Añadir una variante nueva a una elección existente no es un cambio rompedor, y no lo era en ninguna de las dos ramas, porque la línea base de cada rama genuinamente no tenía el treinta y cuatro en uso.

Esta es la forma general del problema y merece la pena enunciarla al margen de protobuf: una comprobación que compara una rama con su propio punto de partida no puede ver un conflicto que solo existe entre dos ramas. La misma clase de error aparece siempre que un trabajo en paralelo escoge un valor de un espacio compartido y ordenado. Las migraciones de base de datos aquí se eligen por la marca de tiempo que llevan en su diario, y esas marcas son números redondos escritos a mano y no leídos de un reloj de verdad, así que dos rondas que escriben una cada una eligen la suya; una migración cuya marca de tiempo cae por debajo de una que ya se ha aplicado se salta de forma permanente, y el migrador informa igualmente de que el esquema está al día. Mecanismo distinto, mismo silencio, misma causa.

Por qué el silencio es la parte cara

Si los dos números hubieran sobrevivido hasta una versión publicada, el fallo habría sido remoto y desconcertante. Protobuf descarta un campo de elección que no reconoce, así que un agente construido a partir de una rama que recibe un comando de la otra no obtiene un error. Obtiene un comando sin nada dentro, e informa de algo parecido a «el mensaje no llevaba ninguna instrucción». La causa está a capas de distancia de la frase, en una máquina que pertenece a otra persona, y quien la lee no tiene ningún motivo para sospechar de un número.

La plataforma ya se niega a enviar instrucciones a agentes que no han anunciado la capacidad correspondiente, precisamente porque este modo de fallo resulta muy poco útil: un agente que sencillamente es demasiado antiguo produce al menos una negativa escrita por la plataforma, que nombra la funcionalidad y la versión. Esa protección cubre el desfase de versiones. No cubre dos números que significan dos cosas, porque desde el lado de la plataforma no hay nada desactualizado.

La regla que salió de esto

Un número de campo que ha salido en una versión publicada queda congelado, así que la que se mueve es la ronda que todavía no ha salido. El treinta y cuatro ya había salido en una versión del agente como la variante del estado de tarea, así que en la fusión la variante de reinstalación de contenedor pasó a ser el treinta y cinco, y el código generado se regeneró a partir del esquema en lugar de fusionarse a mano. La regeneración es la mitad importante: una fusión manual de salida generada es un codificador escrito a mano que nadie revisó como tal.

La instrucción práctica para quien añada el siguiente es corta. Leer el número más alto de la línea principal en el momento de hacer el commit, no en el momento de ramificar. Y en cada fusión que toque el esquema eso se comprueba a propósito, por una persona, porque ninguna herramienta de la cadena lo está mirando desde ese ángulo.

Los números reservados, y por qué merece la pena respetarlos

El mismo esquema enseña qué aspecto tiene esta disciplina cuando funciona. Hay tres números reservados para integraciones que se planificaron y nunca se construyeron, de modo que nadie puede reutilizarlos en silencio. Otros dos se guardaron para dos paneles de control concretos, y los dos acabaron ocupados por las rondas a las que se les habían prometido. Un tercer panel que dio la casualidad de estar listo antes no se quedó con ninguno de ellos; tomó en cambio el siguiente número libre más adelante.

Ese es todo el sentido de una reserva. Comerse una porque uno resultó estar listo antes es exactamente la manera de que un esquema de numeración deje de significar nada, y el comentario del fichero lo dice con todas las letras.

Ocupar un número reservado tal como estaba previsto sí que lo informa el comprobador de cambios rompedores, como la retirada de una reserva. Ese informe se espera y es correcto aceptarlo: ningún campo llevó nunca ese número, así que ningún lector desplegado puede tener una opinión al respecto. Merece la pena saberlo de antemano, porque la primera vez parece alarmante y la tentación es elegir otro número para que la herramienta se calle, lo cual tira por la borda la reserva que se estaba respetando.

Lo que dejaríamos por escrito

  1. Tratar un número publicado como inmutable, y tratar «el número más alto que veo» como una afirmación sobre la propia rama y no sobre el proyecto.
  2. Leer el espacio compartido en el momento del commit, no en el de la ramificación. Ese es el único momento en que el valor se está reclamando de verdad.
  3. Regenerar el código generado, nunca fusionarlo. Un conflicto en la salida generada es una señal para ejecutar el generador, no una invitación a editar.
  4. Saber cuáles de las comprobaciones son relativas a una línea base. No pueden ver colisiones entre hermanas, y ese hueco necesita a una persona en la fusión.
  5. Reservar los números prometidos o planificados, con un comentario que diga a quién pertenecen, y aceptar el ruido que produce ocupar uno correctamente.
  6. Buscar la misma forma en otros sitios. Cualquier valor asignado a mano en un espacio compartido y ordenado, migraciones incluidas, tiene este fallo y suele tenerlo en silencio.

Para dejarlo claro: no se publicó nada con la colisión dentro. Se cazó en la fusión, la variante posterior se movió, el código generado se reconstruyó a partir del esquema, y las dos funcionalidades salieron como versiones de agente separadas con horas de diferencia. Lo que la hizo cazable fue que alguien leyó el conflicto en vez de resolverlo, cosa que no es un proceso que se pueda automatizar pero sí uno que se puede dejar por escrito.

Del trabajo de plataforma en Provibr. La versión del mismo fallo en la base de datos está en la migración que nunca se ejecutó, y por qué una actualización firmada del agente se rechaza a sí misma en vez de fiarse del remitente está en una actualización firmada que se rechaza sola.

Empiece con un host y un agente

Cree una cuenta, instale el agente con un solo comando y conecte su primer host de Proxmox. El periodo de prueba dura 14 días y no pide ningún dato de pago.