|
3 | 3 |
|
4 | 4 | # Processing model |
5 | 5 |
|
6 | | -In the literature, it's expected that all state-machine events should execute on a |
7 | | -[run-to-completion](https://en.wikipedia.org/wiki/UML_state_machine#Run-to-completion_execution_model) |
8 | | -(RTC) model. |
| 6 | +The engine processes events following the |
| 7 | +[SCXML](https://www.w3.org/TR/scxml/#AlgorithmforSCXMLInterpretation) |
| 8 | +**run-to-completion** (RTC) model: each event is fully processed — all |
| 9 | +callbacks executed, all states entered/exited — before the next event |
| 10 | +starts. This guarantees the system is always in a consistent state when |
| 11 | +a new event arrives. |
| 12 | + |
| 13 | +> **Run to completion** — SCXML adheres to a run to completion semantics |
| 14 | +> in the sense that an external event can only be processed when the |
| 15 | +> processing of the previous external event has completed, i.e. when all |
| 16 | +> microsteps (involving all triggered transitions) have been completely |
| 17 | +> taken. |
| 18 | +> |
| 19 | +> — [W3C SCXML Specification](https://www.w3.org/TR/scxml/#AlgorithmforSCXMLInterpretation) |
9 | 20 |
|
10 | | -> All state machine formalisms, including UML state machines, universally assume that a state machine |
11 | | -> completes processing of each event before it can start processing the next event. This model of |
12 | | -> execution is called run to completion, or RTC. |
13 | | -
|
14 | | -The main point is: What should happen if the state machine triggers nested events while |
15 | | -processing a parent event? |
16 | | - |
17 | | -This library adheres to the {ref}`RTC model <rtc-model>` to be compliant with the specs, where |
18 | | -the {ref}`event` is put on a queue before processing. |
19 | | - |
20 | | -Consider this state machine: |
21 | | - |
22 | | -```py |
23 | | ->>> from statemachine import StateChart, State |
24 | | - |
25 | | ->>> class ServerConnection(StateChart): |
26 | | -... disconnected = State(initial=True) |
27 | | -... connecting = State() |
28 | | -... connected = State(final=True) |
29 | | -... |
30 | | -... connect = disconnected.to(connecting, after="connection_succeed") |
31 | | -... connection_succeed = connecting.to(connected) |
32 | | -... |
33 | | -... def on_connect(self): |
34 | | -... return "on_connect" |
35 | | -... |
36 | | -... def on_enter_state(self, event: str, state: State, source: State): |
37 | | -... print(f"enter '{state.id}' from '{source.id if source else ''}' given '{event}'") |
38 | | -... |
39 | | -... def on_exit_state(self, event: str, state: State, target: State): |
40 | | -... print(f"exit '{state.id}' to '{target.id}' given '{event}'") |
41 | | -... |
42 | | -... def on_transition(self, event: str, source: State, target: State): |
43 | | -... print(f"on '{event}' from '{source.id}' to '{target.id}'") |
44 | | -... return "on_transition" |
45 | | -... |
46 | | -... def after_transition(self, event: str, source: State, target: State): |
47 | | -... print(f"after '{event}' from '{source.id}' to '{target.id}'") |
48 | | -... return "after_transition" |
49 | | - |
50 | | -``` |
51 | | - |
52 | | -(rtc-model)= |
53 | | -(rtc model)= |
54 | | -(non-rtc model)= |
55 | | - |
56 | | -## RTC model |
57 | | - |
58 | | -In a run-to-completion (RTC) processing model (**default**), the state machine executes each |
59 | | -event to completion before processing the next event. This means that the state machine |
60 | | -completes all the actions associated with an event before moving on to the next event. This |
61 | | -guarantees that the system is always in a consistent state. |
62 | | - |
63 | | -Internally, the events are put on a queue before processing. |
64 | | - |
65 | | -```{note} |
66 | | -While processing the queue items, if other events are generated, they will be processed |
67 | | -sequentially in FIFO order. |
68 | | -``` |
69 | | - |
70 | | -Running the above state machine will give these results: |
71 | | - |
72 | | -```py |
73 | | ->>> sm = ServerConnection() |
74 | | -enter 'disconnected' from '' given '__initial__' |
75 | | - |
76 | | ->>> sm.send("connect") |
77 | | -exit 'disconnected' to 'connecting' given 'connect' |
78 | | -on 'connect' from 'disconnected' to 'connecting' |
79 | | -enter 'connecting' from 'disconnected' given 'connect' |
80 | | -after 'connect' from 'disconnected' to 'connecting' |
81 | | -exit 'connecting' to 'connected' given 'connection_succeed' |
82 | | -on 'connection_succeed' from 'connecting' to 'connected' |
83 | | -enter 'connected' from 'connecting' given 'connection_succeed' |
84 | | -after 'connection_succeed' from 'connecting' to 'connected' |
85 | | -['on_transition', 'on_connect'] |
86 | | - |
87 | | -``` |
88 | | - |
89 | | -```{note} |
90 | | -Note that the events `connect` and `connection_succeed` are executed sequentially, and the |
91 | | -`connect.after` runs in the expected order. |
| 21 | +```{seealso} |
| 22 | +See {ref}`actions` for the callback execution order within each step, |
| 23 | +{ref}`sending-events` for how to trigger events, and {ref}`behaviour` |
| 24 | +for customizations that affect how the engine processes transitions. |
92 | 25 | ``` |
93 | 26 |
|
94 | 27 |
|
95 | 28 | (macrostep-microstep)= |
96 | 29 |
|
97 | 30 | ## Macrosteps and microsteps |
98 | 31 |
|
99 | | -The processing loop is organized into two levels: **macrosteps** and **microsteps**. |
100 | | -Understanding these concepts is key to predicting how the engine processes events, |
101 | | -especially with {ref}`eventless transitions <eventless>`, internal events |
102 | | -({func}`raise_() <StateMachine.raise_>`), and {ref}`error.execution <error-execution>`. |
| 32 | +The processing loop is organized into two levels: |
103 | 33 |
|
104 | 34 | ### Microstep |
105 | 35 |
|
106 | | -A **microstep** is the smallest unit of processing. It takes a set of enabled transitions |
107 | | -and executes them atomically: |
108 | | - |
109 | | -1. Run `before` callbacks. |
110 | | -2. Exit source states (run `on_exit` callbacks). |
111 | | -3. Execute transition actions (`on` callbacks). |
112 | | -4. Enter target states (run `on_enter` callbacks). |
113 | | -5. Run `after` callbacks. |
| 36 | +A **microstep** is the smallest unit of processing. It takes a set of |
| 37 | +enabled transitions and walks them through a fixed sequence of |
| 38 | +callback groups defined in the {ref}`execution order <actions>`: |
| 39 | + |
| 40 | +1. **Prepare** — enrich event kwargs. |
| 41 | +2. **Validators / Conditions** — check if the transition is allowed. |
| 42 | +3. **Before** — run pre-transition callbacks. |
| 43 | +4. **Exit** — leave source states (innermost first). |
| 44 | +5. **On** — execute transition actions. |
| 45 | +6. **Enter** — enter target states (outermost first). |
| 46 | +7. **Invoke** — spawn background work. |
| 47 | +8. **After** — run post-transition callbacks (always runs, even on error). |
| 48 | + |
| 49 | +```{tip} |
| 50 | +If an error occurs during steps 3–6 and `error_on_execution` is enabled, |
| 51 | +the error is caught at the **block level** — remaining actions in that block |
| 52 | +are skipped, but the microstep continues. See |
| 53 | +{ref}`error-execution` and the |
| 54 | +{ref}`cleanup / finalize pattern <error-handling-cleanup-finalize>`. |
| 55 | +``` |
114 | 56 |
|
115 | | -If an error occurs during steps 1–4 and `error_on_execution` is enabled, the error is |
116 | | -caught at the **block level** — meaning remaining actions in that block are skipped, but |
117 | | -the microstep continues and `after` callbacks still run. Each phase (exit, `on`, enter) |
118 | | -is an independent block, so an error in the transition `on` action does not prevent target |
119 | | -states from being entered. See {ref}`block-level error catching <error-execution>` and the |
120 | | -{ref}`cleanup / finalize pattern <sphx_glr_auto_examples_statechart_cleanup_machine.py>`. |
121 | 57 |
|
122 | 58 | ### Macrostep |
123 | 59 |
|
124 | | -A **macrostep** is a complete processing cycle triggered by a single external event. It |
125 | | -consists of one or more microsteps and only ends when the machine reaches a **stable |
126 | | -configuration** — a state where no eventless transitions are enabled and the internal |
127 | | -queue is empty. |
| 60 | +A **macrostep** is a complete processing cycle triggered by a single |
| 61 | +external event. It consists of one or more microsteps and only ends when |
| 62 | +the machine reaches a **stable configuration** — no eventless transitions |
| 63 | +are enabled and the internal queue is empty. |
128 | 64 |
|
129 | 65 | Within a single macrostep, the engine repeats: |
130 | 66 |
|
131 | | -1. **Check eventless transitions** — transitions without an event trigger that fire |
132 | | - automatically when their guard conditions are met. |
133 | | -2. **Drain the internal queue** — events placed by {func}`raise_() <StateMachine.raise_>` |
134 | | - are processed immediately, before any external events. |
| 67 | +1. **Check eventless transitions** — transitions without an event that |
| 68 | + fire automatically when their guard conditions are met. |
| 69 | +2. **Drain the internal queue** — events placed by `raise_()` are |
| 70 | + processed immediately, before any external events. |
135 | 71 | 3. If neither step produced a transition, the macrostep is **done**. |
136 | 72 |
|
137 | | -After the macrostep completes, the engine picks the next event from the **external queue** |
138 | | -(placed by {func}`send() <StateMachine.send>`) and starts a new macrostep. |
| 73 | +After the macrostep completes, the engine picks the next event from the |
| 74 | +**external queue** (placed by `send()`) and starts a new macrostep. |
| 75 | + |
139 | 76 |
|
140 | 77 | ### Event queues |
141 | 78 |
|
142 | 79 | The engine maintains two separate FIFO queues: |
143 | 80 |
|
144 | | -| Queue | How to enqueue | When processed | |
145 | | -|--------------|----------------------------------------------------------------|-----------------------------------| |
146 | | -| **Internal** | {func}`raise_() <StateMachine.raise_>` or `send(..., internal=True)` | Within the current macrostep | |
147 | | -| **External** | {func}`send() <StateMachine.send>` | After the current macrostep ends | |
| 81 | +| Queue | How to enqueue | When processed | |
| 82 | +|---|---|---| |
| 83 | +| **Internal** | {func}`raise_() <StateMachine.raise_>` or `send(..., internal=True)` | Within the current macrostep | |
| 84 | +| **External** | {func}`send() <StateMachine.send>` | After the current macrostep ends | |
148 | 85 |
|
149 | | -This distinction matters when you trigger events from inside callbacks. Using `raise_()` |
150 | | -ensures the event is handled as part of the current processing cycle, while `send()` defers |
151 | | -it to after the machine reaches a stable configuration. |
| 86 | +This distinction matters when you trigger events from inside callbacks. |
| 87 | +Using `raise_()` ensures the event is handled as part of the current |
| 88 | +processing cycle, while `send()` defers it to after the machine reaches |
| 89 | +a stable configuration. |
152 | 90 |
|
153 | 91 | ```{seealso} |
154 | 92 | See {ref}`sending-events` for examples of `send()` vs `raise_()`. |
155 | 93 | ``` |
156 | 94 |
|
| 95 | + |
157 | 96 | ### Processing loop overview |
158 | 97 |
|
159 | | -The following diagram shows the complete processing loop algorithm: |
| 98 | +The following diagram shows the complete processing loop: |
160 | 99 |
|
161 | 100 | ``` |
162 | 101 | send("event") |
@@ -198,20 +137,93 @@ The following diagram shows the complete processing loop algorithm: |
198 | 137 | └─────────────────────────────────────┘ |
199 | 138 | ``` |
200 | 139 |
|
| 140 | + |
| 141 | +(rtc-model)= |
| 142 | +(rtc model)= |
| 143 | +(non-rtc model)= |
| 144 | + |
| 145 | +## Run-to-completion in practice |
| 146 | + |
| 147 | +Consider a state machine where one transition triggers another via an |
| 148 | +`after` callback: |
| 149 | + |
| 150 | +```py |
| 151 | +>>> from statemachine import StateChart, State |
| 152 | + |
| 153 | +>>> class ServerConnection(StateChart): |
| 154 | +... disconnected = State(initial=True) |
| 155 | +... connecting = State() |
| 156 | +... connected = State(final=True) |
| 157 | +... |
| 158 | +... connect = disconnected.to(connecting, after="connection_succeed") |
| 159 | +... connection_succeed = connecting.to(connected) |
| 160 | +... |
| 161 | +... def on_connect(self): |
| 162 | +... return "on_connect" |
| 163 | +... |
| 164 | +... def on_enter_state(self, event: str, state: State, source: State): |
| 165 | +... print(f"enter '{state.id}' from '{source.id if source else ''}' given '{event}'") |
| 166 | +... |
| 167 | +... def on_exit_state(self, event: str, state: State, target: State): |
| 168 | +... print(f"exit '{state.id}' to '{target.id}' given '{event}'") |
| 169 | +... |
| 170 | +... def on_transition(self, event: str, source: State, target: State): |
| 171 | +... print(f"on '{event}' from '{source.id}' to '{target.id}'") |
| 172 | +... return "on_transition" |
| 173 | +... |
| 174 | +... def after_transition(self, event: str, source: State, target: State): |
| 175 | +... print(f"after '{event}' from '{source.id}' to '{target.id}'") |
| 176 | +... return "after_transition" |
| 177 | + |
| 178 | +``` |
| 179 | + |
| 180 | +When `connect` is sent, the `after` callback triggers `connection_succeed`. |
| 181 | +Under the RTC model, `connection_succeed` is enqueued and processed only |
| 182 | +after `connect` completes: |
| 183 | + |
| 184 | +```py |
| 185 | +>>> sm = ServerConnection() |
| 186 | +enter 'disconnected' from '' given '__initial__' |
| 187 | + |
| 188 | +>>> sm.send("connect") |
| 189 | +exit 'disconnected' to 'connecting' given 'connect' |
| 190 | +on 'connect' from 'disconnected' to 'connecting' |
| 191 | +enter 'connecting' from 'disconnected' given 'connect' |
| 192 | +after 'connect' from 'disconnected' to 'connecting' |
| 193 | +exit 'connecting' to 'connected' given 'connection_succeed' |
| 194 | +on 'connection_succeed' from 'connecting' to 'connected' |
| 195 | +enter 'connected' from 'connecting' given 'connection_succeed' |
| 196 | +after 'connection_succeed' from 'connecting' to 'connected' |
| 197 | +['on_transition', 'on_connect'] |
| 198 | + |
| 199 | +``` |
| 200 | + |
| 201 | +Notice that `connect` runs all its phases (exit → on → enter → after) before |
| 202 | +`connection_succeed` starts. The `after` callback of `connect` fires while |
| 203 | +the machine is still in `connecting` — and only then does `connection_succeed` |
| 204 | +begin its own microstep. |
| 205 | + |
| 206 | +```{note} |
| 207 | +The `__initial__` event is a synthetic event that the engine fires during |
| 208 | +initialization to enter the initial state. It follows the same RTC model |
| 209 | +as any other event. |
| 210 | +``` |
| 211 | + |
| 212 | + |
201 | 213 | (continuous-machines)= |
202 | 214 |
|
203 | | -## Continuous state machines |
| 215 | +## Chaining transitions |
204 | 216 |
|
205 | | -Most state machines are driven by external events — you call `send()` and the machine |
206 | | -responds. But some use cases require a machine that **processes multiple steps |
207 | | -automatically** within a single macrostep, driven by eventless transitions and internal |
208 | | -events rather than external calls. |
| 217 | +Some use cases require a machine that processes multiple steps automatically |
| 218 | +within a single macrostep, driven by internal events or eventless transitions |
| 219 | +rather than external calls. |
209 | 220 |
|
210 | | -### Chaining with `raise_()` |
211 | 221 |
|
212 | | -Using {func}`raise_() <StateMachine.raise_>` inside callbacks places events on the internal |
213 | | -queue, so they are processed within the current macrostep. This lets you chain multiple |
214 | | -transitions from a single `send()` call: |
| 222 | +### With `raise_()` |
| 223 | + |
| 224 | +Using {func}`raise_() <StateMachine.raise_>` inside callbacks places events |
| 225 | +on the **internal queue**, so they are processed within the current macrostep. |
| 226 | +This lets you chain multiple transitions from a single `send()` call: |
215 | 227 |
|
216 | 228 | ```py |
217 | 229 | >>> from statemachine import State, StateChart |
@@ -248,14 +260,16 @@ transitions from a single `send()` call: |
248 | 260 |
|
249 | 261 | ``` |
250 | 262 |
|
251 | | -All three steps execute within a single macrostep — the caller receives control back only |
252 | | -after the pipeline reaches a stable configuration. |
| 263 | +All three steps execute within a single macrostep — the caller receives |
| 264 | +control back only after the pipeline reaches a stable configuration. |
| 265 | + |
253 | 266 |
|
254 | | -### Self-loop with eventless transitions |
| 267 | +### With eventless transitions |
255 | 268 |
|
256 | | -{ref}`Eventless transitions <eventless>` fire automatically whenever their guard condition |
257 | | -is satisfied. A self-transition with a guard creates a loop that keeps running within the |
258 | | -macrostep until the condition becomes false: |
| 269 | +{ref}`Eventless transitions <eventless>` fire automatically whenever their |
| 270 | +guard condition is satisfied. Combined with a self-transition, this creates |
| 271 | +a loop that keeps running within the macrostep until the condition becomes |
| 272 | +false: |
259 | 273 |
|
260 | 274 | ```py |
261 | 275 | >>> from statemachine import State, StateChart |
@@ -297,6 +311,7 @@ macrostep until the condition becomes false: |
297 | 311 |
|
298 | 312 | ``` |
299 | 313 |
|
300 | | -The machine starts, enters `trying` (attempt 1), and the eventless self-transition keeps |
301 | | -firing as long as `can_retry()` returns `True`. Once the limit is reached, the eventless |
302 | | -`give_up` transition fires — all within a single macrostep triggered by initialization. |
| 314 | +The machine starts, enters `trying` (attempt 1), and the eventless |
| 315 | +self-transition keeps firing as long as `can_retry()` returns `True`. Once |
| 316 | +the limit is reached, the second eventless transition fires — all within a |
| 317 | +single macrostep triggered by initialization. |
0 commit comments