Joins and splits¶
Every node routes tokens in two directions, and each direction is a pluggable policy:
- a split governs the node's outgoing flows: how a firing node fans out;
- a join governs the node's incoming flows: when a node with several incoming branches fires.
For a picture of each pattern and what the engine does with it, see Join patterns.
A plain task node defaults to an immediate join and an all split, so it just flows straight through. You only set a join or split when a node fans out or converges. A gateway is a node that presets a join/split pair (see below); you can also set them directly on any node for finer control.
The simple rule
Choose the join by what should happen when a path you drew does not come.
Example: a split sends work to steps A, B and C, and all three lead to the same join. The flow to C has a condition, and this time it is false. So nothing goes to C.
- Wait for all stops on an incident: you drew a path from C into the join, and it will not come. An operator decides what to do.
- Wait for matching waits for A and B, then goes on. It waits only for the paths actually taken.
Either way, a join never goes on while something is still on its way to it.
Splits (outgoing)¶
A split chooses which outgoing flows a firing node takes, among those whose condition holds (an unconditioned flow always holds).
| Split | Takes | Pattern |
|---|---|---|
All (all, default) |
every outgoing flow whose condition holds | parallel fork (unconditioned) or inclusive fan-out (conditioned) |
First (first) |
only the first outgoing flow whose condition holds, tried top to bottom | exclusive choice |
Per member (per_member) |
every outgoing flow whose condition holds, once for each member of the node's audience | one branch per person: ask each of them, then decide on their answers |
So the same first split is an exclusive gateway when its flows carry
conditions, and all is both the parallel fork and the inclusive fan-out
depending on whether the flows are conditioned.
Top to bottom is the order the workflow stores its flows in, the order the Complete modeler lists them. A save from BPMN.io keeps that order, whatever order the diagram lists its elements in: a flow drawn there goes after the flows the workflow already had, and so does a flow whose source or target was renamed.
To change it, drag a flow by its title in the Complete modeler, or use its Move up and Move down buttons. A flow moves only among the flows leaving the same node, and under a First split each of them states its priority in its title. Nodes are reordered the same way; their order changes only how the workflow reads. Sort from start to end, beside Save, orders the nodes along the flows from the start node, each after the nodes that lead to it, and leaves the order of every node's flows as it is.
One branch per item¶
Say a document must be validated by every member of the "Reviewer" role and
by the board chair, and a majority of them decides. Nobody knows, when the
workflow is drawn, how many reviewers there will be. A per_member split
does it with one flow:
start
-> Ask every validator (per_member split)
-> Validate (user task)
-> Decide (quorum join, by majority)
-> ...
per_member is one split creating one branch per item: its items are
the members of an audience. Any such split works the same way:
- When a token leaves its node, the split lists its items. For each item, it
takes the outgoing flows whose condition holds, as
alldoes. Each condition is tested with the item in the split's variable, so a flow can be taken for some items and not others. An item none of whose flows holds gets no branch. - Every token it creates is a branch of its own, and receives its item in that variable, local to the branch.
- A join counts one branch per item for each flow of the split leading to
it, as it counts one per flow of an
allsplit. A second flow from the split, to a notification for instance, leads each item somewhere else and does not change that count. - A
wait_alljoin after it requires, for each item it sent a branch to, every flow of the split leading to the join. A flow not taken for one item stops the join on an incident, as for anallsplit. - A split that lists no item raises an incident, as a split that takes no flow does.
- The items are read when the split runs: what changes afterwards changes no branch.
per_member¶
- The node's Audience names who is asked: users, roles and groups, in any mix, each named or read from a variable, as a human step's assignments do. Its members are every user those entries name, each once: someone named by two entries gets one branch. An entry naming no account, such as Everyone, adds nobody.
- Each branch receives its member's user ID in the split's Member
variable (
memberunless renamed). The Validate step assigns its task with Users by variable on that variable, so each member gets a task of their own. - A join counts the branches that arrive, one per task, whoever completes it: a task reassigned or delegated still counts once.
- Someone who joins the audience after the split ran gets no branch, and someone who leaves it keeps theirs. A role or a group with more than 1000 active members is cut to the first 1000, and a warning is logged, as for its notifications.
The example_member_validation workflow in orchestra_examples is this
example.
A split that takes nothing is reported
If a node has outgoing flows and none of them is taken (every condition
evaluated false), the branch has nowhere to go: it would end there, and the
run could complete looking successful. That is treated as a fault and raises
an incident naming the conditions and the values they saw, rather than ending
quietly (see
Incidents). To end
a branch deliberately, give the node a catch-all flow to an end node.
Joins (incoming)¶
A join decides when a node where several branches meet goes on.
| Join | Fires when | Pair with |
|---|---|---|
Immediate (immediate, default) |
each token arrives, no waiting | a node one token reaches at a time |
Wait for all (wait_all) |
every path drawn into it has delivered; it stops on an incident if one will not | a split where every branch must arrive |
Wait for matching (matching) |
every path taken has delivered, and nothing else is on its way | a conditional split (only some branches run) |
Threshold (threshold) |
N branches have arrived (early fire) | a split where a partial result is enough (N of M) |
Quorum (quorum) |
its quorum condition holds on enough branches, or that becomes unreachable | a vote where either outcome can be called early |
Branches, passes and the opening split¶
A split fans out when it can send one token down several flows at once:
the all split on a node with more than one outgoing flow, and a split
creating one branch per item (such as per_member) on any node. Each token it
creates starts a branch, and every token created later inside that branch
belongs to it too.
Every join except the immediate one waits for the branches of one split, its opening split. To find it, take the splits that every path from the start node to the join goes through. The opening split is the one of them nearest to the join that fans out. Each time a token goes through that split, it starts a pass, with branches of its own. The join fires once per pass, with that pass's branches only. A loop that comes back to the split starts a new pass, so the join fires again for it. Two passes waiting at the join at the same time never mix.
The join waits only for the branches the split actually started down flows leading to it. A flow whose condition was false started no branch, so it is not waited for. A branch sent down a flow that leads somewhere else is not waited for either.
When several branches meet at an inner join before reaching this one, the inner join sends one token on for all of them, and this join counts that token as one branch.
A branch can also split again before the join. Say S sends work to A, B and
C, and C sends work both straight to the join and through a step X. The join
then counts every branch drawn into it, at every level: A, B, C's branch
straight to the join, and C's branch through X. That is four, and a
threshold, a quorum or a collect line counts each of them. Each of them is
one of the paths drawn into the join, the paths a wait_all join requires. An exclusive
choice inside a branch adds none: only one of its flows is taken. A loop
inside a branch adds none either: going through C again brings C's same two
branches.
A step whose timer sends branches out while it waits (the spawn timeout
action) works differently, because its branches leave at different moments:
- While the step waits, the join waits for a branch on every flow of the step that leads to the join. The timer can still send one down any of them.
- A step halted on an incident counts as waiting: a retry brings it back.
- Once the step has moved on, or its branch is canceled, the join waits only for the branches the step actually sent. For example, a step answered before its timer fired sent no alert, so the join does not wait for an alert.
Some workflows are refused when they are saved, because a join in them could not tell which branches to wait for:
- a split fans out to the join, but some path to the join avoids that split; close that split's branches with a join of their own, then bring the paths together with an immediate join;
- a branch can reach the join both through an inner join and around it; draw each path so that it reaches the join only one way;
- a join requires more branches (a threshold or quorum count) than its opening split can send it;
- a split creating one branch per item on a node whose timer sends branches out: the items are only known when the timer sends them, so a join could not tell which branches to wait for.
A join that no split fanning out can reach receives one token at a time, and simply passes each one on.
How a join knows which branches are on their way¶
A branch is on its way to a join while one of its tokens stands on a node from which the join can still be reached. The token can be running, parked, waiting at another join, or halted on an incident that a retry would bring back.
Whether the join can be reached is read from the workflow's flows, all of them, whatever their conditions. A branch that has not yet decided which way to go is still on its way. Only the join's own level is read: a child workflow embedded on the way counts through its subprocess step.
When a branch leaves for nodes from which the join cannot be reached, ends, or
is canceled, a join waiting for it decides again with what can still arrive.
A wait_all join stops on an incident instead (see below).
A join never fires while a token of its pass is still on its way to it. A branch can split again before the join, with one path straight into the join and another through a step X. The join then waits for the token coming through X too, and its fire takes every token that arrived.
A loop upstream of a matching join
A token in a loop that can come back to the join counts as on its way, so
a matching join waits for it. When that is not what you want, use a
wait_all join.
wait_all or matching?¶
The two differ when a path drawn into the join does not come.
wait_allrequires every path drawn into it. A path does not come when:- a split that sends tokens down several flows does not take its flow towards the join (its condition was false);
- a token goes where the join can no longer be reached, or ends there;
- a token is canceled on the way.
As soon as that happens, the join halts on an
incident that says what
happened, for example "the flow from n_c to n_x was not taken". An operator
fires it with what arrived, or cancels the instance. While another token of
the same pass is halted on an incident of its own, the join waits: once that
incident is resolved, it decides again.
- matching waits only for the paths actually taken, and goes on with
what arrived.
Some things are not a path that does not come:
- an exclusive choice (a
firstsplit) that takes one of its flows, as long as that flow still leads to the join; - a step's timer that never fired;
- a path that first meets an inner join: that join decides about it.
After a split where every branch must finish, wait_all says what you mean.
After a conditional split, or when a branch may legitimately end elsewhere,
use matching.
When you save a workflow in the form, it warns you about each flow that can
stop a wait_all join this way: a flow with a condition at a split that sends
tokens down several flows, or a choice that can lead where the join cannot be
reached. The workflow is saved all the same: sometimes the incident is what
you want.
Conditions on a join's incoming flows¶
A join reads no condition on its own incoming flows. Which branches it waits
for comes from the split: put the conditions on the split's outgoing flows.
A condition left on a flow into a join still decides whether a token can take
that flow, like on any flow. For a wait_all join, a flow not taken at a
split that sends tokens down several flows is a path that does not come, so
the join stops on an incident. Use matching when that is expected.
After a fire: the branches the join went without¶
A join can fire before every branch of its pass has arrived: a threshold or quorum join decides early, a join timeout fires any join, and an operator can fire any join from an incident. Its When it fires setting decides what becomes of the branches it fired without:
n_decision:
type: passthrough
join:
plugin: threshold
settings:
count: 1
on_fire: ignore # cancel (the default), cancel_split or ignore
- Cancel the branches heading here (
cancel, the default): the fire cancels the tokens of the pass still on their way to this join (see How a join knows which branches are on their way), with their open tasks. Nobody is left holding a task for a decision already made. Tokens of the pass that can no longer reach the join run on: side work going to another join or to an end is kept. - Cancel the split (
cancel_split): the fire cancels everything still running under the nearest split that the winning branch and the others all came out of, wherever it is heading, and closes that split. Use it when the side work only matters while the decision is open, for example a reminder loop that would otherwise keep the instance running. - Ignore (
ignore): the fire cancels nothing. The branches still running finish their own work, tasks included. One that reaches the join afterwards is ignored there: consumed without firing the join again, and recorded in the audit trail asignored.
With either cancel setting, a branch caught in the middle of a step stops at its next step. A token halted on an incident is canceled too, with the tokens halted below it, inside an embedded child included, and their incidents are resolved as canceled.
A token that has not chosen its way yet
A token counts as heading to the join as long as the join can still be reached from where it stands, whatever the conditions on the flows. So with the default setting, a token on a step that could go to the join or elsewhere is canceled, even if it would have gone elsewhere.
Once a pass has fired the join, a token of that pass that does not come from the fire is one of two things:
- a late branch, one the fire went without (for example, one a timer sent out after the fire): it is ignored, whatever the setting;
- a surplus, a second token of a branch that already reached the join (for example, from a timer that fires twice down the same flow). It waits at the join. Once nothing else in the instance can move, it halts on an incident. There an operator cancels it, or fires the join again with it.
A token that comes from the fire is not late: a loop that comes round to the join without going back through the split starts from the fire's token, so its new round counts and can fire the join again.
Threshold (N of M): firing early¶
Where wait_all waits for every branch, a threshold join fires the moment
the configured number of branches have arrived. Set it on the node where the
branches meet, with a count:
n_decision:
type: passthrough
join:
plugin: threshold
settings:
count: 2 # fire once two branches arrive
Use it for a partial result, "proceed once two of three reviewers approve," without waiting on the slowest branch. Like the other joins that wait for branches it can merge a variable from each arrived branch, so a tally (say, the approvals) is available to the outgoing flows, here over the branches that arrived in time. What becomes of the branches still running is its When it fires setting.
The count is never lowered to the branches that run. When every branch the
join waits for has arrived and they are still fewer than count (a
conditional split ran fewer branches than it asks for), the join can no longer
fire: the branch that settles it halts on an
incident, where an operator fires
the join with the branches that arrived, or lowers the count the instance runs
and retries. To wait for every branch that ran, whatever their number, use
wait_all or matching instead. A workflow whose count is above the
branches that can reach the join, at every level, is refused when it is
saved.
Quorum: deciding either way early¶
A threshold join counts arrivals; a quorum join counts the branches its quorum condition holds on. The condition is a flow condition, tested on each arrived branch with that branch's own variables, the way a flow tests the token leaving on it. The join fires the moment the decision is settled, in either direction:
- the condition holds on enough branches (the quorum is reached), or
- so few can still make it hold that the quorum is out of reach (enough have come back without it).
A branch counts as it was when it arrived: whether the condition held on it is kept on its token, so a variable changed afterwards does not change its vote.
The condition says what is counted: a vote equal to approved (a
comparison, which the editor starts on, with equals), a value other than
rejected, one of several outcomes (an any of comparisons), an amount over
a limit. Counting approvals, a "two of three approvals" review passes the
instant the second approval arrives, and fails the instant the second
rejection arrives, without waiting on the last reviewer. As with the threshold
join, the branches still running are then canceled, or left to finish when the
join is set to Ignore.
n_decide:
type: passthrough
join:
plugin: quorum
settings:
quorum_condition: # tested on each branch
plugin: comparison
settings: { variable: vote, operator: '==', value: approved }
quorum_unit: count # count, percent or majority
quorum_value: 2 # branches it must hold on
quorum_result: approvals # how many it held on, written when the join fires
quorum_reached: approved # whether that reached the quorum
scope: token
split: { plugin: first }
The join only fires; the outgoing flows route on its result. When it fires,
whichever way the quorum went, the join writes to quorum_result how many
branches the condition held on, in the join's scope. A comparison condition
then tells the outcomes apart: approvals >= 2 on the flow taken when the
quorum is reached, approvals < 2 on the other. The join also writes to
quorum_reached whether the quorum was reached, TRUE or FALSE, so the
flows can test that instead: approved is not empty on one, is empty on
the other. It is the one to route on with a percentage or a majority (below),
where the number of branches it takes depends on how many ran.
The variables the condition reads must be written token-locally (each task writing its payload token-scoped), so each branch carries its own.
To route on the branches' values themselves, or keep them after the join, also
gather them with a collect line, for
example vote|votes, and route with a count condition over the list. The
result variable cannot be the target of a collect line: both are written when
the join fires, so one would overwrite the other.
How many branches make the quorum is set by quorum_unit:
| Unit | Quorum | Example |
|---|---|---|
count (default) |
quorum_value branches |
2 of any number of reviewers |
percent |
quorum_value percent of the branches, rounded up |
50: 2 of 4, 2 of 3 |
majority |
more than half the branches (quorum_value is not used) |
3 of 4, 2 of 2 |
The share is always taken of the branches the join waits for, never of the branches arrived so far: with four reviewers at 50%, a first approval is 1 of the 2 needed, not 100% of the arrivals. After an inclusive split, the branches it waits for are those the split took, so a share is always within their reach. A count is not lowered to them: when fewer branches run than the count, the quorum is out of reach from the start, so the first branch to arrive settles it as not reached. A workflow whose count is above the branches that can reach the join, at every level, is refused when it is saved.
A branch that splits again before the join can bring the quorum several
votes, one per branch it splits into (see
Branches, passes and the opening split).
Before it has split, the quorum counts every vote it could still bring, so
it never decides "not reached" while that branch could still reach the
quorum. A percentage or a majority needs to know how many branches there
are in all, which such a branch only tells once it has split. So a quorum by
percent or majority that a branch reaches after splitting again, with
several branches into the join, is refused when the workflow is saved: bring
that branch's branches together with a join of their own first, or use a
count.
To set the quorum per instance, name a variable in quorum_value_variable:
the join reads the value from it, in the arriving branch's view, instead of
quorum_value, in the unit chosen. Set it before the split, so every branch
reads the same. A variable that is not set, or holds anything but a whole number
from 1 (to 100 for a percentage), raises an
incident rather than deciding on a number nobody chose.
The quorum_condition is required: with none, the quorum has nothing to count
and waits for every branch. It is a setting of its own, not a collect line, so
the lists the join writes are free for anything else, and a distinct list
cannot change what is counted.
Quorum vs the synchronous count
Routing on a count condition after a wait_all join also decides pass or
fail, but only once every branch has voted. The quorum join is the
early-decision form: it stops as soon as the outcome cannot change.
Giving up on a slow branch¶
Any join that waits for branches can give up on a deadline: "collect every
approval, but give up after seven days." The deadline is not a join setting
but a timeout of the join's node, join_timeout, which fires the join with
whatever has arrived:
n_gather:
type: passthrough
join:
plugin: matching
features:
join_timeout:
deadline_settings:
duration: P7D # seconds or an ISO-8601 duration
It has the deadline providers, anchors, actions and outcome a task's timeout has. See Timeouts at a join.
The split/join pairings (and gateways)¶
Most useful combinations correspond to a BPMN gateway, which is just a node that presets the pair:
| Pattern | Split | Join | Gateway |
|---|---|---|---|
| Parallel | all |
wait_all |
parallel |
| Inclusive | all |
matching |
inclusive |
| Exclusive | first |
immediate |
exclusive |
Using a gateway node is the readable way to express these; setting the join and
split directly on a plain task node gives the same behavior with finer control
(for example a wait_all join and a merge policy on an ordinary script node,
as the quorum tally does).
Merge policy (collecting branch values)¶
A join that waits for several branches (wait_all, matching, threshold
or quorum) can gather variables from the joined branches onto the
continuing token, the quorum-tally pattern: as lists, or as one number computed
from the values. The merge policy is the join plugin's own settings:
| Setting | Meaning |
|---|---|
collect |
the collect lines, each {from, into, reducer}: the variable read from each joined branch, the variable the line writes to, and what it writes (none merges nothing) |
scope |
instance (shared with the whole process) or token (local to the continuing branch), for every line |
In the editor the collect lines are typed one per line, as source|target, or
source|target|reducer to write something else than the list:
vote|votes
comment|comments
department|departments_seen|distinct
amount|total_amount|sum
| Reducer | What the line writes |
|---|---|
list (or none) |
the values, as a list |
distinct |
the list without its repeats |
count |
how many branches set the source |
sum |
the sum of the values, 0 when no branch set the source |
avg |
their average, NULL when no branch set the source |
min, max |
the smallest or the largest value, NULL when no branch set the source |
The editor refuses a line of another shape or with another reducer, a reserved
__ name, and a target used by two lines, whose second value would overwrite
the first. A workflow saved another way (an import, a recipe) with a reducer
there is none of is refused too.
When the join fires, each line reads its from variable as each joined
branch sees it: from the branch's own tokens and their ancestors. It writes
what it computes from those values to its into variable. A count flow
condition downstream can then decide on a list, e.g. "at least two of the
collected votes are approved", and a comparison condition on a number,
e.g. total_amount >= 10000.
n_tally:
type: passthrough
join:
plugin: wait_all
settings:
collect:
- from: vote # each reviewer wrote their vote here (token-scoped)
into: votes # gathered into this list on the continuing token
reducer: list
scope: token
split:
plugin: first # then route exclusively on the count
The lists follow the order the branches arrived in at the join: the first
value is the first arrival's. With distinct, a value already in the list is
not added again, so the occurrence kept is the first arrival's. Values are
compared strictly, after a structured {outcome, comment} payload is reduced
to its outcome: "1" and 1 are two values, and a structured value is
compared as a whole.
Numbers: sum, avg, min and max¶
sum, avg, min and max take numbers only: an integer, a decimal, or a
text holding one, as a webform answer is. A value that is not a number (another
text, a list, TRUE or FALSE, NULL) stops the join from firing, and the branch
that would have fired it halts on an incident naming each such
value, the line that read it and the flow it came down. Leaving the value out
on its own would quietly change the number the workflow routes on. The
operator then corrects the values or the workflow and retries, or chooses
Fire the join without the values that are not numbers: the lines are then
computed from the numbers alone, and each value left out is logged.
On a join that fires before every branch arrived (a threshold or quorum
join, or one its timeout fired), a line covers the branches that arrived
before the join fired, as a list does: an average is the average of
those.
The lists do not line up branch by branch
Each line is its own list. A branch that never set a line's source adds
nothing to that list, and distinct leaves repeats out, so the second
vote and the second comment may come from different branches. To keep a
branch's vote with its comment, keep the branches' variables with
Branch variables, or give each branch a variable name
of its own.
With matching the lists hold the values of only the branches that ran,
which is what makes it the right join for an applicability-based tally.
The collected variable is the branch-local one
A line's from is read as each branch sees it, so it is normally a
token-scoped (branch-local) variable. The quorum's vote is set with
completion_scope: token, so the reviewers' votes do not overwrite each
other. A token-scoped variable is visible only to its own branch, never to
a sibling branch. That is exactly why it works for collecting values: one
variable is gathered after the branches join.
Counting the arrivals¶
Three reviewers are asked, and the join gives up after a week. The step after it needs to know how many answered: "2 of 3". Name two variables on the join and every fire writes them:
n_gather:
join:
plugin: matching
settings:
arrived_variable: answers # how many branches arrived
expected_variable: asked # how many the join waited for
- Every fire writes them, whatever fired the join: the last arrival, a threshold or quorum deciding early, its timeout, or an operator from an incident.
- When every branch arrived, the two are equal. A flow condition such as
answers < askedtells a fire that went on with fewer, without writing the number of branches into the condition. That matters after an inclusive split, where it changes from one pass to the next. - They count branches of the split. A branch that split again on its way counts once. A branch that can no longer arrive (it ended elsewhere, or was canceled) is not counted as waited for, at a join that stops waiting for it.
- They are written with the merge, in its scope.
Branch variables (keeping what the branches set)¶
Where branches meet, a synchronizing join ends every token that arrived and goes on with one new token, created under the token the branches split at. The steps after the join therefore see the instance variables and the token-scoped variables set before the split, but not the ones a branch set token-locally after it: a validation branch's decision or comment is gone past the join. The merge above carries one variable across as a list, which fits a tally; the Branch variables setting carries the plain values themselves. It is on the four synchronizing joins (wait all, matching, threshold, quorum), next to the merge settings:
branch_variables |
The new token gets |
|---|---|
drop (default) |
none of the branches' token-scoped variables |
first |
each variable from the earliest arrival that set it |
last |
each variable from the latest arrival that set it, the way an instance-scoped variable keeps its last write |
n_review_done:
type: passthrough
join:
plugin: wait_all
settings:
branch_variables: last # each variable from the last branch that set it
- The values are taken variable by variable: a branch that never set a variable does not hide another branch's value for it.
- Only what a branch set after the split counts. A value set before it is seen past the join anyway, so a branch that left it as it was does not pass it on as its own.
- Only the tokens the fire joins take part. A branch an early fire cancels did not finish its work, so its values are left out, and so are a late branch's that a join set to Ignore lets go.
- Arrivals are taken in the order they reached the join, which is the order of their token IDs.
- The values are written on the new token, so the steps after the join read them as token-scoped variables. A token-scoped merge into a variable of the same name wins: its list is written after them. An instance-scoped one does not: the new token's own value comes first in its view.
A join fired by a single branch meets nothing, so this setting does not apply to it: no other branch's values can clash with its own, and the branch goes on as it is, seeing everything it saw, the values it set after the split included. This happens, for example, at the matching join after an inclusive split that took only one flow, and at a threshold or quorum join, or one its timeout fires, while only one branch has arrived. The branch's token goes on itself, or, when the fire cancels the other branches, a new token is created under it, outside the split the fire closes. The merge is written as usual, as a list of one value.
Keeping makes the result depend on the arrival order
With first or last, two instances of the same process can end with
different values when their branches finish in a different order. Name
each branch's variables apart (legal_decision, finance_decision) when
the steps after the join need all of them.
When to use matching: concrete examples¶
Each of these has a conditional split that starts only some branches, and
branches that may end elsewhere: matching waits for the paths taken that can
still arrive, where wait_all would stop on an incident for the others.
A worked example: conditions on the split only¶
A decision before the fork records what to do (here two instance-scoped booleans). The split carries the conditions; the flows into the matching join carry none:
flowchart LR
d["Choose channels<br/>sets notify_email, notify_sms<br/>(instance-scoped, before the fork)"]
d --> s{{"split: all"}}
s -->|notify_email| e["Send email"]
s -->|notify_sms| m["Send SMS"]
e --> j{{"join: matching"}}
m --> j
j --> done["Log delivery"]
flows:
# split -> branches
- id: f_email
from: g_split
to: n_email
condition: { plugin: comparison, settings: { variable: notify_email, operator: '==', value: true } }
- id: f_sms
from: g_split
to: n_sms
condition: { plugin: comparison, settings: { variable: notify_sms, operator: '==', value: true } }
# branches -> join: no condition
- id: f_email_join
from: n_email
to: g_join
- id: f_sms_join
from: n_sms
to: g_join
- Pick email only: the split takes
f_email, so onlyn_emailruns. When it reaches the join, nothing can come downf_sms_joinany more, so the join goes on. - Pick both: when the first branch arrives, the other is still on its way, so the join waits for it.
- A branch may change
notify_emailornotify_smsafter the split: the join does not read them.
The shipped example_inclusive expresses the same idea with a single channel
choice and an any condition; two booleans are shown here for readability.
- Multi-channel notifications: the user picks email / SMS / push; only the
chosen channels send, and the join waits for the selected ones. (Shipped as
example_inclusive.) - Applicability-based review: Legal reviews only if there is a contract,
Finance only if the amount exceeds a threshold, Security only if it touches
PII. The join waits for just the applicable reviews; a merge collects each
applicable reviewer's verdict so a
countcan require no rejections. - Optional data enrichment: geocode if an address is present, credit-check if an amount is set, KYC if the customer is new. The join waits for whichever enrichments were triggered.
- Multi-target publishing: website always, plus social / newsletter / partner feed when flagged. The join waits for the selected destinations' confirmations.
- Order fulfillment sub-tasks: gift-wrap if requested, customs paperwork if international, age verification if restricted. The join waits for the sub-tasks that apply before shipping.
- Conditional integrations: sync to CRM if customer-facing, ERP if it affects inventory, analytics always. The join waits for the integrations that fired.
Configuring joins and splits¶
- In the Complete modeler each routing-capable node shows
a Join and/or Split select. The chosen plugin's description appears
under the select, and any settings it has (a merging join's
collect/into/scopeand Branch variables) appear in a collapsible section below. - In config a node carries
joinandsplitas{plugin, settings}maps; a gateway node presets them and is not edited directly. The merge policy lives in the join'ssettings. - Examples to read:
example_parallel(all+wait_all),example_inclusive(all+matching),example_quorum(wait_all+ merge + acountcondition), andexample_member_validation(per_member+ aquorumby majority). - A
per_membersplit shows an Audience section on its node in the Complete modeler, with the same entries editor as a human step's assignments, and its Member variable under the split.