Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 87 additions & 4 deletions docs/invoke.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,13 @@ that runs for the duration of a state and is cancelled when the state is exited.
Invoke handlers run **outside** the main state machine processing loop:

- **Sync engine**: each invoke handler runs in a **daemon thread**.
- **Async engine**: each invoke handler runs in a **thread executor**
(`loop.run_in_executor`), wrapped in an `asyncio.Task`. The executor is used because
invoke handlers are expected to perform blocking I/O (network calls, file access,
subprocess communication) that would freeze the event loop if run directly.
- **Async engine**:
- **Sync handlers** run in a **thread executor** (`loop.run_in_executor`), wrapped
in an `asyncio.Task`. The executor is used because blocking I/O (network calls,
file access, subprocess communication) would freeze the event loop if run directly.
- **Coroutine functions** and `IInvoke` handlers with `async def run()` are
**awaited directly** on the event loop, with no executor overhead. This is the
natural choice for non-blocking async I/O (e.g., `aiohttp`, async DB drivers).

When a handler completes, a `done.invoke.<state>.<id>` event is automatically sent back
to the machine. If the handler raises an exception, an `error.execution` event is sent
Expand Down Expand Up @@ -270,6 +273,86 @@ Events from cancelled invocations are silently ignored.

```

## Coroutine functions

Coroutine functions (`async def`) can be used as invoke targets. On the async engine,
they are awaited directly on the event loop instead of running in a thread executor.
This is ideal for non-blocking async I/O:

```py
>>> import asyncio

>>> async def async_fetch():
... await asyncio.sleep(0.01) # simulates async I/O
... return {"status": "ok"}

>>> class AsyncLoader(StateChart):
... loading = State(initial=True, invoke=async_fetch)
... ready = State(final=True)
... done_invoke_loading = loading.to(ready)
...
... def on_enter_ready(self, data=None, **kwargs):
... self.result = data

>>> async def main():
... sm = AsyncLoader()
... await sm.activate_initial_state()
... await asyncio.sleep(0.1)
... await sm._processing_loop()
... return sm

>>> sm = asyncio.run(main())

>>> "ready" in sm.configuration_values
True
>>> sm.result
{'status': 'ok'}

```

The `IInvoke` protocol also supports `async def run()`. Since `IInvoke` handlers
are wrapped internally, you need at least one async callback in the machine to
trigger the async engine (e.g., an `async def` action or listener):

```py
>>> class AsyncFetcher:
... async def run(self, ctx: InvokeContext):
... await asyncio.sleep(0.01)
... return "async_fetched"

>>> class AsyncFetcherMachine(StateChart):
... loading = State(initial=True, invoke=AsyncFetcher)
... ready = State(final=True)
... done_invoke_loading = loading.to(ready)
...
... async def on_enter_ready(self, data=None, **kwargs):
... self.result = data

>>> async def run_fetcher():
... sm = AsyncFetcherMachine()
... await sm.activate_initial_state()
... await asyncio.sleep(0.1)
... await sm._processing_loop()
... return sm

>>> sm = asyncio.run(run_fetcher())

>>> "ready" in sm.configuration_values
True
>>> sm.result
'async_fetched'

```

Cancellation of coroutine handlers works through `asyncio.Task.cancel()`, which
raises `CancelledError` at the next `await` point, giving proper async cancellation
semantics without cooperative polling.

```{note}
Coroutine functions automatically select the async engine. Using an `IInvoke` with
`async def run()` on the sync engine raises `InvalidDefinition`.
```

## Event data propagation

When a state with invoke handlers is entered via an event, the keyword arguments from
Expand Down
24 changes: 24 additions & 0 deletions docs/releases/3.1.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,30 @@ machine instance concurrently. This is now documented in the
[#592](https://github.com/fgmacedo/python-statemachine/pull/592).


### Coroutine functions as invoke targets

Invoke now supports `async def` functions and `IInvoke` handlers with `async def run()`.
On the async engine, coroutines are awaited directly on the event loop instead of running
in a thread executor, making invoke a natural fit for non-blocking async I/O
(e.g., `aiohttp`, async DB drivers).

```python
async def fetch_data():
async with aiohttp.ClientSession() as session:
resp = await session.get("https://api.example.com/data")
return await resp.json()

class Loader(StateChart):
loading = State(initial=True, invoke=fetch_data)
ready = State(final=True)
done_invoke_loading = loading.to(ready)
```

See {ref}`invoke:Coroutine functions` for details.
[#611](https://github.com/fgmacedo/python-statemachine/pull/611),
fixes [#610](https://github.com/fgmacedo/python-statemachine/issues/610).


### Bugfixes in 3.1.0

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