Skip to main content

Cancellation in NServiceBus 8

NServiceBus endpoints have always been good at running reliably, but they could have been better at stopping. And when you want an endpoint to stop, you want it to stop…now.

In NServiceBus 8.0, which is now available, we have introduced support for cooperative cancellation, which will give you greater control over how an NServiceBus endpoint behaves when you need to shut it down.

Let’s talk about what cancellation is, how it relates to NServiceBus, and how we’re delivering cancellation in NServiceBus 8 without forcing a massive breaking change on your existing systems.

🔗What is cancellation?

As simply as possible, cooperative cancellation in .NET means passing around a CancellationToken that is associated with a CancellationTokenSource so that the code observing the cancellation token can later be told to stop or cancel.

You can observe a token and then return from a loop when the token is signaled:

public async Task BigLoop(CancellationToken cancellationToken)
{
    while (!cancellationToken.IsCancellationRequested)
    {
        // Do more work
    }
}

However, if you consult Microsoft’s recommended patterns for CancellationToken, it’s better to always throw an OperationCanceledException so your caller knows that the work was interrupted:

public async Task DoSomething(CancellationToken cancellationToken)
{
    while (KeepDoingStuff)
    {
        cancellationToken.ThrowIfCancellationRequested();

        // Do more work
    }
}

When calling a cancellable API, you can create a CancellationToken that automatically expires after a given amount of time, or you can use CancellationToken.None or default to say you don’t care about cancellation and that you want the method to complete no matter what:

// Cancel after 10 seconds
using (var tokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(10)))
{
  var token = tokenSource.Token;
  await DoSomething(token);
}

// Do not cancel (the following two lines are equivalent)
await DoSomething(CancellationToken.None);
await DoSomething(default);

If you’re designing an API, you can also make the cancellation token optional:

public async Task DoSomething(CancellationToken cancellationToken = default)
{
    // Do stuff
}

// These calls are equivalent due to the parameter default
await DoSomething();
await DoSomething(CancellationToken.None);
await DoSomething(default);

There are a lot of other fancy things you can do with cooperative cancellation, but let’s talk about what cancellation means for NServiceBus.

🔗NServiceBus and cancellation

In an NServiceBus endpoint, the main reason to care about cancellation is when the endpoint is shutting down. When the endpoint shuts down, you want it to stop cleanly in a reasonable amount of time without having to forcibly kill the process. This is especially true if, for example, you are hosting your endpoints as Docker containers in Kubernetes, and the cluster needs to move your node from one host to another.

In this case, you want to stop receiving new messages from the queue, then give the existing “in-flight” messages a chance to complete processing successfully before shutting down the process cleanly.

But as a developer coding a message handler, there’s nothing you can do in NServiceBus version 7 because you can’t access a CancellationToken within that handler.

If a handler just started running a SQL query that could run for 100 seconds, what do you do?

In NServiceBus version 8, you can access a CancellationToken and pass it to the SQL query so you can shut down efficiently. We’ve also done it in such a way that you won’t have to change every single message handler.

🔗Breaking changes

Correctly implementing cancellation is an important addition for NServiceBus, but we couldn’t do it without breaking changes. That’s why we did it in a major version, which is NServiceBus 8.

The accepted method of implementing cancellation is to add CancellationToken parameters to all async methods and for a caller method to pass the token along to the callee, kind of like a bucket brigade passing the same token value all the way down the call stack.

This pattern is supported by tooling in Visual Studio. For example, there’s a Roslyn analyzer CA2016: Forward the CancellationToken parameter to methods that take one that reinforces this pattern and offers a code fix that will fix all violations of the rule, even in an entire solution, with just a few clicks.

CA2016 code fix in action
CA2016 code fix in action

Just click the Solution link at the bottom of the pop-up and Visual Studio will fix every instance where you forgot to forward the CancellationToken parameter. It’s pretty slick.

Unfortunately, CA2016 is only an informational message, not a warning or build error. And you might not even have the analyzer at all unless your project is set up correctly.

To make sure you forward cancellation tokens correctly, we recommend enabling the built-in .NET Analyzers and using an .editorconfig file to upgrade CA2016 to a warning or error to make it more visible:

[*.cs]

# Make it a green squiggle
dotnet_diagnostic.CA2016.severity = warning

# Or make it a red squiggle that fails the build
dotnet_diagnostic.CA2016.severity = error

To implement cancellation properly, we have to add a CancellationToken parameter to every async method. For a class method, that’s not such a big deal — you just add CancellationToken cancellationToken = default to the parameter list. In most cases, someone calling that method can recompile with no changes required.

But with interfaces, it’s a different story. For example, here’s an interface before and after adding cancellation:

// No cancellation
public interface IDoSomething
{
    Task DoIt();
}

// Cancellation added
public interface IDoSomething
{
    Task DoIt(CancellationToken cancellationToken = default);
}

However, it doesn’t matter that you marked the parameter as optional. Because an interface is a contract, a class implementing that interface must add the token parameter:

The class must implement the contract, including the parameter, even though it has a default
The class must implement the contract, including the parameter, even though it has a default

