AI-native Architect: escalar com evidência — scheduling justo, ADR de evolução e portfólio auditável
![]()
Alguém desenha, numa reunião de revisão de arquitetura do RelayOps, um diagrama com três caixas novas: ingestion-service, extraction-service, review-service, cada uma com seu próprio deploy, seu próprio repositório, sua própria fila. O argumento é "é assim que sistema sério escala" - o diagrama é limpo, as setas são retas, e ninguém no comitê tem um número que o contradiga. A resposta certa não é aceitar o diagrama nem recusá-lo por princípio. É perguntar: qual gargalo, medido, esse diagrama resolve? Ninguém no comitê consegue responder, porque ninguém mediu nada ainda.
examples/scale/run.mjs simula três cenários determinísticos com relógio virtual e custos de estágio inventados. Nessa configuração — burst instantâneo, quatro workers simulados e FIFO — a espera modelada na fila supera os estágios de processamento. No cenário de tenant dominante, dois tenants pequenos esperam mais que o dobro do dominante porque seus jobs entram depois. Isso revela uma propriedade da ordem FIFO sob essa fixture, não um gargalo medido no RelayOps real. A hipótese de trocar o scheduler merece um teste pareado; a decisão de escalar infraestrutura exige dados operacionais ainda ausentes.
Este é o capítulo final da série AI-native Architect. Ele retoma a pergunta deixada pelo capítulo 7: "um único serviço, um único worker pool, uma única fila" continua adequado? A simulação testa a distribuição da vez de atendimento, não responde à capacidade real. O scheduler justo reduz a espera virtual dos tenants pequenos na mesma fixture, com regressão declarada para o dominante. O ADR registra uma escolha local e reversível; o gatilho para separar serviços permanece condicionado a medições futuras em piloto.
Estado de entrada
O capítulo 7 fechou segurança e resiliência: guardas determinísticas de autorização que nunca leem o documento como fonte de autoridade, um circuit breaker que nunca descarta um job, checagem de compatibilidade de schema para rollback, e um restore drill local que mede recuperação em vez de presumi-la. Este capítulo não reabre nenhum desses arquivos. Ele assume que RelayOps já sabe se defender de um documento hostil e de uma falha de provedor, e faz uma pergunta diferente: a FORMA do sistema - um único processo, uma fila compartilhada - continua certa quando o volume cresce, quando um tenant domina a fila, ou quando os tipos de documento variam o bastante para que "um pipeline serve todos" pare de ser verdade? A única coisa que este capítulo reusa dos anteriores é a forma dos contratos já fixados - o vocabulário queued/leased da fila durável do capítulo 4, a disciplina de reserva-antes-de-gastar do capítulo 6, o formato de gate por estrato do capítulo 5 - nunca um arquivo importado literalmente.
O menor exemplo que separa "mais capacidade" de "ordem justa"
Antes de qualquer cenário completo, a distinção cabe em uma comparação de duas funções com a mesma assinatura:
// Antes: cega a tenants, ordem = ordem de chegada na fila compartilhada
function leaseNextNaive(queue, now) {
const job = queue.shift();
if (!job) return null;
job.status = 'leased';
job.leasedAt = now;
return job;
}
// Depois: cada tenant com fila não-vazia é visitado, em rotação,
// antes de qualquer tenant repetir a vez - Deficit Round Robin
function leaseNextFair(scheduler, now) {
return scheduler.leaseNext(now); // examples/src/tenant-scheduler.mjs
}
A primeira função é o que qualquer fila compartilhada faz por padrão: shift() no array, sem olhar de quem é o job. A segunda visita tenants em rotação e só concede a vez a quem tem fila não-vazia e crédito suficiente - um tenant que enfileirou 200 jobs não consegue, por isso, tomar a vez de um tenant que enfileirou 5. Nenhuma das duas funções muda quanto tempo um documento leva para ser processado depois de arrematado; as duas mudam só QUANDO cada tenant é atendido. É essa distinção - capacidade versus ordem - que o resto deste capítulo mede, compara e decide.
Intuição: sinal local antes de coordenação central, amostra grande só quando o efeito é pequeno
Fontes primárias desta seção: Google SRE — Handling Overload e Anthropic — Demystifying evals for AI agents. A analogia com o scheduler RelayOps é interpretação deste capítulo.
O livro de SRE do Google descreve proteção contra sobrecarga baseada num sinal de utilização puramente local: conforme a utilização se aproxima de um limite configurado, o sistema começa a rejeitar requisições por criticidade, e um mecanismo de throttling adaptativo no qual cada cliente rastreia sua própria taxa recente de aceitação e se autorregula - porque, nas palavras do próprio livro, "é quase igualmente custoso rejeitar uma requisição... quanto aceitá-la e processá-la" para alguns serviços, então mover a decisão para o lado do cliente evita que o servidor gaste recursos só para dizer não. O scheduler justo deste capítulo usa exatamente essa forma: um contador de déficit local a cada tenant, sem coordenador central, sem timer, sem processo em segundo plano - a mesma disciplina de "sinal local antes de recurso compartilhado" que o circuit breaker do capítulo 7 já aplicava à chamada de provedor, agora aplicada à ordem de atendimento entre tenants.
A segunda intuição vem de um lugar diferente: o guia da Anthropic sobre avaliação de agentes observa que times adiam construir evals achando que precisam de centenas de tarefas, quando "20-50 tarefas simples tiradas de falhas reais já é um bom começo" - porque, cedo, o efeito de uma mudança costuma ser grande, e um efeito grande não exige amostra grande para aparecer; times mais maduros, medindo efeitos mais sutis, precisam de amostras maiores. Esta é exatamente a lógica que examples/scale/scenarios.json aplica ao declarar, no próprio arquivo, que os 11 jobs de cada tenant pequeno no cenário de tenant dominante bastam para mostrar uma mudança de ordem de grandeza (13x mais rápido), mas não bastam para um p99 confiável daquele tenant sozinho - a amostra pequena não é escondida, é dimensionada para o tamanho do efeito que ela precisa provar.
Evolução RelayOps: do ensaio de três cenários ao portfólio
RelayOps ganha, neste capítulo, cinco peças novas, independentes de qualquer arquivo dos capítulos 1-7:
- Simulação determinística de tempo virtual (
examples/scale/scenarios.json,examples/scale/run.mjs): três cenários sintéticos — maior volume, tenant dominante e tipos documentais variados — com seed e janela declaradas. A chave legadawarmupDocsexclui IDs iniciais da amostra; esses jobs ainda executam, sem prewarm de workers. Custos dos cinco estágios são inventados; p50/p95/p99 são saídas do modelo, independentes do hardware host. - Ensaio local de wall clock (
examples/scale/real-trial.mjs): quatro worker threads executam CPU sintética. Uma execução no Apple M3 Pro / Node v26.8.1 mostrou espera menor para os tenants pequenos; o dominante também melhorou nessa rodada, embora regrida na simulação virtual. A medida varia entre execuções e não estabelece capacidade de produção. - Scheduler justo por tenant (
examples/src/tenant-scheduler.mjs):createNaiveFifoScheduler(o "antes") ecreateFairScheduler(o "depois", Deficit Round Robin) - a evolução mínima discriminante deste capítulo, medida no mesmo cenário, mesmo seed, antes e depois. - Architecture fitness functions (
examples/scale/fitness-functions.mjs): cinco checagens - tenancy, contratos, gate de avaliação, orçamento, recuperação - cada uma com um comentário próprio declarando o que passar NÃO prova sobre produção. - ADR-006 (
examples/adrs/006-evolution.md): o comportamento observado na simulação, cinco alternativas com custo operacional explícito, decisão local, migração expand/contract planejada e gatilho de revisão dependente de métricas reais. - Portfólio (
examples/portfolio/): diagramas context/container/sequence, o log de ADRs de toda a série, um índice de evidência claim-por-claim (demonstrado, simulado, pendente de piloto), e um plano de piloto explicitamente pendente.
Navegação do portfólio: revisão de arquitetura, índice de evidências, plano de piloto e ADR-006. Traces, custos, evals e runbooks dos capítulos anteriores precisam ser conferidos nos próprios capítulos; este índice não os reproduz nem atesta validação cruzada.
Nenhuma dessas peças chama rede, banco de dados, provedor real ou container Docker. Os 22 testes cobrem contratos locais; a duração da suíte não mede capacidade do RelayOps.
Contratos e trade-offs
O contrato mais importante deste capítulo separa capacidade (workers e throughput) de ordem de atendimento (quem recebe a próxima vaga). Com workers finitos e FIFO, um burst enfileirado primeiro pode atrasar tenants posteriores. Adicionar workers muda a espera absoluta, mas não muda a regra de escolha; com workers suficientes para todos os jobs, a espera de fila desapareceria. O efeito relativo depende da carga e precisa ser medido. O scheduler justo conserva jobs sob leaseNext(), como fixa o teste does not lose or duplicate a single job across a full drain. O parâmetro weight também tem teste próprio (Weight is proportional, not cosmetic): um bug inicial fazia pesos diferentes alternarem 1:1; a implementação atual concede vagas aproximadamente na razão dos pesos.
As quatro falhas obrigatórias
Falha 1 — microsserviços como sinônimo de escala
Sintoma: um diagrama com serviços novos aparece antes de qualquer gargalo medido - a justificativa é "é assim que sistemas sérios escalam", nunca "medimos X e Y resolveria".
Causa: confundir uma forma arquitetural (quantidade de serviços, quantidade de agentes) com uma medida de maturidade. Nenhuma das duas é a mesma coisa que capacidade resolvida ou risco reduzido - e a forma é fácil de desenhar antes de qualquer evidência existir, porque não exige medir nada.
Resposta: examples/adrs/006-evolution.md compara alternativas e escolhe testar scheduling justo no processo atual por ser reversível e melhorar a ordem de atendimento na simulação. Separação de serviço fica adiada: falta medição operacional para justificá-la.
Falha 2 — extrapolar stub para cloud
Sintoma: um número produzido por custos de estágio inventados é citado como latência de provedor real ou o ensaio é chamado de "benchmark de nuvem".
Causa: o stub existe para medir o CONTRATO e o COMPORTAMENTO do código chamador - a ordem em que jobs são atendidos, se nenhum é perdido, se a fairness funciona - não para medir quanto tempo um modelo real leva para responder. Os dois números parecem comparáveis porque as duas coisas se chamam "latência", mas medem coisas completamente diferentes.
Resposta: DOC_TYPE_PROFILES fornece custos INVENTADOS ao relógio virtual. "invoice" custa menos que "contract" somente na fixture. examples/portfolio/pilot-plan.md exige medir os percentis por estágio com provedores e carga reais antes de atribuir gargalos ao RelayOps. A dominância da fila é resultado do modelo sob burst instantâneo.
Falha 3 — benchmark sem ambiente nem denominador
Sintoma: um número de simulação é citado sem seed, janela, denominador ou aviso de que a unidade é milissegundo virtual.
Causa: "13x mais rápido" sem unidade e contexto sugere ganho empírico que o modelo não sustenta.
Resposta: examples/scale/scenarios.json fixa seed, IDs iniciais excluídos (chave legada warmupDocs) e janela. verification.md registra custos virtuais e comparação pareada dos mesmos jobs: acme-small (n=11) 976.003→72.433 ms virtuais (13.47x); initech-small (n=11) 1027.072→77.703 (13.22x); globex-dominant (n=198) 469.164→569.398 (+21.36%). sampleSizeLimitation impede inferir p99 estável para cada tenant pequeno. Hardware host não explica esses números, tampouco valida capacidade real.
Falha 4 — portfólio apaga falhas e trade-offs
Sintoma: um portfólio de arquitetura mostra só o resultado final aprovado - o scheduler que funcionou, o ADR que foi aceito - sem registrar o que foi tentado e rejeitado, o bug encontrado no caminho, ou o custo que a decisão aceita realmente impõe a alguém.
Causa: um portfólio que só mostra sucesso otimiza para parecer bem-sucedido, não para ser auditável - e um leitor que só vê a versão final não consegue avaliar se a decisão foi bem tomada ou só bem apresentada.
Resposta: examples/portfolio/evidence-index.md distingue contratos demonstrados por teste, resultados simulados, medida local sintética e evidência pendente. O ADR-006 registra +21.36% de espera virtual para o dominante; o ensaio local não repetiu essa regressão na rodada registrada. allowedRegressionTenantIds nomeia a exceção do gate de fixture. O bug anterior do peso segue documentado no scheduler.
Avaliação: o que 22 testes provam, e o que não provam
Os 22 testes de examples/scale/fairness.test.mjs verificam que FIFO atrasa um tenant pequeno até a 51ª concessão após 50 jobs dominantes; fairness concede a vaga na 2ª chamada; nenhum job é perdido ou duplicado; pesos 2:1 produzem frequência aproximadamente proporcional; as fitness functions detectam violações injetadas. Testes novos verificam comparação pareada dos jobs simulados e conservação no ensaio local de CPU sintética.
O que esses testes NÃO provam: a simulação virtual usa um único processo; real-trial.mjs usa worker threads com CPU sintética, sem isolamento entre nós ou pipeline real. Nenhum teste mede contenção representativa entre tenants ou restaura leases em voo após restart. checkRecoveryFitness verifica apenas contagens após serialização JSON. O gatilho operacional do ADR permanece pendente.
Custo, segurança e reversibilidade
A decisão deste capítulo custa pouco para reverter e pouco para adotar: createFairScheduler é uma estrutura de dados nova dentro do mesmo processo, sem deploy novo, sem limite de rede novo, sem chave de configuração de produção a migrar - reverter para createNaiveFifoScheduler é trocar uma chamada de função, não uma reversão de infraestrutura. Essa reversibilidade barata é exatamente o motivo pelo qual o ADR aceita a mudança agora e adia a separação de serviço: a separação, se algum dia acontecer, custa caro para desfazer (um limite de rede novo, um contrato versionado entre dois deploys, jobs em voo atravessando um boundary), e por isso o ADR já escreve o plano expand/contract e o rollback ANTES de a separação virar necessária, não depois - a mesma disciplina de "documentar antes de precisar sob pressão de incidente" que o runbook de rollback do capítulo 7 já aplicava a reversão de código.
Sobre segurança, o scheduler decide ordem, nunca autorização. checkTenancyFitness verifica IDs no estado sintético; autorização permanece em authorizeToolCall do capítulo 7. Na simulação pareada, o dominante paga 100.234 ms virtuais adicionais por job, em média. É trade-off do modelo, aceito para testar fairness; o custo operacional real ainda precisa de piloto.
Checklist
- Nenhuma proposta de separar serviço, adicionar agente ou criar microsserviço entra em ADR sem um gargalo medido localmente que a justifique.
- Todo número de desempenho citado declara ambiente, seed, IDs iniciais excluídos da amostra e tamanho de amostra - nunca um percentual solto.
- Nenhum número medido contra um stub é citado como se fosse latência de provedor real ou capacidade de produção.
- Toda melhoria medida vem acompanhada da pergunta "quem paga o custo desta melhoria, e quanto" - e a resposta, se houver regressão, é registrada, não escondida.
- Todo scheduler ou fila nova preserva conservação de throughput: nenhum job perdido ou duplicado, testado explicitamente.
- Toda architecture fitness function declara, no próprio comentário, o que passar NÃO prova sobre produção.
-
node --test examples/scale/fairness.test.mjspassa localmente antes de qualquer mudança no scheduler, no ensaio ou nas fitness functions. - Todo ADR de evolução inclui alternativas comparadas com custo operacional explícito, não só a opção escolhida.
Exercícios
Básico — identificar o gargalo a partir do relatório por estágio. Critério verificável: rodar node scale/run.mjs --scenario higher-volume e confirmar que queueMs (p50, p95 ou p99) é pelo menos 10 vezes maior que qualquer um dos outros quatro estágios (parseMs, extractMs, validateMs, reviewMs) na mesma janela de amostra. Solução comentada: o teste 'under the higher-volume scenario, queue wait dominates...' em fairness.test.mjs já fixa essa comparação com um limiar de 10x, sobre a mesma configuração de cenário - rodar o comando não deveria produzir um resultado diferente do que o teste já prova, porque os dois usam o mesmo seed e a mesma lógica de simulação.
Intermediário — aplicar fairness e provar que o tenant pequeno progride. Critério verificável: usando createFairScheduler com um tenant dominante (50 jobs enfileirados primeiro) e um tenant pequeno (1 job enfileirado depois), o job do tenant pequeno precisa ser concedido em, no máximo, a 2ª chamada de leaseNext() - nunca depois da 51ª, que é o que a fila ingênua produziria no mesmo cenário. Solução comentada: os dois testes correspondentes em fairness.test.mjs ('a dominant tenant burst of 50 jobs delays a small tenant's 1 job until lease call #51' para a fila ingênua e 'the same 50-dominant-then-1-small burst gives the small tenant its lease on call #2...' para a justa) rodam exatamente esse cenário lado a lado, provando as duas metades da comparação com o mesmo conjunto de jobs.
Avançado — escrever um ADR de separação com gatilho, migração/rollback e evidência adicional exigida antes de produção. Critério verificável: o ADR precisa nomear pelo menos três condições mensuráveis (não subjetivas) que, juntas, justificariam reabrir a decisão de separar serviço, um plano expand/contract que rode os dois caminhos (processo único e serviço novo) em paralelo antes de desligar qualquer um dos dois, e um rollback que preserve reconciliação de jobs em voo. Solução comentada: examples/adrs/006-evolution.md já contém essa estrutura completa - as três condições do "Gatilho de revisão futura" (piso operacional de queueMs definido e ultrapassado, contenção de CPU/memória medida e não apenas presumida, mais de um tenant consentido com requisito contratual de isolamento), a migração expand/contract de seis passos, e o rollback que reusa a disciplina de reconciliação do capítulo 7 (findJobsNeedingReconciliation, citada como referência conceitual, nunca reimportada) em vez de inventar uma nova.