Kirk Evans Blog

.NET From a Markup Perspective

Understanding Persistence in Windows Workflow Foundation

Understanding Persistence in Windows Workflow Foundation

  • Comments 2

This post will show how to use the DelayActivity in Windows Workflow Foundation 3.0 to scale an application out to multiple processes.  I will show how to enable persistence and how to create an application that can resume workflows whose timers have expired.  I also show passing parameters to a workflow, creating multiple workflow instances, and how to process persisted instances.

Background

I have given probably a hundred demos of Windows Workflow Foundation and persistence in the past couple of years.  The demo that I usually do is from Dharma Shukla's Essential Windows Workflow Foundation.  For certain audiences, that demo works incredibly well because you can quickly get into the guts of ActivityExecutionContext, activity status, and authoring custom activities.  For high-level audiences seeing WF for probably the first time, I needed a simpler demo.  I think I finally came up with one that explains the persistence service succinctly.

The Scenario

When people are introduced to the concept of Windows Workflow Foundation, they usually picture document approval and wonder how to use WF for similar scenarios.  You open up Visual Studio, drag a delay shape onto it, use a parallel activity, and then are left scratching your head wondering what is going on.  The first thing that typically bites people is understanding how the ParallelActivity works.  The second thing that is confusing is figuring out how to have one application that can pause, and another application that can resume.  This is probably the most valuable concept in WF and is also the one that seems to be least understood.

We will have 2 projects, "Persist" and "Resume" in a single solution.  Both projects will point to the same database for persistence.  We'll see how persisting in one process can allow a second process to resume the workflow exactly where it left off.

There will be quite a bit of text in this post, but as you will see there is very little code.

Creating the Workflow

The first thing we want to do is create a project that contains a workflow.  The easiest way is to create a new Sequential Workflow Console Application in Visual Studio 2008.  Call it Persist.  That creates an empty workflow named Workflow1.  We will create two dependency properties properties on the workflow to hold information passed as parameters to the workflow called "InstanceIndex" (an integer) and "StartTime" (a DateTime). 

Next, we add a code activity that simply writes the values of our workflow's properties as well as its Workflow Instance ID.

Console.WriteLine("First activity for workflow {0} with index {1} started at {2}",
WorkflowEnvironment.WorkflowInstanceId.ToString(), 
InstanceIndex, 
StartTime.ToLongTimeString());

Next, add a delay activity with a TimeoutDuration of 20 seconds.  Finally, add another code activity that does pretty much the same thing as the previous one, but indicates itself as the second activity.  The completed code-behind is shown here.

using System;
using System.ComponentModel;
using System.ComponentModel.Design;
using System.Collections;
using System.Drawing;
using System.Linq;
using System.Workflow.ComponentModel.Compiler;
using System.Workflow.ComponentModel.Serialization;
using System.Workflow.ComponentModel;
using System.Workflow.ComponentModel.Design;
using System.Workflow.Runtime;
using System.Workflow.Activities;
using System.Workflow.Activities.Rules;

namespace Persistence
{
    public sealed partial class Workflow1 : SequentialWorkflowActivity
    {
        public Workflow1()
        {
            InitializeComponent();
        }


        public int InstanceIndex
        {
            get { return (int)GetValue(InstanceIndexProperty); }
            set { SetValue(InstanceIndexProperty, value); }
        }

        
        public static readonly DependencyProperty InstanceIndexProperty =
            DependencyProperty.Register("InstanceIndex", typeof(int), typeof(Workflow1)   );


        public DateTime StartTime
        {
            get { return (DateTime)GetValue(StartTimeProperty); }
            set { SetValue(StartTimeProperty, value); }
        }
        
        public static readonly DependencyProperty StartTimeProperty =
            DependencyProperty.Register("StartTime", typeof(DateTime), typeof(Workflow1));


        private void codeActivity1_ExecuteCode(object sender, EventArgs e)
        {
            Console.ForegroundColor = ConsoleColor.Cyan;
            Console.WriteLine("First activity for workflow {0} with index {1} started at {2}",
WorkflowEnvironment.WorkflowInstanceId.ToString(), 
InstanceIndex, StartTime.ToLongTimeString());
        }