This change to an interface is a breaking change, and worse, a change that is difficult to decorate with Obsolete attributes to guide the user in their upgrade. We start in a bad spot because the feedback from the compiler here is not helpful—instead of saying that you need to add the CancellationToken to the DoIt method, it instead highlights the IDoSomething interface name, saying the class doesn’t implement the interface anymore. To make matters worse, if you let the compiler “fix” it, it will create a new DoIt method overload that contains the token.

All this brings us NServiceBus’s central interface, IHandleMessages<T>.

🔗IHandleMessages<T>

Every message handler in an NServiceBus system is a class that implements the interface IHandleMessages<T>. Here’s the interface definition:

public interface IHandleMessages<T>
{
    Task Handle(T message, IMessageHandlerContext context);
}

If we changed that interface, users would have to change every single message handler class in their system. That would be an excruciating change 1 to force onto our users, and we’d prefer not to do that if we can help it.

So, on the one hand, we have the generally-accepted way of implementing cancellation, backed up by compiler support through Roslyn analyzers, demanding that we break the interface. But on the other hand, we have all the pain we would cause customers by releasing a breaking change that affects nearly every code file where NServiceBus is referenced.

How do we reconcile these two sides?

🔗So what’s changing?

Luckily, we found a way to support cancellation and keep the benefit of compiler support but not force you through the pain of an IHandleMessages<T> change.

On all of our lesser-used methods and all our internal methods, we’re adding a CancellationToken parameter. Many users won’t even notice this.

However, we are not breaking the IHandleMessages<T> interface.

Anywhere you’re handling a message 2 the existing context object will include a CancellationToken property rather than adding a separate argument that would result in a breaking change.

The built-in CA2016 analyzer provided by the Roslyn team can’t tell you to forward context.CancellationToken to all the other code you call from your message handlers 3, so how do we deal with that?

We’ve had great success bundling our own Roslyn analyzers with NServiceBus, 4 and we can use the same strategy in this case.

In NServiceBus version 8, if you call a method that accepts a CancellationToken parameter, you’ll now get a compilation warning which takes the place of CA2016:

The NSB0002 code fix ensures the context.CancellationToken is forwarded to the method
The NSB0002 code fix ensures the context.CancellationToken is forwarded to the method

Aside from avoiding a breaking change—which is already a big win—we think this has a lot of other advantages.

The default severity for CA2016 is Suggestion. This means that in Visual Studio, you only see three tiny gray dots that are easy to miss, and the message itself is often hidden away in the Messages pane of the Error List window, where it’s often ignored. 5

By making our analyzer a warning, we’re elevating the concept of cancellation where it’s more noticeable. We hope that after learning about cancellation, you’ll also use an .editorconfig file to upgrade CA2016 to a warning as well:

[*.cs]
dotnet_diagnostic.CA2016.severity = warning

Of course, if your project files use TreatWarningsAsErrors this will prevent code that doesn’t pass the cancellation token from compiling. Or, maybe cancellation isn’t really a concern for your system, and you don’t want to deal with passing cancellation tokens everywhere. That’s fine too! Roslyn analyzers are configurable, so you can also downgrade our analyzer to suggestion or even silent or none if you wish: 6

[*.cs]
dotnet_diagnostic.NSB0002.severity = suggestion

🔗Summary

NServiceBus version 8 supports cooperative cancellation, which gives you the ability to ensure a message endpoint can shut down quickly and cleanly.

While this feature is critical for many developers, for others, it’s not. That’s why we’ve minimized the breaking changes required to support cancellation, especially on IHandleMessages<T>, our most-used interface.

There is a lot of productivity to be gained by using Roslyn analyzers, and we hope that our new analyzer will help you implement cancellation efficiently and show how analyzers can improve your development workflow.

NServiceBus 8 is available now on NuGet.

Share on Twitter

About the author

David Boike

David Boike is developer at Particular who is still working through feelings from the cancellation of The Expanse…again. I guess it's a good thing I never watched Firefly.


  1. If you remember the upgrade to NServiceBus 6, we had a similar interface change to support async/await. While we still stand by that change now, it's not something we care to repeat.

  2. This technically means anywhere one of your method parameters implements ICancellableContext. This is a new interface we added as it includes both IPipelineContext (from which IMessageHandlerContext derives) as well as IBehaviorContext.

  3. ...or behaviors. But message handlers are far more common, so we'll focus on them here.

  4. NServiceBus already has an analyzer that makes sure you don't forget to await a Task returned by one of our APIs, and a suite of saga analyzers to make saga development easier.

  5. Come on, admit it, you do. I do too. Let's just all agree most of those messages aren't worth our time, right?

  6. silent and none may sound like the same thing, but they're not. If you choose silent, you don't get any squigglies or dots in the code, but if you hover your mouse over the code, you'll still get the analyzer message. If you choose none, then the analyzer won't report diagnostics at all.

Don't miss a thing. Sign up today and we'll send you an email when new posts come out.
Thank you for subscribing. We'll be in touch soon.
 
We collect and use this information in accordance with our privacy policy.