Workflow Foundation 4: Cancellation

Introduction

I got an interesting question from a customer a few days ago: “Is it possible to handle cancellation in a CodeActivity?”

The background for this question is the following: an activity can explicitly handle cancellation by overriding its Cancel() method. However, out of the 4 activity authoring options (Activity, CodeActivity, AsyncCodeActivity and NativeActivity), only AsyncCodeActivity and NativeActivity have the Cancel() virtual method.

Turns out that, in order to properly address the question, a bit of background information is needed. This is what this post is all about. A deep understanding of how cancellation works is useful when writing custom activities. I have seen more than one situation when “unexpected” workflow behavior was due to poor implementation of cancellation for one of its activities.

The post also provides a pattern for proper implementation of cancellation using the declarative (Activity) authoring option.

How cancellation works

Cancellation is a request from the parent for graceful, early termination of a child activity. A parent does this by calling NativeActivityContext.CancelChildren() or NativeActivityContext.CancelChild(ActivityInstance).

Note: this same functionality is available to the host through WorkflowApplication.Cancel() and WorkflowInvoker.CancelAsync(): they request cancellation of the root ActivityInstance.

The workflow runtime does the following when CancelChild()/CancelChildren() are called (you can look at this logic with a .NET Reflection Tool like .Net Reflector or ILSpy, the actual logic is in ActivityExecutor.CancelActivity()):

  1. If the child ActivityInstance is in a completed state (Closed, Canceled or Faulted), nothing happens
  2. If the child ActivityInstance is in the Executing state
    1. if it does not have pending work (children in the Executing state, outstanding bookmarks or pending asynchronous operations), the ActivityInstance is transitioned to the Canceled state without executing any work item of that ActivityInstance.
    2. otherwise, a CancelActivityWorkItem for the child ActivityInstance is scheduled for execution.

The important thing to note is that only in 2.b above the cancellation of an ActivityInstance implies the execution of a work item on the ActivityInstance itself. In cases 1 and 2.a the cancellation does not involve calling any methods on the activity.

Also note that 2.a above can only hold if the child ActivityInstance has been scheduled, but hasn’t executed any work item yet. This is because, as part of the post-processing of work item execution, the WF Runtime transitions the ActivityInstance to a completed state if the ActivityInstance has no pending work. This logic is in ActivityInstance.UpdateState().

Everything else about cancellation stems from the simple rules above, and from the fundamental rule of the WF Runtime: work items for a given WorkflowInstance are executed sequentially.

What happens next?

As a former colleague of mine used to say: “When I don’t know, I go and look” :-). Again, a .NET Reflection Tool is our friend here.

The actual function that is called by the execution of CancelActivityWorkItem is InternalCancel():

 internal class CancelActivityWorkItem : ActivityExecutionWorkItem 
{
    ...
    public override bool Execute(ActivityExecutor executor, BookmarkManager bookmarkManager) 
    { 
        try 
        { 
            ActivityInstance.Cancel(executor, bookmarkManager); 
        } 
        catch (Exception exception) 
        { 
            if (Fx.IsFatal(exception)) 
                throw; 
            ExceptionToPropagate = exception; 
        } 
        return true; 
    }
}
 public sealed class ActivityInstance : ActivityInstanceMap.IActivityReferenceWithEnvironment, ActivityInstanceMap.IActivityReference 
{ 
    ...
    internal void Cancel(ActivityExecutor executor, BookmarkManager bookmarkManager) 
    { 
        Activity.InternalCancel(this, executor, bookmarkManager); 
    } 
 } 

So how cancellation works for the 4 different activity authoring options actually boils down to how each of them implements the virtual method InternalCancel(). Let’s look at each case.

Activity

 

 public abstract class Activity
 { 
    ... 
    internal virtual void InternalCancel(ActivityInstance instance, ActivityExecutor executor, BookmarkManager bookmarkManager) 
    { 
        NativeActivityContext item = executor.NativeActivityContextPool.Acquire(); 
        try 
        { 
            item.Initialize(instance, executor, bookmarkManager); 
            item.Cancel(); 
        } 
        finally 
        { 
            item.Dispose(); 
            executor.NativeActivityContextPool.Release(item); 
        } 
    } 
 }

