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.
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:
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:
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.
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.
This technically means anywhere one of your method parameters implements
ICancellableContext
. This is a new interface we added as it includes bothIPipelineContext
(from whichIMessageHandlerContext
derives) as well asIBehaviorContext
....or behaviors. But message handlers are far more common, so we'll focus on them here.
NServiceBus already has an analyzer that makes sure you don't forget to
await
aTask
returned by one of our APIs, and a suite of saga analyzers to make saga development easier.Come on, admit it, you do. I do too. Let's just all agree most of those messages aren't worth our time, right?
silent
andnone
may sound like the same thing, but they're not. If you choosesilent
, 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 choosenone
, then the analyzer won't report diagnostics at all.