Repository navigation
feat: add event deferral to states - #664
SahilRajputdotexe wants to merge 1 commit into
Conversation
A state can list events to defer with State(defer=[...]), and compound states defer for their descendants. An event that no transition handles and that every active atomic state defers is queued instead of dropped, and sent again, in order, once the configuration stops deferring it. Adds on_defer/on_replay callbacks, the deferred_events, pending_deferred_event_ids, is_event_deferred() and clear_deferred_queue() introspection API on StateChart, and an opt-in validate_deferred_events check. Both the sync and async engines support it. Signed-off-by: Sahil Rajput <90515841+SahilRajputdotexe@users.noreply.github.com>
|
There was a problem hiding this comment.
Hi @SahilRajputdotexe, welcome! This looks like your first contribution here, and it is a substantial one. Thank you for the time you put into it: the writeup, the docs page with runnable examples, and the tests on both engines made this a pleasure to review.
On to the technical side. Blocking: deferred events are released per microstep, before the configuration is stable, and the alias handling in _check_deferred_events rejects valid error_ events (see inline).
- note: #665 rewrites both
processing_loops andTriggerDataordering;git merge-treereports conflicts inengines/sync.pyandengines/async_.py, so whichever lands second rebases by hand, and the recall ordering needs re-checking on top of it. - suggestion (non-blocking, follow-up): the native JSON/YAML format cannot express
defer/on_defer/on_replay(_STATE_KEYSinio/native.pyandio/schemas/statechart.schema.jsonwere not extended, and_parse_statebuildsState(...)explicitly). Fine as a follow-up, worth a line in the release notes.
| if target.final: | ||
| self._handle_final_state(target, on_entry_result) | ||
|
|
||
| self._replay_deferred_events() |
There was a problem hiding this comment.
issue (blocking): Releasing here runs once per microstep, before the macrostep reaches a stable configuration, and phase 3 of the loop pops the re-queued event right away. With s1(defer=["a"]) --go--> s2, eventless s2.to(s3), s3(defer=["a"]): send("a"); send("go") ends in {s3} with a gone, popped in s2 where it is neither handled nor deferred, against what docs/deferral.md line 66 promises. With hiding(defer=["call"]) --leave--> gone(final) on a StateMachine: send("leave") raises TransitionNotAllowed: Can't call when in Gone after the transition completed, because _handle_final_state already set running = False and the recall lands in the terminated machine (on_replay fires with state.id == "gone"). Evaluate the release once per macrostep in the processing loop, on a stable configuration and only while self.running, instead of inside _enter_states. Same at async_.py line 282.
| self.external_queue.put(trigger_data) | ||
| return released | ||
|
|
||
| def _replay_deferred_events(self): |
There was a problem hiding this comment.
issue (blocking): The events leave the deferred queue for the external queue before the on_replay callbacks run, still inside the microstep. When a callback raises on a StateMachine (catch_errors_as_events=False), microstep rolls the configuration back and the loop's except calls self.clear(), so the released events vanish from both queues: a(defer=["e"]) --go--> b(on_replay=<raises>), send("e"); send("go") gives RuntimeError, configuration {a}, pending_deferred_event_ids == [], external queue empty. Keep the events in the deferred queue until the callbacks have run, or run the callbacks outside the microstep rollback path.
| def _check_deferred_events(cls): | ||
| if not cls.validate_deferred_events: | ||
| return | ||
| known_event_ids = {str(e.id) for e in cls._events} |
There was a problem hiding this comment.
issue (blocking): e.id is the aliased string for the error_ convention, so validate_deferred_events = True rejects a correct definition: a = State(initial=True, defer=["error.execution"]) with error_execution = a.to(b) raises defers unknown event(s): ['error.execution']. Declared events are: ['error_execution error.execution'].
| known_event_ids = {str(e.id) for e in cls._events} | |
| known_event_ids = {str(name) for event in cls._events for name in event.split()} |
|
|
||
| The `speak` event arrived while the council was still `gathering`, which has no transition for it | ||
| and defers it. Nothing happened, but the event was remembered. Once the machine leaves | ||
| `gathering`, the queued event is put back at the end of the external queue and processed |
There was a problem hiding this comment.
suggestion (non-blocking): The external queue is a PriorityQueue keyed on execution_time, and the released TriggerData keeps its original stamp, so a recalled event runs ahead of anything sent after it, not at the end: a deferred, then go whose on_enter sends c, processes a before c. That is the UML event-pool semantics and the better contract, but today it rests on the ordering key, which #665 replaces with a sequence counter. State it here as the guarantee and pin it with a test where a newer event is already queued when the recall happens.
| still_deferred.append(trigger_data) | ||
| else: | ||
| released.append(trigger_data) | ||
| self._deferred_queue = still_deferred |
There was a problem hiding this comment.
suggestion (non-blocking): Rebinding the list lets a clear_deferred_queue() or stop() from another thread be overwritten, since both mutate without _processing: thread B clears the old list while A iterates it, A then assigns still_deferred built from what it already read, and the cleared events are replayed. Mutate in place under the lock, or make the clear a flag the loop honours.
| return self._parallel | ||
|
|
||
| @property | ||
| def deferred_events(self) -> frozenset[str]: |
There was a problem hiding this comment.
nitpick (non-blocking): State.deferred_events is a set of ids and StateChart.deferred_events is a list of TriggerData: same name, different kinds. State.deferred_event_ids or StateChart.deferred_queue would read better, and the rename is free before release.
| return released | ||
|
|
||
| def _replay_deferred_events(self): | ||
| on_error = self._on_error_handler() |
There was a problem hiding this comment.
nitpick (non-blocking): Runs on every _enter_states, initial activation included, allocating the error handler and two lists with an empty queue. if not self._deferred_queue: return keeps charts without defer off the hot path.
| The callbacks run once for each active state that declares them, and receive the usual | ||
| {ref}`dependency injection <dependency-injection>` arguments: `machine`, `model`, `event`, | ||
| `state` and `source` (the same state) and `target`, which is always `None`. Arguments passed | ||
| to `send()` are available too. With the async engine the callbacks can be coroutines. |
There was a problem hiding this comment.
nitpick (non-blocking): await sm.send() of an event that gets deferred resolves to None when it is queued, and the recall's result or TransitionNotAllowed never reaches that caller. Worth one sentence here.
| States also accept `on_defer` and `on_replay` callbacks, `StateChart` exposes | ||
| `deferred_events`, `pending_deferred_event_ids`, `is_event_deferred()` and | ||
| `clear_deferred_queue()`, and the opt-in `validate_deferred_events` flag checks the deferred | ||
| ids against the declared events. See {ref}`deferral`. |
There was a problem hiding this comment.
nitpick (non-blocking): The other release notes end each entry with the PR link, like [#639](https://github.com/fgmacedo/python-statemachine/pull/639).; add [#664](https://github.com/fgmacedo/python-statemachine/pull/664).
| if not self.sm.allow_event_without_transition: | ||
| if self._is_event_deferred(self._event_id(external_event)): | ||
| await self._defer_event(external_event) | ||
| self._resolve_future(event_future, None) |
There was a problem hiding this comment.
praise: Resolving the future at deferral time, not at recall, is what keeps await sm.send() from hanging forever when the recall never comes (machine terminates, queue cleared). Keep it.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## develop #664 +/- ##
==========================================
Coverage 100.00% 100.00%
==========================================
Files 52 52
Lines 5582 5683 +101
Branches 883 903 +20
==========================================
+ Hits 5582 5683 +101
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|



This adds UML-style event deferral. A state can list events it wants to hold back with
State(defer=[...]). If an event arrives, no transition handles it, and every active atomic state defers it, the engine queues it instead of dropping it (or raisingTransitionNotAllowed). Once the machine reaches a configuration that no longer defers the event, the queued events go back to the external queue in the order they were sent, with their original arguments.It is useful when an event can legitimately show up before the machine is ready for it, so callers don't have to buffer it themselves.
Details:
class mordor(State.Compound, defer=["rest"])). In parallel states the event is only deferred if all regions defer it.on_defer/on_replaycallbacks, inline or by naming convention (on_defer_event,on_defer_<state>,on_replay_event,on_replay_<state>).StateChartgetsdeferred_events,pending_deferred_event_ids,is_event_deferred()andclear_deferred_queue().validate_deferred_events = True(off by default) checks the ids against the declared events.I added a docs page (
docs/deferral.md), entries in the states and validations pages, and a 3.3.0 release notes file since this is a new feature and 3.2.2 is a bug fix release. Happy to move the notes if you prefer another place.Tests are in
tests/test_statechart_deferral.py, most of them run on both engines throughsm_runner. The full suite passes with 100% coverage, and ruff and mypy are clean. Lint and tests pass on my fork for Python 3.10 to 3.14.