From 6dfacf31f721326d6a770d5dd7d571ad7298ce64 Mon Sep 17 00:00:00 2001 From: Norbert Hartl Date: Thu, 10 Sep 2026 13:06:49 +0200 Subject: [PATCH] Deferred choice: the first branch whose wait goes on wins FBDeferredChoice is a split whose branches each lead into a waiting spot - a condition wait, a trigger target, a remote flow, a sub flow call - and of which exactly one goes on: the first one whose wait is resumed. The others fall the moment the choice is decided, queued or parked, standing in a loop or a sub flow, in this instance or one executing inside it. An FBDeferredChoiceDecided records the winner. The decision is made where a wait goes on, in the one continuation path FBBaseExecutor>>resume:withToken:value:, before the wait is taken out; a condition wait whose condition holds at once and a sub flow call that runs through decide it as well. A pass remembers its origin token and whether it was decided. A branch that could not wait is refused when the choice runs. A withdrawal now takes the tokens of standing activations too - a fork whose loop is open, a sub flow node whose child runs - when they lie on the strand taken back: the loop starts nothing more and the frame pops quietly, the call reading #cancelled. cancelStrand: into a sub flow thus ends the call instead of failing the caller. Co-Authored-By: Claude Fable 5.1 --- .../FBCancellationTest.class.st | 19 +- .../FBDeferredChoiceTest.class.st | 221 ++++++++++++++++++ source/FluxBase-Core/FBBaseExecutor.class.st | 24 +- .../FluxBase-Core/FBDeferredChoice.class.st | 43 ++++ .../FBDeferredChoiceBehavior.class.st | 18 ++ .../FBDeferredChoiceDecided.class.st | 52 +++++ source/FluxBase-Core/FBFlowInstance.class.st | 74 ++++-- source/FluxBase-Core/FBNode.class.st | 16 ++ .../FluxBase-Core/FBRemoteFlowTask.class.st | 6 + .../FluxBase-Core/FBSplitExecution.class.st | 44 +++- .../FluxBase-Core/FBSubFlowExecutor.class.st | 15 +- source/FluxBase-Core/FBSubFlowNode.class.st | 8 + source/FluxBase-Core/FBWait.class.st | 7 + .../FBWaitForConditionBehavior.class.st | 5 +- 14 files changed, 516 insertions(+), 36 deletions(-) create mode 100644 source/FluxBase-Core-Tests/FBDeferredChoiceTest.class.st create mode 100644 source/FluxBase-Core/FBDeferredChoice.class.st create mode 100644 source/FluxBase-Core/FBDeferredChoiceBehavior.class.st create mode 100644 source/FluxBase-Core/FBDeferredChoiceDecided.class.st diff --git a/source/FluxBase-Core-Tests/FBCancellationTest.class.st b/source/FluxBase-Core-Tests/FBCancellationTest.class.st index 2c3edf4..f2ceb29 100644 --- a/source/FluxBase-Core-Tests/FBCancellationTest.class.st +++ b/source/FluxBase-Core-Tests/FBCancellationTest.class.st @@ -185,11 +185,11 @@ FBCancellationTest >> testCancellingAParkedTransitionTakesItsAddressAndLeavesThe { #category : 'tests' } FBCancellationTest >> testCancellingIntoASubFlowTakesTheChildsStrands [ "the child's strands descend from the caller's token (FBFlowInstance>>descendFrom:), - so cancelling the calling strand reaches a wait inside the sub flow. The child is - then over without an end, and until a cancelled scope carries that as its state, - the frame fails its caller the way an errored child does - the instance stops in - #error rather than hanging on a frame nothing will pop" - | child parent instance caller wait | + so cancelling the calling strand reaches a wait inside the sub flow. The call's own + token falls with the strand - the sub flow node stands, unqueued, and is part of it - + so the frame pops quietly, the call reads #cancelled, and with nothing left the + instance is over" + | child parent instance caller wait waitToken | child := self flowWaitingFromOutside. parent := self flowCalling: child. instance := parent newInstance. @@ -197,15 +197,18 @@ FBCancellationTest >> testCancellingIntoASubFlowTakesTheChildsStrands [ self assert: instance state equals: #waiting. caller := instance activations detect: [ :each | each node isKindOf: FBSubFlowNode ]. wait := instance suspended first. + waitToken := instance openWaitingEntries first token. self assertConsistent: instance. - self assert: (instance cancelStrand: caller token) size equals: 1. + self assert: (instance cancelStrand: caller token) asSet equals: { waitToken. caller token } asSet. self assert: instance suspended isEmpty. self assert: instance subFlowInstances first suspended isEmpty. self assert: (instance subFlowInstances first activations detect: [ :each | each node == wait ]) state equals: #cancelled. - self assert: instance state equals: #error. - self assert: instance atEnd. + self assert: caller state equals: #cancelled. + self assert: instance isRootExecutor. + self assert: instance state equals: #done. + self assert: instance hasEnded. self assert: (self cancelledEventsOf: instance) size equals: 1. self assertConsistent: instance ] diff --git a/source/FluxBase-Core-Tests/FBDeferredChoiceTest.class.st b/source/FluxBase-Core-Tests/FBDeferredChoiceTest.class.st new file mode 100644 index 0000000..afe2327 --- /dev/null +++ b/source/FluxBase-Core-Tests/FBDeferredChoiceTest.class.st @@ -0,0 +1,221 @@ +" +A deferred choice: the first branch whose wait goes on wins, the others fall - wherever +they stand. Built on FBCancellationTest for its helpers and its state invariant. +" +Class { + #name : 'FBDeferredChoiceTest', + #superclass : 'FBCancellationTest', + #category : 'FluxBase-Core-Tests', + #package : 'FluxBase-Core-Tests' +} + +{ #category : 'helpers' } +FBDeferredChoiceTest >> choiceBetween: aNode and: anotherNode into: aCollector [ + "start -> choice -> aNode -> after -> end + \-> anotherNode -/ ; after's argument is collected" + | start choice after | + start := FBStartEvent new. + choice := start addOutgoingNode: (FBDeferredChoice named: 'choice'). + choice --> aNode. + choice --> anotherNode. + after := (FBPluggableTask named: 'after') block: [ :act | + aCollector add: act argument. + act value: act argument ]. + aNode --> after. + anotherNode --> after. + after addOutgoingNode: FBEndEvent new. + ^ FBFlow new start: start +] + +{ #category : 'helpers' } +FBDeferredChoiceTest >> externalWaitNamed: aString [ + ^ FBExternalWait new condition: [ :each | false ]; name: aString; yourself +] + +{ #category : 'helpers' } +FBDeferredChoiceTest >> decisionsOf: anInstance [ + ^ anInstance events select: [ :each | each isKindOf: FBDeferredChoiceDecided ] +] + +{ #category : 'tests' } +FBDeferredChoiceTest >> testTheFirstBranchResumedWinsAndTheOtherFalls [ + | seen a b instance entryA entryB | + seen := OrderedCollection new. + a := self externalWaitNamed: 'a'. + b := self externalWaitNamed: 'b'. + instance := (self choiceBetween: a and: b into: seen) newInstance. + instance executeWith: 1. + self assert: instance state equals: #waiting. + self assert: instance openWaitingEntries size equals: 2. + entryA := instance openWaitingEntries detect: [ :each | each token node == a ]. + entryB := instance openWaitingEntries detect: [ :each | each token node == b ]. + self assertConsistent: instance. + + self assert: (instance signal: entryB correlationId with: 'B'). + + "b went on, a fell: its wait, its address, its activation reads cancelled" + self assert: instance openWaitingEntries isEmpty. + self assert: entryA token isCancelled. + self assert: (instance activationOfNode: a) state equals: #cancelled. + self assert: (instance activationOfNode: b) state equals: #done. + self assert: (self decisionsOf: instance) size equals: 1. + self assert: (self decisionsOf: instance) first choiceName equals: 'choice'. + self assert: (self decisionsOf: instance) first winnerTokenId equals: entryB token id. + self assertConsistent: instance. + instance execute. + self assert: instance state equals: #done. + self assert: seen asArray equals: #( 'B' ). + self assertConsistent: instance +] + +{ #category : 'tests' } +FBDeferredChoiceTest >> testASecondImpulseOnTheLoserIsSilent [ + | seen a b instance entryA entryB before | + seen := OrderedCollection new. + a := self externalWaitNamed: 'a'. + b := self externalWaitNamed: 'b'. + instance := (self choiceBetween: a and: b into: seen) newInstance. + instance executeWith: 1. + entryA := instance openWaitingEntries detect: [ :each | each token node == a ]. + entryB := instance openWaitingEntries detect: [ :each | each token node == b ]. + self assert: (instance signal: entryA correlationId with: 'A'). + instance execute. + before := instance events size. + + self deny: (instance signal: entryB correlationId with: 'late'). + + self assert: instance events size equals: before. + self assert: instance state equals: #done. + self assert: seen asArray equals: #( 'A' ). + self assertConsistent: instance +] + +{ #category : 'tests' } +FBDeferredChoiceTest >> testAParkedTriggerBranchLoses [ + "the losing branch leads into a trigger target: its transition was parked, never + activated. It falls with its address, and the trigger never runs" + | seen a trigger instance entryA entryTrigger | + seen := OrderedCollection new. + a := self externalWaitNamed: 'a'. + trigger := FBTriggerTask named: 'trigger'. + instance := (self choiceBetween: a and: trigger into: seen) newInstance. + instance executeWith: 1. + self assert: instance openWaitingEntries size equals: 2. + entryA := instance openWaitingEntries detect: [ :each | each token node == a ]. + entryTrigger := instance openWaitingEntries detect: [ :each | each waitingNode == trigger ]. + self assertConsistent: instance. + + self assert: (instance signal: entryA correlationId with: 'A'). + + self assert: instance openWaitingEntries isEmpty. + self assert: instance suspended isEmpty. + self assert: entryTrigger token isCancelled. + self deny: (instance activations anySatisfy: [ :each | each node == trigger ]). + instance execute. + self assert: instance state equals: #done. + self assert: seen asArray equals: #( 'A' ). + self assertConsistent: instance +] + +{ #category : 'tests' } +FBDeferredChoiceTest >> testALoserInsideASubFlowFallsWithItsFrame [ + "the losing branch called a sub flow that waits, so its frame is on top. Deciding + the choice takes the child's wait and the call itself: the frame pops quietly, the + call reads #cancelled, and the winner goes on in the root" + | seen a call instance entryA | + seen := OrderedCollection new. + a := self externalWaitNamed: 'a'. + call := FBSubFlowNode new. + call subFlow: self flowWaitingFromOutside. + instance := (self choiceBetween: a and: call into: seen) newInstance. + instance executeWith: 1. + self assert: instance state equals: #waiting. + self assert: (instance executor isKindOf: FBSubFlowExecutor). + self assert: instance openWaitingEntries size equals: 2. + entryA := instance openWaitingEntries detect: [ :each | each token node == a ]. + self assertConsistent: instance. + + self assert: (instance signal: entryA correlationId with: 'A'). + + self assert: instance openWaitingEntries isEmpty. + self assert: (instance activationOfNode: call) state equals: #cancelled. + self assertConsistent: instance. + instance execute. + self assert: instance isRootExecutor. + self assert: instance state equals: #done. + self assert: seen asArray equals: #( 'A' ). + self assertConsistent: instance +] + +{ #category : 'tests' } +FBDeferredChoiceTest >> testAChoiceInsideAnIterationDecidesThatIterationOnly [ + "each iteration of a parallel scope makes its own choice: deciding the second + iteration's leaves the first iteration's two waits standing" + | start fork choice a b after flow instance fork2 entriesOf seen | + seen := OrderedCollection new. + start := FBStartEvent new. + fork := start addOutgoingNode: (FBParallelNode named: 'fork'). + choice := fork addOutgoingNode: (FBDeferredChoice named: 'choice'). + a := choice addOutgoingNode: (self externalWaitNamed: 'a'). + b := choice addOutgoingNode: (self externalWaitNamed: 'b'). + after := (FBPluggableTask named: 'after') block: [ :act | seen add: act argument. act value: act argument ]. + a --> after. + b --> after. + (after addOutgoingNode: (FBParallelJoinNode named: 'scope join')) addOutgoingNode: FBEndEvent new. + flow := FBFlow new start: start. + instance := flow newInstance. + instance executeWith: #( 10 20 ). + fork2 := fork. + entriesOf := [ :index | instance openWaitingEntries select: [ :each | (each token scope iterationOwnedBy: fork2) index = index ] ]. + self assert: instance openWaitingEntries size equals: 4. + self assertConsistent: instance. + + self assert: (instance signal: ((entriesOf value: 2) detect: [ :each | each token node == b ]) correlationId with: 'B2'). + + self assert: (entriesOf value: 2) isEmpty. + self assert: (entriesOf value: 1) size equals: 2. + self assert: (self decisionsOf: instance) size equals: 1. + self assertConsistent: instance. + instance execute. + self assert: (instance signal: ((entriesOf value: 1) detect: [ :each | each token node == a ]) correlationId with: 'A1'). + instance execute. + self assert: instance state equals: #done. + self assert: (self decisionsOf: instance) size equals: 2. + self assert: seen asSet equals: #( 'A1' 'B2' ) asSet. + self assertConsistent: instance +] + +{ #category : 'tests' } +FBDeferredChoiceTest >> testAConditionThatHoldsAtOnceDecidesTheChoice [ + "a condition wait whose condition already holds is a wait that went on: it decides + the choice as it is entered, and the other branch falls before anything waited" + | seen a b instance | + seen := OrderedCollection new. + a := FBPluggableWait new condition: [ :each | true ]; name: 'a'; yourself. + b := self externalWaitNamed: 'b'. + instance := (self choiceBetween: a and: b into: seen) newInstance. + instance executeWith: 1. + self assert: instance state equals: #done. + self assert: instance openWaitingEntries isEmpty. + self assert: (self decisionsOf: instance) size equals: 1. + "once - a condition that holds passes no value on, as before" + self assert: seen size equals: 1. + self deny: (instance activations anySatisfy: [ :each | each node == b and: [ each state ~= #cancelled ] ]). + self assertConsistent: instance +] + +{ #category : 'tests' } +FBDeferredChoiceTest >> testABranchThatCannotWaitIsRejected [ + "a branch that would run at once decides nothing and lets the others go on with it: + the choice refuses to start" + | seen a task instance | + seen := OrderedCollection new. + a := self externalWaitNamed: 'a'. + task := FBTask named: 'task'. + instance := (self choiceBetween: a and: task into: seen) newInstance. + instance executeWith: 1. + self assert: instance state equals: #error. + self assert: instance openWaitingEntries isEmpty. + self assert: seen isEmpty. + self assert: ((instance events detect: [ :each | each isKindOf: FBNodeActivationErrored ]) message includesSubstring: 'waits') +] diff --git a/source/FluxBase-Core/FBBaseExecutor.class.st b/source/FluxBase-Core/FBBaseExecutor.class.st index 181c6fa..37ac5d1 100644 --- a/source/FluxBase-Core/FBBaseExecutor.class.st +++ b/source/FluxBase-Core/FBBaseExecutor.class.st @@ -237,6 +237,18 @@ FBBaseExecutor >> takeTokenForSuspended: anObject [ ^ self takeTokenForSuspended: anObject satisfying: [ :each | true ] ] +{ #category : 'accessing' } +FBBaseExecutor >> tokenForSuspended: anObject satisfying: aBlock [ + "the first token stored for anObject that aBlock accepts, left where it is - the one + #takeTokenForSuspended:satisfying: would take. For a look before the taking + (#resume:withToken:value:)" + | tokens | + suspendedTokens ifNil: [ ^ nil ]. + tokens := suspendedTokens at: anObject ifAbsent: [ ^ nil ]. + (tokens isKindOf: Collection) ifFalse: [ ^ (aBlock value: tokens) ifTrue: [ tokens ] ifFalse: [ nil ] ]. + ^ tokens detect: aBlock ifNone: [ nil ] +] + { #category : 'accessing' } FBBaseExecutor >> takeTokenForSuspended: anObject satisfying: aBlock [ "answer and forget the first token stored for anObject that aBlock accepts, or nil. @@ -334,10 +346,14 @@ FBBaseExecutor >> resume: aSuspension withToken: aToken value: aValue [ submitted on it" | token | - token := self - takeTokenForSuspended: aSuspension - satisfying: [ :each | aToken isNil or: [ each == aToken ] ]. - token := aToken ifNil: [ token ]. + token := aToken ifNil: [ self tokenForSuspended: aSuspension satisfying: [ :each | true ] ]. + "a wait going on is what decides a deferred choice its strand lies on: the branches + that did not go on fall now (FBFlowInstance>>decideDeferredChoiceWonBy:). Before the + wait itself is taken out: the withdrawal settles the executors, and one that saw + nothing pending and nothing suspended - the wait going on being in flight - would + take itself for finished and close its scope under the resumed strand" + token ifNotNil: [ flowInstance decideDeferredChoiceWonBy: token ]. + self takeTokenForSuspended: aSuspension satisfying: [ :each | aToken isNil or: [ each == aToken ] ]. flowInstance dropWaitingEntriesMatching: aSuspension token: token. suspended remove: aSuspension. ^ aSuspension resumeIn: self withToken: token value: aValue diff --git a/source/FluxBase-Core/FBDeferredChoice.class.st b/source/FluxBase-Core/FBDeferredChoice.class.st new file mode 100644 index 0000000..e9883b6 --- /dev/null +++ b/source/FluxBase-Core/FBDeferredChoice.class.st @@ -0,0 +1,43 @@ +" +I am a deferred choice (WCP-16; BPMN's event-based gateway): a split whose branches each +lead into a waiting spot - a condition wait, a trigger target such as a form, a remote +flow - and of which exactly one goes on: the first one whose wait is resumed. The others +fall the moment the choice is decided - queued or parked, in this instance or in a sub +flow a branch called - the way a cancelled strand falls (FBFlowInstance>> +decideDeferredChoice:wonBy:), and an FBDeferredChoiceDecided records which branch won. +Behind me the branches may merge or end on their own; only the winner ever gets there. + +The decision is made where a wait goes on, FBBaseExecutor>>resume:withToken:value: - the +one continuation path - and by a condition wait whose condition already holds when it is +entered (FBWaitForConditionBehavior). There is no tie: the engine is single-threaded, the +first impulse wins, and a second one finds its wait gone and is silent, like a double +submit. + +Every branch has to lead into a node that parks (FBNode>>parksOnEntry); a branch that +would run at once cannot take part in a choice, since steps run to rest before any impulse +from outside arrives, and my behavior refuses such a model. I open a pass like every +split (FBSplitExecution), which is what remembers whether I was decided and where my +branches began (#origin). +" +Class { + #name : 'FBDeferredChoice', + #superclass : 'FBSplitGateway', + #category : 'FluxBase-Core-Model', + #package : 'FluxBase-Core', + #tag : 'Model' +} + +{ #category : 'instance creation' } +FBDeferredChoice class >> typeName [ + ^ #deferredChoice +] + +{ #category : 'accessing' } +FBDeferredChoice >> defaultBehaviorClass [ + ^ FBDeferredChoiceBehavior +] + +{ #category : 'testing' } +FBDeferredChoice >> isDeferredChoice [ + ^ true +] diff --git a/source/FluxBase-Core/FBDeferredChoiceBehavior.class.st b/source/FluxBase-Core/FBDeferredChoiceBehavior.class.st new file mode 100644 index 0000000..76b685a --- /dev/null +++ b/source/FluxBase-Core/FBDeferredChoiceBehavior.class.st @@ -0,0 +1,18 @@ +Class { + #name : 'FBDeferredChoiceBehavior', + #superclass : 'FBAndSplitBehavior', + #category : 'FluxBase-Core-Behavior', + #package : 'FluxBase-Core', + #tag : 'Behavior' +} + +{ #category : 'execution' } +FBDeferredChoiceBehavior >> execute [ + "a deferred choice starts every branch like an AND-split does, and each has to park + at once: a branch that ran on would decide nothing and let the others go on with it" + activation node outgoing do: [ :transition | + transition target parksOnEntry ifFalse: [ + FBError signal: 'every branch of a deferred choice has to lead into a node that waits; ', + transition target printString, ' does not' ] ]. + super execute +] diff --git a/source/FluxBase-Core/FBDeferredChoiceDecided.class.st b/source/FluxBase-Core/FBDeferredChoiceDecided.class.st new file mode 100644 index 0000000..320b03b --- /dev/null +++ b/source/FluxBase-Core/FBDeferredChoiceDecided.class.st @@ -0,0 +1,52 @@ +" +I record that a deferred choice was decided (FBFlowInstance>>decideDeferredChoice:wonBy:): +one branch went on, the others fell. The strands taken back leave their own traces - an +FBNodeActivationCancelled per activation that stood. + + choiceName the name of the FBDeferredChoice + winnerTokenId the first token of the winning branch, by id - the log is an + outside-facing representation +" +Class { + #name : 'FBDeferredChoiceDecided', + #superclass : 'FBFlowInstanceEvent', + #instVars : [ + 'choiceName', + 'winnerTokenId' + ], + #category : 'FluxBase-Core-Event', + #package : 'FluxBase-Core', + #tag : 'Event' +} + +{ #category : 'accessing' } +FBDeferredChoiceDecided >> choice: aNode winner: aToken [ + choiceName := aNode name. + winnerTokenId := aToken id +] + +{ #category : 'accessing' } +FBDeferredChoiceDecided >> choiceName [ + ^ choiceName +] + +{ #category : 'printing' } +FBDeferredChoiceDecided >> message [ + ^ 'deferred choice decided: ', choiceName asString +] + +{ #category : 'printing' } +FBDeferredChoiceDecided >> printOn: aStream [ + super printOn: aStream. + aStream << 'deferred choice decided ' << choiceName asString +] + +{ #category : 'accessing' } +FBDeferredChoiceDecided >> type [ + ^ #deferredChoiceDecided +] + +{ #category : 'accessing' } +FBDeferredChoiceDecided >> winnerTokenId [ + ^ winnerTokenId +] diff --git a/source/FluxBase-Core/FBFlowInstance.class.st b/source/FluxBase-Core/FBFlowInstance.class.st index eb4146f..0bacee7 100644 --- a/source/FluxBase-Core/FBFlowInstance.class.st +++ b/source/FluxBase-Core/FBFlowInstance.class.st @@ -855,9 +855,13 @@ FBFlowInstance >> withdrawStrandsSatisfying: aBlock [ active chain - me and the instances executing inside me - and answer the tokens taken. Out of the executors' queues (FBBaseExecutor>>withdrawTokensSatisfying:) and out of the registries: an address that stood on such a token falls, so a signal on - it is as silent as on a consumed one. Every token taken is #cancelled; a standing - activation on one reads that off it, stays in the log, and gets an - FBNodeActivationCancelled. + it is as silent as on a consumed one. And the tokens of activations that stand + without being queued anywhere - a parallel node whose loop is open, a sub flow node + whose child runs - when aBlock accepts them: they are part of the strand, the loop + starts nothing more for a cancelled fork (FBParallelExecutor>>isStrandCancelled) and + the frame pops quietly for a cancelled call (FBSubFlowExecutor>>settleAfterWithdrawal). + Every token taken is #cancelled; an activation on one reads that off it, stays in + the log, and gets an FBNodeActivationCancelled. Then the joins' side: a token taken that stood for a branch of a pass through a split is a branch that will never arrive, the pass counts it, and a join that was @@ -878,6 +882,13 @@ FBFlowInstance >> withdrawStrandsSatisfying: aBlock [ self activeFlowInstances do: [ :each | each executors do: [ :executor | taken addAll: (executor withdrawTokensSatisfying: aBlock) ]. each dropWaitingAddressesOnTokensSatisfying: aBlock ]. + self activeFlowInstances do: [ :each | + each activations do: [ :activation | + activation isDone ifFalse: [ + | token | + token := activation token. + (token isCancelled not and: [ (taken includes: token) not and: [ aBlock value: token ] ]) + ifTrue: [ taken add: token ] ] ] ]. taken do: [ :token | token cancelled. token activation ifNotNil: [ :activation | @@ -967,29 +978,60 @@ FBFlowInstance >> cancelStrand: aToken [ was left: cancelling twice is cancelling once. aToken itself may stand on no queue at all: the token of an activation that is still - running - a parallel node whose loop is open, a sub flow node whose child runs. - That activation is part of the strand and reads #cancelled from now on; its token is - marked before the withdrawal, since the settling after it asks (a loop whose fork's - strand fell starts nothing more and goes on with nothing, - FBParallelExecutor>>isStrandCancelled). An activation already done is history, its - token stays as it is - the strand from there on is what falls. + running - a parallel node whose loop is open, a sub flow node whose child runs. That + activation is part of the strand and falls with it (#withdrawStrandsSatisfying: takes + standing activations as well); an activation already done is history, its token stays + as it is - the strand from there on is what falls. This is Cancel Activity (WCP-19), and the selection an abbrechender Join makes over its siblings. A join a cancelled branch was heading to knows (FBJoinState>>canRelease)" - | taken standing | - standing := aToken isCancelled not and: [ - aToken activation notNil and: [ aToken activation isDone not ] ]. - standing ifTrue: [ aToken cancelled ]. + | taken | taken := self withdrawStrandsSatisfying: [ :each | each == aToken or: [ each hasAncestor: aToken ] ]. - (standing and: [ (taken includes: aToken) not ]) ifTrue: [ - self addEvent: (FBNodeActivationCancelled new activation: aToken activation) ]. - (taken isEmpty and: [ standing not ]) ifTrue: [ ^ taken ]. + taken ifEmpty: [ ^ taken ]. self addEvent: (FBStrandCancelled new token: aToken). self endIfOver. ^ taken ] +{ #category : 'cancelling' } +FBFlowInstance >> decideDeferredChoiceWonBy: aToken [ + "a wait on aToken's strand goes on. Every deferred choice along its ancestry that is + not decided yet is decided by it: the branch aToken lies on is the winner, the other + branches fall (#decideDeferredChoice:wonBy:). Along the ancestry rather than off + aToken's own pass: a wait inside a sub flow a branch called carries no pass of its + own, and a choice nested in a branch of another decides the outer one as well - the + outer branch has gone on. Sent from the one continuation path + (FBBaseExecutor>>resume:withToken:value:) and by a condition wait that holds at once + (FBWaitForConditionBehavior); a strand that lies on no choice is left alone. + Answers the tokens taken" + + | taken current | + taken := OrderedCollection new. + current := aToken. + [ current notNil ] whileTrue: [ + current split ifNotNil: [ :pass | + (pass node notNil and: [ pass node isDeferredChoice and: [ pass isDecided not ] ]) ifTrue: [ + taken addAll: ((pass origin activation ifNil: [ self ] ifNotNil: [ :origin | origin instance ]) + decideDeferredChoice: pass wonBy: (pass branchOf: current)) ] ]. + current := current parent ]. + ^ taken +] + +{ #category : 'cancelling' } +FBFlowInstance >> decideDeferredChoice: aPass wonBy: aBranchToken [ + "aPass - a pass through an FBDeferredChoice - is decided: the branch beginning with + aBranchToken goes on, everything else that descends from the choice falls, queued or + parked, standing in a loop or a sub flow, in me or in an instance executing inside me + (#withdrawStrandsSatisfying:). Recorded once as an FBDeferredChoiceDecided; the + strands taken leave their own traces. Asked of the instance the choice ran in" + aPass decide. + self addEvent: (FBDeferredChoiceDecided new choice: aPass node winner: aBranchToken). + ^ self withdrawStrandsSatisfying: [ :each | + (each hasAncestor: aPass origin) and: [ + each ~~ aBranchToken and: [ (each hasAncestor: aBranchToken) not ] ] ] +] + { #category : 'cancelling' } FBFlowInstance >> cancelScope: aScope [ "cancel a region of me: aScope is marked, and from then on nothing runs or is taken diff --git a/source/FluxBase-Core/FBNode.class.st b/source/FluxBase-Core/FBNode.class.st index fbd4495..ba0191d 100644 --- a/source/FluxBase-Core/FBNode.class.st +++ b/source/FluxBase-Core/FBNode.class.st @@ -289,6 +289,22 @@ FBNode >> outputFor: anActivation [ ^ anActivation value ] +{ #category : 'testing' } +FBNode >> isDeferredChoice [ + "hook: am I an FBDeferredChoice - a split of which one branch goes on, the first one + whose wait is resumed? Asked of the node that opened a pass when a wait in it goes + on (FBFlowInstance>>decideDeferredChoiceWonBy:)" + ^ false +] + +{ #category : 'testing' } +FBNode >> parksOnEntry [ + "hook: does entering me park the strand - a transition into me suspended + (#requiresTrigger), or my own execution waiting (FBWait, FBRemoteFlowTask)? What a + deferred choice asks of its branches (FBDeferredChoiceBehavior)" + ^ self requiresTrigger +] + { #category : 'testing' } FBNode >> requiresTrigger [ "hook: does this node only proceed on an explicit external trigger (e.g. a diff --git a/source/FluxBase-Core/FBRemoteFlowTask.class.st b/source/FluxBase-Core/FBRemoteFlowTask.class.st index 02fbc88..6287a6c 100644 --- a/source/FluxBase-Core/FBRemoteFlowTask.class.st +++ b/source/FluxBase-Core/FBRemoteFlowTask.class.st @@ -89,6 +89,12 @@ FBRemoteFlowTask >> isExternallyResumable [ ^ true ] +{ #category : 'testing' } +FBRemoteFlowTask >> parksOnEntry [ + "I always park: my result comes from outside (FBRemoteFlowBehavior)" + ^ true +] + { #category : 'accessing' } FBRemoteFlowTask >> maxWait [ ^ maxWait diff --git a/source/FluxBase-Core/FBSplitExecution.class.st b/source/FluxBase-Core/FBSplitExecution.class.st index bfa149f..4915f21 100644 --- a/source/FluxBase-Core/FBSplitExecution.class.st +++ b/source/FluxBase-Core/FBSplitExecution.class.st @@ -21,6 +21,11 @@ I answer what the join needs to know and what the tokens alone cannot say: scope the FBScope the pass runs in - what the FBJoinState made for me falls with (FBFlowInstance>>scopeEnded:) id my identity for the outside: FBToken>>splitId and the event log + origin the token of the split's own activation - every branch's first token is + a child of it (#branchOf:), which is how a deferred choice tells the + winning branch from the rest (FBFlowInstance>>decideDeferredChoice:wonBy:) + decided for a deferred choice: whether one branch has gone on and the others + fell; a later wait on the winner's way decides nothing more cancelledBranches how many of my branches were taken back and will never arrive (FBFlowInstance>>accountCancelledBranchesOf:). The join counts them @@ -43,7 +48,9 @@ Class { 'outer', 'branchCount', 'scope', - 'cancelledBranches' + 'cancelledBranches', + 'origin', + 'decided' ], #category : 'FluxBase-Core-Model', #package : 'FluxBase-Core', @@ -56,6 +63,7 @@ FBSplitExecution class >> openedBy: anActivation branchCount: anInteger [ split execution and in the scope its token carries" ^ self new node: anActivation node; + origin: anActivation token; outer: anActivation token split; scope: anActivation token scope; branchCount: anInteger; @@ -84,6 +92,37 @@ FBSplitExecution >> cancelledBranches [ ^ cancelledBranches ] +{ #category : 'accessing' } +FBSplitExecution >> branchOf: aToken [ + "the first token of the branch aToken lies on - the child of #origin among its + ancestors, aToken itself included - or nil when aToken does not descend from this + pass's split at all" + | current | + current := aToken. + [ current notNil and: [ current parent ~~ origin ] ] whileTrue: [ current := current parent ]. + ^ current +] + +{ #category : 'deciding' } +FBSplitExecution >> decide [ + decided := true +] + +{ #category : 'testing' } +FBSplitExecution >> isDecided [ + ^ decided == true +] + +{ #category : 'accessing' } +FBSplitExecution >> origin [ + ^ origin +] + +{ #category : 'accessing' } +FBSplitExecution >> origin: aToken [ + origin := aToken +] + { #category : 'accessing' } FBSplitExecution >> id [ ^ id @@ -93,7 +132,8 @@ FBSplitExecution >> id [ FBSplitExecution >> initialize [ super initialize. id := FBIdGenerator current newId. - cancelledBranches := 0 + cancelledBranches := 0. + decided := false ] { #category : 'testing' } diff --git a/source/FluxBase-Core/FBSubFlowExecutor.class.st b/source/FluxBase-Core/FBSubFlowExecutor.class.st index 9a00e87..294b850 100644 --- a/source/FluxBase-Core/FBSubFlowExecutor.class.st +++ b/source/FluxBase-Core/FBSubFlowExecutor.class.st @@ -78,6 +78,9 @@ FBSubFlowExecutor >> finish [ 'sub flow ended in error and no #error transition is modelled' ]. isError ifFalse: [ callerActivation value: self childResult ]. self popThenResume: [ + "a call that went through is a branch that went on: it decides a deferred + choice the call lies on, once this frame is off the stack" + callerActivation instance decideDeferredChoiceWonBy: callerActivation token. callerActivation done. matchedTransition ifNotNil: [ callerActivation followTransition: matchedTransition ] @@ -152,12 +155,14 @@ FBSubFlowExecutor >> suspended [ { #category : 'cancelling' } FBSubFlowExecutor >> settleAfterWithdrawal [ "the strands taken out may have been the child's last. A child whose scope was - cancelled - on its own or with the caller's - is over by decision: the frame pops + cancelled - on its own or with the caller's - and a call whose own strand was + taken back (the caller's token falls with the strand it lies on, + FBFlowInstance>>withdrawStrandsSatisfying:) are over by decision: the frame pops without a result and without an error, the call itself reads #cancelled. A child - that is over without an end for another reason - a strand cancelled inside it, - Cancel Activity - fails its caller the way an errored child without an #error - transition does, until a cancelling join gives that case a shape of its own" - childInstance isCancelled ifTrue: [ + that is over without an end for another reason - a strand cancelled inside it + alone - fails its caller the way an errored child without an #error transition + does: it delivered nothing, and the caller was not cancelled" + (childInstance isCancelled or: [ callerActivation token isCancelled ]) ifTrue: [ callerActivation token cancelled. ^ self popThenResume: [ ] ]. childInstance atEnd ifFalse: [ ^ self ]. diff --git a/source/FluxBase-Core/FBSubFlowNode.class.st b/source/FluxBase-Core/FBSubFlowNode.class.st index b3ff2ff..351203c 100644 --- a/source/FluxBase-Core/FBSubFlowNode.class.st +++ b/source/FluxBase-Core/FBSubFlowNode.class.st @@ -45,3 +45,11 @@ FBSubFlowNode >> subFlow: aFBFlow [ FBSubFlowNode >> opensCallScope [ ^ true ] + +{ #category : 'testing' } +FBSubFlowNode >> parksOnEntry [ + "a call may take part in a deferred choice: its child usually waits - at a form, a + remote flow - and a child that runs through decides the choice as it finishes + (FBSubFlowExecutor>>finish), the way a condition that holds at once does" + ^ true +] diff --git a/source/FluxBase-Core/FBWait.class.st b/source/FluxBase-Core/FBWait.class.st index 43d8b5c..740191a 100644 --- a/source/FluxBase-Core/FBWait.class.st +++ b/source/FluxBase-Core/FBWait.class.st @@ -11,3 +11,10 @@ FBWait class >> isAbstract [ ^ self == FBWait ] + +{ #category : 'testing' } +FBWait >> parksOnEntry [ + "a wait may park - a condition wait that finds its condition holding goes on at once + instead, and decides a deferred choice as it does (FBWaitForConditionBehavior)" + ^ true +] diff --git a/source/FluxBase-Core/FBWaitForConditionBehavior.class.st b/source/FluxBase-Core/FBWaitForConditionBehavior.class.st index d9cc389..0dd9007 100644 --- a/source/FluxBase-Core/FBWaitForConditionBehavior.class.st +++ b/source/FluxBase-Core/FBWaitForConditionBehavior.class.st @@ -16,9 +16,12 @@ FBWaitForConditionBehavior >> execute [ A condition that holds is a step that is over: marked #done like any task's, since nothing marks a step done centrally and this activation would otherwise stay - #created in the log" + #created in the log. It is also a wait that went on, at once - which decides a + deferred choice this strand lies on the way a resumed wait does + (FBFlowInstance>>decideDeferredChoiceWonBy:)" (self node evaluateConditionWith: activation argument) ifTrue: [ + activation instance decideDeferredChoiceWonBy: activation token. activation done. activation followAllOutgoing ] ifFalse: [ activation waitForNode: self node withToken: activation token ]