NativeActivityContext.Cancel() propagates the cancellation request to children:

 

 public class NativeActivityContext : ActivityContext 
{ 
    ... 
    internal void Cancel() 
    { 
        ThrowIfDisposed(); 
        CurrentInstance.BaseCancel(this); 
    } 
 }
 public sealed class ActivityInstance : ActivityInstanceMap.IActivityReferenceWithEnvironment, ActivityInstanceMap.IActivityReference 
{ 
    ... 
    internal void BaseCancel(NativeActivityContext context) 
    { 
        performingDefaultCancelation = true; 
        CancelChildren(context); 
    } 
 }

So we can deduce that the behavior of cancellation for Activity is “simply” to propagate cancellation to its children. Since an activity using the Activity authoring option is essentially a composition of other activities and does not provide its own logic, the activity won’t need as well to provide a cancellation logic. This behavior cannot be changed, because InternalCancel() is, as the name says:-), internal, therefore it cannot be overridden by custom activities.

 

CodeActivity

 

 public abstract class CodeActivity : Activity 
{ 
    ... 
    internal sealed override void InternalCancel(ActivityInstance instance, ActivityExecutor executor, BookmarkManager bookmarkManager) 
    { 
    } 
}

This effectively makes InternalCancel() a no-op, and there is no way to override this behavior. CodeActivity executes in one work item only: inside CodeActivtiy.Execute(CodeActivityContext context) it is not possible to create children or bookmarks (CodeActivityContext does not expose these functionalities) or to create pending async operations. Therefore, in the post-processing of the Execute() work item, the CodeActivity will always transition to a completed state.

The condition 2.b above, that would end up in a cancel work item to be executed on the to-be-cancelled activity, cannot hold for a CodeActivity. That’s why CodeActivity does not define a Cancel() method: even if it existed, it would never be called. This answers the original question.

 

AsyncCodeActivity

 

 public abstract class AsyncCodeActivity : Activity, IAsyncCodeActivity 
{ 
    ...
    protected virtual void Cancel(AsyncCodeActivityContext context) 
    { 
    } 

    internal sealed override void InternalCancel(ActivityInstance instance, ActivityExecutor executor, BookmarkManager bookmarkManager) 
    { 
        AsyncOperationContext context; 
        if (executor.TryGetPendingOperation(instance, out context)) 
        { 
            using (AsyncCodeActivityContext context2 = new AsyncCodeActivityContext(context, instance, executor)) 
            { 
                context.HasCalledAsyncCodeActivityCancel = true; 
                Cancel(context2); 
            } 
        } 
    } 
 }

This means that by default cancellation on an AsyncCodeActivity is a no-op, but the activity author has a chance to implement its own cancel semantics. According to the rules above, Cancel() for an AsyncCodeActivity will be called only if BeginExecute() has already been executed, and EndExecute() has not: in this time interval, there is a pending async operation for the activity. A typical implementation of Cancel() will consist in canceling the asynchronous operation that was issued in BeginExecute(), if it hasn’t completed yet. The WF runtime has no knowledge of this asynchronous operation and how to cancel it, so it cannot implement a meaningful cancellation logic. Therefore, it gives the activity author the option to do it.

NativeActivity

 

 public abstract class NativeActivity : Activity, IInstanceUpdatable 
 { 
    ... 
    protected virtual void Cancel(NativeActivityContext context) 
    { 
        if (!context.IsCancellationRequested) 
            throw FxTrace.Exception.AsError(new InvalidOperationException(SR.DefaultCancelationRequiresCancelHasBeenRequested)); 
        context.Cancel(); 
    } 

    internal override void InternalCancel(ActivityInstance instance, ActivityExecutor executor, BookmarkManager bookmarkManager) 
    { 
        NativeActivityContext context = executor.NativeActivityContextPool.Acquire(); 
        try 
        { 
            context.Initialize(instance, executor, bookmarkManager); 
            Cancel(context); 
        } 
        finally 
        { 
            context.Dispose(); 
            executor.NativeActivityContextPool.Release(context); 
        } 
    } 
 }

