Engenharia

O imposto oculto dos agents em paralelo é um diamante de migrations

Seis agents escrevendo para um schema conflitam no banco de dados, não no código, e a CI morre em "multiple heads".

ASR

Apollo Space Research

Apollo Space

· 11 min de leitura

Seis agents terminam seis features não relacionadas na terça-feira. Seis branches limpas, seis suítes de teste verdes, seis merges que todos passam no review. Então a sétima coisa acontece: o deploy se recusa a rodar, o build imprime uma frase que ninguém escreveu de propósito, multiple heads, e o banco de dados agora é um fork. O código deu merge tranquilo. O schema não.

Nenhum daqueles seis agents cometeu um erro. Essa é a parte inquietante.

Seis agents escrevendo para um schema conflitam no banco de dados, não no código, e a CI morre em “multiple heads”. Este post é sobre por que essa falha específica aparece no momento em que você roda agents em paralelo, por que ela não tem nada a ver com quão bons são os agents, e o que construímos para que o conflito se resolva sozinho antes de um humano jamais ver a palavra “heads”.

Por que agents em paralelo são o ponto inteiro

Rode um agent numa codebase e ele funciona lindamente por uma tarde, depois você o vê esperar. Ele lê um arquivo, pensa, escreve, roda testes, espera a suíte, lê o resultado, segue em frente. Um único fio de atenção através de um backlog que tem dezenas de fios independentes nele.

Uma codebase de empresa real tem mais trabalho do que qualquer agent único consegue segurar de uma vez. Então você o ramifica. Um agent adiciona um campo ao billing model, outro constrói uma tabela de notifications, um terceiro indexa uma query lenta, um quarto faz backfill de uma coluna, e todos rodam ao mesmo tempo, cada um na sua própria cópia isolada do repositório. A throughput é a razão pela qual você fez isso. Paralelismo não é um nice-to-have aqui, é o caso econômico inteiro de construir software com uma fleet em vez de uma única mão.

E para o código, isolamento funciona. Dois agents editando dois arquivos diferentes nunca colidem. Mesmo dois agents editando o mesmo arquivo geralmente dão merge limpo, porque o git foi construído por pessoas que esperavam exatamente isso e nos deram three-way merges para lidar com isso. A camada de version control é genuinamente boa em deixar muitos writers tocarem uma tree.

O problema é que a codebase não é a única coisa para a qual esses agents estão escrevendo.

Muitos agents escrevendo apenas arquivos-fonte dão merge limpo através do git, mas os mesmos agents escrevendo migrations de schema todos se ramificam de um estado de banco de dados compartilhado e convergem num fork que o git não consegue reconciliar.

A versão ingênua: tratar uma migration como qualquer outro arquivo

Aqui está a suposição que falha, e ela é tão razoável que quase todo mundo a faz primeiro. Uma migration é um arquivo. Ela vive no repo, é rastreada pelo git, recebe review num pull request como tudo o mais. Então se o git consegue dar merge nas mudanças de código de dois agents, certamente consegue dar merge nas migrations de dois agents.

Ele consegue dar merge nos arquivos. Essa é a armadilha.

Uma migration não é de fato um arquivo. É uma instrução para mudar uma coisa que vive fora do repositório, o banco de dados, e essa coisa tem um único estado atual, compartilhado. Toda migration é escrita como um passo a partir do estado que o autor viu por último. O agent adicionando um campo de billing escreveu sua migration sobre a versão A do estado. O agent construindo a tabela de notifications também escreveu sua migration sobre a versão A do estado, porque esse é o estado que ele viu por último também. Nenhum dos dois estava errado. Ambos estavam olhando para o mesmo ponto de partida, porque começaram ao mesmo tempo.

Agora ambas as migrations entram. O git dá merge nos dois arquivos sem reclamar, eles nem tocam o mesmo diretório de um jeito que ele se importe. Mas a tool de migration os lê e encontra duas instruções diferentes que ambas afirmam ser “o próximo passo depois de A”. Não há mais um único próximo estado. Há dois. A história forkou.

