How to correctly finish RabbitMq consumer and wait to RabbitMq consumer threads (library consumer callbacks) after close connection and model?

Viewed 1417

I have RabbitMq consumer (RabbitMQ.Client.Events.EventingBasicConsumer) that process incoming messages.

But I noticed that if close connection and model they does not wait to finish library processing threads. E.g.:

  1. If I will add thread sleep for several seconds to EventingBasicConsumer::Received callback then I noticed that Close functions (Close() of IModel and IConnection) finished before exit from this consumer callback
  2. After finish Close functions I continue to receive some messages to EventingBasicConsumer::Received callback.

So how to correctly close consumer and wait to finish all processing's in consumer threads of library? I want to ensure that I will not receive any incoming messages from library for my consumer after close all connections/consumers.

Simplified code:

RunTest()
{
    MyConsumer consumer = new MyConsumer();
    consumer.Connect();
    
    // Wait before close for process some count of incoming messages
    Thread.Sleep(10 * 1000);
    
    consumer.Disconnect();
}

class MyConsumer
{
    private RabbitMQ.Client.IConnection     m_Connection = null;
    private RabbitMQ.Client.IModel          m_Channel = null;
        
    public void Connect()
    {
        //
        // ...
        //

        m_Channel = m_Connection.CreateModel();
        m_Consumer = new RabbitMQ.Client.Events.EventingBasicConsumer(m_Channel);
        m_Consumer.Received += OnRequestReceived;
        m_ConsumerTag = m_Channel.BasicConsume(m_Config.RequestQueue, false, m_Consumer);
    }
        
    public void Disconnect()
    {
        Console.WriteLine("---> IModel::Close()");
        m_Channel.Close();
        Console.WriteLine("<--- IModel::Close()");
        
        Console.WriteLine("---> RabbitMQ.Client.IConnection::Close()");
        m_Connection.Close();
        Console.WriteLine("<--- RabbitMQ.Client.IConnection::Close()");
        
        //
        // Maybe there is need to do some RabbitMQ API call of channel/model
        // for wait to finish of all consumer callbacks?
        //
        
        m_Channel = null;
        m_Connection = null;
    }

    private void OnRequestReceived(object sender, RabbitMQ.Client.Events.BasicDeliverEventArgs mqMessage)
    {
        Console.WriteLine("---> MyConsumer::OnReceived");
        
        Console.WriteLine("MyConsumer: ThreadSleep started");
        Thread.Sleep(10000);
        Console.WriteLine("MyConsumer: ThreadSleep finished");
        
        if (m_Channel != null)
        {
            m_Channel.BasicAck(mqMessage.DeliveryTag, false);
        }
        else
        {
            Console.WriteLine("MyConsumer: already closed");
        }
        
        Console.WriteLine("<--- MyConsumer::OnReceived");
    }
}

Result:

---> MyConsumer::OnReceived
MyConsumer: ThreadSleep started

---> IModel::Close() 
<--- IModel::Close() 
---> RabbitMQ.Client.IConnection::Close() 
<--- RabbitMQ.Client.IConnection::Close() 

MyConsumer: ThreadSleep finished
MyConsumer: already closed
<--- MyConsumer::OnReceived

---> MyConsumer::OnReceived
MyConsumer: ThreadSleep started
MyConsumer: ThreadSleep finished
MyConsumer: already closed
<--- MyConsumer::OnReceived

How we see MyConsumer::OnReceived was finished after exit from Close() functions of Consumer and Connection. Moreover how we see there is one more message which was income after finish of previous call of OnReceived and close connection (that means that RqbbitMq continues to process consumer messages until the internal library queues are empty ignoring the fact that consumer and connection are already closed).

2 Answers

This is really bug in RabbitMQ.Client (v5.1.2). Source code of ConsumerWorkService.cs:

namespace RabbitMQ.Client
{
    public class ConsumerWorkService
    {
        ...

        class WorkPool
        {
            readonly ConcurrentQueue<Action> actions;
            readonly AutoResetEvent messageArrived;
            readonly TimeSpan waitTime;
            readonly CancellationTokenSource tokenSource;
            readonly string name;

            public WorkPool(IModel model)
            {
                name = model.ToString();
                actions = new ConcurrentQueue<Action>();
                messageArrived = new AutoResetEvent(false);
                waitTime = TimeSpan.FromMilliseconds(100);
                tokenSource = new CancellationTokenSource();
            }

            public void Start()
            {
#if NETFX_CORE
                System.Threading.Tasks.Task.Factory.StartNew(Loop, System.Threading.Tasks.TaskCreationOptions.LongRunning);
#else
                var thread = new Thread(Loop)
                {
                    Name = "WorkPool-" + name,
                    IsBackground = true
                };
                thread.Start();
#endif
            }

            public void Enqueue(Action action)
            {
                actions.Enqueue(action);
                messageArrived.Set();
            }

            void Loop()
            {
                while (tokenSource.IsCancellationRequested == false)
                {
                    Action action;
                    while (actions.TryDequeue(out action))
                    {
                        try
                        {
                            action();
                        }
                        catch (Exception)
                        {
                        }
                    }

                    messageArrived.WaitOne(waitTime);
                }
            }

            public void Stop()
            {
                tokenSource.Cancel();
            }
        }
    }
}
        

As we see there is no any waitings of thread var thread = new Thread(Loop). So really event RabbitMQ.Client.Events.EventingBasicConsumer::Received can be fired anytime even when there is no consumer or connection for a long time which closed long time ago till internal library queue will empty. As I supposed(( :

Action action;
while (actions.TryDequeue(out action))
{
    try
    {
        action();
    }
    catch (Exception)
    {
    }
}

So IModel::Close() will set only CancelationToken without join of thread and there is need some workaround for this Bug.

Just to confirm @Alexander's findings, this issue is still present in v6.2.2 of the .Net Client and the issue is also present with the event driven callback consumer.

I've found that:

  • The 'Received' callback registered with both the EventingBasicConsumer and AsyncEventingBasicConsumer will be invoked for up to the prefetchCount setting of the BasicQos setup on the channel, after the connection is closed.
  • In manual Ack mode, the call to BasicAck will throw a RabbitMQ.Client.Exceptions.AlreadyClosedException if the connection is closed, and the message won't be acked from the queue, however, the callback will continue to process subsequent messages. This could lead to idempotence problems with receiving the same message more than once during a disconnect.

This may be another good reason to ensure that the prefetchCount is set to a finite and sane value (in addition to e.g. Memory considerations of unbounded prefetch counts).

Finally, if I deliberately close the Connection unexpectedly (e.g. during testing), I found that I needed to explicitly detach my consumer Received handler AND explicitly call consumerChannel.Close(); (i.e. IModel.Close) before I could create a new connection (the Rabbit client would tend to 'hang').

  • The connection.ConnectionShutdown event doesn't seem to fire reliably if I have set a ConsumerDispatchConcurrency > 1 with the synchronous EventingBasicConsumer
  • The Shutdown event on AsyncEventingBasicConsumer doesn't fire either, if I have ConsumerDispatchConcurrency > 1` when using the async consumer.
  • However, I did find that the ModelShutdown event on the IModel / channel fires reliably for sync / async and concurrency > 1.
consumerChannel.ModelShutdown += (sender, args) =>
{
    consumer.Received -= handler; // e.g. AsyncEventingBasicConsumer
    consumerChannel.Close(); // IModel
};
Related