Problem/Motivation
When branches meet at a join, the join goes on with a new token created under the token the branches share (#3629852: Store each token's ancestry as a path, and go on with a new token when a join fires, so a token's lineage never changes): the steps after the join see what was set before the split, and not what each branch set after it. That shared token is found by comparing the arrivals' lineages, so it needs at least two arrivals.
When a single token reaches a join with several incoming flows and fires it alone, there is nothing to compare, and JoinCoordinator::getLoneArrivalParentId() creates the new token under the token's fork instead, the token whose split fanned its branch out. A split that activates a single flow, such as an inclusive split with one condition true, fans nothing out and records no fork: its branch keeps the fork of the split around it. So inside an outer split, the new token is created under the outer split's token, and everything the outer branch set token-locally between the two splits is no longer seen after the inner join, although it was set before the split that join closes. Nothing reports it: the steps after the join read the variable as never set.
At the top level the token has no fork, goes on itself, and loses nothing.
Steps to reproduce
- Build: start, a parallel split into branches A and B; in A, a step, then an inclusive split with two flows on
route == oneandroute == two, each to a step, both into an inclusive join (its incoming flows carrying the same conditions), then a step after it. - Start with
route = one. Complete A's first step, settingxtoken-locally. - Complete the inner step on the
onebranch: the inner join fires alone. - The new token at the inner join has the outer split's token as parent, and the step after it does not see
x.
Proposed resolution
A join fired by a single token meets nothing: there is nothing to merge, and no other branch whose values could clash with this one's. So let that token go on as it is, seeing everything it saw, wherever the join stands:
- A lone fire with nothing to cancel (an inclusive join reached by its only active branch, a join set to Ignore firing on its first arrival): the token goes on itself, as it does today at the top level. No new token is created.
- A lone fire that cancels the rest (a threshold, quorum or timeout join set to Cancel, firing on its first arrival): a new token is still needed, outside the cohort the fire closes, or the winner would be canceled with it. It is created under the arriving token, so it too sees everything the arriving token saw.
- The merge (the collect settings) is written as today, a list of one value, on the token that goes on.
So a lone branch's own token-scoped values stay visible past the join, where branches that meet drop theirs unless the join keeps them (#3629938: Let a synchronizing join keep the branches' token-scoped variables, from the first or the last arrival). That is on purpose: dropping exists so that the values of branches that meet do not clash, and a lone branch has nothing to clash with. It must be said where an author looks for it: in the description of the Branch variables field, which today reads as though Drop applied to every fire, and in the documentation.
getLoneArrivalParentId() goes away: the token the new token goes under is the arriving token whenever no sibling was joined. Cost: one token insert fewer per lone fire with nothing to cancel.
Not chosen: finding the inner split from the workflow's flows, the nearest token of the lineage on a node from which another incoming flow of the join can be reached. It would put the new token where it would have gone had the other branches run, at the price of reading the lineage's tokens on every lone fire, and drop the lone branch's values to no benefit. Recording a fork on every split activation, even with one flow, was also set aside: the fork decides what a discriminator fire cancels.
Remaining tasks
- Let the lone arrival go on itself, or create the new token under it when the fire cancels a split.
- A kernel test reproducing the steps above, failing on 1.x: the step after the inner join sees
x. - A kernel test that a lone discriminator fire set to Cancel creates its new token under the arriving token, outside the closed cohort, and the instance completes.
- Update the tests that expect today's new token under the fork.
- Say it in the description of the Branch variables field (a join fired by a single branch lets it go on as it is, whatever this setting), and check the join plugins' descriptions for any wording about the token a join goes on with; French translations to follow.
- Say it in docs/joins-splits.md (the Branch variables section and the joins' description of what goes on after a fire) and docs/concepts.md.
User interface changes
The description of the Branch variables field says that a join fired by a single branch lets it go on as it is.
API changes
None.
Data model changes
None.
AI-Generated: Yes (Claude Code was used to find this, reproduce it with a kernel test, and draft this issue summary. I reviewed it.)
Issue fork orchestra-3629949
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #2
mably commentedComment #4
mably commentedComment #6
mably commented