Posted in:

In this series:

In an earlier post, we saw some basic code examples of how to send and receive messages from an Azure Service Bus queue. In this post, we're going to look at some options we can configure when receiving messages.

MessageHandlerOptions

If you recall, our basic message receiving code looked a bit like this, where we passed a message handler function to RegisterMessageHandler.

static void Main(string[] args)
{
    var connectionString = args[0];
    var queueName = "queue1";
    var queueClient = new QueueClient(connectionString, queueName);
    var messageHandlerOptions = new MessageHandlerOptions(OnException);
    queueClient.RegisterMessageHandler(OnMessage, messageHandlerOptions);
    Console.WriteLine("Listening, press any key");
    Console.ReadKey();
}

But you can also see that we can pass an instance of MessageHandlerOptions, which allows us to control the way we receive messages. It only has four properties, so let's look at each of them.

ExceptionReceivedHandler

We've already seen that we're required to provide an exception handler function. This will be called if there are problems communicating with the Service Bus. We not only get access to the exception that was thrown, but the ExceptionReceivedContext tells us a little bit more about what exactly it was trying to do when the problem occurred. In our demo we just write to the console, but here you'd typically write out to logs, and maybe even do something that will cause an alert to be raised if this is a critical application running in production.

static Task OnException(ExceptionReceivedEventArgs args)
{
    Console.WriteLine("Got an exception:");
    Console.WriteLine(args.Exception.Message);
    Console.WriteLine(args.ExceptionReceivedContext.Action);
    Console.WriteLine(args.ExceptionReceivedContext.ClientId);
    Console.WriteLine(args.ExceptionReceivedContext.Endpoint);
    Console.WriteLine(args.ExceptionReceivedContext.EntityPath);
    return Task.CompletedTask;
}

MaxConcurrentCalls

We can also specify how many messages we want to process concurrently. By default, this is set to 1, which means that we'll process 1 message off the queue, and only when that has finished will we move onto the next one.

Note that MaxConcurrentCalls applies to just the process that you are running. If for example you had three instances of the Receiver application each listening to the same queue with MaxConcurrentCalls set to 1, then you would process three messages in parallel.

In most situations, a certain amount of concurrent message handling is desirable, since it allows you to work through a queue more quickly, but as we mentioned earlier in this series, there are some trade-offs. If you handle too many messages in parallel, you could overload downstream services such as a database. Also, if all the messages relate to the same entity in a database, you could find that each handler tries to make conflicting updates to the same row in a database table, resulting in concurrency errors.

I updated my Receiver demo application to optionally sleep for 5 seconds in the message handler, and then set MaxConcurrentCalls to 4. Then when I ran it with 5 messages already in the queue, it was clear that the first four were handled concurrently, and then the fifth was handled afterwards. Feel free to download the demo application and experiment with it yourself.

AutoComplete

One nice feature of Service Bus is that when you retrieve a message from the queue, it's not necessarily removed instantaneously. Instead, that message is "locked" for a time period, so it's not visible to anyone else. When you've finished handling the message you then "complete" that message which tells Service Bus you've finished handling the message, and it can safely delete it. This is called the "peek-lock" mode, and is the default behaviour with the SDK.

However, you can also "abandon" the message - in other words you tell Service Bus that for whatever reason, you were not able to process the message. In that case the message becomes available again to be retried.

If AutoComplete is set to true (which is the default), then assuming your handler completes successfully, the Service Bus SDK will automatically complete the message for you. I recommend you use this behaviour, but in some more advanced scenarios you might want to take control yourself.

If you are completing messages yourself, then you do so by calling CompleteAsync (or AbandonAsync) on the QueueClient, and you need to pass in the LockToken for the message, which is available on the SystemProperties of the Message.

await queueClient.CompleteAsync(message.SystemProperties.LockToken);

If you never get back to the Service Bus to complete or abandon the message, then Service Bus will eventually time out the "lock" you have on the message and make it visible again, which is what the next property we'll discuss is about.

MaxAutoRenewDuration

Ideally, your message handler should complete quite quickly, before Service Bus decides to time it out. But sometimes your message handler might legitimately need to perform a long-running task. In this case, you need to keep checking in with Service Bus to tell it that you're still working on the message, and that it should retain the lock. With the Service Bus SDK, you can do this with MaxAutoRenewDuration.

The default is five minutes, meaning that periodically, the SDK will talk back to Service Bus asking to "renew" the lock. It might do this every 30 seconds or so. But after five minutes, it will no longer renew the lock, so it's important if you have a handler that might take an hour or more, to extend this duration to greater than the longest time you might need. Otherwise, while one instance of your message handling microservice is processing the message, it could become visible again, and another instance might concurrently work on the same message, which can often result in bugs that are hard to track down.

Summary

The default message receive options are fine for many use cases, but as you build more complex applications, you will find that you need to fine tune some of these parameters. In particular I've found that it is very useful to make MaxAutoRenewDuration and MaxConcurrentCalls adjustable through configuration in your application, so if you find in production that there is a problem with throughput for a queue, or with messages taking longer to process than expected, you can tweak the values without needing to update source code.

In our next post, we'll start looking at the concepts of Topics and Subscriptions.