The implementation is somewhat similar to that of AsyncCodeActivity, but here Cancel() calls context.Cancel(). Context.Cancel(), as we have seen in the section on Activity above, propagates the cancellation request to children. This is necessary for NativeActivity and not for AsyncCodeActivity, because NativeActivity, unlike AsyncCodeActivity, can have children.

Summary of Cancellation Behavior

We can summarize the considerations above with a simple table:

 

Cancels Children

Can Override

Activity

Ö

c

CodeActivity

n/a

c

AsyncCodeActivity

n/a

Ö

NativeActivity

Ö

Ö

 

Custom Cancellation

If the default implementation of cancellation for AsyncCodeActivity and NativeActivity does not suit your needs, you can override it. Overriding NativeActivity.Cancel() is called custom cancellation, as opposed to the behavior provided by NativeActivity.Cancel(), which is called default cancellation. Here I am providing some quick considerations on custom cancellation.

Cancellation of Children

NativeActivity.Cancel(), as mentioned above, propagates the cancelation request to all the child ActivityInstances. If you override Cancel(), however, it is your responsibility to propagate cancellation requests to children, if applicable. You can do this selectively, by calling Context.CancelChild(ActivityInstance inst), or in a generalized fashion, by calling Context.CancelChildren(). Cancel() is free not to do cancellation of children, however this prevents the activity itself from transitioning to a completed state. So, in essence, such an implementation of Cancel() would not allow an early completion of the activity.

Scheduling of Children

You may have noted that, whenever default cancellation is carried out, as opposed to custom cancellation, the performingDefaultCancelation field of ActivityInstance is set to true (see the code snippet for ActivityInstance.BaseCancel() above). This setting is used because default cancellation prevents scheduling of new child activities. Or, better: scheduling an activity for execution when default cancellation is in progress causes NativeActivityContext.ScheduleActivity() to return an ActivityInstance whose state is Canceled. This logic is implemented in NativeActivityContext.InternalScheduleActivity(), called by NativeActivityContext.ScheduleActivity():

 private ActivityInstance InternalScheduleActivity(Activity activity, CompletionBookmark onCompleted, FaultBookmark onFaulted)
{
    ...
    if (currentInstance.IsPerformingDefaultCancelation)
    {
        currentInstance.MarkCanceled();
        return ActivityInstance.CreateCanceledInstance(activity);
    }
    return executor.ScheduleActivity(activity, currentInstance, onCompleted, onFaulted, null);
}

Overriding Cancel() disables this behavior, so you’ll be able to continue scheduling child activities for execution. This is, indeed, one reason why you may want to implement custom cancellation: as part of your cancellation logic, your activity may need to execute child activities. Some OOB activities do exactly that. For instance, the CancellationScope activity schedules the CancellationHandler child activity when its child Body is cancelled. However, if Body were cancelled as the result of default cancellation of its parent, CancellationScope would not be able to schedule CancellationHandler at that point. Therefore, CancellationScope overrides Cancel(). The CompensableActivity OOB activity also uses this technique. Keep in mind that, if your override of Cancel() calls base.Cancel(), default cancellation kicks in and scheduling of children would again be disallowed. For this reason, base.Cancel() should not be called by an override of Cancel(), if the cancellation logic requires execution of child activities.

Bookmarks

Other than having running children, the other reason why a NativeActivity may stay in the Executing state is outstanding bookmarks. If your activity is in the Executing state and has outstanding bookmarks when cancellation is requested, Cancel() will be called, per rule 2.b above.

What happens next depends on whether default cancellation or custom cancellation is being used. Let’s experiment with this ourselves. Run the following code:

 class Program
{
    static void Main(string[] args)
    {
        ManualResetEvent mre = new ManualResetEvent(false);
        Activity workflow1 = new MyActivity();
        WorkflowApplication wfApp = new WorkflowApplication(workflow1);
        wfApp.Completed = wacea => { Console.WriteLine("Workflow Completed"); mre.Set(); };
        wfApp.Run();
        // give root activity a chance to execute its first work item
        Thread.Sleep(1000);
        wfApp.Cancel(TimeSpan.MaxValue);
        mre.WaitOne();
    }
}

