finite-state-machine icon indicating copy to clipboard operation
finite-state-machine copied to clipboard

Different source and target for same event

Open ptrstn opened this issue 4 years ago • 6 comments

Hello!

Let's assume the following state machine:

stateDiagram-v2
    [*] --> A
    A --> B: event_2
    A --> C: event_3
    B --> A: event_1
    B --> C: event_4
    C --> D: event_4
    D --> A: event_1

So event_1 triggers the transition from:

  • B --> A
  • D --> A

But event_4 triggers the transition from (different source and target in each case):

  • B --> C
  • C --> D

If I understood correctly this would result in the following code:

from finite_state_machine import StateMachine, transition


class ExampleStateMachine(StateMachine):
    initial_state = "A"

    def __init__(self):
        self.state = self.initial_state
        super().__init__()

    @transition(source=["B", "D"], target="A")
    def event_1(self):
        print("Transitioning to A by event_1")

    @transition(source="A", target="B")
    def event_2(self):
        print("Transitioning to B by event_2")

    @transition(source="A", target="C")
    def event_3(self):
        print("Transitioning to C by event_3")

    @transition(source="B", target="C")
    @transition(source="C", target="D")
    def event_4(self):
        pass

The problem is with event_4 since it would require two different transitions

  @transition(source="C", target="D") 
  @transition(source="B", target="C")
  def event_4(self):
      pass

But when I try to run this machine, I get the following error message:

fsm = ExampleStateMachine()
fsm.event_2()
fsm.event_1()
fsm.event_2()
fsm.event_4()  # <- InvalidStartState exception raised here
fsm.event_4()
fsm.event_1()
fsm.event_3()
fsm.event_4()
fsm.event_1()
finite_state_machine.exceptions.InvalidStartState: Current state is B. event_4 allows transitions from ['C'].

Now I am not sure where my thinking error is. Is my code wrong or are multiple transitions really not supported?

Thanks a lot!

ptrstn avatar Dec 07 '21 06:12 ptrstn

Possible workaround inspired by:

  • https://github.com/Shuttl-Tech/simple-state-machine#what-if-my-function-has-2or-more-possible-transitions
  • https://stackoverflow.com/a/35448590/9907540
from finite_state_machine import StateMachine, transition


class ExampleStateMachine(StateMachine):
    initial_state = "A"

    def __init__(self):
        self.state = self.initial_state
        super().__init__()

    @transition(source=["B", "D"], target="A")
    def event_1(self):
        print("Transitioning to A by event_1")

    @transition(source="A", target="B")
    def event_2(self):
        print("Transitioning to B by event_2")

    @transition(source="A", target="C")
    def event_3(self):
        print("Transitioning to C by event_3")

    @transition(source="B", target="C")
    def _transition_to_C(self):
        print("Transitioning to C by event_4")

    @transition(source="C", target="D")
    def _transition_to_D(self):
        print("Transitioning to D by event_4")

    def event_4(self):
        if self.state == "C":
            self._transition_to_D()
        if self.state == "B":
            self._transition_to_C()

Running this machine seems to work fine:

fsm = ExampleStateMachine()
fsm.event_2()
fsm.event_1()
fsm.event_2()
fsm.event_4()  # <- works
fsm.event_4()
fsm.event_1()
fsm.event_3()
fsm.event_4()
fsm.event_1()
Transitioning to B by event_2
Transitioning to A by event_1
Transitioning to B by event_2
Transitioning to C by event_4
Transitioning to D by event_4
Transitioning to A by event_1
Transitioning to C by event_3
Transitioning to D by event_4
Transitioning to A by event_1

ptrstn avatar Dec 07 '21 07:12 ptrstn

For reference, the desired finite state machine implemented with transitions which you also talked about in your talk:

from transitions import Machine, State


class SomeClass:
    pass


class StateA(State):
    def enter(self, event_data):
        print("Entering State A")

    def exit(self, event_data):
        print("Exiting State A")


class StateB(State):
    def enter(self, event_data):
        print("Entering State B")

    def exit(self, event_data):
        print("Exiting State B")


class StateC(State):
    def enter(self, event_data):
        print("Entering State C")

    def exit(self, event_data):
        print("Exiting State C")


class StateD(State):
    def enter(self, event_data):
        print("Entering State D")

    def exit(self, event_data):
        print("Exiting State D")


states = [StateA(name="A"), StateB(name="B"), StateC(name="C"), StateD(name="D")]

transitions = [
    {"trigger": "event_2", "source": "A", "dest": "B"},
    {"trigger": "event_3", "source": "A", "dest": "C"},
    {"trigger": "event_1", "source": "B", "dest": "A"},
    {"trigger": "event_4", "source": "B", "dest": "C"},
    {"trigger": "event_4", "source": "C", "dest": "D"},
    {"trigger": "event_1", "source": "D", "dest": "A"},
]

some_object = SomeClass()

machine = Machine(some_object, states=states, transitions=transitions, initial="A")

Here, the finite state machine works as expected:

print(f"Initial State: {some_object.state}")

print()
print("Calling event_2")
some_object.event_2()
print(f"Now in State: {some_object.state}")

print()
print("Calling event_1")
some_object.event_1()
print(f"Now in State: {some_object.state}")

