Architecture¶
The kernel¶
The orchestra_engine module is a self-contained engine with no dependencies
beyond Drupal core and the orchestra contract. It provides:
- Entities: three config entities,
Workflow(a model, shared or tenant-scoped),TenantandStatus(a tenant-scoped vocabulary naming where a run stands for its initiator); plus six runtime content entities,ProcessInstance,Token,Variable,WorkItem(the per-user item of work a token parked on ahumannode creates),WorkflowVersion(an immutable snapshot a running instance is pinned to) andIncident(a recorded execution failure), each carrying atenant. - Plugin types: the engine's extension points, each with its own attribute
and manager:
TaskType(what a node does),FlowCondition(is a flow live?),Split(which live flows a node takes),Join(when a node fires on its incoming branches),Gateway(a routing node expressed as a(join, split)preset),NodeFeature(opt-in behavior added to a node, such as external interaction, notify-on-arrival or a payload variable),VariableProvider(a value avariablesnode computes from the run itself),DeadlineProvider(when a parked task's wait runs out),TimeoutAction(what a task's timer does when it fires) andAudience(who a task or a notification reaches). Routing (exclusive, parallel, inclusive) is composed from conditions, a split and a join rather than hard-coded. - The engine:
WorkflowEngine(orchestra_engine.engine), the only stateful actor:start(),advance(),signal(),resumeWithPayload(),spawn()andcancel(), plus the incident operations (retryIncident(),resumeIncident(),skipIncident(),cancelIncident(),failFromIncident()) that recover a failed step. See Incidents. Internally the engine is a thin facade:WorkflowExecutorruns the advance loop,IncidentManagercarries the operator incident actions andInstanceRecoverythe cron recovery sweeps, all backed by small leaf services (token lineage, variable resolution, deadline math, definition resolution, the incident store). Depend onWorkflowEngineInterface; the split is an internal detail. - The queue worker:
orchestra_advanceadvances queued tokens on cron.
The branch lineage: what joins count¶
A join counts branches, not tokens. It learns which branches a token belongs to from the token's branch lineage. The lineage has one entry for each split the token is inside. Each entry says:
- which split: the node the split is on;
- which pass through that split: the ID of the token that left it;
- which branch: its name.
The branches a pass sent are not repeated in every entry, so an entry stays short whatever the number of branches. They are the tokens the split created, all children of the token that left it, and a join reads their names from them.
A branch is named after the node its flow goes to. When two flows of a split go
to the same node, the second branch gets ~1 added, the third ~2, and so on.
The order of the flows does not change a name, and neither do their IDs. This
matters because an editor may reorder flows, and the modeler gives a flow
without a condition a new ID each time it saves. Neither of them moves a flow to
another node.
Code that creates tokens, moves them, or changes a model under a running instance has to keep four rules. If one is broken, a join waits for a branch that will never come, or fires without one that will.
Every token is created in one place. That place is
WorkflowExecutor::saveNewToken(), and the lineage is given there:
- a token created by a split starts a new branch;
- the token a join sends on stands for all the branches it brought together;
- any other token copies the lineage of its parent.
A unit test fails if shipped code creates a token anywhere else. A new way to create tokens must call that method.
The lineage can always be worked out again. BranchLineageBuilder rebuilds
every token's lineage from what the tokens store (parent, node, flow, scope)
and the workflow they run in. One thing cannot be told from those alone: a
join's own token and a branch arriving at the join can share their parent,
node and flow. A token's stored lineage tells it: the join's token ends with
"@" and the join, so the builder reads it there.
The lineage the engine writes and the one the builder rebuilds must be the
same. After each kernel test that installs the engine's tables, the two are
compared. So a change that makes them differ fails in the test that shows it.
A migration moves every token, finished ones included. A migration can
rename nodes, and so rename the branches named after them. It rebuilds every
token's lineage in the new version. It then moves the live tokens, and
BranchLineageBuilder::moveFinishedTokens() moves the finished ones: their
node, their flow and their lineage. The finished tokens matter for two reasons:
- When a join fires, the token it sends on copies the lineage of the token that split the branches. That token finished long before.
- A later migration rebuilds the lineages from the tokens' nodes. Those nodes must belong to the version the instance runs in.
Two flows of a split can go to the same step: their branches are n_a and
n_a~1, told apart by the flow each token came down. So a migration gives a
token the flow at the same position among the new version's flows between the
same two nodes, whatever their IDs. When the new version would give two
branches of one pass the same name, or cannot name a branch at all, and a join
counts that split's branches, the part stays on its version.
A join also records each pass it has fired, under a name made of the join node
and the split node. A wait_all join records, the same way, a path of a pass
that will not deliver. The migration renames these records too
(JoinCoordinator::renameJoinRecords()). Otherwise a branch arriving after the
fire would fire the join a second time.
With versioning off, an instance reads the live workflow, and saving an edit rewrites no lineage. An edit that renames or removes a node a branch is named after, or moves a split, can then leave a join unable to count a running branch. That is why versioning off is only for workflows nobody edits while instances of them run.
Submodules¶
Everything optional lives in a submodule, so the kernel stays small and you pay only for what you enable. These ship today:
| Module | Responsibility |
|---|---|
orchestra |
The OrchestraClientInterface contract and what both ends of the wire share. Every site holds it, as a dependency of the engine or of the client, and it is hidden from the Extend page. See Distributed execution. |
orchestra_engine |
Engine kernel: entities, plugin types, engine service, queue worker, task timeouts. See Timers. |
orchestra_inbox |
Human tasks: assign, claim and complete parked work from an inbox. See Human tasks. |
orchestra_inbox_views |
Expose work items to Views: the personal task lists, the tenant task overview and the actions that open and complete a task. See Views. |
orchestra_delegation |
Absence cover: for a period, one user acts on another's tasks. See Delegation. |
orchestra_interaction |
External-party interactive waits: a public, capability-token-gated dispatcher that lets a non-logged-in party act on a parked step. See External interaction. |
orchestra_interaction_webform |
Start a workflow from a Webform submission, or collect one during an interactive wait and resume on submit. See External interaction. |
orchestra_interaction_task |
Run an interaction plugin as a logged-in operator's inbox task (the task doorway), so the same interaction serves a public link or an in-site task. See External interaction. |
orchestra_interaction_operation |
Run an interaction plugin as a pull-based operation instead: the work item is stamped operation mode, so it is acted on from the assignee's pending-actions list under their own identity rather than pushed to the inbox. See Human tasks. |
orchestra_content |
Bind a process to a content entity and edit it as the work of a step. See Content. |
orchestra_content_moderation |
Drive a content entity's moderation state from a process (a state-transition task). |
orchestra_content_eca |
ECA glue: expose a process's attached entity to ECA, so a reacting model can act on it. |
orchestra_action |
Run a Drupal Action plugin as automated work in a process, on the entity a variable names. See Actions. |
orchestra_content_action |
Run a Drupal Action plugin on the content a process is attached to. See Actions. |
orchestra_ui |
Browser UI to start, observe and manage instances. |
orchestra_modeler |
Author workflows visually through the Modeler API (BPMN.io). |
orchestra_cm |
Author workflows in accessible Drupal forms, without a diagram canvas. |
orchestra_bpmn_io |
Adapt BPMN.io to Orchestra: preserve the diagram layout across modeler switches and restrict the editor to shapes Orchestra can model. |
orchestra_eca |
ECA integration: start processes from events, emit events from tasks. See Integrations. |
orchestra_server |
OAuth-gated HTTP API exposing the client contract to remote consumers, served by the engine's in-process LocalOrchestraClient. Only a site that lets other sites call its engine installs it. |
orchestra_client |
RemoteOrchestraClient: drives a remote Orchestra over HTTP, and is the site's client wherever it is installed, ahead of the engine's own. |
orchestra_presentation |
The markup and styles the surfaces share: the status tag, the lifecycle marker, the labeled column and the card row they lay out in, each a theme hook a theme overrides once for every surface, plus their component CSS. Depends on nothing but the engine, so a site with the Views integration and no UI still styles its rows. |
orchestra_views |
Expose processes, tokens, variables and tasks to Views, with readable labels, a tenant filter and ready-made dashboards. See Views. |
orchestra_vbo |
Bulk actions on processes and tokens (cancel, delete, signal) from a dashboard, via Views Bulk Operations. |
orchestra_vbo_inbox |
Bulk actions on the task inbox (claim, complete, reassign) via Views Bulk Operations. |
orchestra_audit_trail |
Record process transitions into the Audit Trail chain: a durable, tamper-evident log. See Audit. |
orchestra_mail |
Default email channel: turns each notification event into one email per recipient. A dumb channel, it decides neither who nor when. See Notification delivery. |
orchestra_easy_email |
Deliver notifications through Easy Email templates instead, an alternative to Orchestra Mail. See Notification delivery. |
orchestra_domain |
Resolve the active tenant from the current domain, binding each domain to a tenant. See Multi-tenancy. |
orchestra_payment |
Take a payment as a workflow step: a payment interaction node and a settlement subscriber, backed by the Kessai payment engine. Experimental. See Payment. |
orchestra_examples |
Ready-to-run example workflows. See Integrations. |
orchestra_interaction_webform_examples |
A ready-to-run submission-validation workflow (submit, review, request changes, modify, process) built on the Webform interaction. |
orchestra_payment_example |
A ready-to-run workflow that takes a payment through the payment submodule and the Kessai simulator gateway. Experimental. |
Extending Orchestra¶
Add a node behavior by implementing a TaskType plugin:
#[TaskType(
id: 'my_task',
label: new TranslatableMarkup('My task'),
)]
final class MyTask extends TaskTypeBase {
public function execute(TokenInterface $token, ProcessInstanceInterface $instance): TaskDecision {
// Do work, then advance...
return TaskDecision::Advance;
// ...or park to wait for an external signal.
// return TaskDecision::Park;
}
}
A plugin that returns TaskDecision::Park is resumed by calling
WorkflowEngine::signal() on its token, which is exactly how the
orchestra_inbox submodule turns a parked wait into a human task, and how
the core timeout sweep resumes a task whose deadline has passed (see
Timers).
Routing is extended the same way. Implement a FlowCondition (e.g. one that
checks the weather or a user's role), a Split (e.g. weighted or random), or a
Join (e.g. a threshold or a quorum): each is a plugin with its attribute and
manager, dropped in without touching the engine.
Resuming a parked task from anywhere¶
Code that holds the engine service resumes a task directly with
WorkflowEngine::resumeWithPayload($token, $payload), writing the payload to
the task's payload variable (if it declares one, so outgoing flows can route on
it) and signals the token.
A node declares that variable in its node settings: a Payload variable name
and a Completion scope (instance-wide or local to the branch). The plain
wait primitive exposes this pair too, not just the higher task types, so a
bare wait resumed by a signal or a timeout action can still route what it was
resumed with. The two fields are defined once in CompletionConfigTrait and
reused across every node type that writes a completion. With no payload variable
a resume just continues with no routing signal (a timeout, for instance, has
nowhere to write its outcome), so give a wait a payload variable whenever its
outgoing flows need to tell a timeout from a normal resume.
Both that and signal() return whether this call claimed the parked token.
Only one caller can: the claim is a guarded flip, so a second one, a re-queued
timeout or a double-clicked link, is a no-op that answers FALSE. Gate anything
a completion does besides the resume itself on that answer, or a lost race
fires it twice for one completion.
When the resumer does not hold the engine (and to keep it
integration-neutral), dispatch the ResumeTokenEvent instead:
$event = new \Drupal\orchestra_engine\Event\ResumeTokenEvent($token_id, $payload);
$dispatcher->dispatch($event);
// $event->resumed is TRUE if a parked token was found and resumed.
A core subscriber catches it and resumes the task through the engine. Callers depend only on the event dispatcher; a token that is unknown, not parked, a step's timer, or in another tenant is a harmless no-op. The tenant is checked by the subscriber rather than left to each caller, because the event carries an opaque id, often one taken from a request, and nothing about an id says which tenant it belongs to.
That tenant is the request's, so the event suits code answering a request, such
as an HTTP controller. On cron, in the queue or on the command line no domain
matches and the request's tenant is the default one, so a dispatcher there
reaches only the default tenant's tokens. Code working in the background
resumes through the engine instead, resumeWithPayload() above, as the ECA
resume action does.
Canceling tokens¶
WorkflowEngine::cancel($token) ends part of a process before it completes on
its own. The token and every still-live token descending from it move to the
terminal CANCELED state, distinct from CONSUMED, which marks a token that
did its job and moved on, so "killed early" stays visible in the instance and
the UI. Canceling cascades down the lineage (canceling a branch root kills its
whole subtree) and then completes the instance if nothing else is live; an
already-terminal token is a no-op.
A cancellation can meet the token's own step, an operator canceling a branch while its task runs, or a threshold join firing elsewhere. Whichever writes first stands: the step consumes, parks or leaves its token waiting at a join only while the token is still active, and a cancellation cancels only a token that is still live. A step that finishes after its token was canceled produces no successors, and canceling a token whose step has just finished does nothing.
This is the shared primitive behind ending work early: a superseded branch, a task's pending timers once it is answered, the late branches of an early-firing join.
Reacting to a timeout¶
The notify timeout action announces a timeout without resuming the task, by
dispatching a channel-neutral TaskTimedOutEvent (token, instance, node and
tenant IDs, plus a tag and message from the node):
// In a subscriber:
public function onTimeout(\Drupal\orchestra_engine\Event\TaskTimedOutEvent $event): void {
// $event->tag, $event->message, $event->instanceId, …
}
The engine never sends a notification itself; it only announces. A plain
EventSubscriber reacts directly; the orchestra_eca submodule re-dispatches the
event to an ECA custom event so a site builder wires email/Slack/log with no
code. Because the task stays parked, the sweep re-arms it and the event recurs
until the task is handled (see Timers).