        private void codeActivity2_ExecuteCode(object sender, EventArgs e)
        {
            Console.ForegroundColor = ConsoleColor.Cyan;
            Console.WriteLine("Second activity for workflow {0} with index {1} started at {2}", 
WorkflowEnvironment.WorkflowInstanceId.ToString(), 
InstanceIndex, 
StartTime.ToLongTimeString());
        }
    }
}

Creating the Persist Host

When you created the Persist project, it included a file, Program.cs.  The contents of that file include logic for starting a single workflow instance, and shutting down the process when the single workflow instance is completed.  We will change the logic to create 5 workflow instances and shut down the process if the user hits the ENTER key. 

We create a SqlWorkflowPersistenceService instance and point to the persistence database.  This database is created by using both the SqlPersistenceService_Logic.sql and SqlPersistenceService_Schema.sql scripts located in the "c:\Windows\Microsoft.NET\Framework\v3.0\Windows Workflow Foundation\SQL\EN" folder when you install .NET 3.0.  Once the database exists, we use the connection string to point to the persistence store:

//Add the persistence service
                SqlWorkflowPersistenceService persist = new SqlWorkflowPersistenceService(
                    @"Data Source=.\sqlexpress;Initial Catalog=Persistence;Integrated Security=True;Pooling=False",
                    true,
                    new TimeSpan(0, 0, 20),
                    new TimeSpan(0, 0, 5));
                workflowRuntime.AddService(persist);

The parameters used in the constructor bear some explanation.  First, we obviously use a SQL connection string.  Next, the "true" value represents a parameter, unloadOnIdle. This means that when the workflow enters an idle state and there is no more work currently scheduled, it will automatically persist.  Next, we specify the instance ownership duration, which means how long our first process (the project called "Persist") owns a lock on the record in the database.  Without this feature of the persistence service, our two projects could theoretically fight over the record.  Workflow locking takes care of that for us. Finally, we specify the loading interval.  I said 5 seconds here, which really means to check the database once every 5 seconds for records of workflows whose timers have expired.

The next step is to create 5 instances of the workflows.  Remember that our workflow instance requires 2 parameters, StartTime and InstanceIndex. We'll create 5 instances, passing the values to each.  Finally, once all 5 workflow instances have been started, we free up the thread to prompt the user for input and wait on their input.  The full code for Program.cs is included below.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Workflow.Runtime;
using System.Workflow.Runtime.Hosting;

namespace Persistence
{
    class Program
    {
        static private AutoResetEvent waitHandle = new AutoResetEvent(false);

        static void Main(string[] args)
        {

            using (WorkflowRuntime workflowRuntime = new WorkflowRuntime())
            {
                //Add the persistence service
                SqlWorkflowPersistenceService persist = new SqlWorkflowPersistenceService(
                    @"Data Source=.\sqlexpress;Initial Catalog=Persistence;Integrated Security=True;Pooling=False",
                    true,
                    new TimeSpan(0, 0, 20),
                    new TimeSpan(0, 0, 5));
                workflowRuntime.AddService(persist);

                //Respond to workflow runtime events
                workflowRuntime.WorkflowPersisted += new EventHandler<WorkflowEventArgs>(workflowRuntime_WorkflowPersisted);
                workflowRuntime.WorkflowCompleted += delegate(object sender, WorkflowCompletedEventArgs e) { waitHandle.Set(); };
                workflowRuntime.WorkflowTerminated += delegate(object sender, WorkflowTerminatedEventArgs e)
                {
                    Console.ForegroundColor = ConsoleColor.Green;
                    Console.WriteLine(e.Exception.Message);
                    waitHandle.Set();
                };                



                //Create 5 workflow instances
                for (int i = 0; i < 5; i++)
                {
                    //Pass parameters to each workflow.
                    Dictionary<string, object> wfParams = new Dictionary<string, object>();
                    wfParams.Add("StartTime", DateTime.Now);
                    wfParams.Add("InstanceIndex", i);
                    
                    WorkflowInstance instance = workflowRuntime.CreateWorkflow(typeof(Persistence.Workflow1), wfParams);
                    instance.Start();
                }


                //Wait until the workflow is completed, terminated, or persisted
                waitHandle.WaitOne();                

                Console.WriteLine("Press <ENTER> to shut down the host.");
                Console.ReadLine();
            }
        }

