Let a matching join wait for the branches still on their way, instead of re-evaluating the conditions on its incoming flows
Closes https://www.drupal.org/project/orchestra/issues/3629950
What changes
JoinCoordinator::evaluate(): amatchingjoin now waits for an incoming flow a token has arrived on, or one a live token can still come down (findRivalTokens()+getArcsStillReachable(), as the discriminators). Expected set = flows whose condition holds ∩ (arrived ∪ still reachable): an unconditioned flow nothing can reach is no longer waited for, and a flow whose condition is present and false stays out (BC for workflows that repeat the split's conditions).- The branches on their way only make a matching join wait: they are never passed as rivals, so no early fire, no canceled fork.
wait_allis unchanged and reads no token: it is the strict join.WorkflowExecutor::resolveJoinsLeftBehind()now also covers matching joins, so a waiting matching join decides again when a branch it waited for leaves, ends or is canceled.JoinCoordinator::isDiscriminatorJoin()is replaced byisWaitingForBranchesOnTheirWay()(its only caller was the executor; the class is@internal).- Matching join description in the Join select updated (+ fr.po);
MatchingJoin,WaitAllJoin,InclusiveGatewaydocblocks. - Docs: joins-splits.md (join table, how a join knows which branches are on their way,
wait_allormatching?, Wiring a conditional join correctly, the worked example without join conditions), concepts, maestro, roadmap, troubleshooting, incidents (a matching join does not halt on a branch that is gone). example_inclusive: the join's incoming flows no longer repeat the split's conditions.
Cost
A matching join reads the branches on its way only while an arc whose condition holds has not delivered yet: the arrival that completes the join reads nothing more than on 1.x. When it does read them, it is one query on the (instance, node_id, state) index, loading only the tokens standing on a node that can reach the join (the discriminators now load only those too, instead of every live token of the instance). Reachability is memoized per definition.
Measured locally (kernel probe, entity caches reset before each signal, 30 unrelated parked branches, inclusive split into a matching join, mean of 15 runs, 3 runs each):
| first arrival (waits) | second arrival (fires) | |
|---|---|---|
| 1.x | 3.49 – 3.75 ms | 6.71 – 6.90 ms |
| first version of this MR (all live tokens loaded) | 4.77 – 5.01 ms | 7.90 – 8.11 ms |
| this MR | 3.65 – 3.90 ms | 6.84 – 6.91 ms |
The remaining ~0.15 ms on a waiting arrival is the indexed query plus loading the branch still on its way, which the feature needs. The executor's left-behind check now also runs for workflows with a matching join: in memory per advance, one query only when a branch leaves the join's reach.
Tests
MatchingJoinReachabilityTest (kernel, 5 tests). Mutation matrix, each run against the class:
| Mutation | Fails |
|---|---|
| matching reads conditions only (1.x behaviour) | testUnconditionedFlowsWaitForTheStartedBranchesOnly, testCanceledBranchLetsTheJoinGoOn, testFalseConditionLeavesTheBranchOnItsWayOutAndRunning |
| matching passes the branches on its way as rivals | testFalseConditionLeavesTheBranchOnItsWayOutAndRunning |
| executor leaves matching joins out of the left-behind resolution | testCanceledBranchLetsTheJoinGoOn |
wait_all reads tokens too |
testWaitAllWaitsForTheBranchThatNeverStarted |
| a false join condition no longer excludes its flow | testFalseConditionLeavesTheBranchOnItsWayOutAndRunning |
| matching ignores the flows still reachable | testUnconditionedFlowsWaitForTheBranchOnItsWay, testCanceledBranchLetsTheJoinGoOn |
| matching never reads the branches on their way | testUnconditionedFlowsWaitForTheBranchOnItsWay, testCanceledBranchLetsTheJoinGoOn |
The "false condition" test uses a mixed model (two join flows repeating the split's conditions, one left without, its branch not started): with the read skipped once every flow whose condition holds has arrived, that is the case where a matching join fires with a branch still on its way.
Rebased on 1.x after !664 (merged), !666 (merged) and !665 (merged). Locally green on the rebase: the join-related kernel classes (Join*, Discriminator*, MatchingBasisJoinTest, LoopJoinTest, ConditionalFlowsTest, UnfiredJoinIncidentTest, CanceledMidStepTest, PluginSettingsFormTest), ExamplesTest, the orchestra_cm and orchestra_modeler kernel tests 1.x changed, the Unit suite, phpcs, cspell, and phpstan on the whole module. The evaluate() guard was also re-checked with three mutations (skip inverted, never read, rivals canceled): each fails the class.
AI-Generated: Yes (Claude Code was used to write the change, its tests and this description. I reviewed them, and each new test was confirmed to fail on the mutation listed above.)