Ir para o conteúdo

Blogue ·

Duas rondas, um número de campo

Dois ramos acrescentaram uma variante à mesma mensagem protobuf no mesmo dia e ambos escolheram o mesmo número. Quase nada disso falha de forma ruidosa.

Por Provibr

Dois trabalhos correram em paralelo sobre o mesmo repositório. Um acrescentou a capacidade de perguntar a um hipervisor o que foi feito de uma tarefa em segundo plano. O outro acrescentou a capacidade de reinstalar um contentor. Ambos precisavam de uma variante nova na mesma mensagem protobuf, ambos olharam para o ficheiro, ambos viram que o número mais alto em uso era o trinta e três, e ambos escreveram trinta e quatro.

Nenhum dos dois foi descuidado. Cada um leu o ficheiro corretamente no momento em que criou o seu ramo. A colisão só existe num terceiro estado que nenhum deles chegou a compilar: a fusão.

Onde a colisão apareceO que acontece
Nos dois ramos separadamenteNada. Cada um está correto face ao seu próprio ponto de partida
Na fusão, no ficheiro de esquemaUm conflito e, se ambos forem mantidos, um erro duro de compilação
Na fusão, no código gerado que está no repositórioUm conflito e, se ambos forem mantidos, nada de nada
No verificador de compatibilidadeVerde nos dois ramos, porque compara cada um com a sua própria linha de base
Um agente e um gateway em produção que discordamNunca chegou aqui: um comando que chegaria vazio, comunicado a camadas de distância da causa

O que a ligação transporta de facto

O nome de um campo protobuf é uma comodidade para humanos. O que passa pela ligação é o número, deslocado três bits para a esquerda com um indicador de tipo nos bits baixos, pelo que o campo trinta e quatro com uma carga útil prefixada pelo comprimento é escrito como a sequência de bytes correspondente a duzentos e setenta e quatro. Mude-se o nome de um campo e não muda nada na ligação binária. Mude-se o seu número e todos os leitores em produção passam a descodificar outra coisa com a mesma chave.

É por isso que um número, uma vez saído numa versão publicada, fica congelado. Não é uma regra de estilo. É o único identificador que as duas pontas partilham.

O que as ferramentas dizem, e o que não dizem

O Git deu por isso. O conflito apareceu no ficheiro de esquema e no TypeScript gerado que está no repositório, das duas vezes como acrescentos sobrepostos no mesmo sítio. A resolução tentadora é aquela que parece obviamente correta para duas funcionalidades independentes: ficar com as duas.

No ficheiro de esquema isso é apanhado. Compilá-lo produz uma recusa seca a nomear a mensagem e o campo que já detém o número, o que foi medido e não presumido: uma mensagem de teste com duas variantes, ambas no trinta e quatro, é rejeitada com linha e coluna e com uma sugestão do número livre seguinte.

O TypeScript gerado é outra história, e é a razão pela qual isto vale a pena ser escrito. Esse ficheiro está no repositório, porque o trabalho do dia a dia não deve exigir ter instalada a cadeia de ferramentas do esquema. O custo dessa escolha é que ele participa nas fusões como qualquer outro ficheiro de código, e é código vulgar. Um ramo duplicado numa instrução switch é TypeScript legal. Compilar um ficheiro pequeno com duas etiquetas case idênticas, ambas a transportar a verificação de etiqueta do campo trinta e quatro, não produz diagnóstico nenhum e termina de forma limpa. O segundo ramo simplesmente nunca é alcançado.

Portanto um ficheiro gerado fundido à mão dá um codificador que escreve a mesma etiqueta para duas mensagens diferentes e um descodificador que a resolve sempre para aquela que estiver listada primeiro. Nada falha na construção, nada falha no arranque, e nada falha na primeira mensagem. Uma das duas funcionalidades está simplesmente errada, de uma maneira que só se manifesta na máquina do outro lado da ligação.

Porque é que a verificação de compatibilidade estava verde

O esquema é vigiado por um verificador de alterações incompatíveis, e ele fez o seu trabalho. Compara um esquema proposto com uma linha de base. Acrescentar uma variante nova a uma escolha existente não é uma alteração incompatível, e não o foi em nenhum dos ramos, porque a linha de base de cada ramo genuinamente não tinha o trinta e quatro em uso.

Esta é a forma geral do problema e vale a pena enunciá-la em separado do protobuf: uma verificação que compara um ramo com o seu próprio ponto de partida não consegue ver um conflito que só existe entre dois ramos. A mesma classe de defeito aparece sempre que trabalho paralelo escolhe um valor num espaço partilhado e ordenado. As migrações de base de dados aqui são escolhidas pela marca temporal que consta do seu diário, e essas marcas temporais são números redondos escritos à mão em vez de lidos de um relógio verdadeiro, pelo que duas rondas que escrevam uma cada escolhem a sua; uma migração cuja marca temporal fique abaixo de uma que já foi aplicada é saltada para sempre, e o migrador continua a comunicar que o esquema está atualizado. Mecanismo diferente, o mesmo silêncio, a mesma causa.

