Skip to content

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), Tenant and Status (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 a human node creates), WorkflowVersion (an immutable snapshot a running instance is pinned to) and Incident (a recorded execution failure), each carrying a tenant.
  • 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 a variables node computes from the run itself), DeadlineProvider (when a parked task's wait runs out), TimeoutAction (what a task's timer does when it fires) and Audience (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() and cancel(), plus the incident operations (retryIncident(), resumeIncident(), skipIncident(), cancelIncident(), failFromIncident()) that recover a failed step. See Incidents. Internally the engine is a thin facade: WorkflowExecutor runs the advance loop, IncidentManager carries the operator incident actions and InstanceRecovery the cron recovery sweeps, all backed by small leaf services (token lineage, variable resolution, deadline math, definition resolution, the incident store). Depend on WorkflowEngineInterface; the split is an internal detail.
  • The queue worker: orchestra_advance advances 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).