Versioning - PHP SDK feature guide
A Temporal Workflow Definition must be deterministic. Temporal uses event sourcing to reconstruct Workflow state by replaying saved History Event data with the Workflow Definition code. This means that any incompatible update to the Workflow definition code could cause a non-deterministic issue if not handled correctly.
Introduction to Versioning
To design for potentially long running Workflows at scale, versioning with Temporal works differently than you might expect. Discover more in this optional 30-minute introduction: https://www.youtube.com/watch?v=kkP899WxgzY
How to use the PHP SDK Patching API
The PHP SDK's patching mechanism operates similarly to other SDKs in a "feature-flag" fashion. 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 inpostPatchActivity
. - Running
postPatchActivity
code with deprecation markers forstep-1
patches. - Running only the
postPatchActivity
code.
Let's walk through this process in sequence.
Suppose you have an initial Workflow version called PrePatchActivity
:
#[WorkflowInterface]
class MyWorkflow
{
private $activity;
public function __construct()
{
$this->activity = Workflow::newActivityStub(
YourActivityInterface::class,
ActivityOptions::new()->withScheduleToStartTimeout(60)
);
}
#[WorkflowMethod]
public function runAsync()
{
$result = yield $this->activity->prePatchActivity();
}
}
Now, you want to update your code to run postPatchActivity
instead. This represents your desired end state.
#[WorkflowInterface]
class MyWorkflow
{
// ...
#[WorkflowMethod]
public function runAsync()
{
$result = yield $this->activity->postPatchActivity();
}
}
Problem: You can't 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 Workflow::getVersion() method to determine which version of the code to execute.
#[WorkflowInterface]
class MyWorkflow
{
// ...
#[WorkflowMethod]
public function runAsync()
{
$version = yield Workflow::getVersion('Step 1', Workflow::DEFAULT_VERSION, 1);
$result = $version === Workflow::DEFAULT_VERSION
? yield $this->activity->prePatchActivity()
: yield $this->activity->postPatchActivity();
}
}
When getVersion()
is run for the new Workflow execution, it records a marker in the Workflow history.
All future calls to GetVersion()
for this change Id (Step 1
in the example) on this Workflow execution will always return the given version number.
This is 1
in the example.
The Id passed to getVersion
identifies the change.
Each change is expected to have its own Id.
If a change spawns multiple places in the Workflow code, and the new code should be either executed in all of them or in none of them, then they have to share the Id.
If you make an additional change, such as replacing ActivityC with ActivityD, you need to add some additional code:
#[WorkflowInterface]
class MyWorkflow
{
// ...
#[WorkflowMethod]
public function runAsync()
{
$version = yield Workflow::getVersion('Step 1', Workflow::DEFAULT_VERSION, maxSupported: 2);
$result = match($version) {
Workflow::DEFAULT_VERSION => yield $this->activity->prePatchActivity()
1 => yield $this->activity->postPatchActivity();
2 => yield $this->activity->anotherPatchActivity();
};
}
}
Note that maxSupported
changed from 1 to 2.
A Workflow that had already passed this GetVersion()
call before it was introduced will return DEFAULT_VERSION
.
A Workflow that was run with maxSupported
set to 1, will return 1.
New Workflows will return 2.
After you are sure that all of the Workflow executions prior to version 1 have completed, you can remove the code for that version. It should now look like the following:
#[WorkflowMethod]
public function runAsync()
{
$version = yield Workflow::getVersion('Step 1', minSupported: 1, maxSupported: 2);
$result = match($version) {
1 => yield $this->activity->postPatchActivity();
2 => yield $this->activity->anotherPatchActivity();
};
}
You'll note that minSupported
has changed from DEFAULT_VERSION
to 1
.
If an older version of the Workflow Execution History is replayed on this code, it will fail because the minimum expected version is 1.
After you are sure that all of the Workflow executions for version 1 have completed, you can remove 1 so your code would look like the following:
#[WorkflowMethod]
public function runAsync()
{
$version = yield Workflow::getVersion('Step 1', minSupported: 2, maxSupported: 2);
$result = yield $this->activity->anotherPatchActivity();
}
This preserves the call to GetVersion()
.
There are two reasons to preserve this call:
- This ensures that if there is a Workflow execution still running for an older version, it will fail here and not proceed.
- If you need to make additional changes for
Step 1
, such as changinganotherPatchActivity
toyetAnotherPatchActivity
, you only need to updatemaxVersion
from 2 to 3 and branch from there.
Sanity checking
The Temporal client SDK performs a sanity check to help prevent obvious incompatible changes. The sanity check verifies whether a Command made in replay matches the Event recorded in History, in the same order. The Command is generated by calling any of the following methods:
Workflow::executeActivity()
Workflow::executeChildWorkflow()
Workflow::timer()
Workflow::sideEffect()
Workflow::newActivityStub()
executeWorkflow::newChildWorkflowStub()
start and signalWorkflow::newExternalWorkflowStub()
start and signal
Adding, removing, or reordering any of the preceding methods triggers the sanity check and results in a non-deterministic error.
The sanity check does not perform a thorough check.
For example, it does not check on the Activity's input arguments or the Timer duration.
If the check is enforced on every property, it becomes too restrictive and harder to maintain the Workflow code.
For example, if you move your Activity code from one package to another package, that move changes the ActivityType
, which technically becomes a different Activity.
You don't want to fail on that change, so check only the function name part of the ActivityType
.