        static void workflowRuntime_WorkflowPersisted(object sender, WorkflowEventArgs e)
        {
            waitHandle.Set();
        }
    }
}

Running the Persist Project

When you run the Persist project, there's not much that happens.  The output probably looks like the following:

image

What you are seeing is the workflow instance id, the time each workflow instance started, and the index from our 5 workflow instances being dumped to console. 

If you wait 20 seconds and don't hit enter, then the output will be updated to look like this:

image

What you are seeing now is that each workflow instance was persisted to disk, then read back from disk, de-serialized into a workflow, and each workflow continues where it left off.  If you breakpoints in the designer, you will see that for each workflow instance, the first activity executes, the timer waits 20 seconds, then the workflow continues with the second activity.  Just as you might expect it to work and have seen demonstrated in other workflow demos and samples.

The more difficult part to wrap your head around is how to split this into 2 separate processes.

Creating the Resume Host

Add a new Sequential Workflow Console Application project to the solution called "Resume".  Set a project reference to the "Persist" project, we'll need this to resolve the workflow type.  Next, find the Workflow1.cs created in this new project and delete it.  This project will use the workflow in the "Persist" project instead of housing its own.

The SqlWorkflowPersistenceService will use the same constructor that we used previously.  Once every 5 seconds, it will poll the database looking for workflows that are not locked by another workflow and not currently in use.  Since our "Resume" project doesn't have its own workflow definition, it will read the blob from the database and de-serialize to the workflow definition that we referenced in the "Persist" project.  We also need to tell the workflow runtime about the types of workflows that we can process.  We do that by adding a TypeProvider as a service to the workflow runtime, indicating the type of workflow that we are looking for and are capable of de-serializing.  Other than setting some delegate handlers to show when things are happening, we are pretty much done.  The last thing we need to do is start the workflow runtime.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Workflow.Runtime;
using System.Workflow.Runtime.Hosting;
using System.Workflow.ComponentModel.Compiler;

namespace Resume
{
    class Program
    {        
        static void Main(string[] args)
        {

            using (WorkflowRuntime workflowRuntime = new WorkflowRuntime())
            {
                //Use the same persistence store as the Persistence project
                SqlWorkflowPersistenceService persist = new SqlWorkflowPersistenceService(
                    @"Data Source=.\sqlexpress;Initial Catalog=Persistence;Integrated Security=True;Pooling=False",
                    true,
                    new TimeSpan(0, 0, 20),
                    new TimeSpan(0, 0, 5));
                workflowRuntime.AddService(persist);


                //Add the type to ensure we pull the right type from the persistence store
                TypeProvider typeProvider = new TypeProvider(workflowRuntime);
                typeProvider.AddAssemblyReference("Persist.exe");
                workflowRuntime.AddService(typeProvider);

                //Respond to workflow runtime events
                workflowRuntime.WorkflowPersisted += new EventHandler<WorkflowEventArgs>(workflowRuntime_WorkflowPersisted);
                workflowRuntime.WorkflowLoaded += new EventHandler<WorkflowEventArgs>(workflowRuntime_WorkflowLoaded);
                workflowRuntime.WorkflowUnloaded += new EventHandler<WorkflowEventArgs>(workflowRuntime_WorkflowUnloaded);
                workflowRuntime.WorkflowCompleted += new EventHandler<WorkflowCompletedEventArgs>(workflowRuntime_WorkflowCompleted);
                
                //Start the runtime.  This will start the SqlWorkflowPersistenceService,
                //which will cause it to start polling once every 5 seconds looking for 
                //expired timers
                workflowRuntime.StartRuntime();                

                Console.WriteLine("Press <ENTER> to stop the host.");
                Console.ReadLine();

                workflowRuntime.StopRuntime();
            }
        }

        static void workflowRuntime_WorkflowCompleted(object sender, WorkflowCompletedEventArgs e)
        {
            Console.ForegroundColor = ConsoleColor.Green;
            Console.WriteLine("\tWorkflow {0} completed", e.WorkflowInstance.InstanceId.ToString());
        }

