From 4ec207e81e9119149ef5d244dc00db7099bb285a Mon Sep 17 00:00:00 2001 From: Norbert Hartl Date: Mon, 21 Sep 2026 12:40:05 +0200 Subject: [PATCH] A parallel scope keeps its own fork activation FBBaseExecutor>>activationOf: answers the most recent activation of a node. A node has one activation per pass through the same fork, so the most recent activation of a fork is only the asking scope's while that scope is the one running - once it is over, the newest belongs to a later pass. A finished scope therefore read a running one's fork. #outputFor: asks whether the loop is over, was told "no" by another loop, and answered the slot of its own last iteration instead of its first value: the same activation said one thing while the second pass ran and another once everything was over. The scope resolves its fork once, on the first use - which is the first step of its own scope, when the newest activation of that fork is still its own, since a second pass cannot start before the scope is popped - and keeps it. Every other node is looked up as before; for those the most recent one is right, because only a live scope looks a node up and its own activations are the newest there are. Found while tracing a customer report of 2026-09-18 (a loop that ran twice wrote its first pass's last element 300 times); the reading path that report went through is ApptiveScript's step(id), fixed separately in apptive-base. Co-Authored-By: Claude Opus 5 --- .../FBParallelExecutorTest.class.st | 39 +++++++++++++++++++ .../FluxBase-Core/FBParallelExecutor.class.st | 25 +++++++++++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/source/FluxBase-Core-Tests/FBParallelExecutorTest.class.st b/source/FluxBase-Core-Tests/FBParallelExecutorTest.class.st index 766dc78..0b42fe3 100644 --- a/source/FluxBase-Core-Tests/FBParallelExecutorTest.class.st +++ b/source/FluxBase-Core-Tests/FBParallelExecutorTest.class.st @@ -21,6 +21,45 @@ FBParallelExecutorTest >> testAddPendingKeepsTheTokenTheTransitionWasQueuedWith self assert: workItem token == token ] +{ #category : 'as yet unclassified' } +FBParallelExecutorTest >> testAFinishedScopeReadsItsOwnForkWhileAnotherPassRuns [ + "A fork that two strands enter opens two scopes one after the other, and the body node + gets one activation per scope. Asked for its output, the first scope's executor looked + its fork up again and found the newest activation of that node - the running second + scope's, which is not done - so instead of answering the value of a closed scope it + answered the slot of its own last iteration. The same activation then said one thing + while the second pass ran and another once everything was over. + + A scope's fork is its own: what a finished scope answers does not depend on whether + another pass through the same fork happens to be running" + + | start split fork body reader flow instance readings after | + readings := OrderedCollection new. + start := FBStartEvent new. + split := start addOutgoingNode: ((FBPluggableTask named: 'split') block: [ :act | + act value: act argument ]). + fork := split addOutgoingNode: (FBParallelNode named: 'fork'). + "a second transition into the same fork: the loop runs twice" + split addOutgoing: (FBTransition source: split target: fork). + body := fork addOutgoingNode: ((FBPluggableTask named: 'body') block: [ :act | + act value: act argument , '*' ]). + reader := body addOutgoingNode: ((FBPluggableTask named: 'reader') block: [ :act | + | firstBody | + firstBody := act flowInstance activations detect: [ :each | each node = body ]. + "only what a scope other than the one that made it reads is at stake here" + firstBody executor == act executor ifFalse: [ readings add: firstBody output ]. + act value: act argument ]). + reader addOutgoingNode: FBEndEvent new. + flow := FBFlow new start: start. + instance := flow newInstance. + instance executeWith: #( 'a' 'b' 'c' ). + + self assert: instance state equals: #done. + after := (instance activations detect: [ :each | each node = body ]) output. + self assert: readings size equals: 3. + readings do: [ :each | self assert: each equals: after ] +] + { #category : 'as yet unclassified' } FBParallelExecutorTest >> testScopeBodyIsQueuedWithTheForksOwnChildToken [ "the body of a parallel scope is queued by the executor itself rather than by an diff --git a/source/FluxBase-Core/FBParallelExecutor.class.st b/source/FluxBase-Core/FBParallelExecutor.class.st index 7a4e89c..b682186 100644 --- a/source/FluxBase-Core/FBParallelExecutor.class.st +++ b/source/FluxBase-Core/FBParallelExecutor.class.st @@ -9,7 +9,8 @@ Class { 'iterationScope', 'iterationScopeIndex', 'openIterationScopes', - 'current' + 'current', + 'forkActivation' ], #category : 'FluxBase-Core-Model', #package : 'FluxBase-Core', @@ -39,6 +40,28 @@ FBParallelExecutor >> activateTransition: aTransition with: value token: aToken self activate: activation ] +{ #category : 'accessing' } +FBParallelExecutor >> activationOf: aFBNode [ + "my fork's activation is mine to keep, not to look up again. A node has one activation + per pass through the same fork, so the newest activation of my fork is only mine while + my scope is the one running: once my scope is over, that one is a later pass's. + Answering it made a finished scope read a running one's fork - #outputFor: asks + whether the loop is over, was told 'no' by another loop, and answered the slot of its + own last iteration instead of its first value. The same activation then said one thing + while the second pass ran and another once everything was over (reported 2026-09-18). + + Resolved on first use rather than handed in at construction: at that moment - the + first step of my own scope, through #scopeToken - the newest activation of my fork is + mine, since a second pass through it cannot start before I am popped. An executor + persisted before this field existed resolves it the same way when it runs on. + + Every other node is looked up as before, and for those the newest one is right: only a + live scope looks a node up, and its own activations are the newest there are" + + aFBNode = node ifFalse: [ ^ super activationOf: aFBNode ]. + ^ forkActivation ifNil: [ forkActivation := super activationOf: aFBNode ] +] + { #category : 'accessing' } FBParallelExecutor >> activationClass [ ^ node activationClass