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(): a matching join 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_all is 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 by isWaitingForBranchesOnTheirWay() (its only caller was the executor; the class is @internal).
  • Matching join description in the Join select updated (+ fr.po); MatchingJoin, WaitAllJoin, InclusiveGateway docblocks.
  • Docs: joins-splits.md (join table, how a join knows which branches are on their way, wait_all or matching?, 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.)

Edited by Frank Mably

Merge request reports

Loading
Loading