This chapter continues the discussion of workflow persistence that began in Chapter 11. In that chapter, you learned the basics of workflow persistence using the SqlWorkflowInstanceStore
. By following the examples presented in that chapter, you should now know how to enable persistence for applications that are hosted by the WorkflowApplication
class as well as workflow services hosted by IIS or self-hosted by the WorkflowServiceHost
class.
This chapter focuses on ways to extend or customize workflow persistence. It builds upon the examples that were presented in Chapter 11. Additional examples in this chapter extend persistence using the PersistenceParticipant
class and demonstrate the promotion of properties to make them externally queryable. Another example demonstrates how to use the WorkflowControlEndpoint
to manage active workflow instances.
The chapter concludes with an example that implements a custom instance store. The instance store persists workflow instances to the file system rather than to a database.
Note This chapter assumes that you are using the examples presented in Chapter 11 as the starting point for this chapter. In particular, the ActivityLibrary
, ServiceLibrary
, ServiceHost
, and OrderEntryConsoleClient
projects that were first developed in Chapter 11 will be used in this chapter.
You can customize workflow persistence in two primary ways. First, you can implement your own instance store. This option provides you with complete flexibility as to how persistence is implemented. However, this is also the most labor-intensive option. Second, WF provides the PersistenceParticipant
class that enables you to participate in workflow persistence without the need to implement your own instance store. By deriving a custom workflow extension from the abstract PersistenceParticipant
class, you can inject additional data that is persisted along with the workflow instance.
Here are the most important members of the PersistenceParticipant
class:
You can follow these steps to use this extension mechanism:
- Develop a custom workflow extension that derives from the
PersistenceParticipant
class (found in theSystem.Activities.Persistence
namespace).- Override the virtual
CollectValues
method to inject additional data to be persisted.- Optionally, override the virtual
PublishValues
method to load additional data that was previously persisted.- Optionally, override the virtual
MapValues
method to review data to be persisted that was collected from all persistence participants.
The CollectValues
and PublishValues
methods complement each other. The CollectValues
method is invoked when a workflow instance is persisted and is your opportunity to add data to be persisted. The method signature defines two out arguments of type IDictionary
that must be populated by your code. One dictionary is used for read-write values and the other for write-only values. The difference between the two collections is that read-write values are expected to make a round-trip. They are persisted and then loaded when the workflow instance is loaded. The write-only values are persisted but not loaded. By specifying them as write-only values, you are indicating that they are not vital to the successful execution of the workflow. They might be queried and used by other nonworkflow portions of the application.
The PublishValues
method is invoked during the process of loading a workflow instance that was previously persisted. The method is passed an IDictionary
of read-write values that were previously persisted. This is your opportunity to retrieve each named value from the collection and load it back into memory.
Each value that you persist or load is uniquely identified with a string name that you must provide. The name is defined as an XName
so it includes a full namespace.
The other virtual method that you can choose to override is MapValues
. The purpose of this method is not as straightforward as the other methods. To better understand its purpose, you need to understand that persistence is accomplished in stages. In the first stage, the CollectValues
method is invoked for all persistence participants that are currently loaded. At the end of this stage, all data to be persisted has been collected. In the second stage, the MapValues
method is invoked to provide you with an opportunity to make additional persistence decisions based on the superset of data that was collected from all participants. The MapValues
method is passed two IDictionary
arguments (one for read-write values and another for write-only values) and returns a new IDictionary
instance. The IDictionary
of values that you must return is added to the previously collected data as write-only values. In the third stage of persistence, the SaveWorkflowCommand
is executed against the instance store to persist the workflow instance and any additional data that was collected.
WF also provides the PersistenceIOParticipant
class, which derives from PersistenceParticipant
. Here are the most important additional members of this class:
The additional methods provided by the PersistenceIOParticipant
class enable you to save and load data during the standard persistence operations, but to use a storage mechanism that is separate from the instance store. For example, you want to use the standard SqlWorkflowInstanceStore
for the primary workflow persistence. But in additional to the standard workflow persistence, you also need to save a subset of the instance data to an XML file or to a table in another application database. The PersistenceIOParticipant
class allows you to accomplish this by overriding the methods listed previously.
You can follow these general guidelines to help you determine which base class to use:
- Use
PersistenceParticipant
when you need to persist and reload additional data with each workflow instance.- Use
PersistenceParticipant
when you need to persist additional write-only data.- Use
PersistenceIOParticipant
when you want to persist and reload workflow instance data using an instance store but you also need to persist (and possibly reload) data from a separate data store.
To demonstrate how to use the PersistenceParticipant
class, you will modify the custom extension that you implemented in Chapter 11. One of the design problems with the previous examples is that the ItemSupportExtension
maintains a running count of available inventory for each item. As each new item is added to an order, the available inventory is decremented by the requested quantity. In the previous examples, the inventory is reset to the starting values when the applications are recycled. It would be better if the current inventory values were persisted and then reloaded along with the workflow instance.
In this example, you will modify the ItemSupportExtension
class to persist the inventory values. You will add the PersistenceParticipant
class as the base class and override the CollectValues
and PublishValues
methods.
Note Even with these changes, this is not an optimal solution to this particular test scenario. Saving the available inventory with each workflow instance works great if you work with only a single instance at any one time. In a more realistic scenario where you might have hundreds or thousands of orders executing at any one time, the inventory would naturally be persisted in a database. However, this contrived example should be adequate to demonstrate the persistence concepts that are the focus of this chapter.
You originally implemented the ItemSupportExtension
class in Chapter 11. It is located in the ActivityLibrary
project. Here is an abbreviated copy of the class showing the changes that you need to make:
using System;
using System.Activities.Persistence;
using System.Collections.Generic;
using System.Linq;
using System.Xml.Linq;
namespace ActivityLibrary
{
public class ItemSupportExtension : PersistenceParticipant, IItemSupport
{
…
private XName _itemsName = XName.Get(
"ItemDefinitions", "ActivityLibrary.ItemSupportExtension");
private XName _orderIdName = XName.Get(
"OrderId", "ActivityLibrary.ItemSupportExtension");
…
#region PersistenceParticipant members
The code in the CollectValues
method adds the entire collection of ItemDefinition
objects (the _items
variable) as a read-write value. This is the collection containing the available inventory values for each item. Additionally, the value of the _orderId
variable is added to the write-only collection. Since it is added to the write-only collection, this value is serialized and persisted along with the instance store but is not reloaded. It will be used in the next example to demonstrate the use of the Promote
method of the SQL Server instance store.
protected override void CollectValues(
out IDictionary<System.Xml.Linq.XName, object> readWriteValues,
out IDictionary<System.Xml.Linq.XName, object> writeOnlyValues)
{
readWriteValues = new Dictionary<System.Xml.Linq.XName, object>();
lock (_items)
{
readWriteValues.Add(_itemsName, _items);
}
if (_orderId > 0)
{
writeOnlyValues = new Dictionary<System.Xml.Linq.XName, object>();
writeOnlyValues.Add(_orderIdName, _orderId);
_orderId = 0;
}
else
{
writeOnlyValues = null;
}
}
The PublishValues
method retrieves the value of the collection of ItemDefinition
objects that was previously persisted and assigns it to the _items
member variable.
protected override void PublishValues(
IDictionary<System.Xml.Linq.XName, object> readWriteValues)
{
object value = null;
if (readWriteValues.TryGetValue(_itemsName, out value))
{
if (value is Dictionary<Int32, ItemDefinition>)
{
lock (_items)
{
_items = value as Dictionary<Int32, ItemDefinition>;
}
}
}
}
#endregion
}
}
After rebuilding the solution, you can test the enhanced extension class by running the ServiceHost
and OrderEntryConsoleClient
projects as you did for the previous example. Start a new order, add a few items, and then close the projects without completing the order. Then restart the projects and complete the order.
You should see that the final ending inventory that is displayed has been reduced by the items that you added to the order. This confirms that the collection of ItemDefinition
objects was persisted and loaded along with the workflow instance. Previously, the inventory values were reset to their original values when the ServiceHost
project was restarted.
Caution The ServiceHost
project loads a singleton instance of the ItemSupportExtension
that is shared by all workflow instances. When you are adding your own extensions, you can also use the override of the WorkflowExtensions.Add
method that allows you to assign a delegate. The delegate is executed for each workflow instance, allowing you to construct a new instance of the extension for each workflow instance.
Be careful if you use this approach with an extension that derives from the PersistenceParticipant
class. If the extension implements an interface that you defined (IItemSupport
in this example), you might be tempted to specify the interface as the generic type when you add the extension like this: host.WorkflowExtensions.Add<IItemSupport>(() => return new ItemSupportExtension())
. If you do this, the extension will not be recognized as a persistence participant, and the persistence methods (CollectValues
, MapValues
, PublishValues
) will not be invoked. Instead, you need to specify the concrete type name as the generic type like this: host.WorkflowExtensions.Add<ItemSupportExtension>(() => return new ItemSupportExtension())
.
When you extend workflow persistence by providing your own PersistenceParticipant
-derived class, the additional data that you inject is serialized and saved in the instance store along with the workflow instance. This means that the data is completely opaque and not easily available for consumption by any process except for workflow persistence. You can’t easily query it or use it to look up the workflow instance ID that is associated with it.
However, many times you might want to extend workflow persistence specifically to provide a lookup mechanism. For example, in the workflow service examples that were presented in Chapter 11, each workflow instance was associated with an order ID number. You might need the ability to look up the workflow instance ID that is associated with a particular order ID.
To address this need, the SqlWorkflowInstanceStore
class supports the concept of property promotion. Promotion instructs the instance store to save the properties that you identify to another table in the persistence database. The fully qualified name of this table is System.Activities.DurableInstancing.InstancePromotedProperties
. There is also a view provided with the same name (InstancePromotedProperties
). The table contains a number of variant columns (named Value1
, Value2
, and so on) that can store most simple data types (integers, strings, and so on) and columns that can store binary data. The workflow instance ID is one of the columns in the table. Therefore, once the properties are persisted in this manner, they are queryable and available for your use. If you query for some known value (for example an order ID), you will be able to identify the workflow instance ID associated with that value.
Promotion is enabled by calling the Promote
method of the SqlWorkflowInstanceStore
class. The method is defined with this signature:
public void Promote(string name, IEnumerable<XName> promoteAsVariant,
IEnumerable<XName> promoteAsBinary)
The name parameter should be a meaningful name that you apply to the promotion. It is also saved to the database along with the individual properties that you specify in the collection. The promoteAsVariant
parameter is a collection of properties that you want to store in the variant columns. Each one is uniquely identified by a namespace-qualified name that must exactly match the name you used in your custom PersistenceParticipant
class to persist the values. The properties that you specify are persisted in the variant columns in the order in which they are defined. So, the first property goes into the Value1
column, the second in Value2
, and so on. The second collection is for properties that you want to save as binary data.
Note The promoted data exists for the same duration as the workflow instance itself. When the workflow instance completes and is removed from the database, the promoted data is also removed.
To demonstrate property promotion, you will make a few small changes to the previous example to promote the order ID property. Since the order ID is used for content correlation, it makes sense that someone might want to query for this value.
You will complete these tasks to implement this example:
- Modify the
ServiceHost
project to promote the order ID.- Modify the
OrderEntryConsoleClient
project to query for the promoted values.- Add the SQL Server connection string to the
App.config
file of theOrderEntryConsoleClient
project.
Modify the Program.cs
file of the ServiceHost
project to promote the OrderId
property. Here is an abbreviated listing of the code showing the new code that you need to add:
namespace ServiceHost
{
class Program
{
…
private static WorkflowServiceHost CreateServiceHost(
String xamlxName, IItemSupport extension)
{
…
List<XName> variables = new List<XName>()
{
XName.Get("OrderId", "ActivityLibrary.ItemSupportExtension")
};
storeBehavior.Promote("OrderEntry", variables, null);
host.Description.Behaviors.Add(storeBehavior);
…
}
}
}
Tip Notice that the property name and namespace specified here exactly match the name used in the ItemSupportExtension
class to save the order ID.
You will now modify the OrderEntryConsoleClient
project to query the table containing the promoted values. This provides the client application with the ability to display a list of all active and incomplete workflow instances along with their order IDs.
Here is an abbreviated listing of the Program.cs
file of the OrderEntryConsoleClient
project with the implementation of the private Query
method:
namespace OrderEntryConsoleClient
{
class Program
{
…
static void Query()
{
String sql =
@"Select Value1, InstanceId from
[System.Activities.DurableInstancing].[InstancePromotedProperties]
where PromotionName = 'OrderEntry'
order by Value1";
try
{
queriedInstances.Clear();
string connectionString = ConfigurationManager.ConnectionStrings
["InstanceStore"].ConnectionString;
using (SqlConnection conn = new SqlConnection(connectionString))
{
conn.Open();
SqlCommand cmd = new SqlCommand(sql, conn);
using (SqlDataReader reader = cmd.ExecuteReader())
{
Console.WriteLine("Promoted OrderId values:");
while (reader.Read())
{
Int32 orderId = (Int32)reader["Value1"];
Guid instanceId = (Guid)reader["InstanceId"];
Console.WriteLine("OrderId={0}, InstanceId={1}",
orderId, instanceId);
if (!queriedInstances.ContainsKey(orderId))
{
queriedInstances.Add(orderId, instanceId);
}
}
}
}
}
catch (Exception exception)
{
lastOrderId = 0;
Console.WriteLine("Query Unhandled exception: {0}",
exception.Message);
}
}
…
}
}
The code not only displays the results of the query on the console, but it also saves the data in a dictionary. This dictionary will be used in a subsequent example to cancel an active workflow instance.
The query code assumes that the SQL Server connection string is defined in the App.config
file, so you’ll need to add it to the existing file:
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<connectionStrings>
<add name="InstanceStore"
connectionString="Data Source=localhostSQLExpress;
Initial Catalog=InstanceStore;Integrated Security=True;
Asynchronous Processing=True"
providerName="System.Data.SqlClient" />
</connectionStrings>
…
</configuration>
After rebuilding the project, you should be ready to run the ServiceHost
and OrderEntryConsoleClient
projects. To test this new functionality, I started two new orders, adding an item to each order. I then executed the query command, which successfully listed the workflow instance IDs along with the order ID that is associated with each instance. Here is a representative sample of my results:
Commands: start | add | complete | query | cancel | exit
start
Enter an OrderId (int) for the order
1
New order 1 started
Commands: start | add | complete | query | cancel | exit
add
Enter ItemId and Quantity (Ex: 101 1)
101 1
Ordered 1 of ItemId 101 for OrderId 1
Commands: start | add | complete | query | cancel | exit
start
Enter an OrderId (int) for the order
2
New order 2 started
Commands: start | add | complete | query | cancel | exit
add
Enter ItemId and Quantity (Ex: 101 1)
202 2
Ordered 2 of ItemId 202 for OrderId 2
Commands: start | add | complete | query | cancel | exit
query
Promoted OrderId values:
OrderId=1, InstanceId=0ce418f4-74a4-49d0-85cb-886d9139d120
OrderId=2, InstanceId=4b6a0ade-9afe-435e-ba5d-99dee801e5fc
Commands: start | add | complete | query | cancel | exit
WF provides a standard WCF management endpoint (WorkflowControlEndpoint
) that can be added to a service. This endpoint is designed to assist with the management of long-running workflow services, such as the one that you have been using in this chapter and in Chapter 11. This endpoint supports operations that manage existing service instances such as Cancel
, Suspend
, Terminate
, Abandon
, Unsuspend
, and Run
.
To enable this endpoint, you simply need to add it to the WorkflowServiceHost
instance. This can be accomplished in code or via entries in the App.config
file for the project. You can also add these same entries to a Web.config
file if you are using IIS to host your workflow services.
To consume the management endpoint, WF also provides a client proxy class named WorkflowControlClient
. It eliminates the need for you to create your own client proxy for the management endpoint.
To demonstrate a realistic use of the managed endpoint and client class, you will modify the previous example to allow cancellation of an active workflow instance. The client application will use the results of the query (implemented in the last example) to identify the workflow instance ID to cancel.
You will complete these tasks to implement this example:
- Add the
WorkflowControlEndpoint
to theServiceHost
configuration file.- Modify the
OrderEntryConsoleClient
project to use theWorkflowControlClient
.- Define the management endpoint in the client configuration file.
Since the ServiceHost
project is already defining all endpoints via the App.config
file, it makes sense to add the WorkflowControlEndpoint
in the same manner. Here are the additional entries that you need to make to the App.config
file of the ServiceHost
project:
<?xml version="1.0"?>
<configuration>
…
<system.serviceModel>
<services>
<service name="OrderEntryService">
…
<endpoint kind="workflowControlEndpoint"
address="http://localhost:9000/OrderEntryControl"
binding="wsHttpBinding" />
</service>
</services>
…
</system.serviceModel>
…
</configuration>
The OrderEntryConsoleClient
project will now be modified to cancel an active workflow instance using the WorkflowControlClient
class. Here is the implementation for the private Cancel
method in the Program.cs
file of this project:
namespace OrderEntryConsoleClient
{
class Program
{
…
static void Cancel()
{
try
{
Console.WriteLine("Enter an OrderId to cancel");
String input = Console.ReadLine();
Int32 orderIdToCancel = 0;
if (String.IsNullOrEmpty(input))
{
Console.WriteLine("A value must be entered");
return;
}
Int32.TryParse(input, out orderIdToCancel);
if (orderIdToCancel == 0)
{
Console.WriteLine("OrderId must not be zero");
return;
}
The workflow instance ID to cancel is obtained from the private queriedInstances
dictionary. This dictionary is populated by a call to the Query
method.
Guid instanceId = Guid.Empty;
if (!queriedInstances.TryGetValue(orderIdToCancel,
out instanceId))
{
Console.WriteLine("Instance not found");
return;
}
using (WorkflowControlClient client =
new WorkflowControlClient("ClientControlEndpoint"))
{
client.Cancel(instanceId);
}
}
catch (Exception exception)
{
lastOrderId = 0;
Console.WriteLine("Cancel Unhandled exception: {0}",
exception.Message);
}
}
}
}
The client project now requires the address of the management control endpoint that is hosted by the ServiceHost
project. Add these lines to the App.config
file of the OrderEntryConsoleClient
project:
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
…
<system.serviceModel>
…
<client>
…
<endpoint name="ClientControlEndpoint"
contract="System.ServiceModel.Activities.IWorkflowInstanceManagement"
address="http://localhost:9000/OrderEntryControl"
binding="wsHttpBinding" />
</client>
</system.serviceModel>
</configuration>
After rebuilding the solution, start the ServiceHost
and OrderEntryConsoleClient
projects once again. To test this new functionality, you need to have one or two active workflow instances. If you didn’t complete the instances from the previous test, you can use them. Otherwise, you’ll need to add at least one instance with at least one item. Use the query command to prepare a list of active instances. Then you can use the cancel command to cancel one of the instances. Finally, execute the query command one more time to verify that the canceled instance is actually gone.
Here are my results, which assume that the instances from the previous example were not completed and are still active:
Commands: start | add | complete | query | cancel | exit
query
Promoted OrderId values:
OrderId=1, InstanceId=0ce418f4-74a4-49d0-85cb-886d9139d120
OrderId=2, InstanceId=4b6a0ade-9afe-435e-ba5d-99dee801e5fc
Commands: start | add | complete | query | cancel | exit
cancel
Enter an OrderId to cancel
1
Commands: start | add | complete | query | cancel | exit
query
Promoted OrderId values:
OrderId=2, InstanceId=4b6a0ade-9afe-435e-ba5d-99dee801e5fc
Commands: start | add | complete | query | cancel | exit
complete
Enter an OrderId (int) for the order
2
Order 2 Is Completed
Ordered Items:
ItemId=202, Quantity=2, UnitPrice=2.34, Total=4.68
Commands: start | add | complete | query | cancel | exit
As you have already seen, WF includes an instance store (SqlWorkflowInstanceStore
) that uses a SQL Server database for persistence. This instance store should really be your default answer when you need to add workflow persistence to an application. In most garden-variety scenarios, the instance store that is provided with WF should meet your needs.
However, WF does provide the necessary classes to develop your own instance store. In this section of the chapter, you will develop a custom instance store that persists workflow instances to the file system instead of a database.
I chose to persist to the file system for a couple of reasons. First, WF already has an instance store that uses a SQL Server database, and I wanted to demonstrate something that was significantly different from what you get out of the box. Second, using the file system keeps things as simple as possible. It eliminates the SQL (or LINQ), connection strings, transaction management, and so on. It allows me to present code that focuses on the required interaction with WF rather than with SQL Server.
To implement a custom instance store, you must derive from the InstanceStore
class (located in the System.Runtime.DurableInstancing
namespace). This class defines a number of members, but here are the virtual members that you must override:
The WF runtime communicates with an instance store through a set of command classes (listed in the next section). Your custom instance store is responsible for handling these commands.
The BeginTryCommand
and EndTryCommand
methods are invoked by the persistence framework to execute commands asynchronously. The commands that are passed to BeginTryCommand
are the result of automatic persistence operations (for example, when a workflow instance becomes idle). On the other hand, the TryCommand
method is invoked in response to some direct action by you. For example, when you execute CreateWorkflowOwnerCommand
or DeleteWorkflowOwnerCommand
against an instance store, those commands are passed to TryCommand
, not to BeginTryCommand
.
What this means is that you must provide an implementation for the synchronous and asynchronous versions of these methods. However, as you will see in the example code, the TryCommand
can simply pass the command to BeginTryCommand
to satisfy this requirement.
All persistence commands derive from the base InstancePersistenceCommand
class. Here is a brief summary of each command and its purpose:
Some of these command classes contain additional properties that you will definitely need to use in your instance store. For example, the SaveWorkflowCommand
contains the workflow instance data that you need to persist. And the LoadWorkflowByInstanceKeyCommand
contains the lookup key (a correlation key) that you must use to retrieve the workflow instance.
Note The properties of these commands are more easily understood by seeing them in actual use. For this reason, I’ll skip the usual list of properties for each class to keep this discussion moving along.
The InstancePersistenceContext
class is similar in purpose to the ActivityContext
that you reference within custom activities. The ActivityContext
provides access to the workflow runtime environment. In a similar way, the InstancePersistenceContext
provides access to the runtime environment for persistence. It provides properties and methods that you use to interact with the WF persistence runtime.
An InstancePersistenceContext
object is passed as an argument to the TryCommand
and BeginTryCommand
methods of the InstanceStore
class along with the command to execute.
One of the more important properties of the InstancePersistenceContext
class is the InstanceView
property. It provides access to an InstanceView
object, which is a snapshot of a single workflow instance.
The InstanceView
contains properties that describe the workflow instance that the current command executes against. For example, this class has an InstanceId
property that uniquely identifies the workflow instance. You will frequently need to navigate to this InstanceId
property of the InstanceView
when you need to know the ID of the workflow instance to load or save.
The custom instance store that you are about to develop is separated into two classes. The FileSystemInstanceStore
class is derived from the base InstanceStore
class and is responsible for the handling of persistence commands and interacting with the WF persistence API. It defers all the actual file system I/O to a second class named FileSystemInstanceStoreIO
. This separation should make it easier to distinguish between the code that responds to the persistence commands (which you will need to implement regardless of the storage medium) and the I/O code that is specific to the chosen storage medium (the file system).
To begin the implementation of the instance store, add a new class to the ActivityLibrary
project named FileSystemInstanceStore
. Here is the annotated code for this class:
using System;
using System.Activities.DurableInstancing;
using System.Collections.Generic;
using System.Runtime.DurableInstancing;
using System.Threading;
using System.Xml.Linq;
namespace ActivityLibrary
{
public class FileSystemInstanceStore : InstanceStore
{
private Guid _ownerId = Guid.NewGuid();
private Guid _lockToken = Guid.NewGuid();
private FileSystemInstanceStoreIO _dataStore;
public FileSystemInstanceStore()
{
_dataStore = new FileSystemInstanceStoreIO();
}
#region InstanceStore overrides
The BeginTryCommand
is invoked to asynchronously process a persistence command. The code uses a switch
statement to branch the processing based on the type of command that is received. The code to process each command follows a consistent pattern. The only real difference is the private method that is invoked to process each command.
To easily process the commands asynchronously, a Func
delegate is declared that executes the correct private method. The BeginInvoke
method of the delegate is used to begin asynchronous execution of the code that is assigned to the delegate. The Func
delegate is declared to return an Exception
. This is necessary in order to pass any unhandled exception from the asynchronous thread to the original thread. The callback code that is assigned to the BeginInvoke
method first calls EndInvoke
on the delegate. This completes the asynchronous operation. It then passes the Exception
(if any) to the original callback that was provided as an argument to the BeginTryCommand
method. Execution of the original callback triggers execution of the EndTryCommand
method.
protected override IAsyncResult BeginTryCommand(
InstancePersistenceContext context,
InstancePersistenceCommand command,
TimeSpan timeout, AsyncCallback callback, object state)
{
Console.WriteLine("BeginTryCommand: {0}", command.GetType().Name);
switch (command.GetType().Name)
{
case "CreateWorkflowOwnerCommand":
Func<Exception> createFunc = () =>
{
return ProcessCreateWorkflowOwner(context,
command as CreateWorkflowOwnerCommand);
};
return createFunc.BeginInvoke((ar) =>
{
Exception ex = createFunc.EndInvoke(ar);
callback(new InstanceStoreAsyncResult(ar, ex));
}, state);
case "LoadWorkflowCommand":
Func<Exception> loadFunc = () =>
{
return ProcessLoadWorkflow(context,
command as LoadWorkflowCommand);
};
return loadFunc.BeginInvoke((ar) =>
{
Exception ex = loadFunc.EndInvoke(ar);
callback(new InstanceStoreAsyncResult(ar, ex));
}, state);
case "LoadWorkflowByInstanceKeyCommand":
Func<Exception> loadByKeyFunc = () =>
{
return ProcessLoadWorkflowByInstanceKey(context,
command as LoadWorkflowByInstanceKeyCommand);
};
return loadByKeyFunc.BeginInvoke((ar) =>
{
Exception ex = loadByKeyFunc.EndInvoke(ar);
callback(new InstanceStoreAsyncResult(ar, ex));
}, state);
case "SaveWorkflowCommand":
Func<Exception> saveFunc = () =>
{
return ProcessSaveWorkflow(context,
command as SaveWorkflowCommand);
};
return saveFunc.BeginInvoke((ar) =>
{
Exception ex = saveFunc.EndInvoke(ar);
callback(new InstanceStoreAsyncResult(ar, ex));
}, state);
default:
return base.BeginTryCommand(
context, command, timeout, callback, state);
}
}
The EndTryCommand
method is paired with the execution of the BeginTryCommand
method. Execution of this method is triggered by invoking the original callback argument that was passed to the BeginTryCommand
.
A private InstanceStoreAsyncResult
class is passed to this method by the Func
delegate callback code. This private class was needed to pass the unhandled exception (if any) along the standard IAsyncResult
properties. If an Exception
was passed, it is rethrown here in order to throw it on the correct thread.
protected override bool EndTryCommand(IAsyncResult ar)
{
if (ar is InstanceStoreAsyncResult)
{
Exception exception = ((InstanceStoreAsyncResult)ar).Exception;
if (exception != null)
{
throw exception;
}
}
return true;
}
The TryCommand
method defers execution of the command to the BeginTryCommand
. Since BeginTryCommand
executes the command asynchronously, TryCommand
has to wait for the command to complete before returning.
protected override bool TryCommand(
InstancePersistenceContext context,
InstancePersistenceCommand command, TimeSpan timeout)
{
ManualResetEvent waitEvent = new ManualResetEvent(false);
IAsyncResult asyncResult = BeginTryCommand(
context, command, timeout, (ar) =>
{
waitEvent.Set();
}, null);
waitEvent.WaitOne(timeout);
return EndTryCommand(asyncResult);
}
#endregion
#region Command processing
The ProcessCreateWorkflowOwner
command is executed to associate a host application as an instance owner. The BindInstanceOwner
method of the context is executed to satisfy the requirements of this command.
private Exception ProcessCreateWorkflowOwner(
InstancePersistenceContext context,
CreateWorkflowOwnerCommand command)
{
try
{
context.BindInstanceOwner(_ownerId, _lockToken);
return null;
}
catch (InstancePersistenceException exception)
{
Console.WriteLine(
"ProcessCreateWorkflowOwner exception: {0}",
exception.Message);
return exception;
}
}
The ProcessLoadWorkflow
method is invoked to load a workflow instance when a LoadWorkflowCommand
is received. This command may be sent to load an existing workflow instance that was previously persisted or to initialize a new instance.
If an existing instance is to be loaded, the instance ID to load is obtained from the InstanceView.InstanceId
property of the context. This value, along with the context, is passed to a private SharedLoadWorkflow
method.
private Exception ProcessLoadWorkflow(
InstancePersistenceContext context,
LoadWorkflowCommand command)
{
try
{
if (command.AcceptUninitializedInstance)
{
context.LoadedInstance(InstanceState.Uninitialized,
null, null, null, null);
}
else
{
SharedLoadWorkflow(context, context.InstanceView.InstanceId);
}
return null;
}
catch (InstancePersistenceException exception)
{
Console.WriteLine(
"ProcessLoadWorkflow exception: {0}",
exception.Message);
return exception;
}
}
The ProcessLoadWorkflowByInstanceKey
method is invoked in response to the receipt of a LoadWorkflowByInstanceKeyCommand
. This command is used to retrieve an existing workflow instance based on an instance key. This is not the workflow instance ID. It is a correlation key that must be used to look up the actual workflow instance ID. A single workflow instance may have multiple instance keys.
private Exception ProcessLoadWorkflowByInstanceKey(
InstancePersistenceContext context,
LoadWorkflowByInstanceKeyCommand command)
{
try
{
Guid instanceId = _dataStore.GetInstanceAssociation(
command.LookupInstanceKey);
if (instanceId == Guid.Empty)
{
throw new InstanceKeyNotReadyException(
String.Format("Unable to load instance for key: {0}",
command.LookupInstanceKey));
}
SharedLoadWorkflow(context, instanceId);
return null;
}
catch (InstancePersistenceException exception)
{
Console.WriteLine(
"ProcessLoadWorkflowByInstanceKey exception: {0}",
exception.Message);
return exception;
}
}
The SharedLoadWorkflow
method is common code that is executed by the ProcessLoadWorkflow
and ProcessLoadWorkflowByInstanceKey
methods. It loads the instance and instance metadata for the workflow instance. After loading the data, the LoadedInstance
method of the context is invoked to provide this data to the persistence API.
private void SharedLoadWorkflow(InstancePersistenceContext context,
Guid instanceId)
{
if (instanceId != Guid.Empty)
{
IDictionary<XName, InstanceValue> instanceData = null;
IDictionary<XName, InstanceValue> instanceMetadata = null;
_dataStore.LoadInstance(instanceId,
out instanceData, out instanceMetadata);
if (context.InstanceView.InstanceId == Guid.Empty)
{
context.BindInstance(instanceId);
}
context.LoadedInstance(InstanceState.Initialized,
instanceData, instanceMetadata, null, null);
}
else
{
throw new InstanceNotReadyException(
String.Format("Unable to load instance: {0}", instanceId));
}
}
The ProcessSaveWorkflow
is invoked to persist a workflow instance. It has a number of tasks that it must complete. First, if the CompleteInstance
property of the command is true, it means that the command is signaling the completion of the workflow instance. This means that the data that was previously persisted can be deleted. Second, if instance data or instance metadata is available, it is persisted. Finally, if there are any instance keys to associate with the instance, they are persisted. The instance keys are correlation keys that are later used to retrieve the correct workflow instance.
private Exception ProcessSaveWorkflow(
InstancePersistenceContext context,
SaveWorkflowCommand command)
{
try
{
if (command.CompleteInstance)
{
_dataStore.DeleteInstance(
context.InstanceView.InstanceId);
_dataStore.DeleteInstanceAssociation(
context.InstanceView.InstanceId);
return null;
}
if (command.InstanceData.Count > 0 ||
command.InstanceMetadataChanges.Count > 0)
{
if (!_dataStore.SaveAllInstanceData(
context.InstanceView.InstanceId, command))
{
_dataStore.SaveAllInstanceMetaData(
context.InstanceView.InstanceId, command);
}
}
if (command.InstanceKeysToAssociate.Count > 0)
{
foreach (var entry in command.InstanceKeysToAssociate)
{
_dataStore.SaveInstanceAssociation(
context.InstanceView.InstanceId, entry.Key, false);
}
}
return null;
}
catch (InstancePersistenceException exception)
{
Console.WriteLine(
"ProcessSaveWorkflow exception: {0}", exception.Message);
return exception;
}
}
#endregion
#region Private types
private class InstanceStoreAsyncResult : IAsyncResult
{
public InstanceStoreAsyncResult(
IAsyncResult ar, Exception exception)
{
AsyncWaitHandle = ar.AsyncWaitHandle;
AsyncState = ar.AsyncState;
IsCompleted = true;
Exception = exception;
}
public bool IsCompleted { get; private set; }
public Object AsyncState { get; private set; }
public WaitHandle AsyncWaitHandle { get; private set; }
public bool CompletedSynchronously { get; private set; }
public Exception Exception { get; private set; }
}
#endregion
}
}
Add another class to the ActivityLibrary
project, and name it FileSystemInstanceStoreIO
. This class is referenced by the FileSystemInstanceStore
class and implements the logic to persist workflow instances to the file system. Here is the code for this class:
using System;
using System.Activities.DurableInstancing;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.DurableInstancing;
using System.Runtime.Serialization;
using System.Text;
using System.Xml;
using System.Xml.Linq;
namespace ActivityLibrary
{
internal class FileSystemInstanceStoreIO
{
private String _dataDirectory = String.Empty;
public FileSystemInstanceStoreIO()
{
CreateDataDirectory();
}
#region Save Methods
The SaveAllInstanceData
method is designed to persist the instance data of a workflow instance. The instance data can be found in the InstanceData
property of the SaveWorkflowCommand
that is passed to this method. This property is a collection of named elements that are individually serialized by calling the private SaveSingleEntry
method. All of this instance data is saved to a single XML file that uses the workflow instance ID Guid
as the file name.
public Boolean SaveAllInstanceData(Guid instanceId,
SaveWorkflowCommand command)
{
Boolean isExistingInstance = false;
try
{
String fileName = String.Format("{0}.xml", instanceId);
String fullPath = Path.Combine(_dataDirectory, fileName);
isExistingInstance = File.Exists(fullPath);
XElement root = new XElement("Instance");
root.Add(new XAttribute("InstanceId", instanceId));
XDocument xml = new XDocument(root);
NetDataContractSerializer serializer =
new NetDataContractSerializer();
XElement section = new XElement("InstanceData");
root.Add(section);
foreach (var entry in command.InstanceData)
{
SaveSingleEntry(serializer, section, entry);
}
SaveInstanceDocument(fullPath, xml);
}
catch (IOException exception)
{
Console.WriteLine(
"SaveAllInstanceData Exception: {0}", exception.Message);
throw exception;
}
return isExistingInstance;
}
The SaveAllInstanceMetaData
method is similar to the SaveAllInstanceData
method just above this. The difference is that this method saves workflow instance metadata rather than the instance data. The metadata includes elements such as the workflow type that are needed by the infrastructure.
The metadata is saved to a separate file from the instance data. This separate file is necessary because of the differences in the way instance data and metadata are saved. Each time instance data is saved, it is a complete replacement of the previously saved instance data. However, metadata is made available as changes instead of a complete replacement. Each time metadata changes are available to be saved, they should be merged with any previously saved metadata.
However, this custom instance store takes a few shortcuts and saves the metadata only the first time a workflow instance is saved. No subsequent changes to metadata are actually saved. This is sufficient to produce a working instance store since no metadata changes actually occur in these examples. Saving the metadata to a separate file allows this code to completely replace the instance data without the need to merge previously saved metadata.
public void SaveAllInstanceMetaData(Guid instanceId,
SaveWorkflowCommand command)
{
try
{
String fileName = String.Format("{0}.meta.xml", instanceId);
String fullPath = Path.Combine(_dataDirectory, fileName);
XElement root = new XElement("Instance");
root.Add(new XAttribute("InstanceId", instanceId));
XDocument xml = new XDocument(root);
NetDataContractSerializer serializer =
new NetDataContractSerializer();
XElement section = new XElement("InstanceMetadata");
root.Add(section);
foreach (var entry in command.InstanceMetadataChanges)
{
SaveSingleEntry(serializer, section, entry);
}
SaveInstanceDocument(fullPath, xml);
}
catch (IOException exception)
{
Console.WriteLine(
"SaveAllMetaData Exception: {0}", exception.Message);
throw exception;
}
}
The SaveSingleEntry
method serializes an individual data element. This shared code is invoked when instance data and metadata are saved. Each data element is represented by an InstanceValue
object along with a fully qualified name. The InstanceValue
class includes an Options
property that is serialized along with the key and the actual data.
private void SaveSingleEntry(NetDataContractSerializer serializer,
XElement section, KeyValuePair<XName, InstanceValue> entry)
{
if (entry.Value.IsDeletedValue)
{
return;
}
XElement entryElement = new XElement("Entry");
section.Add(entryElement);
Serialize(serializer, entryElement, "Key", entry.Key);
Serialize(serializer, entryElement, "Value", entry.Value.Value);
Serialize(serializer, entryElement, "Options", entry.Value.Options);
}
The SaveInstanceDocument
method physically writes a completed XML document to a file.
private static void SaveInstanceDocument(String fullPath, XDocument xml)
{
using (FileStream stream =
new FileStream(fullPath, FileMode.Create))
{
XmlWriterSettings settings = new XmlWriterSettings();
settings.Encoding = Encoding.UTF8;
using (XmlWriter writer = XmlWriter.Create(stream, settings))
{
writer.WriteRaw(xml.ToString());
}
}
}
#endregion
#region Load Methods
The LoadInstance
method retrieves and deserializes a workflow instance. The instance data and metadata for the workflow instance are both retrieved. This method invokes the LoadSingleEntry
method to deserialize each individual data element.
public Boolean LoadInstance(Guid instanceId,
out IDictionary<XName, InstanceValue> instanceData,
out IDictionary<XName, InstanceValue> instanceMetadata)
{
Boolean result = false;
try
{
instanceData = new Dictionary<XName, InstanceValue>();
instanceMetadata = new Dictionary<XName, InstanceValue>();
String fileName = String.Format("{0}.xml", instanceId);
String fullPath = Path.Combine(_dataDirectory, fileName);
if (!File.Exists(fullPath))
{
return result;
}
NetDataContractSerializer serializer =
new NetDataContractSerializer();
//load instance data
XElement xml = XElement.Load(fullPath);
var entries =
(from e in xml.Element("InstanceData").Elements("Entry")
select e).ToList();
foreach (XElement entry in entries)
{
LoadSingleEntry(serializer, instanceData, entry);
}
//load instance metadata
fileName = String.Format("{0}.meta.xml", instanceId);
fullPath = Path.Combine(_dataDirectory, fileName);
xml = XElement.Load(fullPath);
entries =
(from e in xml.Element(
"InstanceMetadata").Elements("Entry")
select e).ToList();
foreach (XElement entry in entries)
{
LoadSingleEntry(serializer, instanceMetadata, entry);
}
result = true;
}
catch (IOException exception)
{
Console.WriteLine(
"LoadInstance Exception: {0}", exception.Message);
throw exception;
}
return result;
}
The LoadSingleEntry
method deserializes a single data element. It is used to deserialize instance data and metadata elements. Notice that the InstanceValue
options that were previously persisted with the data element are checked in this method. If the value of the option indicates that the data is WriteOnly
, it is not loaded. Data that is flagged with the WriteOnly
option should be persisted but not loaded.
private void LoadSingleEntry(NetDataContractSerializer serializer,
IDictionary<XName, InstanceValue> instanceData, XElement entry)
{
XName key =
(XName)Deserialize(serializer, entry.Element("Key"));
Object value =
Deserialize(serializer, entry.Element("Value"));
InstanceValue iv = new InstanceValue(value);
InstanceValueOptions options =
(InstanceValueOptions)Deserialize(
serializer, entry.Element("Options"));
if (!options.HasFlag(InstanceValueOptions.WriteOnly))
{
instanceData.Add(key, iv);
}
}
#endregion
#region Delete Methods
The DeleteInstance
method is used to remove the instance and metadata files for an instance. It is invoked when a workflow instance is completed and can be removed from the file system.
public void DeleteInstance(Guid instanceId)
{
String fileName = String.Format("{0}.xml", instanceId);
String fullPath = Path.Combine(_dataDirectory, fileName);
if (File.Exists(fullPath))
{
File.Delete(fullPath);
}
fileName = String.Format("{0}.meta.xml", instanceId);
fullPath = Path.Combine(_dataDirectory, fileName);
if (File.Exists(fullPath))
{
File.Delete(fullPath);
}
}
#endregion
#region Association Methods
The SaveInstanceAssociation
method saves a file that associates an instance key with an instance ID. The instance key represents a correlation key that is later used to look up the correct workflow instance ID. The association between the key and the instance ID is maintained by the file name itself. The first node of the name is the instance key, and the second node is the instance ID. This mechanism may be simple, but it does support multiple instance keys for an instance ID.
public void SaveInstanceAssociation(Guid instanceId,
Guid instanceKeyToAssociate, Boolean isDelete)
{
try
{
String fileName = String.Format("Key.{0}.{1}.xml",
instanceKeyToAssociate, instanceId);
String fullPath = Path.Combine(_dataDirectory, fileName);
if (!isDelete)
{
if (!File.Exists(fullPath))
{
File.Create(fullPath);
}
}
else
{
if (File.Exists(fullPath))
{
File.Delete(fullPath);
}
}
}
catch (IOException exception)
{
Console.WriteLine(
"PersistInstanceAssociation Exception: {0}",
exception.Message);
throw exception;
}
}
The GetInstanceAssociation
method retrieves an instance ID based on an instance key that was provided. It does this by finding any files with the requested instance key and parsing the file name to determine the instance ID.
public Guid GetInstanceAssociation(Guid instanceKey)
{
Guid instanceId = Guid.Empty;
try
{
String[] files = Directory.GetFiles(_dataDirectory,
String.Format("Key.{0}.*.xml", instanceKey));
if (files != null && files.Length > 0)
{
String[] nodes = files[0].Split('.'),
if (nodes.Length == 4)
{
instanceId = Guid.Parse(nodes[2]);
}
}
}
catch (IOException exception)
{
Console.WriteLine(
"GetInstanceAssociation Exception: {0}",
exception.Message);
throw exception;
}
return instanceId;
}
The DeleteInstanceAssociation
method removes a specific instance key. This method is invoked when a workflow instance is completed.
public void DeleteInstanceAssociation(Guid instanceKey)
{
try
{
String[] files = Directory.GetFiles(_dataDirectory,
String.Format("Key.*.{0}.xml", instanceKey));
if (files != null && files.Length > 0)
{
foreach (String file in files)
{
File.Delete(file);
}
}
}
catch (IOException exception)
{
Console.WriteLine(
"DeleteInstanceAssociation Exception: {0}",
exception.Message);
throw exception;
}
}
#endregion
#region Private methods
private void CreateDataDirectory()
{
_dataDirectory = Path.Combine(
Environment.CurrentDirectory, "InstanceStore");
if (!Directory.Exists(_dataDirectory))
{
Directory.CreateDirectory(_dataDirectory);
}
}
private XElement Serialize(NetDataContractSerializer serializer,
XElement parent, String name, Object value)
{
XElement element = new XElement(name);
using (MemoryStream stream = new MemoryStream())
{
serializer.Serialize(stream, value);
stream.Position = 0;
using (StreamReader reader = new StreamReader(stream))
{
element.Add(XElement.Load(stream));
}
}
parent.Add(element);
return element;
}
private Object Deserialize(NetDataContractSerializer serializer,
XElement element)
{
Object result = null;
using (MemoryStream stream = new MemoryStream())
{
using (XmlDictionaryWriter writer =
XmlDictionaryWriter.CreateTextWriter(stream))
{
foreach (XNode node in element.Nodes())
{
node.WriteTo(writer);
}
writer.Flush();
stream.Position = 0;
result = serializer.Deserialize(stream);
}
}
return result;
}
#endregion
}
}
You should rebuild the solution at this point to verify that all the code for the custom instance store builds correctly.
To test the new instance store, you can modify the ServiceHost
project to load it instead of the SqlWorkflowInstanceStoreBehavior
. You need to completely replace the existing CreateServiceHost
method with a new implementation that uses the custom instance store instead of the SqlWorkflowInstanceStore
. If you like, you can make a copy of the method under a different method name before you completely replace it. This allows you to later swap back to using the SqlWorkflowInstanceStore
. Here are the changes that you need to make to the Program.cs
file of the ServiceHost
project:
namespace ServiceHost
{
class Program
{
…
private static WorkflowServiceHost CreateServiceHost(
String xamlxName, IItemSupport extension)
{
WorkflowService wfService = LoadService(xamlxName);
WorkflowServiceHost host = new WorkflowServiceHost(wfService);
InstanceStore store = new FileSystemInstanceStore();
host.DurableInstancingOptions.InstanceStore = store;
WorkflowIdleBehavior idleBehavior = new WorkflowIdleBehavior()
{
TimeToUnload = TimeSpan.FromSeconds(0)
};
host.Description.Behaviors.Add(idleBehavior);
if (extension != null)
{
host.WorkflowExtensions.Add(extension);
}
_hosts.Add(host);
return host;
}
…
}
}
You can now rebuild the solution and run the ServiceHost
and OrderEntryConsoleClient
projects to test the new instance store. The observable results that you see from the client application should be consistent with your previous tests that used the SqlWorkflowInstanceStoreBehavior
. Here is a representative set of results from the client application:
Commands: start | add | complete | query | cancel | exit
start
Enter an OrderId (int) for the order
1
New order 1 started
Commands: start | add | complete | query | cancel | exit
add
Enter ItemId and Quantity (Ex: 101 1)
101 1
Ordered 1 of ItemId 101 for OrderId 1
Commands: start | add | complete | query | cancel | exit
add
Enter ItemId and Quantity (Ex: 101 1)
202 2
Ordered 2 of ItemId 202 for OrderId 1
Commands: start | add | complete | query | cancel | exit
complete
Order 1 Is Completed
Ordered Items:
ItemId=101, Quantity=1, UnitPrice=1.23, Total=1.23
ItemId=202, Quantity=2, UnitPrice=2.34, Total=4.68
Commands: start | add | complete | query | cancel | exit
And here are the results from the ServiceHost
using the new instance store:
Item inventory Before Execution:
ItemId=101, QtyAvailable=10
ItemId=202, QtyAvailable=20
ItemId=303, QtyAvailable=30
BeginTryCommand: CreateWorkflowOwnerCommand
Contract: IOrderEntry
at http://localhost:9000/OrderEntry
Contract: IWorkflowInstanceManagement
at http://localhost:9000/OrderEntryControl
Press any key to stop hosting and exit
BeginTryCommand: LoadWorkflowCommand
BeginTryCommand: SaveWorkflowCommand
BeginTryCommand: SaveWorkflowCommand
BeginTryCommand: LoadWorkflowByInstanceKeyCommand
Update: ItemId=101, QtyBefore=10, QtyAfter=9
BeginTryCommand: SaveWorkflowCommand
BeginTryCommand: LoadWorkflowByInstanceKeyCommand
Update: ItemId=202, QtyBefore=20, QtyAfter=18
BeginTryCommand: SaveWorkflowCommand
BeginTryCommand: LoadWorkflowByInstanceKeyCommand
BeginTryCommand: SaveWorkflowCommand
Item inventory After Execution:
ItemId=101, QtyAvailable=9
ItemId=202, QtyAvailable=18
ItemId=303, QtyAvailable=30
Closing services...
BeginTryCommand: DeleteWorkflowOwnerCommand
Services closed
The XML files that are maintained by the instance store are saved to the indebugInstanceStore
folder under the ServiceHost
project. This assumes that you are building and running a debug version of the project. If you take a look in this folder before you complete an order, you should see files similar to these:
4db8abc8-b7f6-40b4-910c-12e2970d1551.xml
4db8abc8-b7f6-40b4-910c-12e2970d1551.meta.xml
Key.07f41faa-52f0-ce5f-8f26-85b7e0299515.4db8abc8-b7f6-40b4-910c-12e2970d1551.xml
The first file is the instance data, the second is the metadata, and the third file is the association file for a correlation key. Once you complete an order, the custom instance store deletes these files.
The focus of this chapter was extending and customizing workflow persistence. The chapter built upon the examples that were first presented in Chapter 11.
The chapter included coverage of mechanisms to extend the standard SQL Server persistence that is supported by WF. Included was an example that used the PersistenceParticipant
class to persist additional data when an instance was persisted. The ability to promote selected properties in order to make them externally queryable was also demonstrated.
Although it is not directly related to the topic of persistence, use of the WorkflowControlEndpoint
was demonstrated. This endpoint supports the management of active workflow instances.
The chapter concluded with the implementation of a custom instance store. This instance store uses the file system for persistence rather than a database.
In the next chapter, you will learn how WF implements transaction support, compensation, and error handling.
3.17.176.72