Dominando o merge: coordenando estado em mundos paralelos

SchemaBridge Team · 2025-12-29 · Concurrency, State Management, Synchronization

Sincronizar ramos paralelos sem condições de corrida: joins duráveis, o problema da cauda longa e os padrões que decidem qual join você recebe.

O paradoxo do paralelismo: liberdade vs. sincronização

Na busca por desempenho, abraçamos o paralelismo. Distribuímos nossas tarefas, disparamos workers assíncronos e espalhamos nossos dados por muitos nós. Ganhamos throughput, mas pagamos um preço alto em complexidade. A parte difícil dos sistemas distribuídos não é iniciar coisas ao mesmo tempo; é reuni-las de volta.

Imagine uma jornada complexa de atendimento de pedidos: você dispara simultaneamente uma tarefa "Cobrar cartão", uma tarefa "Verificar estoque" e uma tarefa "Calcular frete". Para avançar ao próximo passo — imprimir a nota fiscal —, você precisa dos resultados das três. Isso é um vértice Merge, também conhecido como join distribuído ou sincronizador por barreira.

Em um ambiente single-thread, isso é fácil: basta esperar três chamadas de função retornarem. Mas em uma engine distribuída, essas tarefas rodam em máquinas diferentes e podem terminar com segundos ou até horas de diferença. Uma pode falhar enquanto as outras têm sucesso. Esse é o paradoxo do paralelismo: quanto mais você paraleliza o trabalho para ganhar velocidade, mais difícil fica coordenar o resultado final.

A evolução histórica da sincronização por barreira

Para entender por que mesclar estado é tão difícil, precisamos olhar para a história da computação de alto desempenho (HPC). Nas décadas de 1970 e 80, cientistas da computação desenvolveram o conceito de barreira. Uma barreira é um ponto de sincronização em que cada thread de um processo paralelo precisa parar e esperar até que todas as outras threads tenham chegado. Só quando a contagem é atingida o processo pode seguir em frente.

Em sistemas monolíticos, isso era implementado com spin-locks ou mutexes em memória compartilhada. A CPU da máquina gerenciava o estado da barreira a uma velocidade quase infinita. Mas, ao migrarmos para sistemas distribuídos, perdemos a "memória compartilhada". Já não havia uma única CPU para atuar como árbitro.

Nos anos 2000, vimos a ascensão do Map-Reduce (o artigo fundamental do Google). O Map-Reduce oferecia escala massiva para processamento paralelo, mas foi projetado para "cargas de trabalho em lote". Você mapeava seus dados e, depois, havia uma fase "Reduce" que agregava todos os resultados. Uma tarefa de map que falhava era simplesmente reexecutada, mas uma lenta segurava a fase de reduce; o design do Google combatia esses retardatários com tarefas de backup, que reexecutavam de forma especulativa as últimas tarefas em andamento perto do fim do job. Isso nos leva ao desafio operacional mais significativo na mesclagem de estado: o problema da cauda longa.

O problema da cauda longa: o ramo mais lento vence

Em um join distribuído de 1.000 itens, o tempo total de execução não é determinado pela velocidade média dos seus workers. Ele é determinado pela latência do worker mais lento. Se 999 itens terminam em 10 ms, mas 1 item leva 10 segundos por causa de um lock no banco de dados ou de uma instabilidade de rede, todo o seu workflow espera 10 segundos.

Esse é o problema da cauda longa. Em uma implementação ingênua, ele leva a um acúmulo enorme de uso de recursos. Enquanto 999 threads esperam pelo último retardatário, elas consomem memória, ocupam conexões e podem bloquear outros workflows de alta prioridade.

No SchemaBridge, a espera não segura nenhuma thread. À medida que cada ramo termina, ele registra sua chegada no estado durável de join do merge — uma linha de estado de join no PostgreSQL, com as conclusões dos ramos entregues via Valkey Streams — e encerra imediatamente. O vértice Merge espera sem consumir CPU. Quando o número de ramos que chegaram atinge o número esperado, a engine despacha o merge e o workflow segue em frente. Isso é sincronização assíncrona por barreira, e é o que permite que uma lógica de negócio com vários ramos espere minutos ou horas sem prender recursos.

Lidando com sinais tardios: o problema do sinal perdido

Um modo de falha particularmente desagradável na mesclagem de estado é o sinal perdido (stray signal): um ramo, ou uma entrega duplicada da conclusão de um ramo, que reporta depois que o sistema já seguiu em frente. O serviço não estava morto; estava apenas lento, ou a mensagem foi reentregue.

Em um script legado, isso é um desastre. O sinal tardio chega ao seu código e tenta atualizar um estado que já avançou. Isso pode levar a cobranças duplicadas, registros corrompidos no banco de dados ou a um passo executado duas vezes. Você precisa escrever lógica complexa para "ignorar sinais de workflows finalizados".

O SchemaBridge absorve esses sinais com guardas de estado comuns, e não com mecanismos especiais. Um join é despachado apenas uma vez: despachar o merge é uma transição de estado condicional, então uma segunda tentativa de iniciá-lo é recusada. Um passo que já atingiu um status terminal não pode ser concluído novamente, então uma conclusão tardia para ele é registrada como redundante e descartada. E cada tentativa de executar um passo é protegida por uma época de claim, de modo que um worker que perdeu seu claim não pode sobrescrever o resultado do worker que assumiu. Nada disso torna a execução exactly-once — a entrega continua sendo at-least-once —, mas um sinal perdido não consegue avançar um join duas vezes.

O custo de uma sincronização ruim

Joins mal gerenciados são mais do que uma dor de cabeça para os desenvolvedores. Considere um site de e-commerce hipotético que executa um join de "agregador de preços" sobre 50 fornecedores terceiros a cada busca de produto.

