Reputation: 33
I'm testing some feature of Spring state machine (version 4.0.0). When I come across fork/join feature, the behavior of JOIN is somewhat strange to me (FORK is working as I expected). Any explanation will be greatly appreciated.
Basically, I have a state machine (hierarchical state machine) that looks like below: Start from state I, it creates 2 orthogonal regions (L and R). Each region contains 2 states (L1, L2 and R1, R2) and 4 events to change the states back and forth. R2 and L2 join at the end then it leads to final state E
My understanding is that when L2 and R2 are both active, the transition leading to JOIN will be activated and then end up at E.
But when I tried to simulate this sequence of events [TIK, TOK, FUZ] (go to L2 then back to L1 and stay there in the first region, and then go to R2 in the second region), the JOIN transition activated immediately and ended up at E eventhough L2 was not active at that time (L1 was the one active).
States:
states.withStates()
.initial(I)
.fork(FORK)
.state(LR)
.join(JOIN)
.state(E)
.and().withStates().parent(LR)
.region("R")
.initial(R1)
.state(R2)
.and().withStates().parent(LR)
.region("L")
.initial(L1)
.state(L2)
Transitions:
transitions
.withExternal() .source(I) .target(FORK) .event(S)
.and()
.withFork() .source(FORK) .target(L1).target(R1)
.and()
.withExternal() .source(L1) .target(L2) .event(TIK)
.and()
.withExternal() .source(L2) .target(L1) .event(TOK)
.and()
.withExternal() .source(R1) .target(R2) .event(FUZ)
.and()
.withExternal() .source(R2) .target(R1) .event(BAZ)
.and()
.withJoin() .source(R2).source(L2) .target(JOIN)
.and()
.withExternal() .source(JOIN) .target(E)
The code that sends the events:
log.info("Start sending events");
sm5.sendEvent(SM5Config.MEvent.S);
log.info("1. CURRENT STATE = {}", sm5.getState());
sm5.sendEvent(SM5Config.MEvent.TIK);
sm5.sendEvent(SM5Config.MEvent.TOK);
log.info("2. CURRENT STATE = {}", sm5.getState());
sm5.sendEvent(SM5Config.MEvent.FUZ);
log.info("3. CURRENT STATE = {}", sm5.getState());
And the log (I added some listener logs)
Start sending events
================== State entered: LR
================== State entered: L1
================== State entered: R1
1. CURRENT STATE = RegionState [getIds()=[LR, L1, R1]...
================== State entered: L2
================== State entered: L1
2. CURRENT STATE = RegionState [getIds()=[LR, L1, R1]...
================== State entered: R2
================== State entered: E
3. CURRENT STATE = ObjectState [getIds()=[E]...
I'm not sure my code is wrong or it is the expected behavior.
PS: Sorry for my bad Edit/English.
Upvotes: 1
Views: 278
Reputation: 1206
Based on your setup for states/events, your FORK/JOIN has 2 completionListeners
(if you debug stateMachine.getState()
). The moment you go to either L2 or R2, the relevant one is removed from that list. And based on spring implementation there is no way back from this.
Now, you could implement some custom, ugly, workarounds, based on your business case. For example, have a listener to check if either event TOK or BAZ have lead to a state that isn't E and then reset state to FORK (this would do things like delete history etc, so it has to fit your case):
public class SimpleStateMachineInterceptor extends StateMachineInterceptorAdapter<String, String> {
private static final Logger log = Logger.getLogger(SimpleStateMachineInterceptor.class.getName());
@Override
public StateContext<String, String> postTransition(StateContext<String, String> stateContext) {
log.info("Transition was completed. New state: [" + stateContext.getTarget().getId() + "]");
String event = stateContext.getTransition().getTrigger().getEvent();
if ("TOK".equals(event)) {
StateMachine<String, String> stateMachine = stateContext.getStateMachine();
if (!stateMachine.getState().getIds().contains("E") && stateMachine.getState().getIds().contains("R1")) {
resetState(stateMachine);
}
} else if ("BAZ".equals(event)) {
StateMachine<String, String> stateMachine = stateContext.getStateMachine();
if (!stateMachine.getState().getIds().contains("E") && stateMachine.getState().getIds().contains("L1")) {
resetState(stateMachine);
}
}
return stateContext;
}
private void resetState(StateMachine<String, String> stateMachine) {
stateMachine.getStateMachineAccessor()
.doWithAllRegions(sma -> {
sma.addStateMachineInterceptor(this);
sma.resetStateMachine(
new DefaultStateMachineContext<>("FORK", null, null, null));
});
}
}
I ve created a short project to demonstrate it. Have a look in tests for some transition cases.
Extra thoughts:
DefaultStateMachineContext
accepts many values, so play around if needed.Upvotes: 1
Reputation: 33
I think I figured out the answer after reading below example. It's not about Spring statemachine but about the state machine in general, but I guess it will apply to Spring statemachine as well.
So the answer is: It is expected behavior.
It says that:
On Trigger event X, StateA1 will exit and enter StateA2; after the entry and doActivity behavior has run, the completion events of StateA2 are dispatched and recalled. Then the transition from StateA2 to the Join pseudostate is enabled and traversed.
I understand that the transition from L2 (in my question) to JOIN was already done event when the state went back to L1 after that. The JOIN pseudostate only wait for the transition from R2 to come (later) to start its job.
So if I want to achieve the behavior that I am expecting in my question, it need to design it a bit differently.
Still welcome any other ideas/comments.
Upvotes: 1