        static void workflowRuntime_WorkflowUnloaded(object sender, WorkflowEventArgs e)
        {
            Console.ForegroundColor = ConsoleColor.Green;
            Console.WriteLine("\tWorkflow {0} unloaded", e.WorkflowInstance.InstanceId.ToString());
        }

        static void workflowRuntime_WorkflowLoaded(object sender, WorkflowEventArgs e)
        {
            Console.ForegroundColor = ConsoleColor.Green;
            Console.WriteLine("\tWorkflow {0} loaded", e.WorkflowInstance.InstanceId.ToString());
        }


        static void workflowRuntime_WorkflowPersisted(object sender, WorkflowEventArgs e)
        {
            Console.ForegroundColor = ConsoleColor.Green;            
            Console.WriteLine("\tWorkflow {0} persisted", e.WorkflowInstance.InstanceId.ToString());
        }


    }
}

Run the Resume Host

The elusive part about this code is what is really happening with the persistence service.  There is special handling logic for the persistence service that will look for a record in the database with an elapsed timer.  It loads up each of those instances (up to a configured amount), and fires the timer callback.  That causes the workflow runtime to de-serialize the workflow and fire the event to notify the delay activity that it is completed.  When the delay activity receives this notification, it then signals to the workflow runtime that it has completed its work by calling its CloseActivity method.  The workflow runtime sees that there is no more work to be scheduled and moves the activity's ActivityExecutionStatus to closed... enabling the next activity to fire.  Of course, you don't see any of this, because it is all happening in the plumbing.  The only thing you see is that the second code activity fires for each of our 5 workflow instances.  your output will look something like this:

   1:  Press <ENTER> to stop the host.
   2:         Workflow ffda6014-a67c-4174-b39f-1fd0a5eb6eb7 loaded
   3:         Workflow e40cf589-d5e2-471f-81b2-5f10cbcc9c09 loaded
   4:  econd activity for workflow ffda6014-a67c-4174-b39f-1fd0a5eb6eb7 with index 1 s
   5:  arted at 6:41:52 PM
   6:         Workflow 36d16069-a363-4f8c-9024-aa000e8300c6 loaded
   7:         Workflow ffda6014-a67c-4174-b39f-1fd0a5eb6eb7 persisted
   8:         Workflow ffda6014-a67c-4174-b39f-1fd0a5eb6eb7 completed
   9:  econd activity for workflow 36d16069-a363-4f8c-9024-aa000e8300c6 with index 2 s
  10:  arted at 6:41:52 PM
  11:         Workflow 521b6989-f215-4df2-b7a6-b4fe94dfaeef loaded
  12:         Workflow 36d16069-a363-4f8c-9024-aa000e8300c6 persisted
  13:         Workflow 36d16069-a363-4f8c-9024-aa000e8300c6 completed
  14:  econd activity for workflow e40cf589-d5e2-471f-81b2-5f10cbcc9c09 with index 4 s
  15:  arted at 6:41:52 PM
  16:         Workflow 818f4921-728d-4263-9d0c-c6586a2b5f11 loaded
  17:  econd activity for workflow 818f4921-728d-4263-9d0c-c6586a2b5f11 with index 3 s
  18:  arted at 6:41:52 PM
  19:  econd activity for workflow 521b6989-f215-4df2-b7a6-b4fe94dfaeef with index 0 s
  20:  arted at 6:41:50 PM
  21:         Workflow 818f4921-728d-4263-9d0c-c6586a2b5f11 persisted
  22:         Workflow 818f4921-728d-4263-9d0c-c6586a2b5f11 completed
  23:         Workflow e40cf589-d5e2-471f-81b2-5f10cbcc9c09 persisted
  24:         Workflow e40cf589-d5e2-471f-81b2-5f10cbcc9c09 completed
  25:         Workflow 521b6989-f215-4df2-b7a6-b4fe94dfaeef persisted
  26:         Workflow 521b6989-f215-4df2-b7a6-b4fe94dfaeef completed

There are a couple interesting things to notice here.

 

