Parallel submachines break exitActions
If you attach more then one submachine to a state using .parent("XYZ") it will break the exit actions of these submachines. See example below. This bug is hinted at by https://github.com/spring-projects/spring-statemachine/issues/969 but does not include the correct reason why.
Unfortunately i don't have any fancy graphical tool, so you get some excalidraw from me.
This one breaks the state machine:
This one is fine:
public class Testy {
private static final Logger logger = LoggerFactory.getLogger(Testy.class);
public static void main(String[] args) throws Exception {
StateMachineBuilder.Builder<String, String> builder = StateMachineBuilder.builder();
builder.configureConfiguration().withConfiguration()
.regionExecutionPolicy(RegionExecutionPolicy.SEQUENTIAL)
.transitionConflictPolicy(TransitionConflictPolicy.PARENT)
.listener(new StateMachineListenerAdapter<>() {
@Override
public void stateChanged(org.springframework.statemachine.state.State<String, String> from, org.springframework.statemachine.state.State<String, String> to) {
logger.info("from \n{} \nto \n{}", from, to);
}
@Override
public void eventNotAccepted(Message<String> event) {
//logger.info("event not accepted: {}", event);
}
@Override
public void transition(Transition<String, String> transition) {
logger.info("transition: {}", transition);
}
})
.machineId("GD24-machine");
builder.configureStates().withStates()
.initial("order-canExist")
.state("order-Exists", ctx -> logger.info("hello order"), ctx -> logger.info("goodbye order"))
.and().withStates()
.parent("order-Exists")
.initial("ncprogram-canExist")
.state("ncprogram-Exists", ctx -> logger.info("hello ncprog"), ctx -> logger.info("goodbye ncprog"))
.and().withStates()
// change to "ncprogram-Exists" and it will magically work
.parent("order-Exists")
.initial("madeup-canExist")
.state("madeup-Exists", ctx -> logger.info("hello madeup"), ctx -> logger.info("goodbye madeup"));
builder.configureTransitions()
.withExternal().source("order-canExist").target("order-Exists")
.event("order-start")
.and().withExternal().source("order-Exists").target("order-canExist")
.event("order-end")
.and().withExternal().source("ncprogram-canExist").target("ncprogram-Exists")
.event("ncprogram-start")
.and().withExternal().source("madeup-canExist").target("madeup-Exists")
.event("madeup-start");
StateMachine<String, String> stateMachine = builder.build();
stateMachine.startReactively().subscribe();
logger.info("sending ncprog start");
Message<String> message1 = MessageBuilder.withPayload("ncprogram-start").build();
stateMachine.sendEvent(Mono.just(message1)).subscribe();
logger.info("sending order start");
Message<String> message2 = MessageBuilder.withPayload("order-start").build();
stateMachine.sendEvent(Mono.just(message2)).subscribe();
logger.info("sending ncprog start");
Message<String> message3 = MessageBuilder.withPayload("ncprogram-start").build();
stateMachine.sendEvent(Mono.just(message3)).subscribe();
logger.info("sending madeup start");
Message<String> message4 = MessageBuilder.withPayload("madeup-start").build();
stateMachine.sendEvent(Mono.just(message4)).subscribe();
logger.info("sending order end");
Message<String> message5 = MessageBuilder.withPayload("order-end").build();
stateMachine.sendEvent(Mono.just(message5)).subscribe();
}
}
Expected output from first example would be:
15:40:03,713 INFO com.plansee.edge.flink.Testy [] - goodbye madeup
15:40:03,720 INFO com.plansee.edge.flink.Testy [] - goodbye ncprog
15:40:03,720 INFO com.plansee.edge.flink.Testy [] - goodbye order
Instead, only this comes:
15:40:35,452 INFO com.plansee.edge.flink.Testy [] - goodbye order
I assume this is happening because the variant with 2 submachines is triggering a RegionState to ObjectState transition, while with 1 submachine it is triggering a StateMachineState to ObjectState
Looking at the debug output some more, it seems that while building the statemachine using the same parent twice turns the submachines into regions. That seems wrong to me.
Having the very same problem here. And docs confirm that using parent multiple times does turn submachines into regions:
There are no special configuration methods to mark a collection of states to be part of an orthogonal state. To put it simply, orthogonal state is created when the same hierarchical state machine has multiple sets of states, each of which has an initial state. Because an individual state machine can only have one initial state, multiple initial states must mean that a specific state must have multiple independent regions.
The docs also state that:
... whatever you do in your action, you need to be able to catch
InterruptedExceptionor, more generally, periodically check whether Thread is interrupted.
But the problem is that I don't see any signs of those regions' actions being cancelled or interrupted in anyway as I step out of the parent SM and despite the logs show the submachines are getting stopped.
What's worse, it's against behavior explained in docs' examples.
Fully agreed with @wlfbck.
Let me put this a little bit differently. In the following examples I will log state entry, change and exit.
Configuration without regions (similar to docs' showcase)
states
.withStates()
.initial(States.S0)
.and()
.withStates()
.parent(States.S0)
.initial(States.S1)
.state(States.S2)
.and()
.withStates()
.parent(States.S1)
.initial(States.S11)
.state(States.S12)
.and()
.withStates()
.parent(States.S2)
.initial(States.S21)
.state(States.S22);
Starting up this config will log:
Enter S0
Enter S1
Enter S11
State change to S11
State change to S1
State change to S0
Sending any of events A to D will result in the same logs output:
Exit S11
Exit S1
Enter S2
Enter S21
State change to S21
State change to S2
No issues with this part.
Configuration including regions
To the state S0 from the previous example a dummy initial substate S3 has been added effectively splitting S0 into 2 regions:
states
.withStates()
.initial(States.S0)
.and()
.withStates()
.parent(States.S0)
.initial(States.S1)
.state(States.S2)
.and()
.withStates()
.parent(States.S1)
.initial(States.S11)
.state(States.S12)
.and()
.withStates()
.parent(States.S2)
.initial(States.S21)
.state(States.S22)
.and()
.withStates() // add dummy initial substate
.parent(States.S0)
.initial(States.S3);
Starting logs haven't changed much:
Enter S0
Enter S1
Enter S11
State change to S11
State change to S1
State change to S0
Enter S3 <---
State change to S3 <---
Now, sending event A or C will miss the S11 exit:
Exit S1
Enter S2
Enter S21
State change to S21
State change to S2
and sending event B or D will lack any exit at all and will perform some strange state changes:
Enter S2
Enter S21
State change to S21
Enter S21
State change to S21
But the biggest problem in case of regions is that Actions assigned to states are not being interrupted by any transition contrary to what the documentation says. And I would consider this a huge flaw. I'm not sure what State Machine theory says about the above situation but I would expect a region to behave similarly to the first example since such a region is simply considered a submachine.
Ignore the above and instruct user to move his lazy hype-coding ass.