javastate-machinefsmfork-joinspring-statemachine

Unexpected behavior Spring state machine JOIN


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

State chart

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.


Solution

  • 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: