Resolve #3629948 "Let a join collect several branch variables into lists, and let a quorum count a condition, as a number, a percentage or a majority"
Closes #3629948.
What changes
-
Collect lines. On every merging join (wait all, matching, threshold, quorum, timeout), the Collect variable and Into list variable fields become one Collect textarea, one list per line:
source|targetorsource|target|distinct. Config stores them as a sequence of{from, into, distinct}(schema inorchestra.join.merge_settings); the oldcollectstring andintokeys are gone. There is no update hook because the project has no beta yet. -
Arrival order. The lists follow the order the branches reached the join (token ID), as Branch variables does. Before, the list started with the token whose arrival fired the join.
-
distinct. A value already in the list is not added again. Values are compared strictly, after the{outcome, comment}unwrapping, and the first occurrence is kept. -
Quorum condition. The quorum counts the branches a condition holds on, not the branches where one variable equals one value (approvals):
quorum_condition(required, Quorum condition): a flow condition, picked from the same plugins as on flows (comparison,count,any,all), tested on each arrived branch with that branch's own variables. So a quorum can count a value other than one (!=), one of several values (anyof comparisons), an amount over a limit, and so on. The form starts on acomparisonwith equals. Each condition's own fields are embedded and shown with#states, as the Reassign timeout action does, because the editor's settings subform cannot host the AJAX to swap them. Before, the quorum counted the collect variable againstapprove_value.quorum_unit(Quorum):count(default),percentormajority.quorum_value(Quorum value), which wascount, is a number of branches or a percentage of them. A percentage is rounded up (50% of 3 branches is 2). A majority is more than half (3 of 4, 2 of 2) and takes no value, so the value fields hide (#states). The share is always taken of the branches the join waits for, never of those arrived so far, which the first branch to count would reach on its own. A count is never lowered to the branches that run, as #3629951 made it on 1.x: fewer branches than the count means out of reach, settled as not reached at the first arrival. The form refuses a percentage over 100. #3629951's save check (a count above the flows entering the node is refused, inWorkflowand in the Complete modeler) now reads the quorum throughQuorumJoin::getFixedCount(): only acountwritten in the settings is checked, never a percentage, a majority or a value read from a variable.quorum_value_variable(Quorum value variable, optional): the value is read from this variable instead, in the arriving branch's view, so one set before the split is the same for every branch. A variable that is not set, or holds anything but a whole number from 1 (to 100 for a percentage), throws aFatalException, so the advance raises an incident rather than deciding on a number nobody chose.
Its collect lines are optional, as on the other joins.
-
Quorum result. An optional
quorum_result(Result variable) receives, when the join fires, the number of branches the condition held on. It is written whichever way the quorum went (with a quorum of 2: 2 when reached, 0 when out of reach), in the join'sscope, with the collect lines. Acomparisoncondition (approvals >= 2/approvals < 2) then routes the outcome without collecting and counting the values again. An optionalquorum_reached(Reached variable) receives TRUE or FALSE, whether the quorum was reached. It is what a flow routes on with a percentage or a majority, where the number of branches it takes depends on how many ran (reachedis not empty / is empty). The form refuses a result or reached variable that a collect line also writes, and the two naming the same variable. -
Validation in the form. For the collect lines, it refuses a line of another shape (naming the line), a reserved
__source or target, and a target used by two lines. -
example_quorum, docs (joins-splits merge policy + quorum, concepts, variables, roadmap, maestro) andfr.poare updated.check-translations.phpis clean.
New code: CollectLine (value object: readSetting(), parse(), format(), getSettingEntry(), buildList()). BranchConditionJoinInterface (getBranchCondition()): a join that decides on a condition tested on each arrived branch. JoinResultInterface (getResultVariables()): a join that writes what it decided when it fires (for the quorum: the result and the reached variables). Only QuorumJoin implements them, counting through its private countMatchingBranches(), which arrive() uses as well. It now takes the flow condition manager (as PluginManagerInterface) through create(), for its form. JoinCoordinator reads each arc arrival's variables at most once per evaluation (getArcArrivals(), getArcVariables()). It tests the branch condition on them through the new FlowEvaluator::evaluateCondition(), which getLiveFlows() now uses too, and hands the join a match per arc (JoinState::$arrivedMatches, replacing $arrivedValues), plus the arriving branch's own variables (JoinState::$variables, already read, so no extra query) for the quorum value variable. QuorumJoin::countRequiredBranches() turns the unit and value into a number of branches. Every collect line takes its values from the same read. JoinOutcome::$merge becomes {variables, token_scoped}, holding the lists and the result. The executor writes them all in one setVariables(). TimeoutJoin::validateConfigurationForm() now calls the parent so its collect lines are validated too.
Tests (each confirmed to fail with the mutation listed)
| Test | Mutation it catches |
|---|---|
JoinCollectLinesTest::testEachLineGathersItsOwnList |
only the first line written; a branch without the source adding a value |
JoinCollectLinesTest::testListFollowsTheArrivalOrder |
no sort by token ID (firing arrival first) |
JoinCollectLinesTest::testDistinctKeepsTheFirstOccurrence |
loose comparison (1 == "1"); distinct ignored; last occurrence kept |
PluginSettingsFormTest::testCollectLinesAreValidated |
no duplicate-target check; no reserved-name check; any third part accepted; one-part or four-part line accepted |
PluginSettingsFormTest::testCollectLinesRoundTrip |
distinct lost on save; lines not parsed on submit |
PluginSettingsFormTest::testQuorumConditionIsRequiredAndStored |
condition's own validation skipped; its settings not submitted; form not starting on a comparison; reached variable not saved |
RoutingSettingsTest::testQuorumConditionAuthored (orchestra_cm, FunctionalJavascript) |
no #states (every condition's fields shown at once); the quorum value shown for a majority |
DiscriminatorQuorumTest::testQuorumConditionCountsAnyOfSeveralValues |
condition not tested (every branch counted); no condition read |
PluginSettingsFormTest::testCollectLineCannotWriteTheQuorumResults |
no clash check (collect line vs result, collect line vs reached, result vs reached); every line refused as a clash |
DiscriminatorQuorumTest::testStoredQuorumWithNoConditionLetsEveryBranchAct |
the quorum counting a collect line when no condition is set |
JoinDecisionTest::testQuorumUnitsCountTheBranchesWaitedFor |
percentage rounded down; majority taken as half; unit ignored (configured value used as a count) |
JoinDecisionTest::testQuorumValueVariableMustHoldWholeNumbers |
no FatalException on a bad value; 0 accepted; a percentage over 100 accepted; a numeric string refused |
DiscriminatorQuorumTest::testQuorumValueIsReadFromItsVariable |
variable not read; JoinState::$variables left empty by the coordinator |
DiscriminatorQuorumTest::testQuorumValueVariableWithNoNumberRaisesAnIncident |
a missing or non-numeric value deciding the quorum instead of raising an incident |
JoinDecisionTest::testQuorumDecidesAnOutOfReachCountAtOnce (from #3629951, converted) |
count lowered to the branches that run; a lone branch reaching a quorum of two |
JoinCountRefusalTest::testOnlyFixedQuorumCountsAreChecked |
quorum value not the one checked; a percentage, a majority or a variable refused; a missing value read as none required |
OrchestraCmFormTest::testJoinCountAboveTheIncomingFlowsIsRefusedOnTheField (extended) |
quorum error on the wrong field; quorum value not checked |
PluginSettingsFormTest::testQuorumValueFollowsItsUnit |
percentage over 100 accepted; value fields not hidden for a majority |
JoinDecisionTest::testQuorumReachedFollowsTheUnit |
reached compared with the configured value instead of the share; always TRUE; written without a name; a lone fire reading a value it does not need |
DiscriminatorQuorumTest::testQuorumReachedRoutesTheMajority |
reached not written; written TRUE on a missed quorum |
DiscriminatorQuorumTest::testQuorumResultRoutesTheOutcome |
no result written; a branch counted whether or not the condition held on it; result left out of the merge; merge dropped when there is no collect line |
JoinMergeTest::testAnOverDeliveredArcMergesOneValue now expects the firing arrival last (arrival order) instead of first. RoutingSettingsTest (orchestra_cm, FunctionalJavascript) authors two lines, one distinct, through the textarea and asserts the stored sequence.
Run locally: every join-related kernel class in orchestra (40 classes), ExamplesTest, the modeler round trips, RoutingSettingsTest, and every Unit suite. phpcs, phpstan (whole module) and check-translations.php are clean.
Cost
Nothing is added per arrival. Measured against 1.x with a throwaway probe (5 branches, 15 instances per case, XDEBUG off, SQLite, two passes), counting queries and time across all five arrivals:
| Join | 1.x: SELECTs / ms per instance | This MR |
|---|---|---|
wait_all, no collect |
124 / 22.4–22.8 | 124 / 22.5–22.6 |
wait_all, one collect line |
140 / 24.6–24.8 | 130 / 23.4–24.0 |
quorum (vote + one line) |
145 / 26.3–27.0 | 145 / 26.0–26.7 |
Writes are identical (119 / 122). A quorum_result is written in the same batch as the collect lines, so it adds no query to a join that already collects. A quorum that only writes its result costs that one batch on its fire. A join with collect lines but no vote now reads the branches' variables only on the firing arrival, not on every arrival, so it gets cheaper. A quorum reads them on every arrival as before, once for the condition and every line together. The quorum row was measured with the earlier vote setting. Its condition is tested on those already-read variables, in PHP, and adds no query. Several lines are written in one setVariables() batch (three queries whatever the count), not one write per line.
Stored configuration to update when this lands
There is no update hook. A join saved with the old collect/into strings merges nothing until it is saved again. A quorum saved with the old keys has no condition and waits for every branch, with the default quorum of 2 branches. On the local sites:
example_quorum/n_tally(shipped here, updated in this MR; an installed copy keeps the old keys until reinstalled or re-saved).- yoyaku:
yoyaku_orchestra_exampleshipsyoyaku_booking/n_cancel_join, a quorum withcollect: cancel_outcome,into: cancel_votes,count,approve_value. yoyaku#3629993 / yoyaku!404 (merged) (Draft until this merges) moves it toquorum_condition(cancel_outcome == canceled),quorum_value: 1andquorum_result: cancel_count, and routesf_join_canceledoncancel_count >= 1. ItsBookingWorkflowE2eTestpasses 5/5 locally against both heads (also after the rebases on !664 (merged) and !666 (merged)), and turning the condition into!=fails the cancellation and manual-settlement scenarios.
AI-Generated: Yes (Claude Code was used to write the change, its tests, the documentation, the translations and this description. I reviewed them, and each test was confirmed to fail with the mutation listed above and to pass without it.)