Esse fork tem um nome em todo framework de migration, e é a palavra que para o deploy: multiple heads. A tool literalmente não consegue decidir qual branch do schema é a verdadeira, então se recusa a rodar qualquer uma delas. Seu código está merged, seus testes estavam verdes, e seu banco de dados agora é uma árvore com duas pontas e nenhum tronco.

A versão burra da correção é a que um engenheiro cansado busca às 18h: abre os dois arquivos de migration, edita um à mão para apontar para o outro em vez de apontar para A, recostura a cadeia numa linha, e reza para ter acertado a ordem. Funciona. Também significa que um humano agora está fazendo, à mão, a única coisa que a fleet paralela inteira existia para evitar, serializar o trabalho depois do fato.

Por que “só travar o banco de dados” não te salva

O próximo instinto é remover a corrida inteiramente. Se dois agents não podem ambos se ramificar do estado A, então não os deixe. Faça todo agent que quer escrever uma migration pegar um lock, gerar sua migration contra o head verdadeiramente atual, soltar o lock, e deixar o próximo ir.

Isso resolve o conflito. Também silenciosamente deleta a razão pela qual você foi para o paralelo.

Um lock de migration é uma única pista pela qual toda mudança de schema tem que passar uma de cada vez. No momento em que sua fleet faz qualquer coisa com formato de banco de dados, e um produto em crescimento toca o schema constantemente, seus seis agents paralelos formam fila indiana naquela pista e esperam sua vez. Você comprou uma fleet e ganhou uma fila. Pior, o lock tem que ser segurado durante o ciclo inteiro de pensar-e-escrever do agent, que é longo e imprevisível, então a pista não é só estreita, é lenta. A throughput pela qual você estava pagando se esvai pelo único lugar onde você forçou tudo a ser sequencial.

Um lock não remove o conflito. Ele só cobra o conflito antecipadamente, em toda mudança, independentemente de um ter acontecido ou não.

Então você fica com duas opções ruins que são na verdade a mesma opção vestindo roupas diferentes. Serializar antes do trabalho com um lock e pagar o imposto em toda migration. Ou serializar depois do trabalho à mão e pagá-lo em atenção humana sempre que o fork de fato acontecer. Ambas são um imposto sobre o paralelismo. Ambas são a coisa que a fleet deveria tornar obsoleta.

O conflito é real. Fingir que ele não existe quebra a correção; trancá-lo embora quebra a throughput. O único movimento honesto é manter o paralelismo e manter o schema linear, o que significa resolver o fork em vez de preveni-lo.

Nosso jeito: fazer o diamante se resolver sozinho

A forma do problema é um diamante. Muitas migrations partem de um estado compartilhado no topo, se ramificam conforme os agents trabalham em paralelo, e então têm que convergir de volta numa história linear na base. O topo é divergência, esse é o ponto da fleet. A base é convergência, é o que o banco de dados exige. A parte difícil é o canto onde elas se encontram, e esse canto é exatamente o trabalho que automatizamos.

A ideia-chave é simples: um fork na história de migrations não é um erro para escalar. É um evento de rotina para resolver, e resolvê-lo é mecânico. Seis agents escrevendo para um schema conflitam no banco de dados, não no código, e o conflito tem uma forma conhecida, então tem um reparo conhecido.

Quando duas migrations ambas afirmam seguir o estado A, há quase sempre uma resposta verdadeira sobre como torná-las sequenciais, escolha uma ordem, reescreva a segunda para que seu ponto de partida seja o ponto final da primeira, e as duas pontas colapsam de volta num único tronco. Tools de migration shipam um comando para isso há anos; o framework consegue costurar dois heads em um. O que faltava não era o comando. Era algo no hook para rodá-lo no instante em que um fork aparece, em vez de um humano descobrindo três horas depois quando o deploy morre.

Então pusemos um agent nesse hook.