public class MyActivity : NativeActivity
{
Variable<Bookmark> m_Bookmark;

public MyActivity()
{
m_Bookmark = new Variable<Bookmark>();
}

protected override bool CanInduceIdle
{
get
{
return true;
}
}

protected override void CacheMetadata(NativeActivityMetadata metadata)
{
metadata.AddImplementationVariable(m_Bookmark);
}

protected override void Execute(NativeActivityContext context)
{
Console.WriteLine("Execute called");
Bookmark bmk = context.CreateBookmark();
context.SetValue(m_Bookmark, bmk);
}

protected override void Cancel(NativeActivityContext context)
{
Console.WriteLine("Cancel called");
base.Cancel(context);
}
}

You get this output:

 

Execute called Cancel called Workflow Completed

The workflow completes after Cancel() is called. This means that the bookmark has been removed somehow. The logic is in ActivityInstance.UpdateState(), called by the post-processing of ActivityExecutionWorkItem:

 

 internal abstract class ActivityExecutionWorkItem : WorkItem
{
    ...
    public override void PostProcess(ActivityExecutor executor)
    {
        if ((ExceptionToPropagate != null) && !skipActivityInstanceAbort)
            executor.AbortActivityInstance(ActivityInstance, ExceptionToPropagate);
        else if (ActivityInstance.UpdateState(executor))
        {
            Exception exception = executor.CompleteActivityInstance(ActivityInstance);
            if (exception != null)
                ExceptionToPropagate = exception;
        }
    }
}
 public sealed class ActivityInstance : ActivityInstanceMap.IActivityReferenceWithEnvironment, ActivityInstanceMap.IActivityReference
{
    ...
    internal bool UpdateState(ActivityExecutor executor)
    {
        ...
        if (performingDefaultCancelation && OnlyHasOutstandingBookmarks)
        {
            RemoveAllBookmarks(executor.RawBookmarkScopeManager, executor.RawBookmarkManager);
            MarkCanceled();
            SetCanceled();
            flag = true;
        }
        return flag;
    }
}

Note that the bookmarks are removed only if 2 conditions hold:

  • default cancellation is used (performingDefaultCancellation is true)
  • pending work ONLY consists of outstanding bookmarks

Let’s remove one of these conditions by modifying Cancel(), so that the base Cancel() is not called:

 protected override void Cancel(NativeActivityContext context)
{
    Console.WriteLine("Cancel called");
}

Run the program again. You get this output:

 

Execute called Cancel called

 

The workflow does not complete because default cancellation is not used this time, therefore the bookmark is not removed.

 

You may wonder why the check for outstanding bookmarks is done in post-processing of ActivityExecutionWorkItem  and not in the base Cancel(). To answer this question, let’s consider what happens if an activity that is being canceled has BOTH children AND bookmarks. Since there are active children, bookmarks are not removed at this stage (what if a running child later tries to resume one of them?). However, after all children have completed and only bookmarks have remained, we would like to get the same behavior. Therefore, the decision is postponed at work item post-processing, for any activity-related work item (all of them derive from ActivityExecutionWorkItem).

The bottom line, in any case, is that you don’t need to care about outstanding bookmarks, as long as you rely on default cancellation. If you are using custom cancellation, however, it is your responsibility to remove outstanding bookmarks, otherwise your ActivityInstance, and hence the workflow, will not complete. However, if your activity has custom cancellation, and it has executing children AND outstanding bookmarks, it is not necessarily a good idea to remove all bookmarks in the Cancel() override, because child activities that are still executing may use those bookmarks. If this is the case, a good strategy would be to remove all bookmarks once all children have completed. The parent activity can detect when all children have completed by always scheduling a completion callback every time a child activity is scheduled for execution.

As a side note: the components that resume bookmarks for the activity (typically the host, through WorkflowApplication.ResumeBookmark(), or other activities, through NativeActivityContext.ResumeBookmark()) should be resilient to the possibility that the bookmark is no longer there, because the ActivityInstance owning the bookmark has been canceled in the meantime. The different overrides of ResumeBookmark() return a BookmarkResumptionResult value that tells the caller if the bookmark is no longer present (BookmarkResumptionResult.NotFound).

