Dominando fan-outs: construindo workflows duráveis e idempotentes

SchemaBridge Team · 2025-12-15 · Scalability, Idempotency, Orchestration

Lidando com grandes fan-outs sem perder o controle do trabalho. Um mergulho no vértice Spawner, nas chaves de idempotência por item e na entrega at-least-once.

O desafio dos grandes fan-outs: onde os loops vão morrer

Todo desenvolvedor já escreveu um loop. Seja um loop for em Java, um .map() em JavaScript ou uma list comprehension em Python, a lógica é a mesma: pegar uma lista de itens e fazer algo com cada um. Essa é a forma mais simples de processamento de dados. Na escala de um punhado de itens, é trivial. Na escala de algumas centenas, é administrável. Mas, ao cruzar o limiar de muitos milhares, o humilde loop se torna um sério risco para a confiabilidade e a escalabilidade da sua aplicação.

No mundo dos sistemas distribuídos, o loop local é um ponto de falha. Quando você passa de um punhado de itens para muitos milhares, a complexidade não apenas cresce linearmente; ela bate em uma parede de complexidade. Essa parede é feita das realidades duras do gerenciamento de memória, da latência de rede e da falha inevitável das máquinas que executam o seu código. Você para de pensar em "lógica" e começa a lutar contra a "física".

Os limites do loop local: por que Promise.all é pré-escala

Em uma implementação ingênua, você pode receber um payload JSON enorme — digamos, um grande CSV diário de pedidos ou uma exportação em lote de um CRM — e envolver em um loop uma chamada de API para um serviço downstream. Se você é um desenvolvedor JavaScript moderno, talvez use Promise.all() para disparar todas em paralelo. Esse é o primeiro erro do desenvolvedor pré-escala.

Em escala, isso é um desastre por três motivos críticos:

1. Esgotamento de memória: a queda silenciosa

Carregar um número muito grande de objetos complexos na memória pode facilmente derrubar o seu worker. Mesmo um tamanho modesto por objeto se acumula rápido, e os dados brutos podem inchar ainda mais em runtimes que consomem muita memória. O resultado é um erro imediato de "Out of Memory" (OOM) que mata o processo antes mesmo de o primeiro item ser processado.

2. Timeout de execução: o relógio está correndo

A maioria das plataformas tem limites rígidos. Se o seu loop faz muitos milhares de chamadas de API e cada chamada leva apenas 100 ms, o seu script pode rodar por muitos minutos. Mesmo que você paralelize, continua preso aos limites de recursos e à sobrecarga daquele único contêiner. A plataforma vai encerrá-lo antes que o último item seja processado, deixando o seu sistema em um estado indeterminado.

3. Nenhum registro do progresso

Quando o processo morre, o único registro do que foi feito está no sistema de destino. Ninguém consegue dizer quais itens foram enviados, quais tiveram sucesso e quais nunca foram tentados sem uma reconciliação forense.

Entra o Spawner: fan-out como execuções filhas duráveis

No SchemaBridge, o fan-out é um vértice Spawner dedicado. Um Spawner não é apenas um loop; ele transforma cada item em uma unidade de trabalho de primeira classe que o motor rastreia e reporta individualmente, e que você pode reexecutar individualmente.

Como um Spawner realmente funciona: a divisão paralela

O Spawner desacopla a lista da execução dos itens. Cada item deixa de ser uma iteração dentro de um processo e se torna sua própria execução:

1. Uma execução filha por item: Para cada item, o Spawner inicia uma execução filha do workflow de destino. Cada filha é persistida no PostgreSQL com seu próprio ID, seu próprio status e seus próprios logs, e mantém sua linhagem até a execução pai.

2. Chaves determinísticas por item: Cada solicitação de filha carrega uma chave de idempotência derivada da execução pai, do vértice Spawner e da posição do item. Se a própria etapa do Spawner for reentregue — por exemplo, porque o pod do worker reiniciou no meio do fan-out e outro worker reivindicou a etapa —, ela reenvia a lista desde o início, e as filhas que já tinham sido iniciadas são reconhecidas pela chave em vez de serem criadas duas vezes.

3. Independência de ciclo de vida: Se o item 501 falhar, isso não impede que o 502 seja despachado nem que tenha sucesso. Você ganha a granularidade de muitas execuções individuais em vez de um único lote enorme e frágil, e uma filha que falhou pode ser reexecutada isoladamente.

Seja realista quanto à garantia: a deduplicação por essa chave é uma consulta feita antes de a filha ser criada, não uma restrição de unicidade no banco de dados, então ela estreita a janela de duplicatas em vez de fechá-la. O motor é at-least-once, e tudo o que uma filha faz no mundo externo deve ser idempotente no destino.

A economia do paralelismo

Rodar um worker pesado e single-threaded por 15 minutos é caro. Você não paga só pela instância com muita memória, mas também pelo "tempo ocioso" enquanto o seu código espera respostas da API. Você desperdiça ciclos de CPU faturáveis em espera de I/O de rede.

Com execuções filhas por item, o formato do trabalho muda:

Esperaríamos que isso use o processamento de forma mais eficiente do que um script em lote de longa duração que passa a maior parte do tempo esperando I/O, mas o maior ganho é confiabilidade e observabilidade, não a fatura.

O modelo tem limites, e você deve conhecê-los. Um fan-out é verificado contra a cota de execuções do seu plano antes que qualquer filha seja iniciada, porque cada item é uma execução. O Spawner mantém a lista de itens na memória e envia as filhas uma de cada vez a partir de uma única etapa, então não é um cano infinitamente largo. E, quando o armazenamento de payloads da plataforma está sob pressão, o Spawner adia o fan-out inteiro — estacionando a etapa e reativando-a depois a partir de um timer durável — em vez de empurrar mais dados para um armazenamento já sobrecarregado.

