Engenharia

Dois agents escreveram no mesmo arquivo. Quase lançamos um banco de dados que não daria boot.

Paralelismo não falha em alto e bom som, ele falha no único slot pelo qual dois escritores estenderam a mão.

ASR

Apollo Space Research

Apollo Space

· 12 min de leitura

Dois agents pegaram o mesmo número de migration. Não de propósito, cada um trabalhava sozinho, na sua própria cópia do repo, na sua própria feature, e cada um precisava do próximo slot na pasta de migrations. Os dois olharam. Os dois viram que o último arquivo era 0041. Os dois escreveram 0042. Dois 0042s diferentes, dois schemas diferentes, ambos verdes em isolamento, ambos mergeados dentro da mesma hora. O banco de dados que saiu do outro lado não conseguiu decidir qual 0042 era o real, e um boot do zero rodou um deles, depois engasgou no outro.

Nada quebrou quando deveria. Cada branch passou nos seus próprios testes. O CI de cada PR era uma parede de checkmarks verdes. A falha só existia no lugar que nenhum branch sozinho conseguia ver: a costura onde eles se encontraram.

Paralelismo não falha em alto e bom som. Ele falha no único slot pelo qual dois escritores estenderam a mão.

Este post é sobre essa costura, por que rodar muitos agents em paralelo cria uma falha que nenhum agent individual consegue pegar, e a disciplina que a fecha. A correção não é agents mais espertos. É uma regra sobre quem tem permissão de tocar o quê.

Por que rodamos muitos agents de uma vez

Comece com por que a colisão era sequer possível, porque o instinto é concluir que o paralelismo é o erro. Não é.

Uma codebase de verdade tem mais trabalho do que qualquer agent sozinho consegue segurar na cabeça. Refatorar o módulo de billing, adicionar uma página de settings, consertar o teste flaky, estender o schema, esses são trabalhos independentes, e forçá-los por um agent um de cada vez transforma um dia de trabalho numa semana. Então nós os espalhamos. Cada agent recebe sua própria tarefa e seu próprio checkout isolado do repositório, planeja, escreve, testa, e abre uma mudança. Vários rodam de uma vez. O ponto inteiro é que eles não esperam uns pelos outros.

E para o trabalho que é genuinamente independente, isso é quase velocidade de graça. Um agent editando a página de settings nunca lê as mesmas linhas que um agent editando billing. Eles terminam, eles mergeam, nada se toca.

O problema começa no momento em que “independente” se revela uma mentira. Duas tarefas que não têm nada a ver uma com a outra ainda podem ambas precisar do mesmo recurso compartilhado, e uma pasta de migrations é exatamente isso. É uma fila com um único próximo slot, e toda feature que toca o banco de dados estende a mão para ela. Dois agents, duas features não relacionadas, um slot. Nenhum dos dois fez nada errado. Eles só estenderam a mão para a mesma coisa enquanto olhavam para longe um do outro.

Três agents trabalham em cópias isoladas de um repo em tarefas não relacionadas, mas os três estendem a mão para a mesma pasta de migrations compartilhada pelo próximo número, a contenção escondida que o isolamento esconde.

Esse é o formato do problema inteiro. O isolamento torna cada agent rápido e cada agent cego. Ele garante que eles não vão pisar nas linhas uns dos outros, e silenciosamente não diz nada sobre os recursos que todos eles compartilham.

A versão ingênua: só mergeia tudo e deixa os testes pegarem

A resposta óbvia é a que começamos, e vale a pena encenar a dor, porque é a resposta que a maioria dos times busca primeiro.

Você roda os agents, cada um abre um PR, cada PR tem seu próprio CI, e você confia nos checkmarks verdes. Se algo está quebrado, os testes vão pegar. É o contrato que o CI deveria honrar.

Aqui está por que ele não se sustenta. O CI de cada PR builda o branch daquele PR, a base mais as mudanças de um agent. No branch A, há exatamente um 0042, o schema migra limpo, todos os testes passam. No branch B, há também exatamente um 0042, um diferente, e ele também migra limpo e passa. Cada branch é internamente consistente. Cada um está correto contra o mundo em que foi testado. O conflito não vive em nenhum dos branches. Ele nasce no merge, quando os dois 0042s caem na mesma árvore, e a coisa que testou bem é agora uma pasta com dois arquivos reivindicando a mesma posição.

Cada branch estava verde. O bug estava na união dos branches, e nenhum teste de branch consegue ver a união.