Handling the Completion States Canceled and Closed

The fact that cancellation has been requested for an ActivityInstance does not imply that the ActivityInstance will complete its execution in the ActivityInstanceState.Canceled state. Let’s review the cancellation rules defined above:

  • if 1. holds: no change is made to the completed state (which will typically be Closed)
  • if 2.a holds: the completed state will always be Canceled
  • if 2.b holds: the logic is more complex, as we are going to find out now.

If 2.b holds, ActivityInstance will transition to the Canceled state if ActivityInstance.MarkCanceled() is called. MarkCanceled() is simple:

 public sealed class ActivityInstance : ActivityInstanceMap.IActivityReferenceWithEnvironment, ActivityInstanceMap.IActivityReference
{
    ...
    internal void MarkCanceled()
    {
        substate = Substate.Canceling;
    }
}

 

Setting the substate to Canceling does not have an immediate effect, but later on, when a work item for the activity completes and its post-processing updates the state, the following happens:

 

 public sealed class ActivityInstance : ActivityInstanceMap.IActivityReferenceWithEnvironment, ActivityInstanceMap.IActivityReference
{
    ...
    internal bool UpdateState(ActivityExecutor executor)
    {
        ...
        if (!HasPendingWork)
        {
            if (!executor.IsCompletingTransaction(this))
            {
                flag = true;
                if (substate == Substate.Canceling)
                {
                    SetCanceled();
                    return flag;
                }
                SetClosed();
            }
            return flag;
        }
        ...
    }

Therefore, the call to MarkCanceled() determines whether later on, when the ActivityInstance has no more pending work, it will be transitioned to the Canceled state, as opposed to the Closed state. NativeActivityContext.MarkCanceled() and AsyncCodeActivityContext.MarkCanceled() are the public interface to ActivityInstance.MarkCanceled().

Note that when the WF runtime handles a cancellation request for an activity that hasn’t executed yet (rule 2.a above), the same logic takes place: an empty work item is scheduled for execution:

 

 internal class ActivityExecutor : IEnlistmentNotification
{
    ...
    public void CancelActivity(ActivityInstance activityInstance)
    {
        if ((activityInstance.State == ActivityInstanceState.Executing) && !activityInstance.IsCancellationRequested)
        {
            activityInstance.IsCancellationRequested = true;
            if (activityInstance.HasNotExecuted)
                scheduler.PushWork(CreateEmptyWorkItem(activityInstance));
            else
                scheduler.PushWork(new CancelActivityWorkItem(activityInstance));
            if (ShouldTrackCancelRequestedRecords)
                AddTrackingRecord(new CancelRequestedRecord(WorkflowInstanceId, activityInstance.Parent, activityInstance));
        }
    }
}

The post-processing of the empty work item calls SetCanceled() on the ActivityInstance:

 

 public sealed class ActivityInstance : ActivityInstanceMap.IActivityReferenceWithEnvironment, ActivityInstanceMap.IActivityReference
{
    ...
    internal bool UpdateState(ActivityExecutor executor)
    {
        bool flag = false;
        if (HasNotExecuted)
        {
            if (this.IsCancellationRequested)
            {
                if (this.HasChildren)
                    foreach (ActivityInstance instance in this.GetChildren())
                        executor.CancelActivity(instance);
                return flag;
            }
            SetCanceled();
            return true;
        }
    }
    ...
}

 

Why is it important for an ActivityInstance to correctly report its state as Canceled or Closed? The answer is in the implementation of SetCanceled() and SetClosed(), called by UpdateState():

 

 public sealed class ActivityInstance : ActivityInstanceMap.IActivityReferenceWithEnvironment, ActivityInstanceMap.IActivityReference
{
    ...
    private void SetClosed()
    {
        state = ActivityInstanceState.Closed;
    }

    private void SetCanceled()
    {
        TryCancelParent();
        state = ActivityInstanceState.Canceled;
    }

