Up Batch asynchronous tasks with coroutines

Asynchronous errors

Last modified on August 15, 2012 17:23

This topic discusses asynchronous operation error handling. The specific context is coroutine errors but the analysis and discussion apply to all DevForce asynchronous methods.


Errors can arise anywhere in the process.  It's not easy to manage exceptions thrown by asynchronous operations. The Coroutine is asynchronous as are many of its tasks. Silverlight is an asynchronous world. You have to develop effective exception handling practices to survive in that world.

Your synchronous programming instincts lead you in the wrong direction. Your first thought is to wrap the Coroutine.Start() call in a try ... catch like so:

C#
// Coroutine caller
private void LoadTopCustomers() {

  CoroutineOperation coop;

// Don't do this!
try {
    coop = Coroutine.Start(LoadTopCustomersCoroutine);
catch (Exception ex) {
    HandleError(ex);
  };

// more code
}

That won't work. Remember that the LoadTopCustomers method completes almost immediately. It finishes long before the first asynchronous Customer query returns. If the query fails - perhaps because the connection to the server is broken - the server exception arrives much later, packaged inside the query operation object (e.g., the EntityQueryOperation<Customer> object).

You cannot predict when the query will returns. What you do know - for certain - is that if you don't detect and handle the exception reported by the query, DevForce will re-throw the exception on the client. You won't be able to trap that exception because you have no clue where it will be thrown. In practice, it will bubble up to your application's "unhandled application exception" logic (in App.xaml.cs by default) where your only viable option is to log the error and terminate the application.  Not good.

You can prepare for possible error in either of these two ways:

C#
private void LoadTopCustomers_with_Completed_event_handler() {

  CoroutineOperation coop = Coroutine.Start(LoadTopCustomersCoroutine);
 
  coop.Completed += (sender, args) =>
      {if (args.HasError) HandleError(args);};

 // more code
}

private void LoadTopCustomers_with_callback() {

  CoroutineOperation coop =
      Coroutine.Start(
              LoadTopCustomersCoroutine,                // coroutine iterator
             op => {if (op.HasError) HandleError(op);} // callback
         );

 // more code
}

This puts you on the right road. It may not be quite enough; you better call MarkErrorAsHandled, for example. 

But maybe you are already doing that in the HandleError method, in which case you are in good shape. If you are not sure, read on.

DevForce Async Error Interception

DevForce offers a helpful variety of error handling facilities in the Coroutineclass and in the other DevForce asynchronous methods. In all cases, exceptions detected within an asynchronous operation are captured in the async method's "Operation" object - the async operation coordination object returned by an ExecuteAsync() for example.

All async methods return an async operation coordination object that inherits from BaseOperation . All BaseOperation classes and their associated asynchronous operation EventArgs expose the following properties:

It's your responsibility to inspect the BaseOperation or EventArgs when the async operation completes and address failures ... as we did in the original version of LoadTopCustomerCoroutine.

Your first opportunity to catch an asynchronous operation error is inside your iterator coroutine. 

C#
// Coroutine Iterator
private IEnumerable<INotifyCompleted> LoadTopCustomersCoroutine() {

  var userId = CurrentUser.UserId;

  var allCustOperation = Manager.Customers.ExecuteAsync(
     // Callback checks for error
     op => { if (op.HasError) HandleError(op); }
    );

// another way to catch errors
var allCustOperation.Completed += (s1, args1) => {
     if (args1.HasError) HandleError(args1);
     };

yield return allCustOperation; // SUSPEND

// more code

}

Note that we added a callback method to the query's ExecuteAsync - a callback that checks for the error and calls an appropriate method. We also demonstrated an alternative, "event handler" approach that does exactly the same thing. Then we yield.

The DevForce Coroutine Consumer receives the yielded operation object and adds its own handler to the allCustOperation's Completed event.

When the query operation returns, the coroutine callback gets the first crack at interpreting the results. Then the coroutine Completed event handler gets the next look. The outer Coroutine Consumer gets the last look.

Note that we arranged to inspect the results before yielding to the DevForce Coroutine "Consumer". The following example mistakenly adds error interception after the yield.

(

C#
// Coroutine Iterator
private IEnumerable<INotifyCompleted> LoadTopCustomersCoroutine() {

  var userId = CurrentUser.UserId;

  var allCustOperation = Manager.Customers.ExecuteAsync();

yield return allCustOperation; // SUSPEND

// Too late. If there was an error, you'll never get here
if (allCustOperation.HasError) HandleError(allCustOperation);

// more code

}

This won't work the way the author intended. The DevForce Coroutine Consumer will have already inspected the result before giving control back to your coroutine. IF the query reported an error, DevForce would have discovered that error and (by default) will have terminated the coroutine immediately. The coroutine error handling code will not run.

Do Not Touch Results Of An Errant Or Canceled Operation

Many async operation coordination objects and their associated EventArgs have a "Result" or "Results" property. The CoroutineOperation and CoroutineCompletedEventArgs have a "Result" property.

Such properties are undefined it the operation has an error or was canceled. You will get an exception if you attempt to access them.

Mark-As-Handled

It's OK to let the query error bubble up to the outer Coroutine. That may be the best place to handle an error. But that should be a deliberate decision on your part.

You must be aware of the ramifications of that decision ... which means you must know how the DevForce Coroutine Consumer handles task errors and cancellations.

Most important: you must understand the significance of marking - or failing  to mark - an error "as handled".

There is an IsHandled flag on every async operation object and EventArgs; the flag is not always visible but it is there. If the async operation completes with an error and you don't set the IsHandled flag to true somewhere, somehow, then DevForce will re-throw the exception. 

You may have examined the exception. You may think you've addressed the exception. But unless you set IsHandled to true, DevForce has to assume that the exception was unexpected and re-throw it. Your application is going to crash if DevForce re-throws an async operation exception because there is no way for you to trap it.

You do not want DevForce to re-throw that error. You want to be in control. And you can be.

Before we get to how, let's explain why we re-throw it. Note that we cannot tell if you dealt with the exception or if you missed it. Exceptions indicate trouble. It may be trouble you can anticipate and recover from ... in which case you should do so ... and tell DevForce that you did so.

But unhandled exceptions are fatal.  The cause does not matter. You should not continue in a potentially unstable and errant state. The only appropriate course of action is to terminate the application.

If you don't want DevForce to terminate your application, you must:

  1. Detect the exception by examining async operation results
  2. Process the exception as you see fit
  3. If the application should continue ... you must call MarkErrorAsHandled, a method on every DevForce async operation and EventArg.

There are several opportunities to call MarkErrorAsHandled ()). This may be best understood by exploring some scenarios.

Scenario #1: MarkErrorAsHandled not called

  1. The DevForce Coroutine Consumer calls your Iterator
  2. Your iterator issues an async query
  3. Your iterator adds error interception logic (either with a callback or a Completed event handler)
  4. Your iterator yields the query operation coordination object to the Consumer ... and the co-routines suspend
  5. The query fails on the server and returns the server exception
  6. DevForce raises the Completed event on the operation coordination object yielded in step #4. The EventArgs contain the server exception which is also accessible directly from the operation object itself.
  7. Your iterator interception logic, which is either a callback or event handler, examines the exception
  8. The DevForce Coroutine Consumer sees the exception.
  9. The DevForce Coroutine Consumer sees that the exception is not marked as handled ... and terminates the process. It will not ask your iterator for the next yield.
  10. The DevForce Coroutine adopts the query exception as its own exception. Now the Coroutine has an error.
  11. You didn't intercept exceptions on the Coroutine either.
  12. DevForce concludes that the exception is unhandled and re-throws it.
  13. Your application crashes.

Scenario #2: MarkErrorAsHandled called inside the Iterator

 5. The query fails on the server and returns the server exception
 6. DevForce raises the Completed event on the operation coordination object yielded in step #4.
 7. Your iterator interception logic, which is either a callback or event handler, examines the exception
 8. That interception logic calls MarkErrorAsHandled()
 9. The DevForce Coroutine Consumer sees that the exception is handled
10. The DevForce Coroutine resumes calling your coroutine iterator which picks up with the next step.

Scenario #3: MarkErrorAsHandled called in the Coroutine's operation coordinator

 5. The query fails on the server and returns the server exception
 6. DevForce raises the Completed event on the operation coordination object yielded in step #4.
 7. By design, you do not have any error interceptor logic in your iterator.
 8. The DevForce Coroutine Consumer sees that the exception is not handled ... and terminates the process. It will not ask your iterator for the next yield.
 9. The error bubbles up to the Coroutine operation coordinator (the result of Coroutine.Start())
10. You have added error interception logic to that operation coordinator object as we showed at the topic of this topic.
11. That interception logic calls MarkErrorAsHandled()  
12. DevForce see that the exception is handled ... and permits your application to continue.

Error in the Coroutine Itself

Your coroutine consists of synchronous and asynchronous statements. Perhaps all of your async operations succeed ... or would have succeeded. But, sadly, your coroutine threw an exception.

You probably won't see this exception right away ... not unless it occurs before the first yield return. Therefore, you can't rely on a try...catch to guard against this kind of exception either.

Your coroutine is not supposed to throw exceptions but it happens. The outer DevForce Coroutine Consumer will catch it. You could not have handled that exception inside the iterator (otherwise, the Coroutine iterator would not have failed) so the Coroutine must terminate the iterator.

But DevForce will propagate the exception to the Coroutine operation object. Your error handling at that level can decide what to do. You can MarkErrorAsHandled and permit the application to continue running if that makes sense.

Parallel Async Coroutine Error Handling

The reasoning and behavior are essentially the same whether you use the Serial Async Coroutine or the Parallel Async Coroutine.

If you don't handle an exception at some level, DevForce will re-throw it and your application will most likely crash.

If a coroutine async task results in a handled exception (you called MarkErrorAsHandled, both Coroutines continue processing the remaining tasks.

The only difference - and it is slight - is how DevForce treats the remaining tasks when your iterator produces an unhandled error.

  • The Serial Async Coroutine will stop processing your iterator coroutine the moment it sees a task with an unhandled exception. It won't run any of the remaining tasks.
  • In the Parallel case, some or perhaps all of the asynchronous tasks could be running when the unhandled exception arises. DevForce will try to cancel all outstanding tasks, report them as canceled, and will ignore their results even if it can't cancel them. Tasks that already completed will retain their "completed successfully" status and their results remain available.
Created by DevForce on October 14, 2010 13:36

This wiki is licensed under a Creative Commons 2.0 license. XWiki Enterprise 3.2 - Documentation. Copyright © 2015 IdeaBlade