Skip to content

Commit a5a4050

Browse files
committed
docs(processing_model): rewrite with SCXML citation and progressive narrative
Reorganize the processing model page for clarity: - Add W3C SCXML citation and compliance mention in intro - Lead with macrosteps/microsteps before the practical example - Align microstep phases with actions.md execution order (8 groups) - Move RTC example after concepts are established - Rename "Continuous state machines" to "Chaining transitions" - Fix misleading "atomically" wording in microstep description - Fix unclosed seealso block and stale `give_up` reference
1 parent fa76ab4 commit a5a4050

1 file changed

Lines changed: 154 additions & 139 deletions

File tree

docs/processing_model.md

Lines changed: 154 additions & 139 deletions
Original file line numberDiff line numberDiff line change
@@ -3,160 +3,99 @@
33

44
# Processing model
55

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)
920
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.
9225
```
9326

9427

9528
(macrostep-microstep)=
9629

9730
## Macrosteps and microsteps
9831

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:
10333

10434
### Microstep
10535

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+
```
11456

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>`.
12157

12258
### Macrostep
12359

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.
12864

12965
Within a single macrostep, the engine repeats:
13066

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.
13571
3. If neither step produced a transition, the macrostep is **done**.
13672

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+
13976

14077
### Event queues
14178

14279
The engine maintains two separate FIFO queues:
14380

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 |
14885

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.
15290

15391
```{seealso}
15492
See {ref}`sending-events` for examples of `send()` vs `raise_()`.
15593
```
15694

95+
15796
### Processing loop overview
15897

159-
The following diagram shows the complete processing loop algorithm:
98+
The following diagram shows the complete processing loop:
16099

161100
```
162101
send("event")
@@ -198,20 +137,93 @@ The following diagram shows the complete processing loop algorithm:
198137
└─────────────────────────────────────┘
199138
```
200139

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+
201213
(continuous-machines)=
202214

203-
## Continuous state machines
215+
## Chaining transitions
204216

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.
209220

210-
### Chaining with `raise_()`
211221

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:
215227

216228
```py
217229
>>> from statemachine import State, StateChart
@@ -248,14 +260,16 @@ transitions from a single `send()` call:
248260

249261
```
250262

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+
253266

254-
### Self-loop with eventless transitions
267+
### With eventless transitions
255268

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:
259273

260274
```py
261275
>>> from statemachine import State, StateChart
@@ -297,6 +311,7 @@ macrostep until the condition becomes false:
297311

298312
```
299313

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

Comments
 (0)