Essa é a parte que pega as pessoas. Somos treinados a confiar numa suíte de testes verde, e a suíte não estava mentindo, ela corretamente reportou que cada mudança estava bem por conta própria. A falha estava em o que foi testado. Ninguém rodou a suíte contra as duas mudanças ao mesmo tempo, porque no momento em que cada suíte rodou, a outra mudança ainda não existia. Um teste só consegue falhar num estado que de fato avalia, e o estado colidente aparece pela primeira vez depois dos dois merges, quando nenhum teste está observando.

Pior, o git em si frequentemente não vai te salvar. Um conflito de merge de verdade, duas edições nas mesmas linhas, é sinalizado e bloqueia o merge. Mas dois arquivos novos, 0042_add_invoices.sql e 0042_add_audit_log.sql, não se sobrepõem em um único caractere. O git os mergeia feliz. Não há conflito para resolver, porque o conflito não é textual. É semântico: a pasta tem uma regra que o git nunca ouviu falar, que é uma migration por número, aplicada em ordem. A ferramenta que deveria pegar colisões só pega o tipo que ela consegue ver.

Então os checks barulhentos ficaram quietos. O paralelismo não falhou em alto e bom som aqui também, ele falhou no único slot pelo qual dois escritores estenderam a mão, depois de todo check que poderia tê-lo sinalizado já ter dito sim.

Por que “seja mais cuidadoso” não é a correção

A jogada tentadora seguinte é fazer os agents checarem antes de escrever. Faça cada agent, logo antes de reivindicar 0042, re-escanear a pasta para confirmar que o número ainda está livre.

Parece que deveria funcionar. Não funciona, e a razão é velha o suficiente para ter um nome.

Entre o instante em que um agent “a última migration é 0041” e o instante em que ele escreve 0042, outro agent consegue fazer exatamente a mesma leitura e chegar exatamente à mesma conclusão. Os dois checaram. Os dois viram 0041. Os dois escreveram 0042. O check passou para os dois porque nada mudou a pasta durante a olhada de cada agent, a mudança aconteceu no gap entre olhar e escrever. Pessoal de banco de dados chama isso de race condition; o check-depois-age só é seguro se nada puder agir no gap, e com escritores paralelos, algo sempre pode.

Você não consegue consertar uma race checando com mais força. Uma segunda olhada é só mais uma leitura, e uma leitura não consegue reservar nada. Dois agents educadamente checando a mesma porta destrancada ambos a atravessam. A falha não é que eles não olharam. É que olhar não reivindica, e sem uma reivindicação, dois agents cuidadosos colidem exatamente com a mesma confiabilidade que dois descuidados.

Então a correção real tem que fazer a única coisa que um check não consegue: tornar o ato de tomar o slot atômico, indivisível, para que entre decidir tomá-lo e tê-lo tomado, ninguém mais consiga se infiltrar. Os agents não precisam ser mais cuidadosos. Eles precisam de um árbitro que só consiga entregar o slot a um deles.

Nosso jeito: uma reivindicação, não um check

A disciplina em que nos fixamos é pequena, e é o tipo de coisa que soa óbvia no momento em que você a diz e é invisível até algo te queimar. Antes de um agent poder tocar um recurso compartilhado de slot único, ele tem que reivindicá-lo, e a reivindicação é atômica, então só uma reivindicação pode vencer.

Concretamente: o próximo número de migration não é algo que um agent lê da pasta e torce que ainda seja verdade. É algo que um agent requisita, e a requisição ou concede a ele aquele número, unicamente, ou diz que outra pessoa acabou de pegá-lo e entrega o próximo. Dois agents pedindo no mesmo instante não recebem ambos 0042. Um recebe 0042, o outro recebe 0043, e nenhum dos dois jamais viu um slot livre que não lhe foi de fato dado. A race de ler-depois-escrever sumiu porque não há ler-depois-escrever, há um único e indivisível “me dê o próximo”.

O modelo mental que fez isso fazer sentido para nós: pare de tratar os agents como editores de um documento compartilhado e comece a tratá-los como quem liga para uma única linha aberta. Duas pessoas conseguem ler o mesmo número de uma parede. Só uma consegue receber a próxima senha de um dispensador, porque o dispensador avança no instante em que entrega uma. O recurso compartilhado precisa de um dispensador, não de uma parede.

Uma race de check-depois-escreve deixa dois agents ambos lerem o slot 0042 e ambos escreverem-no; uma reivindicação atômica manda ambos por um dispensador que entrega 0042 a um e 0043 ao outro.