    private void TryCancelParent()
    {
        if ((parent != null) && parent.IsPerformingDefaultCancelation)
            parent.MarkCanceled();
    }
}

The difference rests in the call to TryCancelParent() made by SetCanceled(): if the parent is using default cancellation, the parent will be marked as canceled, so it is bound to terminate in the Canceled state. Or, said another way: once an activity marks itself as canceled, cancellation propagates back to the parent chain, as long as the activities in the parent chain use default cancelation.

If your custom activity implements custom cancellation AND the activity has children, it is the responsibility of the activity to properly set its completion state. As a rule of thumb, you may want to mimic the behavior of default cancellation, and set the parent activity state to Canceled if at least one child completes in the canceled state. The parent activity uses completion callbacks to do that. A sample should (hopefully :-)) clarify the concept. Run this code:

 

 class Program
{
    static void Main(string[] args)
    {
        ManualResetEvent mre = new ManualResetEvent(false);
        Activity workflow1 = new MyActivity();
        WorkflowApplication wfApp = new WorkflowApplication(workflow1);
        wfApp.Completed = wacea => { Console.WriteLine("Workflow Completed with status " + wacea.CompletionState); mre.Set(); };
        wfApp.Run();
        // give root activity a chance to execute its first work item
        Thread.Sleep(1000);
        wfApp.Cancel(TimeSpan.MaxValue);
        mre.WaitOne();
    }
}

public class MyActivity : NativeActivity
{
    private Delay myDelayChild;

    public MyActivity()
    {
        myDelayChild = new Delay
        {
            Duration = new InArgument<TimeSpan>(TimeSpan.FromSeconds(10))
        };
    }

    protected override void CacheMetadata(NativeActivityMetadata metadata)
    {
        metadata.AddImplementationChild(myDelayChild);
    }

    protected override void Execute(NativeActivityContext context)
    {
        context.ScheduleActivity(myDelayChild);
    }
}

You get this output:

 

Workflow Completed with status Canceled

 

The cancellation request from the host goes to the root activity. The root activity uses Default Cancellation, so the request propagates to the child Delay activity, which uses custom cancellation and terminates in the Canceled state (see the implementation of Delay.Cancel()). Since the parent uses Default Cancellation, the cancellation propagates back to it. This is the root activity of the workflow, hence the output.

 

Now let’s modify the root activity to use custom cancellation and propagate the cancellation request to children:

 

 protected override void Cancel(NativeActivityContext context)
{
    context.CancelChildren();
}

Run the program and you get this output:

 

Workflow Completed with status Closed

 

The root activity is now using custom cancellation. Therefore, when the child Delay activity completes in the Canceled state, there is no attempt to cancel the parent.

The parent can still complete in the Canceled state if the child completed in the Canceled state, but it must do so explicitly in the child completion callback.

This requires these changes to the previous code:

 

 protected override void Execute(NativeActivityContext context)
{
    context.ScheduleActivity(myDelayChild, onDelayChildCompleted);
}

private void onDelayChildCompleted(NativeActivityContext context, ActivityInstance completedInstance)
{
    if (completedInstance.State == ActivityInstanceState.Canceled)
        context.MarkCanceled();
}

Run the program and you get this output:

 

Workflow Completed with status Canceled

The rationale behind this type of implementation would be that, since the logic of a parent activity is defined in terms of the logic of its children, if at least one children cancels (that is, does not complete its work), then the parent activity also did not complete its work, hence it should cancel.

 

You may want to divert from this behavior, however, if a child ending in the Canceled state does not imply that the parent did not complete its work. Among the OOB activities, Parallel is one such example. Parallel has a CompletionCondition child expression of Boolean type. If CompletionCondition evaluates to true when a child completes, the other branches (child activities) are canceled, but the parallel activity has completed its work and should transition to the Closed state. Therefore, in the completion callback for the CompletionCondition expression, cancellation is requested for outstanding children, but the hasCompleted flag is also set (once again, use a .Net Reflection Tool to look at the code):

 

 private void OnConditionComplete(NativeActivityContext context, ActivityInstance completedInstance, bool result)
{
    if (result)
    {
        context.CancelChildren();
        hasCompleted.Set(context, true);
    }
}

