Async method without cancellation support, oh my!

Sometimes you have to interact with asynchronous APIs that you don’t control. Those APIs might be executing for a very long time but have no way to cancel the request. Dennis Doomen was exactly in such a situation while building his opinionated Event Sourcing projections library for .NET called LiquidProjections.

The library is using NEventStore under the covers. The actual access to the NEventStore is adapted in a class called NEventStoreAdapter. The adapter takes care of loading pages from the store from a given checkpoint onwards. This asynchronous method is running in potentially indefinitely because of the underlying NEventStore EventStore client. Let’s look at code:

await eventStoreClient.GetPageAsync(checkpoint).ConfigureAwait(false);

So in his case, the pretty simple sample code above could execute forever, but it cannot be canceled since there is no overload available which accepts a CancellationToken. Dennis wrote the following extension to be able to write an asynchronous execution which is cancelable:

public static class TaskExtensions {
   public static async Task<TResult> WithWaitCancellation<TResult>(this Task<TResult> task,
              CancellationToken cancellationToken) {
   Task completedTask = await Task.WhenAny(task, Task.Delay(Timeout.Infinite, cancellationToken));		             
   if (completedTask == task) {		
      return await task;		
   }		
   else {		             
      cancellationToken.ThrowIfCancellationRequested();		
      throw new InvalidOperationException("Infinite delay task completed.");
   }
}

and here the usage of this extension method

await eventStoreClient.GetPageAsync(checkpoint).WithWaitCancellation(cancelationToken).ConfigureAwait(false);

The idea of the code was the following:

We package the actual task to be executed together with a Task.Delay(Timeout.Infinite) into a Task.WhenAny. When the actual task completes, it is returned from WhenAny, and we await the outcome of the task*. If the completed task is not the actual task, then we ran into the case where the Task.Delay was canceled because the cancellation token passed into that task was canceled. In this case, it is sufficient to throw the OperationCanceledException with ThrowIfCancellationRequested.

That is a nifty idea and a quite clever solution. Unfortunately, Dennis found out that this extension method produced a nasty memory leak when running for a longer period. When the actual task completes the task representing the Task.Delay operation will still be executing and therefore leak memory. Dennis came up with a nice solution with linked cancellation token sources.

His tweet

Note to self: always await or cancel a call to Task.Delay if one wants to avoid memory leaks…

caught my attention and I started working on a more robust and scalable extension method that doesn’t require Task.Delay. I will discuss the extension method I came up with in the next post. Thanks Dennis for this nice asynchronous challenge!

* Remember await a task makes the task result being materialized. If there is a result it is returned, if the task is faulted the exception is unpacked and rethrown.

About the author

Daniel Marbach

2 comments

By Daniel Marbach

Recent Posts