Skip to main content

Versioning - .NET SDK

This page shows how to do the following:

Introduction to Versioning

Because we design for potentially long running Workflows at scale, versioning with Temporal works differently. We explain more in this optional 30 minute introduction:

Use the .NET SDK Patching API

How to use the .NET SDK Patching API using the Temporal .NET SDK

In principle, the .NET SDK's patching mechanism operates similarly to other SDKs in a "feature-flag" fashion. However, the "versioning" API now uses the concept of "patching in" code.

To understand this, you can break it down into three steps, which reflect three stages of migration:

  • Running PrePatchActivity code while concurrently patching in PostPatchActivity.
  • Running PostPatchActivity code with deprecation markers for my-patch patches.
  • Running only the PostPatchActivity code.

Let's walk through this process in sequence.

Suppose you have an initial Workflow version called PrePatchActivity:

[Workflow]
public class MyWorkflow
{
[WorkflowRun]
public async Task RunAsync()
{
this.result = await Workflow.ExecuteActivityAsync(
(MyActivities a) => a.PrePatchActivity(),
new() { StartToCloseTimeout = TimeSpan.FromMinutes(5) });

// ...
}
}

Now, you want to update your code to run PostPatchActivity instead. This represents your desired end state.

[Workflow]
public class MyWorkflow
{
[WorkflowRun]
public async Task RunAsync()
{
this.result = await Workflow.ExecuteActivityAsync(
(MyActivities a) => a.PostPatchActivity(),
new() { StartToCloseTimeout = TimeSpan.FromMinutes(5) });

// ...
}
}

Problem: You cannot deploy PostPatchActivity directly until you're certain there are no more running Workflows created using the PrePatchActivity code, otherwise you are likely to cause a nondeterminism error.

Instead, you'll need to deploy PostPatchActivity and use the Patched method to determine which version of the code to execute.

Implementing patching involves three steps:

  1. Use Patched to patch in new code and run it alongside the old code.
  2. Remove the old code and apply DeprecatePatch.
  3. Once you're confident that all old Workflows have finished executing, remove DeprecatePatch.

Overview

We take a deep dive into the behavior of the patched() function in this optional 37 minute YouTube series:

Behavior When Not Replaying

If not replaying, and the execution hits a call to patched, it first checks the event history, and:

  • If the patch ID is not in the event history, it will add a marker to the event history, upsert a search attribute, and return true. This happens in a given patch ID's first block.
  • If the patch ID is in the event history, it won't modify the history, and it will return true. This happens in a given patch ID's subsequent blocks.

There is a caveat to the above, and we will discuss that below.

Behavior When Replaying With Marker Before-Or-At Current Location

If Replaying:

  • If the code has a call to patched, and if the event history has a marker from a call to patched in the same place (which means it will match the original event history), then it writes a marker to the replay event history and returns true. This is similar to the behavior of the non-replay case, and just like in that case, this happens in a given patch ID's first block
  • If the code has a call to patched, and the event history has a marker with that Patch ID earlier in the history, then it will simply return true and not modify the replay event history. This is similar to the behavior of the non-replay case, and just like in that case, this happens in a given patch ID's subsequent blocks
Behavior When Replaying With Marker After Current Location

If the Marker Event is after where the execution currently is in the event history, then, in other words, the patch is before the original patch, then the patch is too early. It will attempt to write the marker to the replay event history, but it will throw a non-deterministic exception because the replay and original event histories don't match

Behavior When Replaying With No Marker For that Patch ID

It will return false and not add anything to the event history. Furthermore, and this is the caveat mentioned in the preceeding section Behavior When Not Replaying, it will make all future calls to patched with that ID false -- even after it is done replaying and is running new code.

Why is this a caveat?

In the preceding section where we discussed the behavior when not replaying , we said that if not replaying, the patched function will always return true, and if the marker doesn't exist, it will add it, and if the marker already exists, it won't re-add it.

But what this is saying is that this doesn't hold if there was already a call to patched with that ID in the replay code, but not in the event history. In this situation, it won't return true.

A Summary of the Two Potentially Unexpected Behaviors
  1. When Replaying, in the scenario of it hits a call to patched, but that patch ID isn't before/on that point in the event history, you may not expect that the event history after where you currently are matters. Because:

    1. If that patch ID exists later, you get an NDE (see above: Behavior When Replaying With Marker After Current Location).
    2. If it doesn't exist later, you don't get an NDE, and it returns false (see above: Behavior When Replaying With No Marker For that Patch ID).
  2. When Replaying, if you hit a call to patched with an ID that doesn't exist in the history, then not only will it return false in that occurence, but it will also return false if the execution surpasses the Replay threshold and is running new code. (see above: Behavior When Replaying With No Marker For that Patch ID).

