Skip to content

Commit 5a85209

Browse files
authored
perf: optimize engine hot paths — 5x-7x event throughput improvement (#592)
1 parent 11ffa95 commit 5a85209

17 files changed

Lines changed: 916 additions & 292 deletions

AGENTS.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,16 @@ current event.
7777
- `on_error_execution()` works via naming convention but **only** when a transition for
7878
`error.execution` is declared — it is NOT a generic callback.
7979

80+
### Thread safety
81+
82+
- The sync engine is **thread-safe**: multiple threads can send events to the same SM instance
83+
concurrently. The processing loop uses a `threading.Lock` so at most one thread executes
84+
transitions at a time. Event queues use `PriorityQueue` (stdlib, thread-safe).
85+
- **Do not replace `PriorityQueue`** with non-thread-safe alternatives (e.g., `collections.deque`,
86+
plain `list`) — this would break concurrent access guarantees.
87+
- Stress tests in `tests/test_threading.py::TestThreadSafety` exercise real contention with
88+
barriers and multiple sender threads. Any change to queue or locking internals must pass these.
89+
8090
### Invoke (`<invoke>`)
8191

8292
- `invoke.py``InvokeManager` on the engine manages the lifecycle: `mark_for_invoke()`,
@@ -127,6 +137,16 @@ timeout 120 uv run pytest -n 4
127137

128138
Testes normally run under 60s (~40s on average), so take a closer look if they take longer, it can be a regression.
129139

140+
### Debug logging
141+
142+
`log_cli_level` defaults to `WARNING` in `pyproject.toml`. The engine caches a no-op
143+
for `logger.debug` at init time — running tests with `DEBUG` would bypass this
144+
optimization and inflate benchmark numbers. To enable debug logs for a specific run:
145+
146+
```bash
147+
uv run pytest -o log_cli_level=DEBUG tests/test_something.py
148+
```
149+
130150
When analyzing warnings or extensive output, run the tests **once** saving the output to a file
131151
(`> /tmp/pytest-output.txt 2>&1`), then analyze the file — instead of running the suite
132152
repeatedly with different greps.

docs/processing_model.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,3 +315,50 @@ The machine starts, enters `trying` (attempt 1), and the eventless
315315
self-transition keeps firing as long as `can_retry()` returns `True`. Once
316316
the limit is reached, the second eventless transition fires — all within a
317317
single macrostep triggered by initialization.
318+
319+
320+
(thread-safety)=
321+
322+
## Thread safety
323+
324+
State machines are **thread-safe** for concurrent event sending. Multiple threads
325+
can call `send()` or trigger events on the **same state machine instance**
326+
simultaneously — the engine guarantees correct behavior through its internal
327+
locking mechanism.
328+
329+
### How it works
330+
331+
The processing loop uses a non-blocking lock (`threading.Lock`). When a thread
332+
sends an event:
333+
334+
1. The event is placed on the **external queue** (backed by a thread-safe
335+
`PriorityQueue` from the standard library).
336+
2. If no other thread is currently running the processing loop, the sending
337+
thread acquires the lock and processes all queued events.
338+
3. If another thread is already processing, the event is simply enqueued and
339+
will be processed by the thread that holds the lock — no event is lost.
340+
341+
This means that **at most one thread executes transitions at any time**, preserving
342+
the run-to-completion (RTC) guarantee while allowing safe concurrent access.
343+
344+
### What is safe
345+
346+
- **Multiple threads sending events** to the same state machine instance.
347+
- **Reading state** (`current_state_value`, `configuration`) from any thread
348+
while events are being processed. Note that transient `None` values may be
349+
observed for `current_state_value` during configuration updates when using
350+
[`atomic_configuration_update`](behaviour.md#atomic_configuration_update) `= False`
351+
(the default on `StateChart`, SCXML-compliant). With `atomic_configuration_update = True`
352+
(the default on `StateMachine`), the configuration is updated atomically at
353+
the end of the microstep, so `None` is not observed.
354+
- **Invoke handlers** running in background threads or thread executors
355+
communicate with the parent machine via the thread-safe event queue.
356+
357+
### What to avoid
358+
359+
- **Do not share a state machine instance across threads with the async engine**
360+
unless you ensure only one event loop drives the machine. The async engine is
361+
designed for `asyncio` concurrency, not thread-based concurrency.
362+
- **Callbacks execute in the processing thread**, not in the thread that sent
363+
the event. Design callbacks accordingly (e.g., use locks if they access
364+
shared external state).

docs/releases/3.1.0.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,22 @@ See {ref}`diagram:Sphinx directive` for full documentation.
3333
[#589](https://github.com/fgmacedo/python-statemachine/pull/589).
3434

3535

36+
### Performance: 5x–7x faster event processing
37+
38+
The engine's hot paths have been systematically profiled and optimized, resulting in
39+
**4.7x–7.7x faster event throughput** and **1.9x–2.6x faster setup** across all
40+
machine types. All optimizations are internal — no public API changes.
41+
See [#592](https://github.com/fgmacedo/python-statemachine/pull/592) for details.
42+
43+
44+
### Thread safety documentation
45+
46+
The sync engine is thread-safe: multiple threads can send events to the same state
47+
machine instance concurrently. This is now documented in the
48+
{ref}`processing model <thread-safety>` and verified by stress tests.
49+
[#592](https://github.com/fgmacedo/python-statemachine/pull/592).
50+
51+
3652
### Bugfixes in 3.1.0
3753

3854
- Fixes silent misuse of `Event()` with multiple positional arguments. Passing more than one

pyproject.toml

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,11 @@ markers = [
8888
]
8989
python_files = ["tests.py", "test_*.py", "*_tests.py"]
9090
xfail_strict = true
91-
log_cli_level = "DEBUG"
91+
# Log level WARNING by default; the engine caches a no-op for logger.debug at
92+
# init time, so DEBUG here would bypass that optimization and slow benchmarks.
93+
# To enable DEBUG logging for a specific test run:
94+
# uv run pytest -o log_cli_level=DEBUG
95+
log_cli_level = "WARNING"
9296
log_cli_format = "%(relativeCreated)6.0fms %(threadName)-18s %(name)-35s %(message)s"
9397
log_cli_date_format = "%H:%M:%S"
9498
asyncio_default_fixture_loop_scope = "module"
@@ -131,7 +135,14 @@ disable_error_code = "annotation-unchecked"
131135
mypy_path = "$MYPY_CONFIG_FILE_DIR/tests/django_project"
132136

133137
[[tool.mypy.overrides]]
134-
module = ['django.*', 'pytest.*', 'pydot.*', 'sphinx_gallery.*', 'docutils.*', 'sphinx.*']
138+
module = [
139+
'django.*',
140+
'pytest.*',
141+
'pydot.*',
142+
'sphinx_gallery.*',
143+
'docutils.*',
144+
'sphinx.*',
145+
]
135146
ignore_missing_imports = true
136147

137148
[tool.ruff]

statemachine/configuration.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
from typing import TYPE_CHECKING
2+
from typing import Any
3+
from typing import Dict
4+
from typing import Mapping
5+
from typing import MutableSet
6+
7+
from .exceptions import InvalidStateValue
8+
from .i18n import _
9+
from .orderedset import OrderedSet
10+
11+
_SENTINEL = object()
12+
13+
if TYPE_CHECKING:
14+
from .state import State
15+
16+
17+
class Configuration:
18+
"""Encapsulates the dual representation of the active state configuration.
19+
20+
Internally, ``current_state_value`` is either a scalar (single active state)
21+
or an ``OrderedSet`` (parallel regions). This class hides that detail behind
22+
a uniform interface for reading, mutating, and caching the resolved
23+
``OrderedSet[State]``.
24+
"""
25+
26+
__slots__ = (
27+
"_instance_states",
28+
"_model",
29+
"_state_field",
30+
"_states_map",
31+
"_cached",
32+
"_cached_value",
33+
)
34+
35+
def __init__(
36+
self,
37+
instance_states: "Mapping[str, State]",
38+
model: Any,
39+
state_field: str,
40+
states_map: "Dict[Any, State]",
41+
):
42+
self._instance_states = instance_states
43+
self._model = model
44+
self._state_field = state_field
45+
self._states_map = states_map
46+
self._cached: "OrderedSet[State] | None" = None
47+
self._cached_value: Any = _SENTINEL
48+
49+
# -- Raw value (persisted on the model) ------------------------------------
50+
51+
@property
52+
def value(self) -> Any:
53+
"""The raw state value stored on the model (scalar or ``OrderedSet``)."""
54+
return getattr(self._model, self._state_field, None)
55+
56+
@value.setter
57+
def value(self, val: Any):
58+
self._invalidate()
59+
if val is not None and not isinstance(val, MutableSet) and val not in self._states_map:
60+
raise InvalidStateValue(val)
61+
setattr(self._model, self._state_field, val)
62+
63+
@property
64+
def values(self) -> OrderedSet[Any]:
65+
"""The set of raw state values currently active."""
66+
v = self.value
67+
if isinstance(v, OrderedSet):
68+
return v
69+
return OrderedSet([v])
70+
71+
# -- Resolved states -------------------------------------------------------
72+
73+
@property
74+
def states(self) -> "OrderedSet[State]":
75+
"""The set of currently active :class:`State` instances (cached)."""
76+
csv = self.value
77+
if self._cached is not None and self._cached_value is csv:
78+
return self._cached
79+
if csv is None:
80+
return OrderedSet()
81+
82+
instance_states = self._instance_states
83+
if not isinstance(csv, MutableSet):
84+
result = OrderedSet([instance_states[self._states_map[csv].id]])
85+
else:
86+
result = OrderedSet([instance_states[self._states_map[v].id] for v in csv])
87+
88+
self._cached = result
89+
self._cached_value = csv
90+
return result
91+
92+
@states.setter
93+
def states(self, new_configuration: "OrderedSet[State]"):
94+
if len(new_configuration) == 0:
95+
self.value = None
96+
elif len(new_configuration) == 1:
97+
self.value = next(iter(new_configuration)).value
98+
else:
99+
self.value = OrderedSet(s.value for s in new_configuration)
100+
101+
# -- Incremental mutation (used by the engine) -----------------------------
102+
103+
def add(self, state: "State"):
104+
"""Add *state* to the configuration, maintaining the dual representation."""
105+
csv = self.value
106+
if csv is None:
107+
self.value = state.value
108+
elif isinstance(csv, MutableSet):
109+
csv.add(state.value)
110+
self._invalidate()
111+
else:
112+
self.value = OrderedSet([csv, state.value])
113+
114+
def discard(self, state: "State"):
115+
"""Remove *state* from the configuration, normalizing back to scalar."""
116+
csv = self.value
117+
if isinstance(csv, MutableSet):
118+
csv.discard(state.value)
119+
self._invalidate()
120+
if len(csv) == 1:
121+
self.value = next(iter(csv))
122+
elif len(csv) == 0:
123+
self.value = None
124+
elif csv == state.value:
125+
self.value = None
126+
127+
# -- Deprecated v2 compat --------------------------------------------------
128+
129+
@property
130+
def current_state(self) -> "State | OrderedSet[State]":
131+
"""Resolve the current state with validation.
132+
133+
Unlike ``states`` (which returns an empty set for ``None``), this
134+
raises ``InvalidStateValue`` when the value is ``None`` or not
135+
found in ``states_map`` — matching the v2 ``current_state`` contract.
136+
"""
137+
csv = self.value
138+
if csv is None:
139+
raise InvalidStateValue(
140+
csv,
141+
_(
142+
"There's no current state set. In async code, "
143+
"did you activate the initial state? "
144+
"(e.g., `await sm.activate_initial_state()`)"
145+
),
146+
)
147+
try:
148+
config = self.states
149+
if len(config) == 1:
150+
return next(iter(config))
151+
return config
152+
except KeyError as err:
153+
raise InvalidStateValue(csv) from err
154+
155+
# -- Internal --------------------------------------------------------------
156+
157+
def _invalidate(self):
158+
self._cached = None
159+
self._cached_value = _SENTINEL

0 commit comments

Comments
 (0)