Changing a workflow that is already running#

A workflow (the PHP class that describes an execution’s steps; see the glossary) that runs for weeks outlives the deployment that started it. This guide shows what happens when you deploy a change while executions (durable runs of a workflow) are still in flight, and how to handle it.

How a running execution meets new code#

A workflow does not resume where it stopped: it replays from the start on every task, and each step it schedules is matched to the journal (the recorded history of the execution’s steps and their results) by position. Step 3 is the third activity (a call with a side effect) this workflow scheduled, not the third call to that particular activity.

Inserting one call ahead of another therefore shifts everything after it, and position 3 in the code no longer matches position 3 in the journal.

// The code that started the run
$this->await($this->activities->chargeCard($order));   // position 0
$this->await($this->activities->shipOrder($order));    // position 1

// The code you just deployed
$this->await($this->activities->reserveStock($order)); // position 0  ← inserted
$this->await($this->activities->chargeCard($order));   // position 1
$this->await($this->activities->shipOrder($order));    // position 2

The run that is in flight recorded chargeCard at position 0. The new code asks for reserveStock there.

The divergence error#

The execution stops on that task, and the failure names both sides:

Replay divergence at activity slot 0 of execution "order-8f21c3":
history recorded "chargeCard", code scheduled "reserveStock".
This history was written by a different version of the workflow.

On the Temporal backend this fails the workflow task, not the execution. The run stays alive, its history is untouched, and the server retries the task. In the Temporal UI it shows as a workflow task failure, and the execution is still Running.

Every step that carries an identity is checked this way: activities by name, Nexus operations by their endpoint/service/operation triple, child workflows by type.

What to do about it#

Revert the deployment#

The failure means the deployment does not fit the runs it landed on. Put the previous version back, and the next retry replays cleanly, because only the task failed: the run continues exactly where it was, and loses nothing but the time between the two deployments.

Or declare a change point#

When the change fits in a branch, declare a change point (a named fork in workflow code; see the glossary) and let each execution keep the behaviour it started on:

use Gplanchat\Durable\Versioning\ChangePoint;

$version = $this->environment->version('add-discount', minSupported: ChangePoint::DEFAULT_VERSION, maxSupported: 1);

if (ChangePoint::DEFAULT_VERSION === $version) {
    $total = $this->await($this->billing->totalWithoutDiscount($cart));   // runs already in flight
} else {
    $total = $this->await($this->billing->totalWithDiscount($cart));      // everything from now on
}

The answer is fixed the first time an execution reaches that point and read back from its journal afterwards. You can deploy further changes: a run already past the point keeps its behaviour.

Before you use a change point, note the following:

  • The change id lives in the journal. Renaming it later makes every in-flight execution look like it never reached the point. Pick a name you will keep.
  • A run that passed this place before the point existed gets DEFAULT_VERSION. It started on the old behaviour, it finishes on the old behaviour. Nothing is written for it; it is recognised, not marked.
  • Recognising it requires recorded work after the point. “Passed this place” is deduced from the activity, timer, child, Nexus operation or side effect the journal still holds beyond the point. A run that passed it and is now waiting only on a condition (a signal or an update) has none: a point inserted before that wait gives it the new version. Put the point after the wait, or before recorded work.
  • The divergence check still applies everywhere else. An undeclared change three lines below a declared change point still stops the run.

Delete the old branch#

You can delete the branch once no live execution can still resolve to it. On the Temporal backend, the server answers that question, because each marker is accompanied by a standard search attribute:

temporal workflow list --query 'TemporalChangeVersion = "add-discount-1"'

An empty answer means nobody is on version 1 any more, and the DEFAULT_VERSION branch can go.

When a branch goes, raise the minimum with it: version('add-discount', 1, 1) once the DEFAULT_VERSION branch is deleted. An execution still on a version outside that range then fails with a WorkflowTaskFailure that names the change point, its version and the range, instead of silently taking the branch that remains. On Temporal the task fails and the run waits for code that supports it. On the journal backends the execution ends, as a divergence does (see below).

On the journal backends (In-Memory, DBAL and Illuminate) there is no search attribute and no equivalent answer. There, you know that a branch is dead only by knowing your own executions. In practice, keep the branch until you are certain, or use the workflow-type rename below, whose drain window is visible.

Or register the workflow under a new type name#

When you cannot wait for runs to drain, register the changed workflow under a new type name and keep the old class registered until the old executions finish:

#[AsWorkflow('checkout')]      // keep, until the last old run ends
final class CheckoutWorkflow { … }

#[AsWorkflow('checkout-v2')]   // new starts go here
final class CheckoutV2Workflow { … }

Executions resolve their handler by the type recorded when they started, so runs already in flight never see the new class. New runs start on checkout-v2.

This costs two classes and a drain window. Use it when the change is too large to express as a branch, such as a different set of activities and an entirely different shape, where a change point would only make one workflow carry two workflows.

What is not checked#

Timers. A timer records an absolute due date, not the delay that produced it, and its label is optional, so nothing in the journal identifies which timer a position holds. Changing only timer durations therefore replays without being reported.

A shift escapes the check only if it touches timers alone. As soon as an activity moves with it, the check on the activity’s name detects it.

On the backends without workflow tasks#

The journal backends (In-Memory, DBAL and Illuminate) record a workflow task as three events, but a task there has no server to fail it and retry it. There, a divergence ends the execution instead of resolving the wrong recorded value in silence, and reverting does not bring the run back.


Two decision records cover this page: DUR042 for why the check rests only on what the journal already records, and DUR044 for change points, including why an execution older than the point is recognised rather than marked, and why the search attribute is part of the primitive rather than an extra.