Skip to content

Chained steps

Take a workflow that starts, computes a total, routes on it, and then waits for a person:

start → compute total → gateway → pass-through → approve (a human task)

The first four steps do nothing but read and write the site's database. So the engine chains them: it runs them in one advance, with one queue item, one lock and one transaction. The token stops at the human task, which parks it, and that is the only place it waits.

A step in an advance of its own pays its own queue item, lock and transaction. Chained, a step that does not wait costs about 9 queries and 1.1 ms instead of 15 and 2.2 (MariaDB).

Nothing changes in how you model a workflow. The engine decides, step by step, whether the next one can be chained.

Which steps are chained

A step is chained with the step before it when all of this holds:

  • Its work stays in the database. Its task type, and every variable provider it lists, declare it with DatabaseOnlyInterface. A gateway has no task type and no variables, so it qualifies. The step's own setting can decide otherwise.
  • Its join does not synchronize branches. A step with a wait_all, threshold or quorum join is queued. Queued, the branches reaching it stand on it until the first of them is advanced, and that one fires the join for all of them at once. Chained, each branch would arrive and wait on its own, which costs more.
  • It is the only step the one before it leads to. When a step leads to several (a parallel split, one branch per item), each branch is queued, so the work spreads over the queue workers.
  • The advance has steps left. One advance runs at most 100 steps after its first. Past that, the token is queued and goes on in an advance of its own. In synchronous mode, these steps also count against the request's cap.

These steps are chained out of the box:

Step Why
End, pass-through Their task types do nothing outside the database.
Gateways that do not join branches They have no task type. A gateway that joins branches waits for them, so it is queued.
Expressions, decision tables These variable providers compute values.

Any other step is queued, to run in an advance of its own: an HTTP request, a mail, an action, a human task, a wait, a timer, a subprocess, and any task type that does not declare DatabaseOnlyInterface.

Choosing for one step

Every task step has a How this step runs setting, in the workflow form and in the modeler:

Choice What it does
As its step type decides The default: the rules above.
Allow chaining with previous step The step is chained, whatever its type and its variable providers declare.
Never chain with previous step The step is always queued, whatever its type. The steps after it can still be chained with it.

Example: an action that only updates a node has no way to declare that its work stays in the database, since an action can do anything. Set its step to Allow chaining with previous step and it is chained with the steps around it.

Example: a slow step after several chained steps. If it dies in a way PHP cannot catch, the whole advance runs again, the steps before it included. Set it to Never chain with previous step: the steps before it are committed first, and a crash in it runs only it again.

Allow chaining only for work that shows nowhere when run twice: a process that dies in a way PHP cannot catch runs it again. The other rules still hold: a step whose join synchronizes branches, or a step one of several branches leads to, is queued whatever it says.

When a step fails

Each step of the chain runs in a savepoint of the advance's transaction.

Example: a pass-through and a decision table are chained after start, and the decision table throws.

  • The decision table's step rolls back alone. Start and the pass-through stay done.
  • A failure to retry counts as the step's first attempt. Its token is queued, and its retry waits for the step's backoff: it gets the retries a step queued on its own gets.
  • A failure that ends the step, one not to retry or its last attempt, is handled as soon as the advance commits, as for a step queued on its own: the token goes on with its failure outcome, or the incident is raised, or the instance fails, as the step's failure policy says. The step does not run again.
  • A failure outcome is routed without a chain: the step it leads to is queued. Chained, it could lead back to the failed step, which would fail in that chain and be routed again, without end.
  • The audit records the failed step wrote are dropped with it, so the audit log never shows work that was undone.

A deadlock is the one case that rolls back more: the database undoes the whole transaction, savepoints included. The advance then fails as a whole, and is retried from its first step.

A process that dies in a way PHP cannot catch (memory, a time limit, a killed worker) loses the whole open transaction too. Every step the advance ran runs again on the retry. That is why only steps whose work stays in the database are chained: running them again shows nowhere. A mail sent twice would.

If you change the advance

Chaining holds on four rules. A change to the engine's advance, its failure handling or its inline drain keeps all four, or a chain breaks in ways only a failing step shows.

  1. Every path into an advance passes a step budget. The inline drain caps a request at 1000 advances, and the steps a chain runs count against it. An advance started on the drain's behalf, such as the one routing a failure outcome, takes what is left of that cap. Without it, the steps it chains escape the cap.
  2. Handling a failure never chains into the step that failed. A failure outcome routed after a chained step failed runs with a budget of 0. Chained, it can reach the same step again, fail there, and be routed again, without end.
  3. A step may run inside a savepoint. Rolling one back undoes the database, not PHP's memory. Anything that keeps what a step did in memory, such as an entity cache, a buffer or a memo, is reset or told when the savepoint is rolled back: the engine resets every content entity cache, and records the rollback for audit listeners.
  4. A chained step's failure is settled once, after the commit. A failure to retry is counted, and its queue item waits for the backoff. A failure that ends the step is routed or halted there, and its queue item, with nothing left to do, is deleted.

Marking your own task type

Implement DatabaseOnlyInterface on a task type whose work only reads and writes the site's database:

#[TaskType(
  id: 'shop_reserve_stock',
  label: new TranslatableMarkup('Reserve stock'),
)]
final class ReserveStock extends TaskTypeBase implements DatabaseOnlyInterface {

  public function execute(TokenInterface $token, ProcessInstanceInterface $instance): TaskDecision {
    $this->stock->reserve($this->orderFor($instance));
    return TaskDecision::Advance;
  }

}

A variable provider takes the same interface:

final class DepositProvider extends VariableProviderBase implements DatabaseOnlyInterface {
  // ...
}

Do not mark a plugin that sends a mail, calls a service, writes a file, or does anything else a rollback cannot undo. Left unmarked, its step runs in an advance of its own.

Measuring it

BranchCostTest runs a split whose branches each take three pass-through steps before the join. Its queued_three_steps line in the report shows the cost, and the test fails when a step that should be chained is queued again. See What a token costs.