Skip to content

Commit 6a4be03

Browse files
authored
feat: Add queued mode (Support for RTC by default) (#359)
feat: Add rtc mode as default. Closes #80
1 parent ad8dcb2 commit 6a4be03

11 files changed

Lines changed: 441 additions & 93 deletions

File tree

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ observers
1616
mixins
1717
integrations
1818
diagram
19+
processing_model
1920
auto_examples/index
2021
contributing
2122
authors

docs/processing_model.md

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
# Processing model
2+
3+
In the literature, It's expected that all state-machine events should execute on a
4+
[run-to-completion](https://en.wikipedia.org/wiki/UML_state_machine#Run-to-completion_execution_model)
5+
(RTC) model.
6+
7+
> All state machine formalisms, including UML state machines, universally assume that a state machine
8+
> completes processing of each event before it can start processing the next event. This model of
9+
> execution is called run to completion, or RTC.
10+
11+
The main point is: What should happen if the state machine triggers nested events while processing a parent event?
12+
13+
```{hint}
14+
The importance of this decision depends on your state machine definition. Also the difference between RTC
15+
and non-RTC processing models is more pronounced in a multi-threaded system than in a single-threaded system.
16+
In other words, even if you run in {ref}`Non-RTC model`, only one external {ref}`event` will be
17+
handled at a time and all internal events will run before the next external event is called,
18+
so you only notice the difference if your state machine definition has nested event triggers while
19+
processing these external events.
20+
```
21+
22+
There are two distinct models for processing events in the library. The default is to run in
23+
{ref}`RTC model` to be compliant with the specs, where the {ref}`event` is put on a
24+
queue before processing. You can also configure your state machine to run in
25+
{ref}`Non-RTC model`, where the {ref}`event` will be run immediately.
26+
27+
Consider this state machine:
28+
29+
```py
30+
>>> from statemachine import StateMachine, State
31+
32+
>>> class ServerConnection(StateMachine):
33+
... disconnected = State(initial=True)
34+
... connecting = State()
35+
... connected = State()
36+
...
37+
... connect = disconnected.to(connecting, after="connection_succeed")
38+
... connection_succeed = connecting.to(connected)
39+
...
40+
... def on_connect(self):
41+
... return "on_connect"
42+
...
43+
... def on_enter_state(self, event: str, state: State, source: State):
44+
... print(f"enter '{state.id}' from '{source.id if source else ''}' given '{event}'")
45+
...
46+
... def on_exit_state(self, event: str, state: State, target: State):
47+
... print(f"exit '{state.id}' to '{target.id}' given '{event}'")
48+
...
49+
... def on_transition(self, event: str, source: State, target: State):
50+
... print(f"on '{event}' from '{source.id}' to '{target.id}'")
51+
... return "on_transition"
52+
...
53+
... def after_transition(self, event: str, source: State, target: State):
54+
... print(f"after '{event}' from '{source.id}' to '{target.id}'")
55+
... return "after_transition"
56+
57+
```
58+
59+
## RTC model
60+
61+
In a run-to-completion (RTC) processing model (**default**), the state machine executes each event to completion before processing the next event. This means that the state machine completes all the actions associated with an event before moving on to the next event. This guarantees that the system is always in a consistent state.
62+
63+
If the machine is in `rtc` mode, the event is put on a queue.
64+
65+
```{note}
66+
While processing the queue items, if others events are generated, they will be processed sequentially.
67+
```
68+
69+
Running the above state machine will give these results on the RTC model:
70+
71+
```py
72+
>>> sm = ServerConnection()
73+
enter 'disconnected' from '' given '__initial__'
74+
75+
>>> sm.send("connect")
76+
exit 'disconnected' to 'connecting' given 'connect'
77+
on 'connect' from 'disconnected' to 'connecting'
78+
enter 'connecting' from 'disconnected' given 'connect'
79+
after 'connect' from 'disconnected' to 'connecting'
80+
exit 'connecting' to 'connected' given 'connection_succeed'
81+
on 'connection_succeed' from 'connecting' to 'connected'
82+
enter 'connected' from 'connecting' given 'connection_succeed'
83+
after 'connection_succeed' from 'connecting' to 'connected'
84+
['on_transition', 'on_connect']
85+
86+
```
87+
88+
```{note}
89+
Note that the events `connect` and `connection_succeed` are executed sequentially, and the `connect.after` runs on the expected order.
90+
```
91+
92+
## Non-RTC model
93+
94+
In contrast, in a non-RTC (synchronous) processing model, the state machine starts executing nested events
95+
while processing a parent event. This means that when an event is triggered, the state machine
96+
chains the processing when another event was triggered as a result of the first event.
97+
98+
```{warning}
99+
This can lead to complex and unpredictable behavior in the system if your state-machine definition triggers **nested
100+
events**.
101+
```
102+
103+
If your state machine does not trigger nested events while processing a parent event,
104+
and you plan to use the API in an _imperative programming style_, you can consider using the synchronous mode (non-RTC).
105+
106+
In this model, you can think of events as analogous to simple method calls.
107+
108+
```{note}
109+
While processing the {ref}`event`, if others events are generated, they will also be processed immediately, so a **nested** behavior happens.
110+
```
111+
112+
Running the above state machine will give these results on the non-RTC (synchronous) model:
113+
114+
```py
115+
>>> sm = ServerConnection(rtc=False)
116+
enter 'disconnected' from '' given '__initial__'
117+
118+
>>> sm.send("connect")
119+
exit 'disconnected' to 'connecting' given 'connect'
120+
on 'connect' from 'disconnected' to 'connecting'
121+
enter 'connecting' from 'disconnected' given 'connect'
122+
exit 'connecting' to 'connected' given 'connection_succeed'
123+
on 'connection_succeed' from 'connecting' to 'connected'
124+
enter 'connected' from 'connecting' given 'connection_succeed'
125+
after 'connection_succeed' from 'connecting' to 'connected'
126+
after 'connect' from 'disconnected' to 'connecting'
127+
['on_transition', 'on_connect']
128+
129+
```
130+
131+
```{note}
132+
Note that the events `connect` and `connection_succeed` are nested, and the `connect.after`
133+
unexpectedly only runs after `connection_succeed.after`.
134+
```

docs/releases/1.0.1.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Welcome to StateMachine 1.0.1!
77
This version is a huge refactoring adding a lot of new and exciting features. We hope that you enjoy it.
88

99
These release notes cover the [new features in 1.0](#whats-new-in-10), as well as
10-
some [backwards incompatible changes](#backwards-incompatible-changes-in-10) you'll
10+
some [backward incompatible changes](#backward-incompatible-changes-in-10) you'll
1111
want to be aware of when upgrading from StateMachine 0.9.0 or earlier. We've
1212
[begun the deprecation process for some features](#deprecated-features-in-10).
1313

docs/releases/2.0.0.md

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Welcome to StateMachine 2.0.0!
77
This version is the first to take advantage of the Python3 improvements and is a huge internal refactoring removing the deprecated features on 1.*. We hope that you enjoy it.
88

99
These release notes cover the [](#whats-new-in-20), as well as
10-
some [backwards incompatible changes](#backwards-incompatible-changes-in-20) you'll
10+
some [backward incompatible changes](#backward-incompatible-changes-in-20) you'll
1111
want to be aware of when upgrading from StateMachine 1.*.
1212

1313

@@ -18,9 +18,24 @@ StateMachine 2.0 supports Python 3.7, 3.8, 3.9, 3.10, and 3.11.
1818

1919
## What's new in 2.0
2020

21+
### Run to completion (RTC) by default
22+
23+
There are now two distinct methods for processing events in the library. The **new default** is to run in
24+
{ref}`RTC model` to be compliant with the specs, where the {ref}`event` is put on a queue before processing.
25+
You can also configure your state machine to run back in {ref}`Non-RTC model`, where the {ref}`event` will
26+
be run immediately and nested events will be chained.
27+
28+
This means that the state machine now completes all the actions associated with an event before moving on to the next event.
29+
Even if you trigger an event inside an action.
30+
31+
```{seealso}
32+
See {ref}`processing model` for more details.
33+
```
34+
2135
### State names are now optional
2236

23-
{ref}`State` names are now by default derived from the class variable that they are assigned to. You can keep declaring explicit names, but we encourage you to only assign a name
37+
{ref}`State` names are now by default derived from the class variable that they are assigned to.
38+
You can keep declaring explicit names, but we encourage you to only assign a name
2439
when it is different than the one derived from its id.
2540

2641
```py
@@ -90,6 +105,13 @@ See {ref}`internal transition` for more details.
90105

91106
### Statemachine class changes in 2.0
92107

108+
#### The new processing model (RTC) by default
109+
110+
While we've figured out a way to keep near complete backwards compatible changes to the new
111+
{ref}`Run to completion (RTC) by default` feature (all built-in examples run without change),
112+
if you encounter problems when upgrading to this version, you can still switch back to the old
113+
{ref}`Non-RTC model`. Be aware that we may remove the {ref}`Non-RTC model` in the future.
114+
93115
#### `StateMachine.run` removed in favor of `StateMachine.send`
94116

95117
```py

docs/transitions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ an action that `echoes` back the parameters informed.
219219
:language: python
220220
:linenos:
221221
:emphasize-lines: 10
222-
:lines: 12-15
222+
:lines: 12-21
223223
```
224224

225225

pyproject.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ select = [
9494
"B", # flake8-bugbear
9595
"PT", # flake8-pytest-style
9696
]
97+
ignore = [
98+
"UP006", # `use-pep585-annotation` Requires Python3.9+
99+
]
97100

98101
line-length = 99
99102

@@ -125,8 +128,7 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
125128
target-version = "py311"
126129

127130
[tool.ruff.mccabe]
128-
# Unlike Flake8, default to a complexity level of 10.
129-
max-complexity = 5
131+
max-complexity = 6
130132

131133
[tool.ruff.isort]
132134
force-single-line = true

statemachine/dispatcher.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def _get_func_by_attr(attr, *configs):
3737
return func, config.obj
3838

3939

40-
def ensure_callable(attr, *objects): # noqa: C901
40+
def ensure_callable(attr, *objects):
4141
"""Ensure that `attr` is a callable, if not, tries to retrieve one from any of the given
4242
`objects`.
4343

statemachine/event.py

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
1+
from typing import TYPE_CHECKING
2+
13
from .event_data import EventData
24
from .event_data import TriggerData
35
from .exceptions import TransitionNotAllowed
46

7+
if TYPE_CHECKING:
8+
from .statemachine import StateMachine
9+
510

611
class Event:
7-
def __init__(self, name):
8-
self.name = name
12+
def __init__(self, name: str):
13+
self.name: str = name
914

1015
def __repr__(self):
1116
return f"{type(self).__name__}({self.name!r})"
1217

13-
def __call__(self, machine, *args, **kwargs):
14-
return self.trigger(machine, *args, **kwargs)
15-
16-
def trigger(self, machine, *args, **kwargs):
18+
def trigger(self, machine: "StateMachine", *args, **kwargs):
1719
def trigger_wrapper():
1820
"""Wrapper that captures event_data as closure."""
1921
trigger_data = TriggerData(
@@ -27,10 +29,6 @@ def trigger_wrapper():
2729
return machine._process(trigger_wrapper)
2830

2931
def _trigger(self, trigger_data: TriggerData):
30-
event_data = self._process(trigger_data)
31-
return event_data.result
32-
33-
def _process(self, trigger_data: TriggerData):
3432
state = trigger_data.machine.current_state
3533
for transition in state.transitions:
3634
if not transition.match(trigger_data.event):
@@ -43,15 +41,15 @@ def _process(self, trigger_data: TriggerData):
4341
else:
4442
raise TransitionNotAllowed(trigger_data.event, state)
4543

46-
return event_data
44+
return event_data.result
4745

4846

4947
def trigger_event_factory(event):
5048
"""Build a method that sends specific `event` to the machine"""
5149
event_instance = Event(event)
5250

5351
def trigger_event(self, *args, **kwargs):
54-
return event_instance(self, *args, **kwargs)
52+
return event_instance.trigger(self, *args, **kwargs)
5553

5654
trigger_event.name = event
5755
trigger_event.identifier = event

0 commit comments

Comments
 (0)