Um diamante de migrations se resolve sozinho: um head de schema compartilhado se ramifica em migrations paralelas, um detector pega o fork, um agent re-parenteia a migration posterior sobre a anterior, e a história converge de volta para um único head antes do deploy.

Quando as branches da fleet se juntam, um detector checa uma coisa: a história de migrations tem exatamente um head? Se tem, nada acontece, o caso comum continua de graça, porque a maioria dos merges não toca o schema. Se ele encontra dois, ele não para a linha e paga um humano. Ele entrega o fork a um agent cujo único trabalho é torná-lo linear de novo: ler ambas as migrations, decidir qual vem primeiro, re-parentear a posterior sobre o estado final da anterior, regenerar a cadeia, e re-rodar a suíte contra a história costurada para provar que o resultado ainda se aplica limpo.

A Apollo é construído para que essa resolução seja um passo no pipeline, não um incidente. Os agents continuam paralelos, ninguém espera num lock para escrever uma migration. O schema continua linear, o banco de dados nunca vê dois heads chegarem ao deploy. O diamante abre no topo porque é onde está a throughput, e fecha na base porque é o que a correção exige, e o canto onde ele fecha é tratado por algo que roda em segundos e nunca se cansa de fazê-lo.

Note o que isso compra que um lock nunca poderia. Um lock paga o custo de serialização em toda migration, incluindo a esmagadora maioria que nunca teria conflitado. O diamante o paga só nos forks de fato, o raro momento em que dois agents de fato partiram do mesmo estado e de fato ambos tocaram o schema. O caso comum é de graça. O caso caro é automatizado. Você para de taxar a fleet por um conflito que, na maioria dos dias, não aconteceu.

A virada: a fila era o imposto o tempo todo

Há uma versão disto em que você nunca percebe o problema, e é a versão mais triste. Você roda um agent, ele é lento mas nunca forka o schema, e você conclui que agents em paralelo “têm um problema de migration”. Eles não têm. Trabalho sequencial tem um problema de throughput, e paralelismo é a cura, o fork de migration é só a conta que essa cura silenciosamente te entrega, e a maioria dos times a paga sem nunca ler o item da fatura.

Os times que sentem isso mais não são os com agents ruins. São aqueles cujos agents são bons o suficiente para de fato rodar seis de uma vez, quanto melhor sua fleet, mais forte esse canto bate, porque uma fleet que shipa mais mudanças de schema forka mais frequentemente. A falha escala com seu sucesso. Esse é o sinal de que é estrutural, não um bug no prompt de alguém.

O que acabamos construindo não foi de fato um truque de banco de dados. Foi uma recusa a aceitar a troca falsa, paralelo-mas-incorreto de um lado, correto-mas-serial do outro, e uma insistência de que o conflito é pequeno o suficiente para resolver mecanicamente no momento em que aparece. Seis agents escrevendo para um schema conflitam no banco de dados, não no código. Então ensinamos o pipeline a fechar o diamante em vez de pedir a uma pessoa que o fizesse.

A parte que não sai de um framework de migration é o julgamento de que uma história forkada é uma terça-feira normal, não uma crise, de que a resposta certa para “multiple heads” é um re-parenteamento automático e silencioso, não um engenheiro derreado com dois arquivos abertos e um prazo hoje à noite. Esse julgamento é a coisa que uma fleet tem que carregar pelas pessoas que a rodam, porque a promessa inteira de uma fleet é que você ganha a throughput de muitas mãos sem herdar a dor de cabeça de coordenação de muitas mãos.


É isso que estamos construindo na Apollo Space: um sistema operacional onde rodar cem agents em paralelo parece como rodar um, porque as costuras entre eles, os forks, os locks, os merges que o banco de dados nota e o git não, se fecham antes de chegarem a você. Se você já perdeu uma sexta-feira para as palavras multiple heads num deploy que todo o resto dizia estar verde, você já sabe qual parte do trabalho um sistema deveria tirar do seu prato primeiro.

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