Fail to wait for Task completion - ASP.NET Web API Microservices

Viewed 52

I have an interesting problem and I am new to async programming, so any criticism is accepted. I have 2 APIs communicating with each other. The main idea for what I am trying to create is when a user tries to complete his shopping cart - the cart API asks the catalogue API for available quantities. And since it is a message (RabbitMQ) I want to wait for this message to be received back to my cart API, processed and then return to the control flow of the method in the cart API for payment execution to continue. I have 3 classes which are involved: CheckoutShoppingCart - which executes the payment, asks Catalogue API for available quantities. SessionState - holds temporary data for a product/ logged user (Singleton, injected in the 2 classes). EventProcessor - processes incoming RabbitMQ messages.

According to the official documentation of RabbitMQ they use something called correlationId. But I decided to create a workaround, I succeeded to some degree, but the code fails for a very unexpected reason.

The first method below is the one which sends message through RabbitMQ to the Catalogue API.

public class CheckoutShoppingCart 
{
   await PublishMessageAvailableQuantities(loggedUserCart.Products);
   await ProductQuantityEvaluation();
}

Then the second one is the one who is failing and this is its body:

private async Task ProductQuantityEvaluation()
{
 Task.WhenAny(sessionState.CompleteTask());
}

Now, the task within WhenAny - sessionState.CompleteTask() is a dummy method which I created as a flag in the message processor class I have, which processes information messages received from the catalogue API. When the message is successfully received the processor executes this dummy method. And thats why I follow its status - to see if it is executed.

So, when someone clicks to execute the payment of the shopping cart, I want to reach the Task.WhenAny which would wait for this task to be completed by the message processor class, which means that the message for the available quantities is received. When it is received the control flow would continue in the class which executes the payment of the shopping cart.

Everything is working with Task.Wait(10000). While this task is waiting, other thread works on the processing of the message, when it is done the control flow returns here. But when I use the WhenAny, when I debug - for some reason the WhenAny(sessionState.CompleteTask()) invokes the method itself, and when I debug the method sessionState.CompleteTask() it actually gets invoked before the event processor invocation. But from code perspective it is only invoked from the message processor.

One of the cases of the Event processor:

public class EventProcessor 
{
case AppConstants.eventTypeSendActualProductQuantities:
                    var disapprovedProductInfo = JsonSerializer.Deserialize<PublishedProductModel>(message);
                    sessionState.publishedProductModel = disapprovedProductInfo;
                    sessionState.productQuantityStatus = 2;
                    **await sessionState.CompleteTask();**
                    break;
}

This is the dummy method which is only used as a flag.

public class SessionState 
{
public async Task CompleteTask()
        {
            var tcs = new TaskCompletionSource<bool>();
            tcs.SetResult(true);
            
        }
}

This is how I check if the above method is completed:

private async Task ProductQuantityEvaluation()
{
 Task.WhenAny(sessionState.CompleteTask());
}

So, the code is working perfectly with Wait(miliseconds) but when I want to wait for completion of another method using WhenAny(); it does not work. What am I missing?

1 Answers

Currently, your CompleteTask() method just creates a task and completes it:

public async Task CompleteTask()
{
  var tcs = new TaskCompletionSource<bool>();
  tcs.SetResult(true);            
}

So, really, it's just always returning an already completed task.

Also, this code isn't waiting for CompleteTask to be called; it's calling it and then waiting for the task it returned:

// Removing the unnecessary Task.WhenAny and adding the missing await:
private async Task ProductQuantityEvaluation()
{
  await sessionState.CompleteTask();
}

// Is the same as this:
private async Task ProductQuantityEvaluation()
{
  var task = sessionState.CompleteTask();
  await task;
}

What you're actually looking for is a kind of "asynchronous signal". Which in most cases is a TaskCompletionSource<T>. Where you're going wrong is that you're creating it within the notification method; you want to create it before then, have the controller action await the TCS task, and then later trigger it from the notification method. Something like this:

public class SessionState 
{
  private readonly TaskCompletionSource<bool> _tcs = new();
  public Task CompleteTask => _tcs.Task;
  public void Complete() => _tcs.TrySetResult(true);
}

Wait for it like this:

private async Task ProductQuantityEvaluation()
{
  await sessionState.CompleteTask;
}

and trigger it like this:

public class EventProcessor 
{
case AppConstants.eventTypeSendActualProductQuantities:
  var disapprovedProductInfo = JsonSerializer.Deserialize<PublishedProductModel>(message);
  sessionState.publishedProductModel = disapprovedProductInfo;
  sessionState.productQuantityStatus = 2;
  sessionState.Complete();
  break;
}

That should get it basically working.

Now, there are a couple of problems still with this code, that you'll probably need to fix next.

  1. The "set the results and then send a completion signal" is kind of weird. Why aren't the results themselves part of the completion signal? You have a TaskCompletionSource<T>, and I think it would make sense to have the publishedProductModel and productQuantityStatus as part of that T.
  2. You're probably going to need more than one signal. A more normal usage of this kind of pattern is to have a Dictionary<SomeKey, TaskCompletionSource<T>>. Unless you already have a Dictionary<SomeKey, SessionState> elsewhere in your code, and each SessionState represents a single request. In that case, just having the one TaskCompletionSource per SessionState would be fine.
Related