Porque o silêncio é a parte cara

Se os dois números tivessem sobrevivido até uma versão publicada, a falha teria sido remota e desconcertante. O protobuf descarta um campo de escolha que não reconhece, pelo que um agente construído a partir de um dos ramos, ao receber um comando do outro, não obtém erro nenhum. Obtém um comando sem nada lá dentro, e comunica qualquer coisa como «a mensagem não transportava instrução nenhuma». A causa está a camadas de distância da frase, numa máquina que pertence a outra pessoa, e quem a lê não tem razão nenhuma para desconfiar de um número.

A plataforma já recusa enviar instruções a agentes que não tenham anunciado a capacidade correspondente, precisamente porque este modo de falha é tão pouco prestável: um agente que é apenas demasiado antigo produz pelo menos uma recusa escrita pela plataforma, a nomear a funcionalidade e a versão. Essa salvaguarda cobre o desfasamento de versões. Não cobre dois números que significam duas coisas, porque do lado da plataforma não há absolutamente nada desatualizado.

A regra que daí saiu

Um número de campo que já saiu numa versão publicada fica congelado, portanto é a ronda que ainda não saiu que se muda. O trinta e quatro já tinha saído numa versão publicada do agente como variante de estado de tarefa, pelo que, na fusão, a variante de reinstalação de contentor passou a trinta e cinco, e o código gerado foi gerado de novo a partir do esquema em vez de fundido à mão. Voltar a gerar é a metade importante: uma fusão manual de saída gerada é um codificador escrito à mão que ninguém reviu como tal.

A instrução prática para quem acrescentar o próximo é curta. Ler o número mais alto na linha principal no momento do commit, e não no momento em que o ramo foi criado. E todas as fusões que toquem no esquema levam essa verificação de propósito, feita por uma pessoa, porque nenhuma ferramenta da cadeia está a olhar para isto desse ângulo.

Reservas, e porque vale a pena honrá-las

O mesmo esquema mostra o aspeto desta disciplina quando ela funciona. Há três números reservados para integrações que foram planeadas e nunca construídas, de modo a que ninguém os possa reutilizar em silêncio. Dois outros ficaram guardados para dois painéis de controlo específicos, e ambos acabaram por ser preenchidos pelas rondas a que tinham sido prometidos. Um terceiro painel, que calhou ficar pronto mais cedo, não ficou com nenhum deles; ficou com o número livre seguinte, mais à frente.

É esse todo o sentido de uma reserva. Comer uma delas por se calhar estar pronto primeiro é exatamente a maneira de um esquema de numeração deixar de significar seja o que for, e o comentário no ficheiro di-lo com todas as letras.

Preencher um número reservado conforme o previsto é de facto comunicado pelo verificador de compatibilidade, como a remoção de uma reserva. Esse relatório é esperado e é correto aceitá-lo: nunca houve campo nenhum a transportar esse número, portanto leitor nenhum em produção pode ter opinião sobre ele. Vale a pena saber isto de antemão, porque à primeira vez parece alarmante e a tentação é escolher um número diferente para calar a ferramenta, o que deita fora a reserva que se estava a honrar.

O que registaríamos

  1. Tratar um número já publicado como imutável e tratar «o número mais alto que consigo ver» como uma afirmação sobre o ramo próprio e não sobre o projeto.
  2. Ler o espaço partilhado no momento do commit, e não no momento de criar o ramo. É esse o único momento em que o valor está de facto a ser reclamado.
  3. Voltar a gerar o código gerado, nunca fundi-lo. Um conflito em saída gerada é um sinal para correr o gerador, não um convite a editar.
  4. Saber quais das verificações são relativas a uma linha de base. Não conseguem ver colisões entre irmãos, e essa lacuna precisa de uma pessoa na fusão.
  5. Reservar os números prometidos e os planeados, com um comentário a dizer a quem pertencem, e aceitar o ruído que preencher um deles corretamente produz.
  6. Procurar a mesma forma noutros sítios. Qualquer valor atribuído à mão num espaço partilhado e ordenado, migrações incluídas, tem esta falha, e habitualmente tem-na em silêncio.

Para ser claro: nada foi publicado com a colisão lá dentro. Foi apanhada na fusão, a variante mais tardia mudou-se, o código gerado foi reconstruído a partir do esquema, e as duas funcionalidades saíram como versões separadas do agente, com horas de diferença. O que a tornou apanhável foi alguém ter lido o conflito em vez de o resolver, o que não é um processo que se possa automatizar, mas é um processo que se pode escrever.

Do trabalho de plataforma no Provibr. A versão em base de dados desta mesma falha está em a migração que nunca correu, e a razão pela qual uma atualização assinada do agente se recusa a si própria em vez de confiar em quem a envia está em como uma atualização assinada se recusa.

Comece com um host e um agente

Crie uma conta, instale o agente com um único comando e ligue o seu primeiro host Proxmox. O período experimental dura 14 dias e não pede quaisquer dados de pagamento.