When the completion callback is called for the canceled branches, MarkCanceled() is NOT called if hasCompleted is true:

 

 private void OnBranchComplete(NativeActivityContext context, ActivityInstance completedInstance)
{
    if ((CompletionCondition != null) && !hasCompleted.Get(context))
    {
        if ((completedInstance.State != ActivityInstanceState.Closed) && context.IsCancellationRequested)
        {
            context.MarkCanceled();
            hasCompleted.Set(context, true);
        }
        else
        {
            if (onConditionComplete == null)
                onConditionComplete = new CompletionCallback<bool>(OnConditionComplete);
            context.ScheduleActivity<bool>(CompletionCondition, onConditionComplete, null);
        }
    }
}

This causes Parallel to complete in the Closed state, not the Canceled state. You may want to follow a similar pattern in your custom activity.

 

Custom Cancellation Summary

If you are implementing custom cancellation, there are 3 quick takeaways:

  • It is your responsibility to propagate the cancellation request to running children
  • It is your responsibility to remove outstanding bookmarks
  • It is your responsibility to correctly set the completion state of the ActivityInstance: Closed if the ActivityInstance successfully completed its work after receiving a cancel request, Canceled otherwise.

 

Cancellation with the Activity authoring option: CancellationScope

In the previous topic we have covered custom cancellation. Custom cancellation applies when you are writing a custom activity. While there are good reasons to write custom activities, the majority of workflow-related programming takes place using the declarative (Activity) authoring option, so let’s consider the options you have in this case, when it comes to cancelation.

Let’s recall from the table above that activities using the Activity authoring option cannot override cancellation, so they always use default cancellation. Let’s also recall that, with default cancellation, a child completing in the Canceled state causes the parent to also complete in the Canceled state.

This allows for the implementation of a cancellation pattern for Activity that involves the use of CancellationScope. Let’s assume you want to implement the following logic:

 

  • Do some work (possibly split into several parts)
  • If an external condition holds that prevents the work from completing, send a notification about unsuccessful completion

Using the declarative authoring option, you may create a sequence of 2 other activities:

 Sequence
    GatherSomeData
    ProcessTheData

GatherSomeData and ProcessTheData, together, implement the “Do some work” logic, split into two parts. Both activities implement custom cancellation and mark themselves as cancelled (MarkCanceled()) if they receive a cancellation request (which will typically come from the host, in the form of a call to WorkflowApplication,Cancel()).

If cancellation arrives from the host while either GatherSomeData or ProcessTheData are executing, the activities complete in the Canceled state, which causes Sequence to be canceled as well. Otherwise, if both GatherSomeData and ProcessTheData complete in the Closed state, Sequence also completes in the Closed state.

This gives way for wrapping Sequence in a CancellationScope activity:

 CancellationScope
    Body = Sequence
        GatherSomeData
        ProcessTheData
    CancellationHandler = SendCandelDataProcessingEmail

This way, CancellationScope allows to implement cancellation logic in the declarative authoring option. Note that CancellationScope has some tricks up its sleeve to make this work. For instance, you may wonder what happens if a cancellation request comes in while CancellationHandler is executing. In order to implement the intended semantics, CancellationScope would need to disable the propagation of the cancellation request to its child CancellationHandler, because cancellation is already in progress and CancellationHandler must be run to completion in order to obtain the intended semantics. CancellationScope does this by setting a flag once the Body completes, so that cancellation requests received afterwards are not propagated to children:

 

 public sealed class CancellationScope : NativeActivity
{
    ...
    protected override void Cancel(NativeActivityContext context)
    {
        if (!suppressCancel.Get(context))
            context.CancelChildren();
    }

    private void OnBodyComplete(NativeActivityContext context, ActivityInstance completedInstance)
    {
        if ((completedInstance.State == ActivityInstanceState.Canceled) ||                (context.IsCancellationRequested && (completedInstance.State == ActivityInstanceState.Faulted)))
        {
            suppressCancel.Set(context, true);
            context.MarkCanceled();
            if (CancellationHandler != null)
                context.ScheduleActivity(CancellationHandler, new FaultCallback(OnExceptionFromCancelHandler));
        }
    }
}