E a regra generaliza para além de migrations, que é por que ela conquistou um lugar em como construímos em vez de um remendo pontual. Qualquer recurso compartilhado de slot único é uma colisão esperando por dois escritores paralelos: um número de sequência, um lock file, um “próxima porta disponível”, um config singleton que a frota inteira lê, uma fila com uma única cabeça. A pergunta a se fazer de todo trabalho paralelo não é “essas tarefas são independentes?”, tarefas mentem sobre isso. É “alguma coisa que ambas tocam tem exatamente um slot?” Onde quer que a resposta seja sim, um check não vai te salvar e uma reivindicação vai.

A correção para uma colisão nunca é uma olhada melhor. É um árbitro que só consegue dizer sim uma vez.

O que essa disciplina de fato custa

O trade-off honesto, porque ele não é de graça, e fingir que é seria o mesmo otimismo auto-avaliado que causou o bug.

Uma reivindicação é uma pequena peça de coordenação, e coordenação é a coisa que o paralelismo deveria evitar. Toda reivindicação atômica é um momento em que os agents brevemente entram em fila indiana no dispensador em vez de rodarem totalmente independentes. Se você colocar uma reivindicação em torno de tudo, você silenciosamente reconstruiu o gargalo de um-de-cada-vez do qual você se espalhou para escapar, só que com cerimônia extra.

Então o custo é real, e ele define a fronteira de onde isso pertence. Você reivindica os slots únicos genuinamente compartilhados, o número de migration, o lock, o singleton, e deixa todo o resto totalmente aberto. O agent da página de settings e o agent de billing ainda nunca esperam um pelo outro, porque eles não compartilham nada com um slot. A disciplina não é “coordene tudo”. É “encontre os poucos lugares onde a contenção de fato vive, e ponha um árbitro exatamente nesses”. Um branch de feature típico toca zero deles; os que tocam são os que teriam silenciosamente corrompido o merge.

Esse direcionamento é a arte inteira. Coordene demais e você perdeu a velocidade. Coordene de menos e você lança um banco de dados que não dá boot. O skill é saber quais slots são dispensadores disfarçados, e essa é uma pergunta que você responde uma vez por recurso, não uma vez por tarefa.

A virada

Um banco de dados que não dá boot é um jeito dramático de aprender uma lição não dramática, então aqui está a lição sem o drama.

Quando você coloca muitos trabalhadores capazes numa única coisa compartilhada, o trabalho que eles fazem sozinhos quase nunca é onde quebra. A quebra se esconde nos handoffs e nos slots compartilhados, as partes que ninguém é dono porque todo mundo assumiu que outra pessoa estava observando. Isso não é um problema de IA e não é novo. É o problema mais antigo de qualquer time que cresceu além de uma pessoa: o calendário que todo mundo edita, a planilha que duas pessoas abriram ao mesmo tempo, a única vaga de estacionamento com dois carros engatinhando em direção a ela. Nós só batemos na versão escala-de-agent dele, onde o time é grande, rápido e incansável, e as colisões chegam mais rápido do que um humano conseguiria arbitrar à mão.

Que é o ponto de fato. O paralelismo não falha em alto e bom som, e nunca vai, ele falha no único slot pelo qual dois escritores estenderam a mão, quietamente, depois que os testes de todo mundo estão verdes. Você não torna uma frota confiável tornando cada trabalhador mais esperto, um agent mais esperto ainda não consegue ver o branch no qual ele não está. Você a torna confiável sendo honesto sobre o que eles compartilham, e pondo um árbitro exatamente nessas costuras. A inteligência vive nos trabalhadores. A confiabilidade vive nas regras sobre o que eles têm permissão de tocar ao mesmo tempo. Acerte essas regras e o paralelismo deixa de ser uma aposta e começa a ser pura velocidade.


É isso que estamos construindo na Apollo Space: um sistema operacional onde muitos agents se movem de uma vez sem silenciosamente sobrescreverem uns aos outros, rápido porque são independentes, seguro porque fomos honestos sobre os poucos lugares onde não são. A parte difícil de rodar uma frota nunca foi ensiná-la a trabalhar. Foi ensiná-la onde duas mãos não podem estender-se para a mesma coisa ao mesmo tempo, e preferimos aprender isso num número de migration do que em algo que você não pode desfazer.

A Apollo cuida da operação repetitiva da sua empresa pro seu time não precisar.

Entre na lista de espera: acesso antecipado, preço de usuário fundador e um lugar na primeira fila enquanto a gente constrói.

Entrar na lista de espera