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 aparece | O que acontece |
|---|---|
| Nos dois ramos separadamente | Nada. Cada um está correto face ao seu próprio ponto de partida |
| Na fusão, no ficheiro de esquema | Um conflito e, se ambos forem mantidos, um erro duro de compilação |
| Na fusão, no código gerado que está no repositório | Um conflito e, se ambos forem mantidos, nada de nada |
| No verificador de compatibilidade | Verde nos dois ramos, porque compara cada um com a sua própria linha de base |
| Um agente e um gateway em produção que discordam | Nunca 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
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.