Resolve #3629949 "Let a join fired by a single branch go on with that branch's token, so nothing it set before the split is lost"

Closes #3629949.

When a single token reaches a join with several incoming flows and fires it alone, getLoneArrivalParentId() created the new token under the token's fork. A split that activates one flow (an inclusive split with one condition true) records no fork, so inside an outer split the new token went under the outer split's token, and everything the outer branch set token-locally before the inner split was lost past the inner join, silently.

Changes

  • JoinCoordinator::evaluate(): a token firing the join alone goes on under itself (newParentId = its own ID). getLoneArrivalParentId() is removed.
    • 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 already did at the top level. No new token.
    • The fire cancels a split (threshold, quorum or timeout set to Cancel, firing on its first arrival): the executor still consumes the arrival and creates a new token, now under the arriving token, in the cohort around the closed split (unchanged), so it is not canceled with it.
    • The merge is written as before, a list of one value, on the token that goes on.
  • WorkflowExecutor::synchronize() / createJoinContinuation(), JoinOutcome: comments say which token goes on and under what.
  • TokenInterface::getParentId() and the token's parent field description: the exception is the token a join creates where branches meet. A lone fire's new token goes under the token it was produced from, the general rule.
  • Branch variables field description: "A join fired by a single branch lets it go on as it is, whatever this setting." No join plugin description mentions the token a join goes on with, so none changed.
  • docs: joins-splits.md (Branch variables section: a lone branch goes on as it is, its own values included, and why) and concepts.md.
  • fr.po: the two changed descriptions.

Tests

  • JoinBranchVariablesTest::testLoneBranchKeepsWhatItSawBeforeTheSplit (new): the issue's steps. Outer parallel split, branch A sets x token-locally, inclusive split taking one flow, matching join fired alone, Drop (the default). The step after the join sees x. Fails on 1.x's src.
  • DiscriminatorConditionedFlowTest::testWinningBranchGoesOnUnderItsOwnToken (replaces testWinningBranchLocalsStayInTheBranch, which pinned the old behaviour): a threshold-1 join set to Cancel fires on the withdrawal alone. The review is canceled and the split closed, a new token carries on under the consumed arrival with no fork (outside the closed cohort), the branch's local variable is seen past the join, and the request completes. Fails on 1.x's src, and fails on the named assertion with either mutation: the new token kept in the arrival's closed cohort, or the arrival going on itself although its fire cancels a split.

Run locally, all green: DiscriminatorConditionedFlowTest (48), DiscriminatorThreshold/Quorum/TimeoutTest, JoinTest, JoinBranchVariablesTest, JoinCancellationTest, JoinMergeTest, JoinStallRecoveryTest, LoopJoinTest, MatchingBasisJoinTest, TokenLineageTest, and every Unit suite (module + submodules). phpcs clean. Local phpstan reports only the two property.onlyWritten it also reports on 1.x.

Cost

No query or write added. One token insert fewer per lone fire with nothing to cancel. The lone parent is the arriving token's own ID, already in hand. With Keep first / last, getBranchVariables() stops at the arrival itself (depth 0) and returns before its SELECT, so a lone fire now reads nothing there, where it used to read the values between the arrival and its fork.

Audited

  • A token going on itself after a timeout fire keeps its passed join deadline. That is inert: parking always rewrites the deadline (parkDeadline()), the sweep reads only PARKED and WAITING tokens, and the next node gets a new token. Top-level lone fires already behaved this way.
  • Retrying a step that halted after a lone fire passes the join (JoinOutcome::pass): the recorded latest fire is the token that went on. A loop's next round descends from it, so it still counts at a join set to Ignore.
  • The interaction chain (getChainSource(): replaced token, else parent) gives the same chain as before: the arrival's.
  • Docs describing the new token under the common ancestor are all qualified "where branches meet", so they stay true.

AI-Generated: Yes (Claude Code was used to write the change, its tests, the documentation, the translations and this description. I reviewed them, and both new tests were confirmed to fail against 1.x's code and to pass with the change.)

Edited by Frank Mably

Merge request reports

Loading
Loading