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 itself. A parked task
that isn't answered in time runs a timeout action; by default it resumes
with a timeout result, which your outgoing flow conditions route on (typically
to an escalation or rejection branch).
Opt in per task¶
A parking node opts in by declaring a node-level timeout map (beside
config, not inside it), with a result_variable in its config to route on:
n_review:
type: user
config:
result_variable: decision
timeout:
duration: P1D # one day
action: resume # the default action
settings:
timeout_result: expired # optional; defaults to __timeout__
anchor: park # park (default), instance, or node
A duration is a number of seconds ('86400') or an ISO-8601 duration
(P1D a day, PT1H an hour, P7D a week); this holds for every duration in
Orchestra config (timeout.duration, the default_timeout, and a timer's
after). The 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).
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 (until)¶
A duration 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, give the timeout an until
naming the variable that holds the deadline, instead of a duration:
n_wait:
type: wait
timeout:
until: arrival_at # a process variable, seeded earlier
until_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). The optional until_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.
until takes precedence over duration and anchor (which describe a relative
window). Like a duration, the deadline is fixed when the task parks, so changing
the variable later does not move it 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.
Global default (safety net)¶
To protect against any task hanging forever, set a site-wide default at
Configuration → Workflow → Orchestra → Settings
(/admin/config/workflow/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). - Default timeout result: the value recorded on timeout (default
__timeout__).
A node's own timeout always overrides the global default.
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):
n_review:
type: user
timeout:
duration: '86400'
action: resume # the default; others are plugins
resume (in core) is the default; it records the timeout result 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 result) stay dormant:
n_review:
type: user
timeout:
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 implementing the TimeoutActionInterface:
#[TimeoutAction(
id: 'my_action',
label: new TranslatableMarkup('My action'),
)]
final class MyAction extends TimeoutActionBase {
public function onTimeout(TokenInterface $token, ProcessInstanceInterface $instance, array $node): void {
// 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.
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 users / roles /
variable 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 plugin escalates 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
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 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
timeout:
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:
result_variable: decision
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_result: expired } }
(after is a number of seconds or an ISO-8601 duration, like every duration in
config.)
How it's modelled: 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 CANCELLED 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. (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:
timers:
- { after: P1D, action: notify, repeat: 0, settings: { notify_tag: reminder } } # every day
- { after: P7D, action: resume, settings: { timeout_result: expired } } # give up at a week
A recurring timer stops the moment the task is answered (its pending alarm is
cancelled 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.
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 resumeWithResult(), so a timeout is
just an ordinary resume: the result lands on the task's result 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.
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. 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.