Transactional Behavior of Workflow Persistence When Using PersistenceIOParticipant Extensions

October 6th 2014 Workflow Foundation

Windows Workflow Foundation was primarily designed for long-running workflows. Typically their execution will stop at certain points and wait for an external trigger before resuming. Since we don't want the workflow instance to remain in memory during all this time, support for persisting workflow instances is an important part of the framework. It is also easily extendable: additional data can be saved when a workflow is persisted. There are some things to be aware of, when consistency between the persisted workflow and the extra data needs to be ensured. I'll discuss them in a scenario of a custom workflow host.

Different types of instance stores are supported, but only SQL Server instance store is provided by Microsoft; for others you'll need to turn to 3rd party vendors, such as DevExpress and Devart. To enable persistence, of course a database needs to be prepared first. For SQL Server, the database schema scripts are distributed with .NET framework and can be found in C:\Windows\Microsoft.NET\Framework\v4.0.30319\SQL\en. Only a subset of scripts in the folder is required for workflow instance persistence in .NET 4.5; they need to be run in this exact order: SqlWorkflowInstanceStoreSchema.sql, SqlWorkflowInstanceStoreLogic.sql, SqlWorkflowInstanceStoreSchemaUpgrade.sql.

To support persistence when hosting workflows in your own process, WorkflowApplication class needs to be used and set up as follows:

Exception exception = null;
var handle = new AutoResetEvent(false);
var workflowApplication = new WorkflowApplication(workflowDefinition, inputs);

// unload the workflow upon persisting
workflowApplication.PersistableIdle = args => PersistableIdleAction.Unload;
workflowApplication.Unloaded = args => handle.Set();

workflowApplication.Aborted = args =>
{
    exception = args.Reason;
    handle.Set();
};

// setup the instance store - the schema scripts must already be run
workflowApplication.InstanceStore = new SqlWorkflowInstanceStore(ConnectionString);

// start the workflow
workflowApplication.Run();

// wait for the workflow to be unloaded or aborted
handle.WaitOne();

Notice the use of AutoResetEvent for synchronizing the running workflow with the main thread. When the workflow is aborted, the exception which caused that, is included in WorkflowApplicationAbortedEventArgs. I'm storing it in a local variable so that I can inspect it later.

To trigger the persistence, workflow definition must include an activity which creates a bookmark, i.e. pauses the execution. Although there are some built-in activities like that, I decided to use my own trivial implementation:

public class ActivityWithBookmark : NativeActivity
{
    protected override bool CanInduceIdle
    {
        get { return true; }
    }

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

Here's the workflow definition and inputs that can be used with the above sample:

var workflowDefinition = new Sequence
{
    Activities =
    {
        new ActivityWithBookmark()
    }
};

var inputs = new Dictionary<string, object>();

Now that the we got the basic persistence working, it's time to extend it with our own custom code. For this purpose we need to create a workflow instance extension by deriving it from PersistenceIOParticipant base class:

public class PersistenceExtension : PersistenceIOParticipant
{
    private readonly bool _throwException;

    public PersistenceExtension() :
        base(true, false) // make save transactional, load non-transactional
    { }

    protected override IAsyncResult BeginOnSave(
        IDictionary<XName, object> readWriteValues,
        IDictionary<XName, object> writeOnlyValues,
        TimeSpan timeout, AsyncCallback callback, object state)
    {
        // save custom data here
        return base.BeginOnSave(
            readWriteValues, writeOnlyValues, timeout, callback, state);
    }

    protected override void Abort()
    { }
}

The extension needs to be added to WorkflowApplication before running the workflow:

workflowApplication.Extensions.Add(new PersistenceExtension());

Now, our BeginOnSave overload will be called just after the built-in persistence into the instance store is completed. PersistenceIOParticipant constructor parameters allow us to specify, whether we want the save and load operations to be included in the transaction, respectively.

Requiring the save transaction as in our case will cause the transaction in instance store to remain open for the duration of our BeginOnSave method. Not only that; our code is going to be run inside the same TransactionScope, therefore by default any database connection opened in this method will join this same transaction. Since there's no documented way of using the same connection string, the transaction will automatically be promoted to a distributed transaction. If that's okay with you, there's nothing more to do; otherwise you might want to include your database access code in a new TransactionScope, suppressing the ambient transaction:

protected override IAsyncResult BeginOnSave(
    IDictionary<XName, object> readWriteValues,
    IDictionary<XName, object> writeOnlyValues,
    TimeSpan timeout, AsyncCallback callback, object state)
{
    using (new TransactionScope(TransactionScopeOption.Suppress))
    {
        // save custom data here
    }
    return base.BeginOnSave(
        readWriteValues, writeOnlyValues, timeout, callback, state);
}

Don't worry, you can still ensure transactional consistency, even if you do this. If anything goes wrong in your code and you want to roll back the ambient transaction, just throw an exception.

As long as you're using SQL Server 2008 or later, this is all you need to know (and this should be the case, since SQL Server 2005 is already in its extended support period). If you're stuck with an older version, you'll either need to use distributed transactions, or give up transactional consistency. TransactionScope works differently with SQL Server 2005, therefore the transaction will already need to be distributed before you code in BeginOnSave is called at all. The only way to avoid it, is not to require the save operation to be included in the transaction (in PersistenceIOParticipant constructor). If you do this, the built-in persistence to instance store will already be committed when BeginOnSave is called, so there's no way to roll it back. It's your choice.

Get notified when a new blog post is published (usually every Friday):

Copyright
Creative Commons License