diff --git a/spring-statemachine-core/src/main/java/org/springframework/statemachine/config/AbstractStateMachineFactory.java b/spring-statemachine-core/src/main/java/org/springframework/statemachine/config/AbstractStateMachineFactory.java index 37e42bdc..4bbfb460 100644 --- a/spring-statemachine-core/src/main/java/org/springframework/statemachine/config/AbstractStateMachineFactory.java +++ b/spring-statemachine-core/src/main/java/org/springframework/statemachine/config/AbstractStateMachineFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2015-2017 the original author or authors. + * Copyright 2015-2019 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,6 +17,7 @@ package org.springframework.statemachine.config; import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.HashMap; import java.util.Iterator; import java.util.List; @@ -799,27 +800,29 @@ public abstract class AbstractStateMachineFactory extends LifecycleObjectS } else if (stateData.getPseudoStateKind() == PseudoStateKind.JOIN) { S s = stateData.getState(); List list = stateMachineTransitions.getJoins().get(s); - List> joins = new ArrayList>(); + List>> joins = new ArrayList>>(); - // if join source is a regionstate, get - // it's end states from regions + // if join source is an orthogonal state, assume we're joining by region end states + // and actually use list of states from each region to support case where one region + // defines multiple end states. if (list.size() == 1) { State ss1 = stateMap.get(list.get(0)); if (ss1 instanceof RegionState) { Collection> regions = ((RegionState)ss1).getRegions(); for (Region r : regions) { + List> j = new ArrayList>(); Collection> ss2 = r.getStates(); for (State ss3 : ss2) { if (ss3.getPseudoState() != null && ss3.getPseudoState().getKind() == PseudoStateKind.END) { - joins.add(ss3); - continue; + j.add(ss3); } } + joins.add(j); } } } else { for (S fs : list) { - joins.add(stateMap.get(fs)); + joins.add(Collections.singletonList(stateMap.get(fs))); } } diff --git a/spring-statemachine-core/src/main/java/org/springframework/statemachine/state/JoinPseudoState.java b/spring-statemachine-core/src/main/java/org/springframework/statemachine/state/JoinPseudoState.java index 9a695396..3e180eb2 100644 --- a/spring-statemachine-core/src/main/java/org/springframework/statemachine/state/JoinPseudoState.java +++ b/spring-statemachine-core/src/main/java/org/springframework/statemachine/state/JoinPseudoState.java @@ -1,5 +1,5 @@ /* - * Copyright 2015-2018 the original author or authors. + * Copyright 2015-2019 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,6 +17,7 @@ package org.springframework.statemachine.state; import java.util.ArrayList; import java.util.Collection; +import java.util.Iterator; import java.util.List; import org.apache.commons.logging.Log; @@ -37,7 +38,7 @@ import org.springframework.util.Assert; public class JoinPseudoState extends AbstractPseudoState { private final static Log log = LogFactory.getLog(JoinPseudoState.class); - private final List> joins; + private final List>> joins; private final JoinTracker tracker; private final List> joinTargets; @@ -47,7 +48,7 @@ public class JoinPseudoState extends AbstractPseudoState { * @param joins the joins * @param joinTargets the target states */ - public JoinPseudoState(List> joins, List> joinTargets) { + public JoinPseudoState(List>> joins, List> joinTargets) { super(PseudoStateKind.JOIN); this.joins = joins; this.joinTargets = joinTargets; @@ -79,7 +80,7 @@ public class JoinPseudoState extends AbstractPseudoState { * * @return the joins */ - public List> getJoins() { + public List>> getJoins() { return joins; } @@ -104,48 +105,67 @@ public class JoinPseudoState extends AbstractPseudoState { private class JoinTracker { - private final List> track; + private final List>> track; private volatile boolean notified = false; public JoinTracker() { - this.track = new ArrayList>(joins); - for (State tt : joins) { - final State t = tt; - t.addStateListener(new StateListenerAdapter() { + this.track = new ArrayList>>(joins.size()); + for (List> list : joins) { + this.track.add(new ArrayList>(list)); + for (State tt : list) { + final State t = tt; + t.addStateListener(new StateListenerAdapter() { - @Override - public void onComplete(StateContext context) { - boolean trackSizeZero = false; - synchronized (track) { - track.remove(t); - if (track.size() == 0) { - trackSizeZero = true; + @Override + public void onComplete(StateContext context) { + synchronized (track) { + Iterator>> iterator = track.iterator(); + while(iterator.hasNext()) { + List> next = iterator.next(); + if (next.contains(t)) { + iterator.remove(); + } + } + } + if (!notified && track.isEmpty()) { + log.debug("Join complete"); + notified = true; + notifyContext(new DefaultPseudoStateContext(JoinPseudoState.this, PseudoAction.JOIN_COMPLETED)); } } - if (!notified && trackSizeZero) { - log.debug("Join complete"); - notified = true; - notifyContext(new DefaultPseudoStateContext(JoinPseudoState.this, PseudoAction.JOIN_COMPLETED)); - } - } - }); + }); + } } } void reset() { track.clear(); - track.addAll(joins); + for (List> list : joins) { + track.add(new ArrayList>(list)); + } notified = false; } void reset(Collection ids) { - track.clear(); - for (State j : joins) { - if (!ids.contains(j.getId())) { - track.add(j); + // put pack all as normal reset + reset(); + + // remove given states to reflect correct join stage + Iterator>> trackIter = track.iterator(); + while(trackIter.hasNext()) { + List> list = trackIter.next(); + Iterator> iterator = list.iterator(); + while(iterator.hasNext()) { + State next = iterator.next(); + if (ids.contains(next.getId())) { + iterator.remove(); + } + } + // also remove list if it became empty + if (list.isEmpty()) { + trackIter.remove(); } } - notified = false; } public boolean isNotified() { diff --git a/spring-statemachine-core/src/main/java/org/springframework/statemachine/support/DefaultStateMachineExecutor.java b/spring-statemachine-core/src/main/java/org/springframework/statemachine/support/DefaultStateMachineExecutor.java index 5a6616d1..d6bb055e 100644 --- a/spring-statemachine-core/src/main/java/org/springframework/statemachine/support/DefaultStateMachineExecutor.java +++ b/spring-statemachine-core/src/main/java/org/springframework/statemachine/support/DefaultStateMachineExecutor.java @@ -1,5 +1,5 @@ /* - * Copyright 2015-2018 the original author or authors. + * Copyright 2015-2019 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -242,8 +242,10 @@ public class DefaultStateMachineExecutor extends LifecycleObjectSupport im // special handling of join if (StateMachineUtils.isPseudoState(t.getTarget(), PseudoStateKind.JOIN)) { if (joinSyncStates.isEmpty()) { - List> joins = ((JoinPseudoState)t.getTarget().getPseudoState()).getJoins(); - joinSyncStates.addAll(joins); + List>> joins = ((JoinPseudoState)t.getTarget().getPseudoState()).getJoins(); + for (List> j : joins) { + joinSyncStates.addAll(j); + } } joinSyncTransitions.add(t); boolean removed = joinSyncStates.remove(t.getSource()); diff --git a/spring-statemachine-core/src/test/java/org/springframework/statemachine/AbstractStateMachineTests.java b/spring-statemachine-core/src/test/java/org/springframework/statemachine/AbstractStateMachineTests.java index 57895c21..2051ca96 100644 --- a/spring-statemachine-core/src/test/java/org/springframework/statemachine/AbstractStateMachineTests.java +++ b/spring-statemachine-core/src/test/java/org/springframework/statemachine/AbstractStateMachineTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2015-2018 the original author or authors. + * Copyright 2015-2019 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -68,13 +68,13 @@ public abstract class AbstractStateMachineTests { public enum TestStates { SI,S1,S2,S3,S4,SF,SH, S10,S11,S101,S111,S112,S12,S121,S122,S13, - S20,S21,S201,S211,S212, + S20,S21,S22,S201,S211,S212, S1011,S1012,S2011,S2012, S30,S31,S32,S33 } public enum TestEvents { - E1,E2,E3,E4,EF,EH + E1,E2,E3,E4,E5,EF,EH } public static enum TestStates2 { diff --git a/spring-statemachine-core/src/test/java/org/springframework/statemachine/state/CompletionEventTests.java b/spring-statemachine-core/src/test/java/org/springframework/statemachine/state/CompletionEventTests.java index 225c9c88..90a8830d 100644 --- a/spring-statemachine-core/src/test/java/org/springframework/statemachine/state/CompletionEventTests.java +++ b/spring-statemachine-core/src/test/java/org/springframework/statemachine/state/CompletionEventTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2018 the original author or authors. + * Copyright 2019 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -173,6 +173,40 @@ public class CompletionEventTests extends AbstractStateMachineTests { assertThat(machine.getState().getId(), is("S3")); } + @SuppressWarnings({ "unchecked" }) + @Test + public void testRegionWithoutStateActionCompletesWithMultipleEnds1() throws Exception { + context.register(Config6.class); + context.refresh(); + assertTrue(context.containsBean(StateMachineSystemConstants.DEFAULT_ID_STATEMACHINE)); + StateMachine machine = + context.getBean(StateMachineSystemConstants.DEFAULT_ID_STATEMACHINE, StateMachine.class); + + machine.start(); + assertThat(machine.getState().getId(), is("S1")); + + machine.sendEvent(MessageBuilder.withPayload("E1").build()); + machine.sendEvent(MessageBuilder.withPayload("E2").build()); + assertThat(machine.getState().getId(), is("S3")); + } + + @SuppressWarnings({ "unchecked" }) + @Test + public void testRegionWithoutStateActionCompletesWithMultipleEnds2() throws Exception { + context.register(Config6.class); + context.refresh(); + assertTrue(context.containsBean(StateMachineSystemConstants.DEFAULT_ID_STATEMACHINE)); + StateMachine machine = + context.getBean(StateMachineSystemConstants.DEFAULT_ID_STATEMACHINE, StateMachine.class); + + machine.start(); + assertThat(machine.getState().getId(), is("S1")); + + machine.sendEvent(MessageBuilder.withPayload("E1").build()); + machine.sendEvent(MessageBuilder.withPayload("E3").build()); + assertThat(machine.getState().getId(), is("S3")); + } + @Configuration @EnableStateMachine static class Config1 extends StateMachineConfigurerAdapter { @@ -397,6 +431,69 @@ public class CompletionEventTests extends AbstractStateMachineTests { } } + @Configuration + @EnableStateMachine + static class Config6 extends StateMachineConfigurerAdapter { + + @Override + public void configure(StateMachineStateConfigurer states) throws Exception { + states + .withStates() + .initial("S1") + .state("S2") + .state("S3") + .and() + .withStates() + .parent("S2") + .initial("S201") + .state("S202") + .end("S203") + .end("S204") + .and() + .withStates() + .parent("S2") + .initial("S211") + .state("S212") + .end("S213"); + + } + + @Override + public void configure(StateMachineTransitionConfigurer transitions) throws Exception { + transitions + .withExternal() + .source("S1") + .target("S2") + .event("E1") + .and() + .withExternal() + .source("S201") + .target("S202") + .and() + .withExternal() + .source("S202") + .target("S203") + .event("E2") + .and() + .withExternal() + .source("S202") + .target("S204") + .event("E3") + .and() + .withExternal() + .source("S211") + .target("S212") + .and() + .withExternal() + .source("S212") + .target("S213") + .and() + .withExternal() + .source("S2") + .target("S3"); + } + } + private static class TestCountAction implements Action { int count = 0; diff --git a/spring-statemachine-core/src/test/java/org/springframework/statemachine/state/JoinStateTests.java b/spring-statemachine-core/src/test/java/org/springframework/statemachine/state/JoinStateTests.java index 655acf41..016f993a 100644 --- a/spring-statemachine-core/src/test/java/org/springframework/statemachine/state/JoinStateTests.java +++ b/spring-statemachine-core/src/test/java/org/springframework/statemachine/state/JoinStateTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2015-2018 the original author or authors. + * Copyright 2015-2019 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -313,6 +313,72 @@ public class JoinStateTests extends AbstractStateMachineTests { assertThat("Interceptor postStateChange has null transition", nullCheck.get(), is(false)); } + @Test + @SuppressWarnings("unchecked") + public void testJoinSuperMultipleEnds1() throws Exception { + context.register(BaseConfig.class, Config4.class); + context.refresh(); + ObjectStateMachine machine = + context.getBean(StateMachineSystemConstants.DEFAULT_ID_STATEMACHINE, ObjectStateMachine.class); + TestListener listener = new TestListener(); + machine.addStateListener(listener); + listener.reset(1); + assertThat(machine, notNullValue()); + machine.start(); + assertThat(listener.stateChangedLatch.await(2, TimeUnit.SECONDS), is(true)); + assertThat(listener.stateChangedCount, is(1)); + + listener.reset(3); + machine.sendEvent(TestEvents.E1); + assertThat(listener.stateChangedLatch.await(2, TimeUnit.SECONDS), is(true)); + assertThat(listener.stateChangedCount, is(3)); + + listener.reset(1); + machine.sendEvent(TestEvents.E2); + assertThat(listener.stateChangedLatch.await(2, TimeUnit.SECONDS), is(true)); + assertThat(listener.stateChangedCount, is(1)); + + listener.reset(2); + machine.sendEvent(TestEvents.E3); + assertThat(listener.stateChangedLatch.await(2, TimeUnit.SECONDS), is(true)); + assertThat(listener.stateChangedCount, is(2)); + + assertThat(machine.getState().getIds(), contains(TestStates.S4)); + } + + @Test + @SuppressWarnings("unchecked") + public void testJoinSuperMultipleEnds2() throws Exception { + context.register(BaseConfig.class, Config4.class); + context.refresh(); + ObjectStateMachine machine = + context.getBean(StateMachineSystemConstants.DEFAULT_ID_STATEMACHINE, ObjectStateMachine.class); + TestListener listener = new TestListener(); + machine.addStateListener(listener); + listener.reset(1); + assertThat(machine, notNullValue()); + machine.start(); + assertThat(listener.stateChangedLatch.await(2, TimeUnit.SECONDS), is(true)); + assertThat(listener.stateChangedCount, is(1)); + + listener.reset(3); + machine.sendEvent(TestEvents.E1); + assertThat(listener.stateChangedLatch.await(2, TimeUnit.SECONDS), is(true)); + assertThat(listener.stateChangedCount, is(3)); + + listener.reset(1); + machine.sendEvent(TestEvents.E5); + assertThat(listener.stateChangedLatch.await(2, TimeUnit.SECONDS), is(true)); + assertThat(listener.stateChangedCount, is(1)); + + listener.reset(2); + machine.sendEvent(TestEvents.E3); + assertThat(listener.stateChangedLatch.await(2, TimeUnit.SECONDS), is(true)); + assertThat(listener.stateChangedCount, is(2)); + + assertThat(machine.getState().getIds(), contains(TestStates.S4)); + } + @Configuration @EnableStateMachine static class Config1 extends EnumStateMachineConfigurerAdapter { @@ -497,6 +563,70 @@ public class JoinStateTests extends AbstractStateMachineTests { } + @Configuration + @EnableStateMachine + static class Config4 extends EnumStateMachineConfigurerAdapter { + + @Override + public void configure(StateMachineStateConfigurer states) throws Exception { + states + .withStates() + .initial(TestStates.SI) + .state(TestStates.S2) + .join(TestStates.S3) + .state(TestStates.S4) + .and() + .withStates() + .parent(TestStates.S2) + .initial(TestStates.S20) + .end(TestStates.S21) + .end(TestStates.S22) + .and() + .withStates() + .parent(TestStates.S2) + .initial(TestStates.S30) + .end(TestStates.S31); + } + + @Override + public void configure(StateMachineTransitionConfigurer transitions) throws Exception { + transitions + .withExternal() + .source(TestStates.SI) + .target(TestStates.S2) + .event(TestEvents.E1) + .and() + .withExternal() + .source(TestStates.S20) + .target(TestStates.S21) + .event(TestEvents.E2) + .and() + .withExternal() + .source(TestStates.S20) + .target(TestStates.S22) + .event(TestEvents.E5) + .and() + .withExternal() + .source(TestStates.S30) + .target(TestStates.S31) + .event(TestEvents.E3) + .and() + .withJoin() + .source(TestStates.S2) + .target(TestStates.S3) + .and() + .withExternal() + .source(TestStates.S3) + .target(TestStates.S4) + .and() + .withExternal() + .source(TestStates.S4) + .target(TestStates.SI) + .event(TestEvents.E4); + } + + } + private static class TestListener extends StateMachineListenerAdapter { volatile CountDownLatch stateChangedLatch = new CountDownLatch(1);