Versioning - TypeScript SDK
The Temporal Platform requires that Workflow code is deterministic. Because of that requirement, the Temporal TypeScript SDK offers two dedicated versioning features.
Alternatives
Before you explore dedicated versioning features, check whether your needs can be addressed in other ways:
Both options mean that Workflows running v1
code will never migrate to v2
code; they will run v1
code to completion.
If you would like to update Workflows running v1
while they are still running, you might need to "patch in" code.
Version Task Queue
If you're currently running v1 Workflow code on Workers that poll on queue1
, you can run v2 Workflow code on Workers that poll on queue2
:
- Leave some Workers running your v1
Workflow
, on thequeue1
Task Queue. - Change your
Workflow
code and spin up new Workers that are polling aqueue2
Task Queue. - Cut over your Clients to only call
Workflow
onqueue2
from now on. - Remove your v1 Workers when all the v1 Workflows have completed.
Version Workflow Name
Although versioning the Task Queue is usually easier, we can also create a new version of a Workflow by copying it and changing its name:
- Copy the
Workflow1
code to aWorkflow2
function and change what you need. - Register
Workflow2
in your Workers alongsideWorkflow1
. - Cut over your Clients to only call
Workflow2
from now on. - Remove
Workflow1
code when none of them are running anymore.
How to patch Workflow code in TypeScript
The TypeScript SDK Patching API lets you change Workflow Definitions without causing non-deterministic behavior in current long-running Workflows.
Do I need to Patch?
You may need to patch if:
- You want to change the remaining logic of a Workflow while it is still running
- If your new logic can result in a different execution path
This added sleep()
can result in a different execution path:
// from v1
export async function yourWorkflow(value: number): Promise<number> {
await runActivity();
return 7;
}
// to v2
export async function yourWorkflow(value: number): Promise<number> {
await sleep('1 day');
await runActivity();
return 7;
}
If v2 is deployed while there's a Workflow on the runActivity
step, when the Activity completes, the Worker will try to replay the Workflow (in order to continue Workflow execution), notice that the sleep command is called and doesn't match with the Workflow's Event History, and throw a non-determinism error.
Adding a Signal Handler for a Signal type that has never been sent before does not need patching:
// from v1
export async function yourWorkflow(value: number): Promise<number> {
await sleep('1 days');
return value;
}
// to v2
const updateValueSignal = defineSignal<[number]>('updateValue');
export async function yourWorkflow(value: number): Promise<number> {
setHandler(updateValueSignal, (newValue) => (value = newValue));
await sleep('1 days');
return value;
}
Migrating Workflows in Patches
Workflow code has to be deterministic by taking the same code path when replaying History Events. Any Workflow code change that affects the order in which commands are generated breaks this assumption.
So we have to keep both the old and new code when migrating Workflows while they are still running:
- When replaying, use the original code version that generated the ongoing event history.
- When executing a new code path, always execute the new code.
30 Min Video: Introduction to Versioning
Because we design for potentially long-running Workflows at scale, versioning with Temporal works differently than with other Workflow systems. We explain more in this optional 30 minute introduction: https://www.youtube.com/watch?v=kkP899WxgzY
TypeScript SDK Patching API
In principle, the TypeScript SDK's patching mechanism works in a similar "feature-flag" fashion to the other SDKs; however, the "versioning" API has been updated to a notion of "patching in" code. There are three steps to this reflecting three stages of migration:
- Running v1 code with vFinal patched in concurrently
- Running vFinal code with deprecation markers for vFinal patches
- Running "just" vFinal code.
This is best explained in sequence (click through to follow along using our SDK sample).
Given an initial Workflow version v1
:
patching-api/src/workflows-v1.ts
// v1
export async function myWorkflow(): Promise<void> {
await activityA();
await sleep('1 days'); // arbitrary long sleep to simulate a long running workflow we need to patch
await activityThatMustRunAfterA();
}
We decide to update our code and run activityB
instead.
This is our desired end state, vFinal
.
patching-api/src/workflows-vFinal.ts
// vFinal
export async function myWorkflow(): Promise<void> {
await activityB();
await sleep('1 days');
}
Problem: We cannot directly deploy vFinal
until we know for sure there are no more running Workflows created using v1
code.
Instead we must deploy v2
(below) and use the patched
function to check which version of the code should be executed.
Patching is a three-step process:
- Patch in new code with
patched
and run it alongside old code - Remove old code and
deprecatePatch
- When you are sure all old Workflows are done executing, remove
deprecatePatch
Overview
Here is a detailed explanation of how the patched()
function behaves.
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.
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
- If the code has a call to patched, and there no marker on or before that spot in the execution, it returns false.
Implications of the Behaviors
If you deploy new code, it will run the new code if it is not replaying, and if it is replaying, it will just do what it did the previous time.
This means that if it has gotten through some of your code, then you stop the worker and deploy new code, then when it replays, it will use the old code throughout the replay, but switch over to new code after it has passed the replay threshold. This means your new code and your old code must work together. For example, if your Workflow Definition originally looked like this:
console.log('original code before the sleep')
await sleep(10000); // <-- Stop the Worker while this is waiting, and deploy the new code below
console.log('original code after the sleep')
Now we stop the Worker during the sleep, and wrap our original
code in the else part of a patched if
statement, and start
our Worker again.
if (patched('my-change-id')) {
console.log('new code before the sleep')
} else {
console.log('original code before the sleep') // this will run
}
await sleep(10000);
if (patched('my-change-id')) {
console.log('new code after the sleep') // this will run
} else {
console.log('original code after the sleep')
}
In the first part, it will be Replaying, and it will run the old code, and after the sleep, it won't be Replaying, and it will run the new code.
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 {}
Step 1: Patch in new code
patched
inserts a marker into the Workflow history.
During replay, when a Worker picks up a history with that marker it will fail the Workflow task when running Workflow code that does not emit the same patch marker (in this case your-change-id
); therefore it is safe to deploy code from v2
in a "feature flag" alongside the original version (v1
).
patching-api/src/workflows-v2.ts
// v2
import { patched } from '@temporalio/workflow';
export async function myWorkflow(): Promise<void> {
if (patched('my-change-id')) {
await activityB();
await sleep('1 days');
} else {
await activityA();
await sleep('1 days');
await activityThatMustRunAfterA();
}
}
Step 2: Deprecate patch
When we know that all Workflows started with v1
code have completed, we can deprecate the patch.
Deprecated patches bridge between v2
and vFinal
(the end result).
They work similarly to regular patches by recording a marker in the Workflow history.
This marker does not fail replay when Workflow code does not emit it.
If while we're deploying v3
(below) there are still live Workers running v2
code and those Workers pick up Workflow histories generated by v3
, they will safely use the patched branch.
patching-api/src/workflows-v3.ts
// v3
import { deprecatePatch } from '@temporalio/workflow';
export async function myWorkflow(): Promise<void> {
deprecatePatch('my-change-id');
await activityB();
await sleep('1 days');
}
Step 3: Solely deploy new code
vFinal
is safe to deploy once all v2
or earlier Workflows are complete due to the assertion mentioned above.
Best Practice of Using TypeScript Objects as Arguments and Returns
As a side note on the Patching API, its behavior is why Temporal recommends using single objects 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 function body.
It is not designed to be used to set different functions or function 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 function signature can stay constant, and you can do your versioning logic using patched()
within the function body.
Upgrading Workflow dependencies
Upgrading Workflow dependencies (such as ones installed into node_modules
) might break determinism in unpredictable ways.
We recommended using a lock file (package-lock.json
or yarn.lock
) to fix Workflow dependency versions and gain control of when they're updated.
How to use Worker Versioning in TypeScript
Worker Versioning is currently in Pre-release.
See the Pre-release README for more information.
A Build ID corresponds to a deployment. If you don't already have one, we recommend a hash of the code--such as a Git SHA--combined with a human-readable timestamp. To use Worker Versioning, you need to pass a Build ID to your Typescript Worker and opt in to Worker Versioning.
Assign a Build ID to your Worker and opt in to Worker Versioning
You should understand assignment rules before completing this step. See the Worker Versioning Pre-release README for more information.
To enable Worker Versioning for your Worker, assign the Build ID--perhaps from an environment variable--and turn it on.
// ...
const worker = await Worker.create({
taskQueue: 'your_task_queue_name',
buildId: buildId,
useVersioning: true,
// ...
});
// ...
Importantly, when you start this Worker, it won't receive any tasks until you set up assignment rules.
Specify versions for Activities, Child Workflows, and Continue-as-New Workflows
Typescript support for this feature is under construction!
By default, Activities, Child Workflows, and Continue-as-New Workflows are run on the build of the Workflow that created them if they are also configured to run on the same Task Queue. When configured to run on a separate task queue, they will default to using the current assignment rules.
If you want to override this behavior, you can specify your intent via the versioningIntent
field available on the options object for each of these commands.
For example, if you want an Activity to use the latest assignment rules rather than inheriting from its parent:
// ...
const { echo } = proxyActivities<typeof activities>({
startToCloseTimeout: '20s',
versioningIntent: 'USE_ASSIGNMENT_RULES',
});
// ...
Tell the Task Queue about your Worker's Build ID (Deprecated)
This section is for a previous Worker Versioning API that is deprecated and will go away at some point. Please redirect your attention to Worker Versioning.
Now you can use the SDK (or the Temporal CLI) to tell the Task Queue about your Worker's Build ID. You might want to do this as part of your CI deployment process.
// ...
await client.taskQueue.updateBuildIdCompatibility('your_task_queue_name', {
operation: 'addNewIdInNewDefaultSet',
buildId: 'deadbeef',
});
This code adds the deadbeef
Build ID to the Task Queue as the sole version in a new version set, which becomes the default for the queue.
New Workflows execute on Workers with this Build ID, and existing ones will continue to process by appropriately compatible Workers.
If, instead, you want to add the Build ID to an existing compatible set, you can do this:
// ...
await client.taskQueue.updateBuildIdCompatibility('your_task_queue_name', {
operation: 'addNewCompatibleVersion',
buildId: 'deadbeef',
existingCompatibleBuildId: 'some-existing-build-id',
});
This code adds deadbeef
to the existing compatible set containing some-existing-build-id
and marks it as the new default Build ID for that set.
You can promote an existing Build ID in a set to be the default for that set:
// ...
await client.taskQueue.updateBuildIdCompatibility('your_task_queue_name', {
operation: 'promoteBuildIdWithinSet',
buildId: 'deadbeef',
});
You can promote an entire set to become the default set for the queue. New Workflows will start using that set's default build.
// ...
await client.taskQueue.updateBuildIdCompatibility('your_task_queue_name', {
operation: 'promoteSetByBuildId',
buildId: 'deadbeef',
});
You can merge two sets into one, preserving the primary set's default Build ID as the default for the merged set.
// ...
await client.taskQueue.updateBuildIdCompatibility('your_task_queue_name', {
operation: 'mergeSets',
primaryBuildId: 'deadbeef',
secondaryBuildId: 'some-existing-build-id',
});