Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Different source and target for same event #35

Open
ptrstn opened this issue Dec 7, 2021 · 6 comments
Open

Different source and target for same event #35

ptrstn opened this issue Dec 7, 2021 · 6 comments
Labels
enhancement New feature or request

Comments

@ptrstn
Copy link

ptrstn commented Dec 7, 2021

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
Loading

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
Copy link
Author

ptrstn commented Dec 7, 2021

Possible workaround inspired by:

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
Copy link
Author

ptrstn commented Dec 7, 2021

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!"

@alysivji alysivji added bug Something isn't working enhancement New feature or request and removed bug Something isn't working labels Dec 7, 2021
@alysivji
Copy link
Owner

alysivji commented Dec 7, 2021

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

@ptrstn
Copy link
Author

ptrstn commented Dec 8, 2021

  • 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 #35 (comment)

@ptrstn
Copy link
Author

ptrstn commented Dec 8, 2021

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
Loading

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
Copy link
Author

ptrstn commented Dec 8, 2021

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

2 participants