Problem/Motivation
A threshold join quietly lowers its required count to the number of active incoming arcs: ThresholdJoin::arrive() computes min(count, matchingCount), and fires as soon as the first token arrives when only one arc is active. QuorumJoin::arrive() does the same.
For the use case the join exists for, "continue once two of three reviewers approve", that means two required approvals become one when an inclusive split activates a single reviewer branch, and the process goes on as if the threshold had been met. Nothing records it: no incident, no audit event, no log. The documentation presents it as a feature ("A count at or above the number of incoming arcs simply behaves like wait_all"), and nothing is checked when the model is saved, so a count of 5 on a join with three incoming arcs is accepted.
The same gap exists for every synchronizing join once a branch it waits for is canceled or fails, for example when the incident that branch's token halted on is canceled: the join can never fire, and WorkflowExecutor::recoverStalledInstance() only logs a warning ("a join is waiting on a branch that was canceled or failed"), after which the cron sweep rechecks it silently. The instance stays open with a waiting token and nothing in the incident list for an operator to act on. A timeout join whose timeout is missing or is not a duration stalls the same way, with a warning only.
A join should never pass on fewer branches than the model asks for, or hang, without someone deciding it.
Steps to reproduce
Threshold:
- Model an inclusive split with three conditioned arcs into reviewer branches, joined by a
thresholdjoin withcount: 2. - Start an instance whose variables make only one arc condition true.
- Complete that single reviewer task: the join fires and the instance continues past it with one arrival out of the two required.
Stall:
- Model a parallel split into two branches joined by a
wait_alljoin. - Make one branch's step fail so its token halts on an incident, and let the other branch reach the join, where it waits.
- Cancel the incident: the waiting branch stays at the join for ever, and the only trace is the warning in the log.
Proposed resolution
- Threshold, at runtime: stop lowering the count. Once every active arc has arrived and the arrivals are still below
count, the threshold can no longer be reached: halt the token on an incident saying so (and thatwait_allis the join for "every branch that ran"). Until then the join keeps waiting, as today. The admin resolves it like any other incident: skip fires the join anyway (today's behaviour, now an explicit and audited decision), cancel or fail ends the instance, retry re-evaluates after a migration has fixed the model. - Quorum: stop lowering the count too. No incident is needed there: an unreachable quorum is already a legitimate outcome, and the existing "approval can no longer be reached" test fires it as such.
- Stalled joins: when
recoverStalledInstance()finds no timeout it can arm, raise one incident per stalled join, on a token waiting there, instead of logging a warning; the cron sweep raises it too when the stall did not start at a cancel. Skip fires the join with what has arrived, cancel or fail ends the instance, retry rechecks after a migration or a corrected timeout. A timeout join with an unusable timeout gets the same incident, with a message naming the timeout to fix. - At save time: refuse a
countabove the number of incoming arcs on the join's node, since such a join can never be met. - Update
JoinDecisionTest::testThresholdCountsActiveArrivals(it asserts the lowered fire) andJoinStallRecoveryTest(it covers the warning path), the code comments, anddocs/joins-splits.md.
Remaining tasks
- Implement, with kernel coverage for the incident and each resolution.
- Review.
User interface changes
The join form refuses a count above the number of incoming arcs. A new incident appears when a threshold can no longer be reached or a join is stalled.
API changes
A new JoinDecision case for a join that can no longer fire (the enum is @internal).
Data model changes
None.
Release notes snippet
A join that can no longer fire now raises an incident: a threshold join that cannot reach its required count no longer fires on fewer branches, and a join stalled on a canceled or failed branch no longer only logs a warning. A quorum join no longer lowers its count to the active branches.
AI-Generated: Yes (Claude Code was used to help draft this issue summary. I reviewed it.)
Issue fork orchestra-3629951
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