Skip to content

EDU-3824: Add best practice advice to Typescript Versioning #3303

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 15 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions docs/develop/dotnet/versioning.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -183,3 +183,164 @@ public class MyWorkflow
}
}
```

### Detailed Description of the Patched Function

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

<div style={{ position: "relative", paddingBottom: "56.25%", height: 0 }}>
<iframe
src="https://www.youtube.com/embed/videoseries?list=PLytZkHFJwKUdfxFQnuo0Fson0QM0VL9hL"
style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%" }}
frameborder="0"
allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen>
</iframe>
</div>

#### 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](#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](#behavior-when-not-replaying) 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)](#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)](#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)](#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.

<!--SNIPSTART dotnet-patching-example-->

```csharp
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 {
}
```
<!--SNIPEND-->

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.

<!--SNIPSTART dotnet-patching-anti-example-->

```csharp
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 {}
```

<!--SNIPEND-->


### 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.
182 changes: 179 additions & 3 deletions docs/develop/python/versioning.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ title: Versioning - Python SDK
sidebar_label: Versioning
description: Learn how to ensure deterministic Temporal Workflow execution and safely deploy updates using the Python SDK's patching and Worker Versioning APIs, for scalable long-running Workflows.
slug: /develop/python/versioning
toc_max_heading_level: 2
toc_max_heading_level: 4
keywords:
- best practices
- code sample
Expand Down Expand Up @@ -35,7 +35,17 @@ a non-deterministic issue if not handled correctly.

## 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: [https://www.youtube.com/watch?v=kkP899WxgzY](https://www.youtube.com/watch?v=kkP899WxgzY)
Because we design for potentially long running Workflows at scale, versioning with Temporal works differently. We explain more in this optional 30 minute introduction:

<div style={{ position: "relative", paddingBottom: "56.25%", height: 0 }}>
<iframe
src="https://www.youtube.com/embed/kkP899WxgzY?autoplay=0"
style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%" }}
frameborder="0"
allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen>
</iframe>
</div>

## How to use the Python SDK Patching API {#python-sdk-patching-api}

Expand Down Expand Up @@ -115,7 +125,7 @@ Implementing patching involves three steps:

### Patching in new code {#using-patched-for-workflow-history-markers}

Using `patched` inserts a marker into the Workflow History.
Using `patched()` inserts a marker into the Workflow History.

![image](https://user-images.githubusercontent.com/6764957/139673361-35d61b38-ab94-401e-ae7b-feaa52eae8c6.png)

Expand Down Expand Up @@ -199,6 +209,172 @@ class MyWorkflow:
)
```


### Detailed Description of the Patched Function

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

<div style={{ position: "relative", paddingBottom: "56.25%", height: 0 }}>
<iframe
src="https://www.youtube.com/embed/videoseries?list=PLytZkHFJwKUdfxFQnuo0Fson0QM0VL9hL"
style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%" }}
frameborder="0"
allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen>
</iframe>
</div>

#### 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](#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](#behavior-when-not-replaying) 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)](#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)](#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)](#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.

<!--SNIPSTART python-patching-example-->

```python
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.
pass
elif patched('v2'):
pass
else:
pass
```
<!--SNIPEND-->

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.

<!--SNIPSTART python-patching-anti-example-->

```python
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
pass
elif patched('v3'):
pass
else:
pass
```

<!--SNIPEND-->


### Best Practice of Using Python Dataclasses as Arguments and Returns

As a side note on the Patching API, its behavior is why Temporal recommends using single dataclasses as arguments and returns from Signals, Queries, Updates, and Activities, rather than using multiple arguments.
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 dataclass and returns a single dataclass, so the method signature can stay constant, and you can do your versioning logic using `patched()` within the method body.

## How to use Worker Versioning in Python {#worker-versioning}

:::caution
Expand Down
Loading