spring-statemachine icon indicating copy to clipboard operation
spring-statemachine copied to clipboard

Parallel submachines break exitActions

Open wlfbck opened this issue 1 year ago • 3 comments

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

This one is fine: grafik

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

wlfbck avatar Sep 30 '24 13:09 wlfbck

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.

wlfbck avatar Sep 30 '24 14:09 wlfbck

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 InterruptedException or, 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.

marekmaciejewski avatar Jun 10 '25 07:06 marekmaciejewski

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);

Image 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);

Image 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.

marekmaciejewski avatar Jun 16 '25 13:06 marekmaciejewski