Idempotência em fan-outs distribuídos: at-least-once, com salvaguardas

O maior obstáculo nos padrões de fan-out é a idempotência. Se a etapa do Spawner for interrompida e executada novamente, como evitamos iniciar a mesma filha duas vezes?

O SchemaBridge combina várias salvaguardas:

O que isso não oferece é execução exactly-once. Um worker pode cair depois que um efeito colateral aconteceu, mas antes que sua conclusão seja registrada, e então a etapa será executada novamente. É por isso que descrevemos a plataforma como at-least-once com salvaguardas fortes, e é por isso que o destino de qualquer chamada não idempotente precisa de sua própria proteção.

Cenário ilustrativo: a falha de uma grande migração

Imagine uma equipe realizando uma migração crítica de um lote muito grande de lançamentos contábeis. Ela opta por um script Python tradicional. No meio de um job de longa duração, a VPN cai. O script falha.

A recuperação (do jeito caro)

A recuperação é dolorosa: os engenheiros passam dias vasculhando o banco de dados de destino e reconciliando registros manualmente para descobrir exatamente o que foi e o que não foi gravado.

A recuperação (do jeito SchemaBridge)

Agora imagine a mesma migração modelada como um Spawner que distribui uma execução filha por lançamento contábil, e a mesma queda de conectividade.

1. As falhas ficam por item: As filhas que já estavam rodando continuam; as filhas que não conseguiram alcançar o destino falham individualmente, cada uma com seu próprio erro, enquanto o restante do fan-out não é afetado. Se o próprio Spawner não conseguir enviar uma filha, a etapa do Spawner falha de forma visível em vez de fingir que está esperando.

2. Progresso visível: A visualização da execução mostra quantas filhas tiveram sucesso e quantas falharam, de modo que a equipe não precisaria reconstruir o progresso a partir do banco de dados de destino.

3. Recuperação direcionada: Assim que a conectividade volta, a equipe reexecuta individualmente as filhas que falharam em vez de rodar a migração inteira de novo. Reexecutar a etapa do Spawner que falhou não as rodaria de novo: as chaves por item reanexam todas as filhas que já existem, inclusive as que falharam, então um retry do Spawner só cria as filhas que nunca chegaram a iniciar. A recuperação passaria a ser uma ação do operador medida em cliques, não um projeto forense — embora as gravações contábeis em si ainda precisem ser idempotentes, porque uma filha reexecutada pode repetir uma gravação que já tinha sido aplicada.

Comparação detalhada: modelos de escala na prática

| Recurso | Loop ingênuo (forEach) | SQS/Lambda (faça você mesmo) | Spawner do SchemaBridge |

| :--- | :--- | :--- | :--- |

| Gerenciamento de estado | Local (volátil) | Manual (BD/fila) | Nativo (durável, por filha) |

| Tratamento de erros | Um único try/catch | Retries/DLQs manuais | Isolamento de falhas por item + retry pelo operador |

| Visibilidade | Fragmentos de arquivos de log | Profundidade de fila opaca | Status por filha na visualização da execução |

| Lógica de junção | Difícil (thread única) | Muito difícil (contadores) | Vértice Merge nativo |

| ID idempotente | Nenhum | Geração manual | Chave determinística por item |

Checklist de especialista para orquestração de alto volume

Se você está projetando um fan-out de alto volume, siga estas regras práticas:

1. Limite a concorrência no destino: Proteja o sistema downstream com um limite de concorrência. No SchemaBridge, isso é um limite de concorrência por destino (definido na página Rate Limits), que se aplica a toda etapa que chama esse destino, e não um ajuste no próprio Spawner. Comece de forma conservadora e amplie-o enquanto observa a saúde do sistema downstream.

2. Assuma que a falha de um item é normal: Garanta que cada item possa ser reexecutado de forma independente e decida de antemão se uma filha com falha deve fazer o fan-out inteiro falhar ou ser tolerada.

3. Monitore a latência da "cauda longa": Procure a pequena fração de itens que levam muito mais tempo que o restante. Geralmente são seus casos-limite mais complexos ou alvos de locks no banco de dados.

4. Aproveite IDs determinísticos: Use as chaves por item do motor para se proteger contra reinicializações e dê também ao seu destino uma chave de negócio estável. Nunca dependa de um timestamp para garantir unicidade em um fan-out de alta concorrência.

{
  "type": "MERGE",
  "config": {
    "tolerateChildFailures": ["FAILED"],
    "failureTolerance": { "maxFailureRatio": 0.05 },
    "failFast": true
  }
}

A tolerância a falhas parciais é opcional (opt-in) e configurada no merge de callback do fan-out: por padrão, qualquer filha com falha faz a junção falhar, que é a escolha segura quando cada item importa. Acima, filhas com falha são toleradas até 5% do fan-out, e failFast encerra a junção assim que esse limite é definitivamente excedido, em vez de esperar pelas retardatárias.

Conclusão: escalar é um problema de infraestrutura

Dominar fan-outs não é escrever loops melhores; é arquitetar para a distribuição. Ao levar a iteração, o estado por item e a supressão de duplicatas para dentro do motor, uma falha parcial se torna uma lista de itens específicos que falharam em vez de um estado desconhecido — e essa é a diferença entre um retry e um projeto de reconciliação.

Escalar ainda tem limites — cotas, backpressure e a natureza at-least-once da entrega distribuída —, mas esses limites são visíveis e delimitados, em vez de serem descobertos em produção às 3 da manhã.

Veja também: O motor de idempotência: garantindo a integridade transacional, sobre como chaves e idempotência no destino se encaixam, e Dominando o Merge, sobre como coletar os resultados de um fan-out.

Explorar