Skip to content

Commit fde13d9

Browse files
committed
docs: document processing model, raise_(), cleanup/finalize pattern
- Expand docs/processing_model.md with macrostep/microstep definitions, event queues (internal vs external), processing loop diagram, and continuous machine examples (raise_() chaining, eventless self-loops). - Add "Cleanup / finalize pattern" section to docs/statecharts.md showing that after_<event>() acts as a natural finalize with error_on_execution. - Document raise_() vs send() in docs/transitions.md with "External vs internal events" section and fix triggering-events Sphinx anchor. - Improve raise_() docstring in statemachine.py with corrected cross-ref. - Add statechart_cleanup_machine.py sphinx-gallery example demonstrating both success and failure paths with automatic error recovery. - Update AGENTS.md with processing model, error handling, eventless transitions, and callback conventions.
1 parent 68a59a8 commit fde13d9

6 files changed

Lines changed: 446 additions & 12 deletions

File tree

AGENTS.md

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,61 @@ Django integration, diagram generation, and a flexible callback/listener system.
1313

1414
## Architecture
1515

16-
- `statemachine.py` — Core `StateMachine` class
16+
- `statemachine.py` — Core `StateMachine` and `StateChart` classes
1717
- `factory.py``StateMachineMetaclass` handles class construction, state/transition validation
1818
- `state.py` / `event.py` — Descriptor-based `State` and `Event` definitions
1919
- `transition.py` / `transition_list.py` — Transition logic and composition (`|` operator)
2020
- `callbacks.py` — Priority-based callback registry (`CallbackPriority`, `CallbackGroup`)
2121
- `dispatcher.py` — Listener/observer pattern, `callable_method` wraps callables with signature adaptation
2222
- `signature.py``SignatureAdapter` for dependency injection into callbacks
23-
- `engines/sync.py`, `engines/async_.py` — Sync and async run-to-completion engines
23+
- `engines/base.py` — Shared engine logic (microstep, transition selection, error handling)
24+
- `engines/sync.py`, `engines/async_.py` — Sync and async processing loops
2425
- `registry.py` — Global state machine registry (used by `MachineMixin`)
2526
- `mixins.py``MachineMixin` for domain model integration (e.g., Django models)
2627
- `spec_parser.py` — Boolean expression parser for condition guards
2728
- `contrib/diagram.py` — Diagram generation via pydot/Graphviz
2829

30+
## Processing model
31+
32+
The engine follows the SCXML run-to-completion (RTC) model with two processing levels:
33+
34+
- **Microstep**: atomic execution of one transition set (before → exit → on → enter → after).
35+
- **Macrostep**: complete processing cycle for one external event; repeats microsteps until
36+
the machine reaches a **stable configuration** (no eventless transitions enabled, internal
37+
queue empty).
38+
39+
### Event queues
40+
41+
- `send()`**external queue** (processed after current macrostep ends).
42+
- `raise_()`**internal queue** (processed within the current macrostep, before external events).
43+
44+
### Error handling (`error_on_execution`)
45+
46+
- `StateChart` has `error_on_execution=True` by default; `StateMachine` has `False`.
47+
- Errors are caught at the **block level** (per onentry/onexit block), not per microstep.
48+
- This means `after` callbacks still run even when an action raises — making `after_<event>()`
49+
a natural **finalize** hook (runs on both success and failure paths).
50+
- `error.execution` is dispatched as an internal event; define transitions for it to handle
51+
errors within the statechart.
52+
- Error during `error.execution` handling → ignored to prevent infinite loops.
53+
54+
### Eventless transitions
55+
56+
- Bare transition statements (not assigned to a variable) are **eventless** — they fire
57+
automatically when their guard condition is met.
58+
- Assigned transitions (e.g., `go = s1.to(s2)`) create **named events**.
59+
- `error_` prefix naming convention: `error_X` auto-registers both `error_X` and `error.X`
60+
event names (explicit `id=` takes precedence).
61+
62+
### Callback conventions
63+
64+
- Generic callbacks (always available): `prepare_event()`, `before_transition()`,
65+
`on_transition()`, `on_exit_state()`, `on_enter_state()`, `after_transition()`.
66+
- Event-specific: `before_<event>()`, `on_<event>()`, `after_<event>()`.
67+
- State-specific: `on_enter_<state>()`, `on_exit_<state>()`.
68+
- `on_error_execution()` works via naming convention but **only** when a transition for
69+
`error.execution` is declared — it is NOT a generic callback.
70+
2971
## Environment setup
3072

