O backfill que rodou dentro de uma requisição e matou o app no meio da demo
A coisa mais lenta que seu agent faz nunca deveria segurar a conexão pela qual o usuário está esperando.
Apollo Space Research
Apollo Space
Alguém clica num botão para atualizar um dashboard. Por trás desse único clique, um agent decide que os dados subjacentes estão velhos e silenciosamente começa a reler três anos de histórico para corrigir isso. O clique deveria levar um instante. A releitura leva minutos. E por esses minutos, o clique continua aberto, o spinner do navegador girando, a conexão refém de um job que não tem nada a ver com a coisa que a pessoa de fato pediu.
Agora imagine que esse clique aconteceu numa tela que alguém estava compartilhando com uma sala cheia de gente. O botão nunca voltou. O app parecia morto. Não estava morto, estava ocupado fazendo algo enorme que ninguém pediu para esperar.
Esse é o modo de falha sobre o qual este post fala, e é uma das formas mais comuns de um sistema de agents que de outra forma seria bom desabar na frente de um usuário real.
A coisa mais lenta que seu agent faz nunca deveria segurar a conexão pela qual o usuário está esperando.
A forma do bug: um pedido pequeno, um efeito colateral gigante
Vamos descrever a armadilha antes da correção, porque a armadilha é o que torna a correção óbvia.
Um agent recebe uma requisição que parece pequena. Me mostre o resumo desta conta. Para respondê-la bem, o agent nota algo verdadeiro e razoável: o resumo depende de dados que não foram atualizados há um tempo. Um agent diligente não serve números velhos. Então ele faz a coisa responsável, começa a trazer os dados para o atual. Ele faz backfill.
A forma ingênua de fiar isso é a forma como todo tutorial de framework fia: faça o trabalho ali mesmo, dentro da requisição, na mesma chamada que está segurando a conexão do usuário. O agent pensa, o agent age, o agent responde, uma linha reta do clique à resposta. Lê lindamente num diagrama. Demonstra perfeitamente num dataset minúsculo.
Aí uma conta real chega com histórico real, e o “pedido pequeno” arrasta um efeito colateral gigante atrás de si. O resumo precisava de trinta segundos de dados frescos; o backfill que ele disparou precisa percorrer três anos de registros. O usuário fez uma pergunta. O sistema, tentando ser minucioso, decidiu fazer uma hora de dever de casa primeiro, e decidiu fazer tudo isso antes de dizer uma única palavra de volta.
A conexão pela qual uma pessoa está esperando é o recurso mais sensível ao tempo no sistema inteiro, e tínhamos entregado isso ao job mais lento do prédio.
A crueldade disso é que nada estava errado, exatamente. O julgamento do agent estava sólido, dados velhos são ruins, atualizá-los é bom. O backfill estava correto. O resumo estava correto. O único erro foi onde o trabalho pesado rodou: no primeiro plano, no relógio do usuário, dentro da requisição pela qual o usuário estava esperando. Trabalho certo, thread errada.
A correção ingênua que parece uma correção e não é
O primeiro instinto, depois de você sentir essa dor, é deixar a coisa lenta mais rápida.
Então o time otimiza o backfill. Adiciona um índice, faz batch das leituras, corta a caminhada de três anos para noventa segundos. E noventa segundos é genuinamente melhor que cinco minutos, na conta de demo. Todo mundo respira aliviado. O botão volta antes de alguém na sala ficar nervoso.
Mas velocidade não era a propriedade que estava quebrada. Acoplamento era a propriedade que estava quebrada.
Um job de noventa segundos segurando a conexão de um usuário ainda é um job de noventa segundos segurando a conexão de um usuário. Você não removeu a situação de refém; você a encurtou. E no momento em que uma conta ligeiramente maior aparece, uma com cinco anos em vez de três, ou uma onde a fonte upstream está tendo uma tarde lenta, noventa segundos viram quatro minutos de novo, e o app morre na frente da próxima pessoa. Você otimizou o sintoma. A doença é que uma requisição de primeiro plano pode ser segurada aberta por um job do tamanho de background de jeito nenhum.
Deixar a coisa lenta mais rápida não conserta. Só aumenta o tamanho da conta que te quebra.
A coisa mais lenta que seu agent faz nunca deveria segurar a conexão pela qual o usuário está esperando, e “nunca deveria” não significa “deveria ser rápida”. Significa que a conexão e o trabalho pesado não deveriam estar na mesma thread em primeiro lugar. Nenhuma quantidade de otimizar o backfill muda o fato de que ele está parado onde não deve parar.
A correção: responda agora, termine depois
Aqui está a ideia, e ela é velha o suficiente para ser entediante, que é exatamente por que é confiável.
Divida a requisição em duas promessas. A primeira promessa é ao usuário, e é rápida: aqui está a melhor resposta que posso te dar agora, imediatamente, e aqui está uma flag se parte dela ainda está sendo atualizada. A segunda promessa é aos dados, e é paciente: agendei a atualização pesada, ela está rodando em algum lugar que não toca sua conexão, e quando terminar os números estarão certos. A requisição do usuário retorna num instante. O backfill roda no próprio tempo, em background, num worker construído para fazer coisas lentas lentamente.
A linha ingênua era clique → pensar → fazer o job gigante → responder. A linha corrigida é clique → responder com o que temos → e separadamente, à parte, fazer o job gigante. A resposta não espera mais pelo backfill, porque a resposta e o backfill não são mais a mesma coisa acontecendo na mesma thread.
A peça que torna isso honesto em vez de uma esquiva é a flag. Não fingimos que os dados lentos estão frescos quando não estão. A resposta rápida diz a verdade: esta parte está atual de uma hora atrás, e uma atualização está rodando agora. O usuário recebe algo útil instantaneamente e é avisado claramente que um canto ainda está atualizando. Essa é a diferença entre responder rápido e mentir rápido. Uma resposta rápida que esconde sua própria velhice é pior que uma lenta; uma resposta rápida que nomeia exatamente o que ainda está cozinhando é melhor que ambas.
O que você fez, mecanicamente, foi mover o job pesado para fora do critical path. O critical path, a linha entre um clique e uma resposta, deveria carregar apenas o trabalho que tem que acontecer antes de você poder falar. Todo o resto, todo backfill e re-index e releitura em massa, pertence a um relógio diferente. Essa é a disciplina mais útil que conhecemos para manter um app de agents responsivo sob carga real, e quase não tem nada a ver com quão esperto o agent é.
Por que agents tornam essa armadilha pior, não melhor
Você pode achar que isso é só velho conselho de arquitetura web vestindo um chapéu novo, e os ossos disso são. Mas agents tornam a armadilha mais fácil de cair, por uma razão que vale nomear.
Um programa tradicional faz o que você escreveu. Se você não escreveu “vá reler três anos de registros”, ele não faz. Um agent faz o que ele julga necessário, e bom julgamento, num agent, frequentemente significa decidir fazer mais do que o pedido literal. Esse é o ponto inteiro de um agent: você queria a resposta certa, não uma literal, então ele nota os dados velhos e os conserta sem ser avisado. A própria qualidade que torna o agent útil é a qualidade que o faz buscar o efeito colateral gigante.
Então o agent que vale a pena ter é exatamente o agent mais provável de disparar um backfill que você nunca pediu explicitamente. O que significa que você não pode depender de “a gente simplesmente não vai fazer coisas lentas na requisição”. O agent vai decidir fazer uma coisa lenta, por conta própria, porque fazê-la é correto. Sua arquitetura tem que assumir isso, tem que tratar qualquer ação de agent como algo que pode acabar sendo enorme, e roteá-la de acordo.
É por isso que a correção não pode ser uma regra que o agent segue. Tem que ser uma propriedade do sistema ao redor do agent. O agent tem permissão de decidir que os dados precisam de atualização. O sistema é o que garante que decidir isso não segure a conexão de um único usuário aberta enquanto acontece. Construímos para que o agent possa ser tão ambicioso quanto quiser sobre correção, e o runtime silenciosamente garante que a ambição rode em background. A inteligência propõe o trabalho pesado; o encanamento protege o usuário de esperar por ele.
A coisa mais lenta que seu agent faz nunca deveria segurar a conexão pela qual o usuário está esperando, e com um agent, você não tem como saber de antemão qual coisa vai acabar sendo a mais lenta. Então você protege a conexão de todas elas.
Onde a linha de fato vai
A versão prática de tudo isso é uma pergunta que você faz a cada ação numa requisição: o usuário tem que esperar por isto para conseguir uma resposta útil?
Se a resposta é sim, a resposta genuinamente não pode ser formada sem isso, ela fica no critical path, e você a torna tão rápida quanto ela precisa ser. Se a resposta é não, a resposta é útil sem ela, a ação só torna as coisas melhores ou mais frescas ou mais completas, ela vai para o background, ponto final, não importa quão claramente correto seja fazê-la. “Correto fazer” e “tem que acontecer antes de respondermos” são duas perguntas diferentes, e o bug no topo deste post veio inteiramente de tratá-las como uma.
Desenhe essa linha uma vez, honestamente, e a maioria dos problemas de responsividade num app de agents simplesmente para de acontecer. Não porque algo ficou mais rápido, mas porque as únicas coisas que sobraram no relógio do usuário são as coisas pelas quais o usuário de fato está esperando. O backfill que percorre três anos de histórico ainda é lento. Só está lento em algum lugar onde ninguém está olhando um spinner.
A virada: responsividade é uma promessa que você cumpre a uma pessoa
Há uma pessoa do outro lado de cada uma dessas requisições, e o que ela está realmente pedindo não são dados. É uma resposta que volta quando ela espera que volte.
A razão pela qual o botão morto numa tela compartilhada arde tanto não são os minutos perdidos. É que o app fez uma promessa implícita, clique em mim e eu respondo, e depois a quebrou, silenciosamente, na frente de uma plateia, pela razão mais defensável do mundo: estava ocupado sendo minucioso. Usuários não perdoam minúcia que não conseguem ver. Eles experimentam uma conexão segurada como uma quebrada, toda vez, não importa quão bom seja o trabalho acontecendo por trás do spinner. Um sistema que é lento quando você está olhando não parece diligente. Parece sumido.
Então a disciplina não é realmente sobre threads e workers. É sobre respeitar a diferença entre o trabalho pelo qual uma pessoa está esperando e o trabalho que pode acontecer enquanto ela toca a vida. Mantenha esses dois separados e o app parece vivo mesmo enquanto algo enorme mói por baixo. Borre-os juntos e o melhor agent do mundo ainda vai, uma tarde, congelar na frente exatamente da pessoa que você mais queria impressionar.
A resposta rápida e o backfill paciente são ambos corretos. O ofício inteiro é recusar fazer a pessoa esperar por ambos.
É isso que estamos construindo na Apollo Space, um runtime de agents onde o sistema te responde no instante que pode e faz seu trabalho pesado à parte, para que a ambição nunca te custe um spinner. Se você já viu um app perfeitamente bom escurecer na frente de uma sala porque estava silenciosamente fazendo demais no lugar errado, você já sabe por que colocamos essa linha onde colocamos.
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 esperaO 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".
EngenhariaUm orchestrator que não sobrevive ao próprio crash não é um
Um crash que apaga o raciocínio do orchestrator perde a única coisa que você não consegue reconstruir.
EngenhariaColoque um portão determinístico na frente do seu revisor mais esperto
A pega-defeito mais barata é um script burro que checa se duas branches mergeadas ainda sobem antes de qualquer julgamento.