print()
print("Calling event_2")
some_object.event_2()
print(f"Now in State: {some_object.state}")

print()
print("Calling event_4")
some_object.event_4()  # <- works
print(f"Now in State: {some_object.state}")

print()
print("Calling event_4")
some_object.event_4()
print(f"Now in State: {some_object.state}")

print()
print("Calling event_1")
some_object.event_1()
print(f"Now in State: {some_object.state}")

print()
print("Calling event_3")
some_object.event_3()
print(f"Now in State: {some_object.state}")

print()
print("Calling event_4")
some_object.event_4()
print(f"Now in State: {some_object.state}")

print()
print("Calling event_1")
some_object.event_1()
print(f"Now in State: {some_object.state}")
Initial State: A

Calling event_2
Exiting State A
Entering State B
Now in State: B

Calling event_1
Exiting State B
Entering State A
Now in State: A

Calling event_2
Exiting State A
Entering State B
Now in State: B

Calling event_4
Exiting State B
Entering State C
Now in State: C

Calling event_4
Exiting State C
Entering State D
Now in State: D

Calling event_1
Exiting State D
Entering State A
Now in State: A

Calling event_3
Exiting State A
Entering State C
Now in State: C

Calling event_4
Exiting State C
Entering State D
Now in State: D

Calling event_1
Exiting State D
Entering State A
Now in State: A

And illegal transitions are still not possible:

print(f"Current State: {some_object.state}")
print("Calling event_4")
some_object.event_4()
print(f"Now in State: {some_object.state}")
Current State: A
Calling event_4

->

transitions.core.MachineError: "Can't trigger event event_4 from state A!"

ptrstn avatar Dec 07 '21 20:12 ptrstn

@ptrstn Thanks for the detailed write-up!

Right now each transition function is limited to a single transition decorator. The workaround you posted looks like it works, but definitely not the cleanest solution as you are working around the limitations of this library.

A couple of months ago, I refactored the @transition decorator implementation from a function to a class. With this new class-based approach, we can store metadata about each State Machine inside of the class object. When performing a transition, we can do a match on source state to make sure the correct transition decorator is applied.

Things I'm thinking about

  • does it make sense to have more than 1 decorator per transition function?
    • very easy to add another function with a decorator to do the same thing, but this could result in code duplication
    • I have built state machines using transitions that only used a proceed transition function to move the machine thru the various states so I get the use case
  • what happens if 2 transition decorators have the same source state? which decorator is respected? should we raise an Exception if a state machine is built this way?
  • how does django-fsm handle this situation? I would need to work through an example
  • re: simple state machine's solution for handling payment failure and payment success
    • there is an on_error parameter that would be a better solution for this use case

What are your thoughts?

(Right now it's Advent of Code season, I probably won't be able to try out a solution until next year. Definitely open to discussing it further to find the best solution)

alysivji avatar Dec 07 '21 23:12 alysivji

  • does it make sense to have more than 1 decorator per transition function?

I was thinking the same first, but I am not sure how else you would address this problem. Maybe a dictionary of transition values rather than just a single value or list? I think this would break the current public interface though.

  • what happens if 2 transition decorators have the same source state? which decorator is respected? should we raise an Exception if a state machine is built this way?

I would raise an AmbiguousTransition or UndeterministicBehaviour Exception or something like that.

  • how does django-fsm handle this situation? I would need to work through an example

Unfortunately I have no experience with this package, but from what I've read in their GitHub documentation, ~it looks like they also only support one @transition decorator~

Update: see https://github.com/alysivji/finite-state-machine/issues/35#issuecomment-988388607

ptrstn avatar Dec 08 '21 00:12 ptrstn

I just checked what the transitions package does when two transitions have the same source state. It seems like it just does whatever was set first.

stateDiagram-v2
    [*] --> A
    A --> B: event_2
    A --> C: event_3
    B --> A: event_1
    B --> C: event_4
    C --> D: event_4
    C --> A: event_4
    D --> A: event_1

transitions = [
    {"trigger": "event_2", "source": "A", "dest": "B"},
    {"trigger": "event_3", "source": "A", "dest": "C"},
    {"trigger": "event_1", "source": "B", "dest": "A"},
    {"trigger": "event_4", "source": "B", "dest": "C"},
    {"trigger": "event_4", "source": "C", "dest": "A"},
    {"trigger": "event_4", "source": "C", "dest": "D"},
    {"trigger": "event_1", "source": "D", "dest": "A"},
]

-> ignores {"trigger": "event_4", "source": "C", "dest": "D"}

transitions = [
    {"trigger": "event_2", "source": "A", "dest": "B"},
    {"trigger": "event_3", "source": "A", "dest": "C"},
    {"trigger": "event_1", "source": "B", "dest": "A"},
    {"trigger": "event_4", "source": "B", "dest": "C"},
    {"trigger": "event_4", "source": "C", "dest": "D"},
    {"trigger": "event_4", "source": "C", "dest": "A"},
    {"trigger": "event_1", "source": "D", "dest": "A"},
]

-> ignores {"trigger": "event_4", "source": "C", "dest": "A"}

ptrstn avatar Dec 08 '21 00:12 ptrstn

For this issue in django-fsm, see:

  • https://github.com/viewflow/django-fsm/issues/122

ptrstn avatar Dec 08 '21 00:12 ptrstn