3173
```bash

docs/processing_model.md

Lines changed: 225 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
1+
(processing-model)=
2+
13
# Processing model
24

3-
In the literature, It's expected that all state-machine events should execute on a
5+
In the literature, it's expected that all state-machine events should execute on a
46
[run-to-completion](https://en.wikipedia.org/wiki/UML_state_machine#Run-to-completion_execution_model)
57
(RTC) model.
68

79
> All state machine formalisms, including UML state machines, universally assume that a state machine
810
> completes processing of each event before it can start processing the next event. This model of
911
> execution is called run to completion, or RTC.
1012
11-
The main point is: What should happen if the state machine triggers nested events while processing a parent event?
13+
The main point is: What should happen if the state machine triggers nested events while
14+
processing a parent event?
1215

13-
This library atheres to the {ref}`RTC model` to be compliant with the specs, where the {ref}`event` is put on a
14-
queue before processing.
16+
This library adheres to the {ref}`RTC model <rtc-model>` to be compliant with the specs, where
17+
the {ref}`event` is put on a queue before processing.
1518

1619
Consider this state machine:
1720

@@ -45,14 +48,20 @@ Consider this state machine:
4548

4649
```
4750

51+
(rtc-model)=
52+
4853
## RTC model
4954

50-
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.
55+
In a run-to-completion (RTC) processing model (**default**), the state machine executes each
56+
event to completion before processing the next event. This means that the state machine
57+
completes all the actions associated with an event before moving on to the next event. This
58+
guarantees that the system is always in a consistent state.
5159

5260
Internally, the events are put on a queue before processing.
5361

5462
```{note}
55-
While processing the queue items, if others events are generated, they will be processed sequentially in FIFO order.
63+
While processing the queue items, if other events are generated, they will be processed
64+
sequentially in FIFO order.
5665
```
5766

