Timers¶
A parked task waits indefinitely for its signal: a person to act, an ECA model
to answer, a remote system to call back. If that signal never comes, the
instance is stuck. Because parking is a core capability of the engine, so is its
safety net: timeouts are built into orchestra_engine itself. A parked task
that isn't answered in time runs a timeout action; by default it resumes with
a timeout outcome, which your outgoing flow conditions route on (typically to
an escalation or rejection branch).
A join waits too: for the branches of its split, one of which may never arrive. It has a timeout of its own, described in Timeouts at a join.
Opt in per task¶
A parking node opts in by declaring a timeout map under the node's
features key (beside config, not inside it), with a payload_variable in
its config to route on:
n_review:
type: user
config:
payload_variable: decision
features:
timeout:
deadline: relative # which provider decides the moment
deadline_settings:
duration: P1D # one day
anchor: park # park (default), instance, or node
action: resume # the default action
settings:
timeout_outcome: expired # optional; defaults to __timeout__
A timeout names two plugins: a deadline provider, which says when the wait
runs out, and an action, which says what to do once it has. Each carries its
own settings, so the fields under deadline_settings are the provider's and
those under settings are the action's.
Orchestra bundles two providers. relative waits for a duration: a number
of seconds ('86400') or an ISO-8601 duration (P1D a day, PT1H an
hour, P7D a week), or the name of a process variable holding one, falling back
to the site-wide default_timeout when empty. Its anchor sets when the
window starts: park (the default, restarted each time the task parks),
instance (a budget from when the process started) or node (a per-step budget
that spans re-entries and retries). absolute waits until a date, described
below.
After the deadline the task resumes with decision = expired (or __timeout__
by default), and a flow condition such as decision == expired sends it down
the timeout branch. Nodes without a timeout wait as long as they always have.
This works for any parking task: wait, user, eca_event.
Wait until a date (absolute)¶
The relative provider measures a window from a starting point. Some processes
instead need to wait until an absolute moment that is only known at runtime,
held in a process variable: "authorize the deposit a fixed lead before arrival",
"remind two days before the slot". For that, name the absolute provider:
n_wait:
type: wait
features:
timeout:
deadline: absolute
deadline_settings:
until: arrival_at # a process variable, seeded earlier
offset: '-P2D' # optional: two days *before* that date
action: resume
The variable holds the deadline as a Unix timestamp or an ISO-8601
datetime string (parsed as UTC); a literal date can be written there directly
instead. The optional offset is a signed duration that shifts it: a
leading - waits before the date (-P2D is two days before), a plain or +
duration waits after (PT1H, an hour after). So the workflow is simply Wait
(until that date) then its next step, resumed by Orchestra's own timer cron
exactly as a duration timeout is.
A date read from a variable is only as current as the last step that wrote it, and it is read when the task parks, so changing the variable later does not move an already-parked deadline unless the node re-parks. A value already in the past resumes on the next cron sweep; a missing or unreadable value parks the task with no deadline (and logs a warning), so a misconfigured date never silently fires.
A deadline that belongs to something else¶
Both bundled providers answer from what the model carries. When the deadline belongs to a live subject the run does not own - a basket window, a hold, a booking someone can extend - a written value is the wrong shape: it is true when it is written and drifts from then on, so the model ends up carrying a step whose only job is to keep a number current.
A module ships a provider instead, and the node names it like any other:
n_wait:
type: wait
features:
timeout:
deadline: my_subject_deadline
action: my_timeout_action
It is asked when the task parks, so it reports the subject as it stands rather
than as it stood. It is also asked again when a timeout action leaves the
task parked, so a subject that moved its own deadline is waited on afresh. A
provider says whether that re-asking means anything: a relative window
measured from each park recurs, a budget does not, and an absolute date never
does, because asking again would answer the same past moment and fire on every
sweep. Returning nothing is a real answer too, and parks the task with no
deadline, which is how a subject says it does not end by running out of time.
Global default (safety net)¶
To protect against any task hanging forever, set a site-wide default at
Configuration → Workflow → Orchestra → Settings
(/admin/orchestra/settings):
- Default timeout: a parked task times out after this long unless its node
sets its own
timeout. A number of seconds or an ISO-8601 duration (e.g.P7D); leave empty to disable the safety net (the default), which is also what an unusable value would mean, so the form refuses one. - Default timeout outcome: the value recorded on timeout (default
__timeout__).
A node's own timeout always overrides the global default. A timeout is never
implied, though: the editor stores nothing for a timeout section left as it
is, and refuses one given an action but no deadline of its own. The global
default is for the tasks with no timeout at all, never for a join.
Timeout actions¶
What happens on timeout is a plugin, so a node can do more than resume. A
node picks one with the timeout map's action key (default resume).
An action that resumes the task with an outcome of its own declares it, and
the editor says so under the action select, so the outgoing flow can be written
against the value the action really announces rather than a guessed one. An
action that leaves the task parked declares nothing, and neither does resume,
whose outcome is the timeout_outcome you set:
n_review:
type: user
features:
timeout:
deadline: relative
deadline_settings: { duration: '86400' }
action: resume # the default; others are plugins
resume (in core) is the default; it records the timeout outcome and resumes
the task. The core set also includes notify (announce a TaskTimedOutEvent
and keep waiting, a recurring reminder) and spawn (below). An action that
leaves the task parked (a reminder, a forked branch, a released claim) is
automatically re-armed for another timeout window by the sweep, so it does not
fire again on the very next cron run.
spawn: escalate alongside (non-interrupting)¶
resume and spawn are siblings that route the same way (the node's live
outgoing flows + split); the only difference is which token moves:
resume: the task token moves on, so the wait ends.spawn: the task stays parked and new tokens are forked down the flows, so a branch runs in parallel while the task keeps waiting.
spawn exposes a routing variable/value so an escalation flow can select
the branch, while the node's normal-completion flows (keyed off the task's own,
still-unset payload) stay dormant:
n_review:
type: user
features:
timeout:
deadline: relative
deadline_settings: { duration: '259200' }
action: spawn
settings:
variable: escalation
value: manager_alert
# n_review --[ escalation == manager_alert ]--> n_alert_manager (forked; task keeps waiting)
# n_review --[ decision == approved ]---------> n_approved (normal exit)
Add one by extending TimeoutActionBase, and say which waits it acts on:
TaskTimeoutActionInterface for a task's, JoinTimeoutActionInterface for a
join's, or both. An action declaring neither is offered nowhere, and the sweep
refuses to run it.
#[TimeoutAction(
id: 'my_action',
label: new TranslatableMarkup('My action'),
)]
final class MyAction extends TimeoutActionBase implements TaskTimeoutActionInterface {
public function onTimeout(TokenInterface $token, ProcessInstanceInterface $instance, WorkflowNode $node): void {
// $node is the step the token is standing on: $node->getConfig() carries
// the firing timeout's own settings, and $node->getFeature('timers') the
// ladder. Resume, re-park, reassign, cancel...
}
}
Because the plugin type lives in core, a submodule contributes a behavior that
reaches into its own domain without any of them depending on each other. For
example orchestra_inbox ships unclaim: on timeout it releases a
claimed-but-idle task back to its pool (only from the claimed state: a task
already being processed is left alone), so a reserved hold that goes stale frees
up for someone else. See Human tasks.
Ending a lapsed payment window (payment_lapsed)¶
orchestra_payment ships payment_lapsed, for a payment step's backstop.
A payer's real clock is the payment's own, cut short so it lands before the
node's deadline, so ordinarily the payment resolves, the gateway layer announces
it, and the step is resumed by its own outcome. The node's timeout is only the
backstop for when that did not happen.
That backstop is the one road that announces nothing, and it matters because a
consumer typically brackets the round-trip: it holds whatever it must protect
when the handoff is announced and releases it on an outcome that took no money.
Fire the backstop and no payment event exists, so the release never runs, and
drawing a release step on an outgoing edge to compensate would put half a
bracket on the diagram with the other half invisible. payment_lapsed makes the
payment lapse for real instead, through the same expire() the reaper calls,
so the release happens on the payment's own event and the step routes down its
ordinary expired flow:
n_payment:
type: wait
config: { payload_variable: payment_outcome }
features:
timeout:
deadline: relative
deadline_settings: { duration: PT30M } # a backstop, longer than the payer's own window
action: payment_lapsed
What it will not do is claim that no money moved. For a payment the provider has already seen, only the provider can say so - the payer may have paid with both the browser return and the webhook lost - so that payment is left alone and the token left parked: its deadline has already passed, so the next reaper run asks, and either answer resumes the token. A run that does not yet know must not decide. What it does end are the two cases nothing else will: a payment that never reached a provider (no money can have moved, so a local expiry is a true statement), and a step whose payer never asked to pay, which is resumed directly because there is no payment to announce anything and an instance- or node-anchored duration is a one-shot the sweep does not re-arm.
Escalating to a person (reassign)¶
orchestra_inbox also ships reassign: on timeout it hands the task to
a new audience: the timer-driven half of the inbox's manual Reassign. The
audience is chosen with an assignment plugin, the same plugins that set a
task's initial audience (see Task assignment). If
it resolves to a single user, the task is assigned to them (claimed); a pool
(a role, several users) is re-offered: its candidates are replaced and it
returns to the pool to claim. The variable plugins escalate to whoever a process
variable names, resolved against the token's lineage. It does not resume the
task: the task stays parked, so a recurring reminder keeps running until it is
handled.
Paired with notify in a timers ladder, this is the turnkey "remind the
assignee, then escalate to a manager" pattern, with no custom code:
n_review:
type: user
features:
timers:
- after: P1D # remind daily
action: notify
repeat: 0
settings: { notify_tag: assignee_reminder }
- after: P3D # escalate at 3 days
action: reassign
settings:
assignment:
plugin: users
settings: { users: [team_lead] }
Escalating to a pool instead is the same shape with a roles (or
roles_variable) assignment, e.g.
assignment: { plugin: roles, settings: { roles: [manager] } }.
Notifying on timeout¶
The built-in notify action turns a timeout into a reminder. Instead of
resuming, it dispatches a channel-neutral
TaskTimedOutEvent and leaves the task
parked, so the sweep re-arms it and the reminder recurs every timeout window
until the task is handled:
n_review:
type: user
features:
timeout:
deadline: relative
deadline_settings: { duration: '86400' } # remind a day after it parks
action: notify
settings:
notify_tag: assignee_reminder
notify_message: 'Your review is overdue.'
The engine never sends the notification itself; it only announces the event,
carrying the task's IDs plus the timeout's notify_tag and notify_message. A
listener decides what "notify" means. The optional
orchestra_mail submodule turns it into a reminder email to
the task's audience out of the box; or (with the orchestra_eca submodule) an
ECA model triggered by the orchestra_task_timed_out custom event can send an
email, post to Slack, or log, branching on the tag; or a plain EventSubscriber
can do anything at all.
Staggered escalation¶
A single timeout does one thing at one deadline. For a schedule ("remind
every day, alert the manager at 3, give up at 7"), a node declares timers
instead: a list of {after, action, repeat, settings} stages, each running a
timeout action at its own offset.
n_review:
type: user
config:
payload_variable: decision
features:
timers:
- { after: P1D, action: notify, repeat: 0, settings: { notify_tag: assignee_reminder } }
- { after: P3D, action: spawn, settings: { variable: escalation, value: manager } }
- { after: P7D, action: resume, settings: { timeout_outcome: expired } }
(after is a number of seconds or an ISO-8601 duration, like every duration in
config. The editor refuses one it cannot measure with: an unusable offset leaves
the timer token with no deadline, and the sweep never matches a token that has
none, so the timer would simply never fire. One that reaches the engine anyway,
by configuration import or by an edit made while the alarm was already armed, is
reported in the log naming the node and the entry - see
Troubleshooting.)
How it's modeled: each timer is a token. When the task parks, the engine spawns one timer token per entry (a child of the task, tagged with its index). Each is an ordinary single-deadline token, so the same cron sweep fires each at its offset; staggering falls out for free, with no hidden "which stage" counter; the pending/fired timers are visible as tokens. A timer fires once then is consumed, unless it is recurring (below).
Lifecycle: a timer token is PARKED from when the task parks until its
offset, then CONSUMED when it fires, or CANCELED if the task is
answered first. Cancellation is automatic: resuming the task cancels its pending
timers (so the manager isn't pinged after the review is done), and a spawn
branch that already fired keeps running independently. Halting the step into an
incident cancels them too, since it cancels the task they are
about: a retry parks the step again and arms its timers afresh, once. (See the
model walkthrough mirrored on Camunda's timer-jobs + boundary cleanup.)
Each stage's action is any timeout action: notify (remind, keep waiting),
spawn (escalate alongside, keep waiting), reassign (escalate to a person,
keep waiting), resume/unclaim (move or release the task). A node uses
either a single timeout or timers, not both.
Recurring timers (repeat)¶
A stage is one-shot by default. Give it a repeat count and it becomes
recurring: it re-arms for another after interval after each fire, instead of
being consumed:
- omitted or
repeat: 1: fire once (the default). repeat: 3: fire three times, oneafterapart, then stop.repeat: 0: fire until the task is answered (no limit).
This is how you remind repeatedly and still escalate: a recurring notify
alongside one-shot spawn/resume stages, as in the schedule above (remind
every day, alert at 3, give up at 7). The simplest case needs only one stage,
nagging every day until the task is done, then giving up after a week:
features:
timers:
- { after: P1D, action: notify, repeat: 0, settings: { notify_tag: reminder } } # every day
- { after: P7D, action: resume, settings: { timeout_outcome: expired } } # give up at a week
A recurring timer stops the moment the task is answered (its pending alarm is
canceled with the rest), or when a sibling stage ends the wait, so the daily
reminder above goes quiet as soon as the resume fires at a week.
Timeouts at a join¶
Three reviewers are asked for their opinion, and a join gathers the answers.
If one reviewer never answers, the join waits for ever. Give its node a
join_timeout and it gives up after a week, going on with the answers it
has:
n_gather:
type: passthrough
join:
plugin: matching
features:
join_timeout:
deadline: relative
deadline_settings:
duration: P7D
action: fire_join # the default action
settings:
variable: gathered # optional: written on the token going on
timeout_outcome: late # optional; defaults to __timeout__
- When it starts. The deadline is armed when the first branch of a pass arrives at the join. Each pass through the split has its own. The other anchors and the other providers work as they do on a task.
fire_joinfires the join with the branches that arrived, as if the last one had come. What becomes of the branches still on their way is the join's When it fires setting: canceled by default.- The outcome. With
variableset, the token going on gets the timeout outcome in it, so an outgoing flow condition such asgathered == latetells a fire on timeout from a fire on the last arrival. To know how many answered, have the join count its arrivals. - Each join keeps its own rule.
wait_allandmatchinggo on with what arrived. Athresholdshort of its count goes on with fewer branches. Aquorumsettles as not reached, which itsquorum_reachedvariable says. halt_joinstops the join on an incident instead, for an operator to fire it with the branches that arrived or cancel the instance. Use it where going on with fewer branches needs a person's say.notifyannounces the timeout and keeps the join waiting, as on a task.
A join's deadline is stamped when its pass starts waiting. If the join's timeout is removed afterwards (by an edit, or by migrating the instance to a version without it), that deadline fires nothing when it comes: the join waits as it now says.
A join has a single timeout, not a ladder of timers. The people a reminder is
for are the assignees of the branches still running, and each of their tasks
has its own timers. A recurring notify on the join, which re-arms each
window, is for a supervisor watching the join as a whole.
A node that waits twice¶
A node with both a join and a task that parks waits twice: its join waits for the branches, then its task waits for a person. Each wait has its own timeout, so both can be set at once:
n_validate:
type: user
join:
plugin: matching
config:
payload_variable: decision
features:
join_timeout: # give up on missing reviews after a week
deadline_settings: { duration: P7D }
timers: # then remind the manager every day
- { after: P1D, action: notify, repeat: 0 }
The join's actions (fire_join, halt_join, notify) are only offered on
the join timeout, and the task's (resume, reassign, unclaim, spawn)
on the task timeout and timers. A timeout action says which waits it can act on by implementing
TaskTimeoutActionInterface, JoinTimeoutActionInterface, or both.
How it works¶
On each cron run, a sweep finds parked tokens whose deadline (parked-since +
timeout) has passed and hands each to its node's timeout action. The default
resume action resumes via the engine's resumeWithPayload(), so a timeout is
just an ordinary resume: the outcome lands on the task's payload variable like
any other answer.
Because the sweep runs on cron, a task times out on the first cron run after its deadline, not at the exact second, so set timeouts comfortably larger than your cron interval.
A due token the sweep cannot fire right now, because its workflow or pinned version is not currently available (a mid-edit, an in-progress config import) or its node is no longer in the definition, is deferred: its deadline is pushed one hour forward and the sweep logs a warning ("Timeout sweep deferred due token ..."). Deferring keeps a batch head from starving the tokens behind it while still retrying on a throttled schedule; the timeout then fires up to an hour after the gap heals rather than on the very next cron run. This also applies to a token waiting at a join whose definition is unavailable, so the join's timeout moves out by the same window.
Firing a timer early¶
A timer normally fires when its deadline passes on the cron sweep. An operator
can also fire it now, before the deadline: on the instance page, a parked
task carrying a timeout (or an armed timer rung) shows a Fire timer now
action that runs its timeout/timer action immediately, in the request. A token
waiting at a join with a join_timeout shows Fire join timer now, which
fires the join's timer the same way. This
runs the same action the sweep would (resume, notify, escalate), so firing
early runs the action early; it does not skip it. It reuses the sweep's
transactional, exactly-once fire path, so it is safe even alongside a cron run.
The engine exposes the same operation programmatically.