Nem todo trabalho precisa acontecer enquanto o cliente espera. Neste artigo os efeitos de uma venda saem da requisição e passam para processos em segundo plano e para uma fila durável. Também veremos o padrão que impede que um evento se perca entre o banco e a fila.
O que não deveria estar na requisição
Depois do artigo anterior, um POST /pedidos faz, em sequência: grava o pedido, invalida o cache, avisa as conexões SignalR, incrementa o ranking no Redis e, em breve, vai enviar um e-mail de confirmação. Só o primeiro passo é da venda. O resto são reações a ela, e deixá-los na requisição traz três problemas:
- Latência. O cliente espera o servidor de e-mail responder para ver o "pedido confirmado".
- Acoplamento de falhas. Se o provedor de e-mail estiver fora do ar, a venda falha, ou é gravada e responde erro, o que é pior.
- Perda silenciosa. Se o processo cair entre o commit e o envio do e-mail, o e-mail nunca sai, e ninguém fica sabendo.
A saída é separar o fato ("o pedido 42 foi registrado") das reações a ele, e processar as reações de forma assíncrona, com garantias explícitas de entrega.
BackgroundService: trabalho dentro do processo
O ponto de partida é o IHostedService: um serviço que o host inicia junto com a aplicação e para no desligamento. A classe base BackgroundService reduz isso a um único método:
public abstract class BackgroundService : IHostedService, IDisposable
{
protected abstract Task ExecuteAsync(CancellationToken stoppingToken);
// StartAsync chama ExecuteAsync; StopAsync cancela o token e espera
}
A combinação mais simples é um Channel<T> (de System.Threading.Channels: uma fila produtor-consumidor assíncrona e segura entre threads, que aparece pela primeira vez nesta série) com um BackgroundService consumindo:
public sealed class FilaDeEfeitos
{
private readonly Channel<Pedido> _canal = Channel.CreateBounded<Pedido>(
new BoundedChannelOptions(1_000) { FullMode = BoundedChannelFullMode.Wait });
public ValueTask EnfileirarAsync(Pedido pedido, CancellationToken ct) =>
_canal.Writer.WriteAsync(pedido, ct);
public IAsyncEnumerable<Pedido> LerTodosAsync(CancellationToken ct) =>
_canal.Reader.ReadAllAsync(ct);
}
public sealed class ProcessadorDeEfeitos(
FilaDeEfeitos fila,
IServiceScopeFactory escopos,
ILogger<ProcessadorDeEfeitos> log) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var pedido in fila.LerTodosAsync(stoppingToken))
{
try
{
await using var escopo = escopos.CreateAsyncScope();
var efeitos = escopo.ServiceProvider.GetRequiredService<EfeitosDeAlteracao>();
await efeitos.PedidoRegistradoAsync(pedido);
}
catch (Exception e) when (e is not OperationCanceledException)
{
log.LogError(e, "Falha nos efeitos do pedido {PedidoId}", pedido.Numero);
}
}
}
}
builder.Services.AddSingleton<FilaDeEfeitos>();
builder.Services.AddHostedService<ProcessadorDeEfeitos>();
Dois detalhes vêm direto de "Um repositório, três bancos". Primeiro, um BackgroundService é singleton, e por isso pede IServiceScopeFactory e cria um escopo por item processado. Injetar EfeitosDeAlteracao (scoped, e que depende do repositório) no construtor seria uma captive dependency. Segundo, o try/catch dentro do laço é obrigatório. Desde o .NET 6, uma exceção que escapa do ExecuteAsync derruba o host inteiro (o comportamento padrão é BackgroundServiceExceptionBehavior.StopHost), porque um serviço de fundo morto em silêncio é pior que uma aplicação que reinicia.
O endpoint agora só enfileira e responde:
var pedido = await vendas.RegistrarAsync(clienteId, itens, ct);
await fila.EnfileirarAsync(pedido, CancellationToken.None);
return TypedResults.Created($"/pedidos/{pedido.Numero}", pedido.ParaResposta());
A latência melhorou. As outras duas falhas continuam: o Channel vive na memória, então um deploy ou uma queda do processo perde o que estava na fila, e ele só existe dentro de uma réplica. Para uma limpeza periódica de dados temporários, isso basta. Para e-mails de confirmação de compra, não. Precisamos de uma fila fora do processo, durável e compartilhada: um message broker.
RabbitMQ em quatro conceitos
O RabbitMQ é um broker de mensagens de código aberto, escrito em Erlang, que implementa o protocolo AMQP 0-9-1. É um dos brokers mais usados no mundo, ao lado do Apache Kafka, que é um log distribuído com outro modelo, e dos serviços gerenciados de nuvem (Azure Service Bus, Amazon SQS/SNS).
O modelo do RabbitMQ tem uma indireção que os iniciantes costumam estranhar: o produtor nunca publica numa fila.
Produtor ──► Exchange ──(bindings + routing key)──► Fila A ──► Consumidor A
└─────────────────────────────► Fila B ──► Consumidor B
- Exchange: recebe a mensagem com uma routing key e decide para quais filas copiá-la. Os tipos principais são
direct(a chave precisa ser igual à do binding),fanout(copia para todas as filas ligadas) etopic(padrões com curinga:*casa com uma palavra,#com zero ou mais). - Fila: guarda mensagens até algum consumidor confirmá-las.
- Binding: a regra que liga uma fila a uma exchange, com um padrão de chave.
- Ack: o consumidor confirma explicitamente que terminou. Uma mensagem entregue e não confirmada volta para a fila se a conexão do consumidor cair.
A indireção é o que permite que o produtor não saiba quem consome. A API publica "pedido.registrado" uma vez, e o serviço de e-mail, o ranking e, amanhã, a emissão de nota fiscal recebem cada um sua própria cópia em sua própria fila. Acrescentar um consumidor novo é criar uma fila e um binding, sem tocar na API.
docker run -d --name rabbit -p 5672:5672 -p 15672:15672 rabbitmq:4-management
A porta 15672 abre o painel de administração em http://localhost:15672 (usuário e senha guest, que só funcionam a partir de localhost), onde se veem exchanges, filas, taxas e mensagens.
A topologia da Livraria
exchange "livraria.eventos" (topic, durável)
├─ binding "pedido.registrado" ──► fila "livraria.emails" (Worker de e-mail)
├─ binding "pedido.*" ──► fila "livraria.ranking" (Worker de ranking)
└─ binding "produto.#" ──► fila "livraria.api.efeitos" (a própria API)
O cliente .NET oficial é o pacote RabbitMQ.Client. A versão 7, lançada no fim de 2024, reescreveu a API para ser totalmente assíncrona. Tutoriais anteriores usam CreateModel e BasicPublish síncronos, que não existem mais. A declaração da topologia é idempotente: declarar de novo algo que já existe com os mesmos parâmetros não faz nada, então cada serviço declara o que usa ao iniciar.
using RabbitMQ.Client;
public static class Topologia
{
public const string Exchange = "livraria.eventos";
public static async Task DeclararFilaAsync(
IChannel canal, string fila, string padrao, CancellationToken ct)
{
await canal.ExchangeDeclareAsync(Exchange, ExchangeType.Topic,
durable: true, cancellationToken: ct);
await canal.QueueDeclareAsync(fila,
durable: true, exclusive: false, autoDelete: false,
arguments: new Dictionary<string, object?>
{
["x-queue-type"] = "quorum",
["x-dead-letter-exchange"] = "livraria.mortas"
},
cancellationToken: ct);
await canal.QueueBindAsync(fila, Exchange, padrao, cancellationToken: ct);
}
}
durable: true faz exchange e fila sobreviverem ao reinício do broker. x-queue-type: quorum cria uma fila quórum, replicada entre os nós de um cluster pelo algoritmo Raft. No RabbitMQ 4 ela é o tipo recomendado para dados importantes, e as antigas filas clássicas espelhadas foram removidas. A dead-letter exchange recebe as mensagens que falharam demais, e é assunto de uma seção adiante.
O contrato do evento
Eventos são nomeados no passado, porque descrevem algo que já aconteceu e não pode ser recusado:
namespace Livraria.Contratos.Eventos;
public sealed record PedidoRegistrado(
Guid IdDoEvento,
int PedidoId,
string ClienteId,
decimal Total,
IReadOnlyList<ItemVendido> Itens,
DateTimeOffset OcorridoEm);
public sealed record ItemVendido(int ProdutoId, int Quantidade, decimal PrecoUnitario);
Esse record fica num projeto pequeno, Livraria.Contratos, referenciado pela API e pelos workers. É um contrato público como o OpenAPI, e segue as mesmas regras de evolução do exercício 5 de "Validação, ProblemDetails e OpenAPI". Acrescentar campos opcionais é seguro, e remover ou renomear quebra os consumidores, que podem estar rodando uma versão antiga. Mensagens já enfileiradas vão encontrar o código novo, e mensagens novas vão encontrar consumidores antigos.
O problema da escrita dupla
A versão ingênua da publicação é esta:
var pedido = await vendas.RegistrarAsync(clienteId, itens, ct); // 1. commit no banco
await publicador.PublicarAsync(new PedidoRegistrado(...), ct); // 2. publica no RabbitMQ
Entre a linha 1 e a linha 2 o processo pode morrer: um deploy, falta de memória, a máquina reiniciando. O pedido existe e o evento nunca sai, e o cliente não recebe e-mail. Inverter a ordem piora: o evento sai e o commit falha, e o cliente recebe e-mail de um pedido inexistente. Não há transação que abranja o PostgreSQL e o RabbitMQ ao mesmo tempo. Protocolos de transação distribuída existem, mas são lentos, frágeis e mal suportados.
A saída consagrada é o padrão Transactional Outbox, a caixa de saída transacional:
- Na mesma transação que grava o pedido, grava-se também o evento numa tabela
caixa_de_saidado próprio banco. Os dois são gravados juntos ou nenhum é. - Um processo em segundo plano, o retransmissor, lê a caixa de saída, publica no broker e marca como publicado.
Se o processo morrer depois do commit, o evento está seguro no banco, e o retransmissor o publica quando voltar.
No domínio, o pedido anuncia o fato sem saber de filas:
public interface IEventoDeDominio { }
public interface IGeraEventos
{
IReadOnlyList<IEventoDeDominio> RetirarEventos();
}
public sealed class Pedido : IGeraEventos
{
private readonly List<IEventoDeDominio> _eventos = [];
// ... construtor registra: _eventos.Add(new PedidoFoiRegistrado(this));
public IReadOnlyList<IEventoDeDominio> RetirarEventos()
{
var copia = _eventos.ToList();
_eventos.Clear();
return copia;
}
}
No EF Core, o LivrariaContext transforma eventos em linhas da caixa de saída no momento de salvar, dentro da mesma transação:
public sealed class MensagemDeSaida
{
public Guid Id { get; init; }
public required string Tipo { get; init; } // "pedido.registrado"
public required string Conteudo { get; init; } // JSON do contrato
public DateTimeOffset CriadaEm { get; init; }
public DateTimeOffset? PublicadaEm { get; set; }
}
public override async Task<int> SaveChangesAsync(CancellationToken ct = default)
{
var eventos = ChangeTracker.Entries<IGeraEventos>()
.SelectMany(e => e.Entity.RetirarEventos())
.ToList();
foreach (var evento in eventos)
CaixaDeSaida.Add(TradutorDeEventos.ParaMensagem(evento)); // domínio → contrato
return await base.SaveChangesAsync(ct);
}
O TradutorDeEventos converte o evento de domínio no contrato público (PedidoRegistrado) e o serializa. A conversão é o mesmo papel do DTO em relação à entidade: o evento interno pode mudar livremente, e o contrato não. No Mongo, o RepositorioPedidosMongo insere o documento do pedido e o da coleção caixa_de_saida na mesma sessão com transação, o que exige o replica set que configuramos em "MongoDB Avançado".
O retransmissor:
public sealed class RetransmissorDaCaixaDeSaida(
IServiceScopeFactory escopos,
PublicadorRabbit publicador,
ILogger<RetransmissorDaCaixaDeSaida> log) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var relogio = new PeriodicTimer(TimeSpan.FromSeconds(1));
while (await relogio.WaitForNextTickAsync(stoppingToken))
{
try
{
await using var escopo = escopos.CreateAsyncScope();
var db = escopo.ServiceProvider.GetRequiredService<LivrariaContext>();
var pendentes = await db.CaixaDeSaida
.Where(m => m.PublicadaEm == null)
.OrderBy(m => m.CriadaEm)
.Take(100)
.ToListAsync(stoppingToken);
foreach (var mensagem in pendentes)
{
await publicador.PublicarAsync(
mensagem.Tipo, mensagem.Id, mensagem.Conteudo, stoppingToken);
mensagem.PublicadaEm = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(stoppingToken);
}
}
catch (Exception e) when (e is not OperationCanceledException)
{
log.LogWarning(e, "Falha ao retransmitir a caixa de saída; nova tentativa em 1 s");
}
}
}
}
O retransmissor publica e depois marca. Se morrer entre as duas coisas, publicará a mesma mensagem de novo ao voltar. O outbox garante entrega pelo menos uma vez (at-least-once), nunca "exatamente uma vez". Com várias réplicas da API, duas instâncias poderiam pegar as mesmas linhas. No PostgreSQL e no MySQL 8, SELECT ... FOR UPDATE SKIP LOCKED numa transação faz cada instância pegar um lote diferente. A alternativa mais simples é rodar o retransmissor numa única instância dedicada.
O publicador
public sealed class PublicadorRabbit(IConnection conexao) : IAsyncDisposable
{
private IChannel? _canal;
private readonly SemaphoreSlim _trava = new(1, 1);
public async Task PublicarAsync(string tipo, Guid id, string json, CancellationToken ct)
{
await _trava.WaitAsync(ct);
try
{
_canal ??= await conexao.CreateChannelAsync(
new CreateChannelOptions(
publisherConfirmationsEnabled: true,
publisherConfirmationTrackingEnabled: true),
ct);
var propriedades = new BasicProperties
{
MessageId = id.ToString(),
Type = tipo,
ContentType = "application/json",
Persistent = true
};
await _canal.BasicPublishAsync(Topologia.Exchange, tipo, mandatory: true,
propriedades, Encoding.UTF8.GetBytes(json), ct);
}
finally
{
_trava.Release();
}
}
public async ValueTask DisposeAsync()
{
if (_canal is not null) await _canal.DisposeAsync();
}
}
- Uma conexão por aplicação, canais leves por cima. A
IConnectioné registrada como singleton, a mesma regra doConnectionMultiplexerdo Redis. Canais não devem ser usados por várias threads ao mesmo tempo para publicar, daí o semáforo. Persistent = truegrava a mensagem em disco no broker. Sem isso, uma fila durável sobrevive ao reinício, mas vazia.- Publisher confirms: com as confirmações habilitadas no canal, o
BasicPublishAsyncsó completa quando o broker confirma que recebeu e persistiu a mensagem. Sem confirmação, o retransmissor marcaria como publicada uma mensagem que pode ter se perdido na rede. MessageIdé o id do evento, que vai permitir aos consumidores descartar duplicatas.mandatory: truefaz o broker devolver a mensagem se nenhuma fila estiver ligada àquela chave, em vez de descartá-la em silêncio. É útil para detectar um binding esquecido.
Um Worker Service consumidor
Consumidores ficam em processos próprios, que escalam, falham e são implantados de forma independente da API:
dotnet new worker -n Livraria.Worker -o src/Livraria.Worker
dotnet add src/Livraria.Worker package RabbitMQ.Client
dotnet add src/Livraria.Worker reference src/Livraria.Contratos
O template usa o mesmo host genérico do ASP.NET Core, sem servidor HTTP: configuração em camadas, DI, logging e options funcionam exatamente como vimos em "Configuração, logging e testes de integração".
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddSingleton<IConnection>(_ =>
{
var fabrica = new ConnectionFactory
{
Uri = new Uri(builder.Configuration.GetConnectionString("RabbitMQ")!),
ClientProvidedName = "livraria-worker"
};
return fabrica.CreateConnectionAsync().GetAwaiter().GetResult();
});
builder.Services.AddSingleton<IConnectionMultiplexer>(_ =>
ConnectionMultiplexer.Connect(builder.Configuration.GetConnectionString("Redis")!));
builder.Services.AddHostedService<ConsumidorDoRanking>();
builder.Build().Run();
O GetAwaiter().GetResult() na fábrica do singleton é um compromisso conhecido: a DI do .NET não tem fábricas assíncronas, e isso acontece uma vez só, na inicialização. A alternativa é conectar dentro do ExecuteAsync de cada consumidor.
public sealed class ConsumidorDoRanking(
IConnection conexao,
IConnectionMultiplexer redis,
ILogger<ConsumidorDoRanking> log) : BackgroundService
{
private const string Fila = "livraria.ranking";
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var canal = await conexao.CreateChannelAsync(cancellationToken: stoppingToken);
await Topologia.DeclararFilaAsync(canal, Fila, "pedido.*", stoppingToken);
await canal.BasicQosAsync(prefetchSize: 0, prefetchCount: 20, global: false,
stoppingToken);
var consumidor = new AsyncEventingBasicConsumer(canal);
consumidor.ReceivedAsync += async (_, entrega) =>
{
try
{
var evento = JsonSerializer.Deserialize<PedidoRegistrado>(entrega.Body.Span)!;
await ContabilizarAsync(evento);
await canal.BasicAckAsync(entrega.DeliveryTag, multiple: false);
}
catch (Exception e)
{
log.LogError(e, "Falha ao processar {MessageId}", entrega.BasicProperties.MessageId);
await canal.BasicNackAsync(entrega.DeliveryTag, multiple: false, requeue: true);
}
};
await canal.BasicConsumeAsync(Fila, autoAck: false, consumidor, stoppingToken);
await Task.Delay(Timeout.Infinite, stoppingToken);
}
}
autoAck: false: a mensagem só sai da fila com oBasicAckAsync, depois do trabalho feito. ComautoAck: true, o broker a considera entregue ao enviá-la, e uma queda do worker no meio do processamento a perderia.prefetchCount: 20limita quantas mensagens não confirmadas o broker empurra para este consumidor. Sem limite, um consumidor lento acumula milhares de mensagens na memória enquanto outras instâncias ficam ociosas. Com limite, várias instâncias do worker dividem a fila de forma justa, o padrão competing consumers.Task.Delay(Timeout.Infinite, stoppingToken)mantém o serviço vivo até o desligamento. No shutdown, o token cancela, o canal fecha, e mensagens entregues mas não confirmadas voltam para a fila para outra instância. Nenhuma se perde.
Consumidores idempotentes
Entrega pelo menos uma vez significa que a mesma mensagem pode chegar duas vezes, pelo retransmissor que repetiu ou pelo nack de um worker que caiu depois de processar e antes de confirmar. O consumidor precisa ser idempotente: processar duas vezes deve ter o mesmo efeito que processar uma.
Algumas operações já são assim, como invalidar o cache (remover duas vezes é o mesmo que remover uma). O ranking não é: ZINCRBY duas vezes conta a venda em dobro. A solução é registrar o MessageId processado atomicamente junto com o efeito. No Redis, uma transação com condição faz as duas coisas ou nenhuma:
private async Task ContabilizarAsync(PedidoRegistrado evento)
{
var db = redis.GetDatabase();
var marca = $"livraria:processadas:ranking:{evento.IdDoEvento}";
var tx = db.CreateTransaction();
tx.AddCondition(Condition.KeyNotExists(marca));
_ = tx.StringSetAsync(marca, 1, TimeSpan.FromDays(7));
foreach (var item in evento.Itens)
_ = tx.SortedSetIncrementAsync("livraria:mais-vendidos", item.ProdutoId, item.Quantidade);
var aplicou = await tx.ExecuteAsync();
if (!aplicou)
log.LogInformation("Evento {IdDoEvento} já contabilizado; ignorado", evento.IdDoEvento);
}
Registrar a marca depois do efeito, em operações separadas, deixaria a mesma janela de falha que o outbox resolveu do lado do produtor. Quando o efeito é no banco relacional, o equivalente é uma tabela mensagens_processadas com chave primária no id do evento, inserida na mesma transação que o efeito. A violação de chave indica duplicata.
Mensagens venenosas e dead-letter
Uma mensagem que sempre falha, por um JSON malformado ou um produto que não existe mais, voltaria para a fila com requeue: true e seria entregue de novo, para sempre, ocupando o consumidor. É a mensagem venenosa.
As filas quórum contam as entregas de cada mensagem. No RabbitMQ 4, o limite padrão (x-delivery-limit) é 20. Ao ultrapassá-lo, a mensagem é enviada para a dead-letter exchange declarada na fila, livraria.mortas. Uma fila ligada a ela acumula as mensagens mortas para análise humana. O painel mostra o conteúdo, e depois de corrigir o bug é possível republicá-las. Para erros que certamente não vão melhorar com nova tentativa, como uma falha de desserialização, o consumidor pode mandar direto para a dead-letter com BasicNackAsync(..., requeue: false).
Invalidando o cache de todas as réplicas
A fila livraria.api.efeitos, com binding produto.#, é consumida pela própria API. Cada réplica cria uma fila exclusiva e temporária ligada ao mesmo padrão, para que todas recebam todos os eventos. Ao receber produto.alterado, a réplica invalida o HybridCache local e notifica o SignalR. Isso resolve dois limites de "Cache com Redis, HybridCache e output caching":
- A L1 das outras réplicas passa a ser invalidada na hora, e não mais só pelo tempo de expiração.
- Alterações feitas fora da API, por um script de reajuste de preços, também chegam ao cache, desde que o script publique o evento.
A API volta a fazer, dentro da requisição, apenas o que é da venda. Todo o resto reage a eventos.
Bibliotecas de mensageria
Usar o RabbitMQ.Client direto, como fizemos, ensina o que acontece por baixo. Em produção muitas equipes usam uma camada acima, que já traz outbox, retentativas com espera crescente, idempotência, sagas e testes em memória. As principais no .NET são MassTransit, NServiceBus, Wolverine e Rebus. Atenção às licenças: o NServiceBus sempre foi comercial, e o MassTransit anunciou em 2025 que passaria a ter licença comercial a partir da versão 9, com a versão 8 seguindo aberta. Como em qualquer dependência central, vale conferir a licença antes de adotar.
A Livraria agora separa o fato das reações. A venda grava pedido e evento numa só transação, um retransmissor leva o evento ao RabbitMQ com confirmação do broker, e consumidores independentes reagem com idempotência, divisão de carga e uma área de quarentena para o que falha demais. Nada se perde se um processo cair, e um serviço fora do ar só atrasa a sua parte. A comunicação entre os serviços continua assíncrona e textual, JSON por uma fila. Às vezes um serviço precisa de uma resposta imediata de outro: quanto custa o frete para este CEP? Para isso, uma fila é o instrumento errado, e JSON sobre HTTP/1.1 não é o mais eficiente.
Fontes e leituras recomendadas
- Microsoft Learn — Background tasks with hosted services in ASP.NET Core: https://learn.microsoft.com/aspnet/core/fundamentals/host/hosted-services
- Microsoft Learn — Worker services in .NET: https://learn.microsoft.com/dotnet/core/extensions/workers
- RabbitMQ — Tutorials (.NET) e Reliability Guide (confirms e acks): https://www.rabbitmq.com/tutorials
- RabbitMQ — Quorum Queues: https://www.rabbitmq.com/docs/quorum-queues
- Chris Richardson — Pattern: Transactional outbox: https://microservices.io/patterns/data/transactional-outbox.html
- Gregor Hohpe e Bobby Woolf — Enterprise Integration Patterns (Addison-Wesley, 2003): https://www.enterpriseintegrationpatterns.com/
Exercícios
Exercício 1
Um desenvolvedor escreveu o consumidor com autoAck: true "para simplificar" e sem BasicQos. Descreva o que acontece quando o worker é reiniciado durante um pico de 10 mil mensagens na fila.
Ver resposta
✓ Resposta: Sem BasicQos, o broker empurra ao consumidor tantas mensagens quanto a rede aguenta, possivelmente milhares, que ficam no buffer do cliente. Com autoAck: true, o broker considera cada uma entregue no momento do envio e a remove da fila. No reinício, todas as mensagens que estavam no buffer e não tinham sido processadas se perdem: e-mails não enviados, vendas fora do ranking, sem nenhum registro. Com autoAck: false e prefetchCount: 20, no máximo 20 mensagens ficam pendentes. No reinício elas voltam para a fila e são reentregues, o que exige a idempotência vista no artigo.
Exercício 2
Por que o outbox garante "pelo menos uma vez" e não "exatamente uma vez"? Aponte os dois pontos do fluxo em que uma duplicata pode nascer.
Ver resposta
✓ Resposta: Porque em cada passagem de responsabilidade entre sistemas existe uma janela entre "fazer" e "registrar que fez". Ponto 1, no retransmissor: o broker confirmou a publicação, mas o processo morre antes de gravar PublicadaEm. Ao voltar, publica de novo. Ponto 2, no consumidor: o efeito foi aplicado (e-mail enviado), mas o worker morre antes do BasicAckAsync. O broker reentrega a outro consumidor. Fechar essas janelas exigiria uma transação atômica abrangendo sistemas diferentes, que é o que se evitou desde o início. O "exatamente uma vez" efetivo se obtém com entrega pelo menos uma vez somada a consumidores idempotentes.
Exercício 3
Implemente o consumidor de e-mail de modo idempotente. Por que ele é mais difícil de tornar idempotente do que o ranking?
Ver resposta
✓ Resposta: Estrutura: ao receber PedidoRegistrado, verificar numa tabela ou chave emails_enviados se o IdDoEvento já foi processado. Se sim, dar ack e sair. Se não, enviar e registrar. O problema é que o envio de e-mail é um efeito externo que não participa de nenhuma transação nossa: não há como enviar e registrar atomicamente. Se o worker morrer entre o envio e o registro, o e-mail sai duas vezes. As mitigações são: usar a chave de idempotência do próprio provedor de e-mail, quando ele oferece uma (muitos aceitam um id da mensagem e descartam repetições); registrar "enviando" antes e "enviado" depois, e tratar o estado "enviando" órfão com intervenção ou aceitando o risco; e, de forma pragmática, aceitar que um e-mail duplicado raro é tolerável. Um pagamento em dobro não seria, e por isso gateways de pagamento sempre oferecem chaves de idempotência.
Exercício 4
Com duas réplicas da API, cada uma rodando o RetransmissorDaCaixaDeSaida, o que acontece com as mensagens pendentes? Reescreva a consulta para PostgreSQL de forma que cada instância pegue um lote diferente.
Ver resposta
✓ Resposta: As duas instâncias leem as mesmas linhas não publicadas e publicam cada evento duas vezes. Os consumidores idempotentes absorvem isso, mas é trabalho e tráfego em dobro. A correção, dentro de uma transação: var pendentes = await db.CaixaDeSaida.FromSql($"SELECT * FROM caixa_de_saida WHERE publicada_em IS NULL ORDER BY criada_em LIMIT 100 FOR UPDATE SKIP LOCKED").ToListAsync(ct);, seguida de publicar, marcar e dar commit. FOR UPDATE bloqueia as linhas lidas, e SKIP LOCKED faz a outra instância pular as bloqueadas e pegar as próximas, sem esperar. O MySQL 8 tem a mesma sintaxe. No Mongo, o equivalente é FindOneAndUpdate marcando cada documento com "reservado por instância X até T", um documento por vez. A ordem global entre instâncias deixa de ser garantida, e isso é aceitável porque os consumidores não devem depender de ordem entre pedidos diferentes.
Exercício 5
Um evento PedidoRegistrado antigo, publicado antes do deploy, está numa fila quando entra no ar um consumidor que espera o novo campo obrigatório CanalDeVenda. O que acontece, e como evoluir o contrato sem quebrar?
Ver resposta
✓ Resposta: O JsonSerializer desserializa o evento antigo com CanalDeVenda nulo (ou lança exceção, se o record usar required e a opção de respeitar essas propriedades estiver ligada). O consumidor pode falhar nessa mensagem até esgotar as tentativas e mandá-la para a dead-letter, ou processar com um valor inválido. A evolução segura: (1) acrescentar o campo como opcional (string? CanalDeVenda), com consumidores que tratam a ausência com um padrão explícito ("desconhecido"); (2) implantar primeiro os consumidores tolerantes, depois o produtor que preenche o campo; (3) só tornar o campo obrigatório no código depois que nenhuma mensagem antiga puder existir, isto é, filas drenadas e caixa de saída sem pendentes. Para mudanças incompatíveis de verdade, publica-se um evento novo (PedidoRegistrado.v2, com outra routing key) em paralelo, até que todos os consumidores migrem.