On lines 2 and 3, we see that2 of the workflows are loaded from the persistence store into RAM and de-serialized into a workflow instance.  On line 4, the workflow fires the second Code activity.  This is the part that usually blows people's minds when they see this for the first time, that we can pick up a long-running process exactly where we left off.  Also note that in line 4 it writes that the workflow with index "1" is started and then completed on line 8.  On line 11, the workflow with index "0" is loaded, and it is not continued until line 19.  The thing to notice is that there is no guarantee that the workflows will be executed in the order they were persisted.

Looking at Ownership Duration

Some other interesting things to note is the parameters that we used in the constructor for the SqlWorkflowPersistenceService.  Suppose that we used the following constructor in both projects.

   SqlWorkflowPersistenceService persist = new SqlWorkflowPersistenceService(
      @"Data Source=.\sqlexpress;Initial Catalog=Persistence;Integrated Security=True;Pooling=False",
      true,
      new TimeSpan(5, 1, 20),
      new TimeSpan(0, 0, 5));
      workflowRuntime.AddService(persist);

The change here is the ownership duration parameter.  We changed it to 5 hours, 1 minute, and 20 seconds.  What does this do?  Try this... run the Persist project and hit enter within 20 seconds.  Go look at the records in the InstanceState table in the database.  You will see all 5 persisted instances in the database, and the ownerID and ownedUntil columns are null.  This means that the data is persisted and nobody is currently processing the data. 

image

Now, set a breakpoint on the second code activity in the workflow and run the Resume project.  This will re-hydrate the workflows and your breakpoint will be hit.  Now, try something crazy... stop debugging in Visual Studio.  Don't let the workflow complete, just stop debugging.  What has happened is that the workflow runtime re-hydrated only 3 of the 5 workflow instances into RAM and needs to tell other workflow runtimes not to process these particular instances because they are already being processed.  The next time another workflow runtime can process one of these locked workflow instances is 5 hours, 1 minute, and 20 seconds from now.  You can see this by looking at the InstanceState table in the database again. 

image

Now, we seemingly have a problem:  How do we resume the workflow once the ownership duration has been set?  The simple answer is to wait out the 5 hours, 1 minute, and 20 second ownership duration and then re-process the workflow.  This should point out an obvious question... what is a correct value for the ownershipDuration parameter in the workflow?  The simplest answer is to set it to the expected time between persistence points.  Remember that there are 2 persistence points here:  one occurs when the workflow becomes idle (which is when the delay activity starts), and the second is when the workflow completes.  The workflow needs to remove the record from the database so that it is not processed again.  So there are 2 time spans to configure:  the first time span occurs between loading the workflow and the delay activity, the second occurs between the re-hydration of the workflow and when the workflow completes.  If you expect from the time it takes to create all 5 workflows and run through the first code activity will be 2 minutes or less, set it to 2 minutes in the Persist project.  If you expect that the time it takes to re-hydrate the instance and continue processing the second code activity to take longer, say 5 minutes, then set it to 5 minutes.

Now that you understand the impact of setting the ownershipDuration property, consider what happens when you see an example online that sets the ownershipDuration to TimeSpan.MaxValue.  If your workflow re-hydrates a persisted instance and then crashes, the workflow can never be resumed until that duration has elapsed! 

As I write blog posts, I try to remember to include links to others' work on the subject.  When searching, I found a terrific screencast that you should definitely take a look at.

For More Information

Screencast - Using Persistence Services in Windows Workflow Foundation

Understanding the Lifecycle of a Workflow

Fixing the SqlWorkflowPersistenceService Ownership Issue - A handy SQL script for clearing locked workflows

Best Practices for Windows Workflow Foundation Apps

Ten Reasons why WF is not a Toy - Contains a good explanation of the locked and blocked columns in the InstanceState table

Locking in SqlWorkflowPersistenceService - A handy activity to create a persistence point

Workflow Persistence

Attachment: Passivation.zip
  • There is an attachment to this post, passivation.zip, that contains the source code for both the Persist and Resume projects.

  • This post will show how to create a simple console application that executes tracking queries in Windows

Page 1 of 1 (2 items)
Leave a Comment
  • Please add 1 and 6 and type the answer here:
  • Post
Translate This Page
Search
Archive
Archives