5867
Running the above state machine will give these results:
@@ -75,5 +84,214 @@ after 'connection_succeed' from 'connecting' to 'connected'
7584
```
7685

7786
```{note}
78-
Note that the events `connect` and `connection_succeed` are executed sequentially, and the `connect.after` runs on the expected order.
87+
Note that the events `connect` and `connection_succeed` are executed sequentially, and the
88+
`connect.after` runs in the expected order.
89+
```
90+
91+
92+
(macrostep-microstep)=
93+
94+
## Macrosteps and microsteps
95+
96+
The processing loop is organized into two levels: **macrosteps** and **microsteps**.
97+
Understanding these concepts is key to predicting how the engine processes events,
98+
especially with {ref}`eventless transitions <eventless>`, internal events
99+
({func}`raise_() <StateMachine.raise_>`), and {ref}`error.execution <error-execution>`.
100+
101+
### Microstep
102+
103+
A **microstep** is the smallest unit of processing. It takes a set of enabled transitions
104+
and executes them atomically:
105+
106+
1. Run `before` callbacks.
107+
2. Exit source states (run `on_exit` callbacks).
108+
3. Execute transition actions (`on` callbacks).
109+
4. Enter target states (run `on_enter` callbacks).
110+
5. Run `after` callbacks.
111+
112+
If an error occurs during steps 1–4 and `error_on_execution` is enabled, the error is
113+
caught at the **block level** — meaning remaining actions in that block are skipped, but
114+
the microstep continues and `after` callbacks still run (see
115+
{ref}`cleanup / finalize pattern <sphx_glr_auto_examples_statechart_cleanup_machine.py>`).
116+
117+
### Macrostep
118+
119+
A **macrostep** is a complete processing cycle triggered by a single external event. It
120+
consists of one or more microsteps and only ends when the machine reaches a **stable
121+
configuration** — a state where no eventless transitions are enabled and the internal
122+
queue is empty.
123+
124+
Within a single macrostep, the engine repeats:
125+
126+
1. **Check eventless transitions** — transitions without an event trigger that fire
127+
automatically when their guard conditions are met.
128+
2. **Drain the internal queue** — events placed by {func}`raise_() <StateMachine.raise_>`
129+
are processed immediately, before any external events.
130+
3. If neither step produced a transition, the macrostep is **done**.
131+
132+
After the macrostep completes, the engine picks the next event from the **external queue**
133+
(placed by {func}`send() <StateMachine.send>`) and starts a new macrostep.
134+
135+
### Event queues
136+
137+
The engine maintains two separate FIFO queues:
138+
139+
| Queue | How to enqueue | When processed |
140+
|--------------|----------------------------------------------------------------|-----------------------------------|
141+
| **Internal** | {func}`raise_() <StateMachine.raise_>` or `send(..., internal=True)` | Within the current macrostep |
142+
| **External** | {func}`send() <StateMachine.send>` | After the current macrostep ends |
143+
144+
This distinction matters when you trigger events from inside callbacks. Using `raise_()`
145+
ensures the event is handled as part of the current processing cycle, while `send()` defers
146+
it to after the machine reaches a stable configuration.
147+
148+
```{seealso}
149+
See {ref}`triggering-events` for examples of `send()` vs `raise_()`.
79150
```
151+
152+
### Processing loop overview
153+
154+
The following diagram shows the complete processing loop algorithm:
155+
156+
```
157+
send("event")
158+
159+
160+
┌──────────────┐
161+
│ External │
162+
│ Queue │◄─────────────────────────────┐
163+
└──────┬───────┘ │
164+
│ pop event │
165+
▼ │
166+
┌──────────────────────────────────────┐ │
167+
│ Macrostep │ │
168+
│ │ │
169+
│ ┌──────────────────────┐ │ │
170+
│ │ Eventless transitions│◄──┐ │ │
171+
│ │ enabled? │ │ │ │
172+
│ └──────┬───────────────┘ │ │ │
173+
│ yes │ no │ │ │
174+
│ │ │ │ │ │
175+
│ │ ▼ │ │ │
176+
│ │ ┌──────────────┐ │ │ │
177+
│ │ │ Internal │ │ │ │
178+
│ │ │ queue empty? │ │ │ │
179+
│ │ └──┬───────┬───┘ │ │ │
180+
│ │ no │ yes │ │ │ │
181+
│ │ │ │ │ │ │
182+
│ │ │ ▼ │ │ │
183+
│ │ │ Stable │ │ │
184+
│ │ │ config ───┼───────┼──────┘
185+
│ │ │ │ │
186+
│ ▼ ▼ │ │
187+
│ ┌──────────────┐ │ │
188+
│ │ Microstep │────────┘ │
189+
│ │ (execute │ │
190+
│ │ transitions)│ │
191+
│ └──────────────┘ │
192+
│ │
193+
└─────────────────────────────────────┘
194+
```
195+
196+
(continuous-machines)=
197+
198+
## Continuous state machines
199+
200+
Most state machines are driven by external events — you call `send()` and the machine
201+
responds. But some use cases require a machine that **processes multiple steps
202+
automatically** within a single macrostep, driven by eventless transitions and internal
203+
events rather than external calls.
204+
205+
### Chaining with `raise_()`
206+
207+
Using {func}`raise_() <StateMachine.raise_>` inside callbacks places events on the internal
208+
queue, so they are processed within the current macrostep. This lets you chain multiple
209+
transitions from a single `send()` call:
210+
211+
```py
212+
>>> from statemachine import State, StateChart
213+
214+
>>> class Pipeline(StateChart):
215+
... start = State("Start", initial=True)
216+
... step1 = State("Step 1")
217+
... step2 = State("Step 2")
218+
... done = State("Done", final=True)
219+
...
220+
... begin = start.to(step1)
221+
... advance_1 = step1.to(step2)
222+
... advance_2 = step2.to(done)
223+
...
224+
... def on_enter_step1(self):
225+
... print(" step 1: extract")
226+
... self.raise_("advance_1")
227+
...
228+
... def on_enter_step2(self):
229+
... print(" step 2: transform")
230+
... self.raise_("advance_2")
231+
...
232+
... def on_enter_done(self):
233+
... print(" done: load complete")
234+
235+
>>> sm = Pipeline()
236+
>>> sm.send("begin")
237+
step 1: extract
238+
step 2: transform
239+
done: load complete
240+
241+
>>> [s.id for s in sm.configuration]
242+
['done']
243+
244+
```
245+
246+
All three steps execute within a single macrostep — the caller receives control back only
247+
after the pipeline reaches a stable configuration.
248+
249+
### Self-loop with eventless transitions
250+
251+
{ref}`Eventless transitions <eventless>` fire automatically whenever their guard condition
252+
is satisfied. A self-transition with a guard creates a loop that keeps running within the
253+
macrostep until the condition becomes false:
254+
255+
```py
256+
>>> from statemachine import State, StateChart
257+
258+
>>> class RetryMachine(StateChart):
259+
... trying = State("Trying", initial=True)
260+
... success = State("Success", final=True)
261+
... failed = State("Failed", final=True)
262+
...
263+
... # Eventless transitions: fire automatically based on guards
264+
... trying.to.itself(cond="can_retry")
265+
... trying.to(failed, cond="max_retries_reached")
266+
...
267+
... # Event-driven transition (external input)
268+
... succeed = trying.to(success)
269+
...
270+
... def __init__(self, max_retries=3):
271+
... self.attempts = 0
272+
... self.max_retries = max_retries
273+
... super().__init__()
274+
...
275+
... def can_retry(self):
276+
... return self.attempts < self.max_retries
277+
...
278+
... def max_retries_reached(self):
279+
... return self.attempts >= self.max_retries
280+
...
281+
... def on_enter_trying(self):
282+
... self.attempts += 1
283+
... print(f" attempt {self.attempts}")
284+
285+
>>> sm = RetryMachine(max_retries=3)
286+
attempt 1
287+
attempt 2
288+
attempt 3
289+
290+
>>> [s.id for s in sm.configuration]
291+
['failed']
292+
293+
```
294+
295+
The machine starts, enters `trying` (attempt 1), and the eventless self-transition keeps
296+
firing as long as `can_retry()` returns `True`. Once the limit is reached, the eventless
297+
`give_up` transition fires — all within a single macrostep triggered by initialization.

docs/statecharts.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,21 @@ If an error occurs while processing the `error.execution` event itself, the engi
213213
ignores the second error (logging a warning) to prevent infinite loops. The state machine
214214
remains in the configuration it was in before the failed error handler.
215215

216+
### Cleanup / finalize pattern
217+
218+
A common need is to run cleanup code after a transition **regardless of success or failure**
219+
— for example, releasing a lock or closing a resource.
220+
221+
Because `StateChart` catches errors at the **block level** (not the microstep level),
222+
`after_<event>()` callbacks still run even when an action raises an exception. This makes
223+
`after_<event>()` a natural **finalize** hook — no need to duplicate cleanup logic in
224+
an error handler.
225+
226+
For error-specific handling (logging, recovery), define an `error.execution` transition
227+
and use {func}`raise_() <StateMachine.raise_>` to auto-recover within the same macrostep.
228+
229+
See the full working example in {ref}`sphx_glr_auto_examples_statechart_cleanup_machine.py`.
230+
216231
(compound-states)=
217232
## Compound states
218233

0 commit comments

Comments
 (0)