Seja honesto sobre onde uma engine de workflow se encaixa aqui. Um MERGE do SchemaBridge não tem janela de tempo: ele dispara quando todos os ramos esperados chegaram, e não "depois de 500 ms com o que tiver voltado". Se um prazo importa, coloque-o dentro da própria chamada de cada ramo (um timeout curto na requisição ao fornecedor), para que um ramo lento termine — com sucesso ou não — rapidamente. Em um fan-out, o sucesso parcial é expresso tolerando um número limitado de ramos com falha, e não por um prazo. Uma página de busca crítica em latência costuma ser mais bem atendida pelo caminho de requisição da sua própria aplicação, com o workflow cuidando do trabalho em segundo plano mais lento, como a atualização de caches.

Comparando estratégias de merge: Map-Reduce vs. Flow-Sync

| Característica | Map-Reduce (lotes grandes) | Apache Spark (streaming) | SchemaBridge Flow-Sync |

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

| Foco | Processamento offline | Streams quase em tempo real | Lógica de negócio transacional |

| Persistência de estado | Arquivos intermediários | Estado com checkpoints | Estado de join durável no PostgreSQL |

| Tratamento de erros | Reexecutar as tarefas que falharam | Checkpoint/reinício | Falha por ramo, retry ou tolerância limitada |

| Lógica de join | Shuffle por chave | Joins por janela de tempo | Dependência baseada em grafo |

| Durabilidade | Alta | Alta (com checkpoints) | Alta (sobrevive a reinícios de workers) |

Masterclass de "join com estado": qual join você realmente recebeu?

Nem todo merge é "esperar por todos", e o mais importante a saber sobre os joins do SchemaBridge é qual deles você recebe quando não escolhe.

1. O padrão: a primeira chegada vence (WAIT_ONE)

Quando várias arestas convergem em um vértice comum, sua estratégia de ativação padrão é WAIT_ONE: o vértice executa uma vez, acionado pelo ramo que chegar primeiro, e as chegadas posteriores são descartadas. Nada é cancelado — os ramos mais lentos continuam executando até o fim —, mas seus payloads nunca chegam ao join. Isso é exatamente o certo para "use o mais rápido de três provedores de previsão do tempo", desde que você aceite que as outras duas chamadas ainda acontecem. É exatamente o errado se você desenhou um losango esperando os três resultados, e nenhum erro vai avisá-lo disso. Este é o padrão de maior consequência de toda a plataforma, então verifique-o em cada convergência que você desenhar.

2. O merge que espera por todos (barreira)

Para coletar todos os ramos, use um vértice MERGE ou defina a estratégia de ativação como WAIT_ALL. O join só dispara quando todos os ramos esperados chegaram, e você escolhe como os payloads são combinados: sobrescrevendo em um único objeto plano, um mapa indexado por ramo ou um array.

{
  "type": "MERGE",
  "config": {
    "mergeStrategy": "NAMED_MAP"
  }
}

Se algum ramo falhar, o join não dispara e a execução falha de forma visível; não há rollback automático dos ramos que tiveram sucesso, então ações de compensação são algo que você modela explicitamente. Para um fan-out criado por um Spawner, você pode optar por tolerar um número limitado de filhos com falha.

3. O join faminto: uma regra de design

Nunca coloque um vértice WAIT_ALL ou MERGE a jusante de um ramo condicional. Se uma condição desviar de uma das entradas, essa entrada nunca chega, e o join espera para sempre por um ramo que nunca iria executar. Se você precisa coletar resultados de caminhos que podem não ser todos executados, use WAIT_ONE e trate os dados ausentes no corpo do join, ou mova a condição inteiramente para dentro de um dos ramos.

Checklist de especialista para merge distribuído

Para construir uma estratégia de merge resiliente, siga estas heurísticas:

1. Escolha o join explicitamente: toda convergência é de primeira chegada (WAIT_ONE) ou de esperar por todos (MERGE / WAIT_ALL). Decida qual você quer em vez de herdar o padrão.

2. Limite cada ramo, não o merge: o merge não tem timeout próprio, então coloque prazos nas chamadas dentro de cada ramo e garanta que todo ramo alcance um estado terminal.

3. Use idempotência nos ramos: a entrega é at-least-once. Garanta que, se um ramo executar duas vezes, seus efeitos colaterais sejam seguros.

4. Mantenha condicionais fora das barreiras: nunca alimente um join que espera por todos a partir de um caminho condicional.

5. Minimize o tamanho do estado: não carregue dados desnecessários pelo merge. Traga apenas os campos de que o próximo passo precisa, para reduzir custos de serialização.

6. Planeje o sucesso parcial: nem toda lógica de negócio exige 100% das entradas. Pergunte ao seu product manager: "qual é o mínimo de dados viável de que precisamos para prosseguir?"

Conclusão: o merge é a fronteira final da distribuição

Paralelismo sem sincronização gerenciada é simplesmente caos. Levar o join para o estado durável da engine significa que uma espera longa não custa threads, que um sinal tardio ou duplicado não consegue disparar um join duas vezes e que um ramo com falha fica visível em vez de se perder silenciosamente. O que a engine não pode fazer é adivinhar qual join você quis dizer — por isso os padrões e as regras de design acima importam tanto quanto a maquinaria.

Você não deveria se preocupar com mutexes ou latches; deveria se concentrar na lógica que acontece depois que os dados foram reunidos com sucesso.

Veja também: Protegendo o pipeline: Zero-Trust na camada de eventos, sobre como proteger fluxos com vários ramos e as credenciais que eles usam, e Dominando fan-outs, sobre como gerar os ramos, para começar.

Explorar