Implications of the Behaviors

If you deploy new code while a worker is down, any workflows that were in the middle of executing will replay using old code and then for the rest of the execution, they will either:

  1. Use new code if there was no call to patched in the replay code
  2. If there was a call to patched in the replay code, they will run the non-patched code during and after replay

This might sound odd, but it's actually exactly what's needed because that means that if the future patched code depends on earlier patched code, then it won't use the new code -- it will use the old code. But if there's new code in the future, and there was no code earlier in the body that required the new patch, then it can switch over to the new code, and it will do that.

Note that this behavior means that the Workflow does not always run the newest code. It only does that if not replaying or if surpassed replay and there hasn't been a call to patched (with that ID) throughout the replay.

Recommendations

Based on this behavior and the implications, when patching in new code, always put the newest code at the top of an if-patched-block.

if (patched('v3')) {
// This is the newest version of the code.
// put this at the top, so when it is running
// a fresh execution and not replaying,
// this patched statement will return true
// and it will run the new code.
} else if (patched('v2')) {
} else {
}

The following sample shows how patched() will behave in a conditional block that's arranged differently. In this case, the code's conditional block doesn't have the newest code at the top. Because patched() will return true when not Replaying (except with the preceding caveats), this snippet will run the v2 branch instead of v3 in new executions.

if (patched('v2')) {
// This is bad because when doing a new execution (i.e. not replaying),
// patched statements evaluate to True (and put a marker
// in the event history), which means that new executions
// will use v2, and miss v3 below
}
else if (patched('v3')) {}
else {}

Patching in new code

Using Patched inserts a marker into the Workflow History.

During replay, if a Worker encounters a history with that marker, it will fail the Workflow task when the Workflow code doesn't produce the same patch marker (in this case, my-patch). This ensures you can safely deploy code from PostPatchActivity as a "feature flag" alongside the original version (PrePatchActivity).

[Workflow]
public class MyWorkflow
{
[WorkflowRun]
public async Task RunAsync()
{
if (Workflow.Patched("my-patch"))
{
this.result = await Workflow.ExecuteActivityAsync(
(MyActivities a) => a.PostPatchActivity(),
new() { StartToCloseTimeout = TimeSpan.FromMinutes(5) });
}
else
{
this.result = await Workflow.ExecuteActivityAsync(
(MyActivities a) => a.PrePatchActivity(),
new() { StartToCloseTimeout = TimeSpan.FromMinutes(5) });
}

// ...
}
}

Understanding deprecated Patches in the .NET SDK

After ensuring that all Workflows started with PrePatchActivity code have finished, you can deprecate the patch.

Deprecated patches serve as a bridge between PrePatchActivity and PostPatchActivity. They function similarly to regular patches by adding a marker to the Workflow History. However, this marker won't cause a replay failure when the Workflow code doesn't produce it.

If, during the deployment of PostPatchActivity, there are still live Workers running PrePatchActivity code and these Workers pick up Workflow histories generated by PostPatchActivity, they will safely use the patched branch.

[Workflow]
public class MyWorkflow
{
[WorkflowRun]
public async Task RunAsync()
{
Workflow.DeprecatePatch("my-patch")
this.result = await Workflow.ExecuteActivityAsync(
(MyActivities a) => a.PostPatchActivity(),
new() { StartToCloseTimeout = TimeSpan.FromMinutes(5) });

// ...
}
}

Safe Deployment of PostPatchActivity

You can safely deploy PostPatchActivity once all Workflows labeled my-patch or earlier are finished, based on the previously mentioned assertion.

[Workflow]
public class MyWorkflow
{
[WorkflowRun]
public async Task RunAsync()
{
this.result = await Workflow.ExecuteActivityAsync(
(MyActivities a) => a.PostPatchActivity(),
new() { StartToCloseTimeout = TimeSpan.FromMinutes(5) });

// ...
}
}

Best Practice of Using Classes as Arguments and Returns

As a side note on the Patching API, its behavior is why Temporal recommends using a single object as arguments and returns from Signals, Queries, Updates, and Activities, rather than using multiple arguments/returns. The Patching API's main use case is to support branching in an if block of a method body. It is not designed to be used to set different methods or method signatures for different Workflow Versions.

Because of this, Temporal recommends that each Signal, Activity, etc, accepts a single object and returns a single object, so the method signature can stay constant, and you can do your versioning logic using patched() within the method body.