Worker Services e mensageria com RabbitMQ: trabalho fora da requisição

Worker Services e mensageria com RabbitMQ: trabalho fora da requisição

Os efeitos da venda saem da requisição: BackgroundService com Channel, RabbitMQ com exchanges, filas quórum e acks, o padrão Transactional Outbox contra a escrita dupla, publisher confirms, Worker Services consumidores idempotentes e mensagens venenosas na dead-letter.
Linguagem C#

• • 21 min de leitura

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) e topic (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:

  1. Na mesma transação que grava o pedido, grava-se também o evento numa tabela caixa_de_saida do próprio banco. Os dois são gravados juntos ou nenhum é.
  2. 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 do ConnectionMultiplexer do Redis. Canais não devem ser usados por várias threads ao mesmo tempo para publicar, daí o semáforo.
  • Persistent = true grava 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 BasicPublishAsync só 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: true faz 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 o BasicAckAsync, depois do trabalho feito. Com autoAck: true, o broker a considera entregue ao enviá-la, e uma queda do worker no meio do processamento a perderia.
  • prefetchCount: 20 limita 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

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.

Comentários

Mais em Linguagem C#

Métodos: Dividindo o Problema em Partes Que Conversam
Métodos: Dividindo o Problema em Partes Que Conversam

Métodos em C#: declaração, retorno e corpo de expressão, funções locais nas…

LINQ, Parte I: Perguntas ao Catálogo em Vez de Laços
LINQ, Parte I: Perguntas ao Catálogo em Vez de Laços

Consultas com LINQ em C#: Where, Select, OfType, ordenação, Count, Any e All…

Quando Algo Dá Errado: Exceções em C#
Quando Algo Dá Errado: Exceções em C#

Exceções em C#: como ler um stack trace, try, catch na ordem certa, filtros…