fbpx
Publicado em: quinta-feira, 30 de maio de 2024
Oragon.RabbitMQ – A relação entre Ack e Resiliência

Oragon.RabbitMQ está no forno!

Hoje começamos uma série que trata dos argumentos para a criação desse projeto.

Começamos pela relação entre a escolha entre Ack Manual e Ack Automático e por que ack automático remove o benefício da resiliência no consumo de mensagens do RabbitMQ.

Se olharmos os tutoriais por aí vamos ver que a maioria usa o autoack.

Aliás o principal tutorial da documentação, também usa autoack.

Autoack é uma configuração do consumo que determina se o broker encarará a mensagem como processada ao entregá-la para o client ou se o broker aguardará uma resposta programática para determinar o que fazer com a mensagem.

Consiste em uma flag boolean.

Com autoack antes de começar o processamento da mensagem pelo código do usuário, o broker já deleta a cópia da mensagem do próprio broker. Resultado: A mensagem é deletada antes de ser totalmente processada, e, portanto, a mensagem só existe na memória do processo, e qualquer falha não tratada ou ausência de retry, produzirá perda da mensagem.

A solução: um boolean aqui, uma chamada de método ali. Só.

...
var consumer = new EventingBasicConsumer(channel);

consumer.Received += (model, ea) =>
{
    var body = ea.Body.ToArray();

    var message = Encoding.UTF8.GetString(body);

    Console.WriteLine(" [x] Received {0}", message);
};

channel.BasicConsume(queue: "hello", autoAck: true, consumer: consumer);
                                     ^^^^^^^^^^^^^^
...
...
var consumer = new EventingBasicConsumer(channel);

consumer.Received += (model, ea) =>
{
    var body = ea.Body.ToArray();

    var message = Encoding.UTF8.GetString(body);

    Console.WriteLine(" [x] Received {0}", message);

    // Acknowledging the message manually
    channel.BasicAck(deliveryTag: ea.DeliveryTag, multiple: false);
};
channel.BasicConsume(queue: "hello", autoAck: false, consumer: consumer);
                                     ^^^^^^^^^^^^^^
...

O primeiro, com ack automático, joga pela janela toda a resiliência do RabbitMQ e sua capacidade de não perder mensagens.

O segundo, com ack manual, faz com que o broker aguarde um comando que determine o que fazer com aquela mensagem. Enquanto isso, sua mensagem está no broker, por mais que seu processamento demore.

A diferença entre o tutorial e o mundo real

As duas demonstrações acima focam na simplicidade e copiam tutoriais, porque no mundo real, um consumidor é mais parecido com o código abaixo.

...
var consumer = new EventingBasicConsumer(channel);

consumer.Received += (model, ea) =>
{
	MyMessage message = default; 	
	try
	{
		// Obtem o body da mensagem
		var messageBytes = ea.Body.ToArray();

		// Tentativa de transformação do array de bytes em string
		var messageText = Encoding.UTF8.GetString(messageBytes);

		// Tentativa de desserialização
		message = JsonSerializer.Deserialize<MyMessage>(messageText);
	}
	catch (Exception ex)
	{
		// Rejeitar a mensagem se houver falha na desserialização		
		channel.BasicReject(deliveryTag: ea.DeliveryTag, requeue: false);

		return;
	}
	try
	{	
		// Tentativa de processamento da mensagem
		...

		// Ack (confirmação) de processamento informando sucesso!
		channel.BasicAck(deliveryTag: ea.DeliveryTag, multiple: false);
	}
	catch (Exception ex)
	{
		// Nack (Não confirmação) de processamento da mensagem,  
		// em caso de falha no processamento
		channel.BasicNack(deliveryTag: ea.DeliveryTag, multiple: false, requeue: true);
	}
};

channel.BasicConsume(queue: "hello", autoAck: false, consumer: consumer);
...

Nesse exemplo, se o formato da mensagem é inválido, rejeitamos a mensagem, sem reenfileirar chamando o BasicReject.

As configurações de dead letter são importantes para definir para onde vai essa mensagem, se será perdida mesmo ou se vai para uma rota alternativa (para tratamento manual, como identificar quem enviou a mensagem errada).

Caso o processamento seja concluído com sucesso, confirmamos com BasicAck.

Aqui a mensagem é intencionalmente deletada no RabbitMQ pois já foi processada.

Caso ocorra algum erro no processamento, negamos o processamento com BasicNack, mas solicitamos o reenfileiramento para que outra instância, ou até a mesma, pegue a mensagem e processe.

Nack e Reject possuem flags que determinam se a mensagem deve ou não ser reenfileirada na mesma fila. As configurações de dead-letter dizem o que fazer quando não for reenfileirada.

Nesse exemplo, caso o processo morra abruptamente, como nos casos de queda/queima de hardware, ou problemas graves, geram timeout.

Quando a mensagem é entregue a um consumidor, uma cópia fica no broker. Conforme a resposta (Ack, Nack e Reject) o Broker toma uma ação com a mensagem que ficou lá. Com autoack, após a entrega para o client, a mensagem já é apagada, mesmo antes de ter seu processamento concluído, evitando que o consumer tenha de programaticamente enviar uma resposta.

Já com ack manual, você tem o controle para enviar Ack, Nack ou Reject conforme necessário. E isso muda tudo!

Essa resposta diz para o broker o que fazer com a mensagem. Caso a conexão seja encerrada, ou seu consumer falhe e não envie resposta alguma, o timeout poderá ser utilizado para devolver a mensagem à fila.

Por que isso não é exposto assim?

Porque gera muito mais confusão, dúvida e insegurança.

Gera uma imensidão de “e se…” nos comentários, nos posts, em tudo quanto é lugar.

Note que se você faz Nack ou Reject da mensagem:

  • Sem requeue
  • Sem ter uma dead letter configurada na fila

Então a mensagem é perdida.


Na verdade estamos falando de 3 regras:

Regra 1:

Reject e Nack possuem a flag Requeue.

Quando Requeue é true, a mensagem é reenfileirada, ou seja, no broker a mensagem perde o status de em processamento e passa a ficar disponível para ser entregue novamente ao próximo consumidor disponível.

Quando Requeue é false, a mensagem vai para o fluxo de dead-letter.

A mensagem é perdida? Não sabemos, depende do fluxo de dead-letter.

Regra 2:

Para configurar a dead-letter de uma fila, adicionamos 2 propriedades na criação da fila:

  • x-dead-letter-exchange
  • x-dead-letter-routing-key

Quando uma mensagem de uma fila com as 2 configurações recebe uma negação de processamento com Reject ou Nack com requeue=false, o fluxo de dead-letter será acionado.

A mensagem que já estava no message broker então é enviada para exchange configurada na dead-letter, e será enviada com a routing-key configurada.

A mensagem é perdida? Não sabemos, depende da consistência dos parâmetros x-dead-letter-exchange e x-dead-letter-routing-key.

Já se não temos uma dead-letter configurada, se houver um reject ou nack com requeue=false, a mensagem é descartada.

Regra 3:

A regra 3 é uma regra básica de roteamento. O conjunto Exchange e Routing Key precisam encontrar uma rota para uma fila para que a mensagem não seja perdida.

Se as regras de roteamento esbarrarem em um conjunto Exchange e Routing Key coerentes, a mensagem será roteada para uma ou mais filas. Senão, será descartada.

Aqui o que vale é o tipo da exchange, e suas configurações de bind. É o fluxo padrão de roteamento.


Conclusão

Embora essas regras sejam simples e básicas, geram grande confusão.

E o que menos queremos quando estamos começando algo, é confusão.

Juntas essas regras se comportam como uma coreografia.

Como o Oragon.RabbitMQ lida com isso

A natureza opinativa do Oragon.RabbitMQ tem como objetivo entregar um fluxo pre-moldado.

Erro na desserialização é um típico erro irrecuperável, diferente de um erro no processamento.

Portanto, erro na desserialização causa Reject sem Requeue. Se a fila tem uma dead-letter, a mensagem vai para lá, senão, a mensagem é descartada!

No caso de sucesso no processamento temos Ack.

No caso de falha no processamento temos Nack com Requeue para a mensagem poder ser reprocessada.

Aqui temos uma demonstração:

builder.Services.AddSingleton<BusinessService>();

builder.Services.AddSingleton<IAMQPSerializer, SystemTextJsonAMQPSerializer>();

builder.Services.MapQueue<BusinessService, BusinessCommandOrEvent>(config => config
    .WithDispatchInRootScope()    
    .WithAdapter((svc, msg) => svc.DoSomethingAsync(msg))
    .WithQueueName("events")
    .WithPrefetchCount(1)
);
public class BusinessService
{
    public async Task DoSomethingAsync(BusinessCommandOrEvent commandOrEvent)
    {
        ... business core ...
    }
}

A ideia é reduzir o esforço de codificação mantendo o máximo de aproveitamento do RabbitMQ.

As principais decisões continuam na mão do dev, mas escrevemos substancialmente menos código.

0 comentários

Enviar um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *

Esse site utiliza o Akismet para reduzir spam. Aprenda como seus dados de comentários são processados.

Lives

Fique de olho nas lives

Fique de olho nas lives no meu canal do Youtube, no Canal .NET e nos Grupos do Facebook e Instagram.

Aceleradores

Existem diversas formas de viabilizar o suporte ao teu projeto. Seja com os treinamentos, consultoria, mentorias em grupo.