Repository navigation
feat: add event deferral to states #664
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,205 @@ | ||
| (deferral)= | ||
|
|
||
| # Event deferral | ||
|
|
||
| ```{versionadded} 3.3.0 | ||
| ``` | ||
|
|
||
| ```{seealso} | ||
| See {ref}`processing-model` for how events are queued and consumed, and {ref}`events` | ||
| for how they are declared and sent. | ||
| ``` | ||
|
|
||
| By default, an event that no active transition handles is dropped (or rejected, depending on | ||
| `allow_event_without_transition`). Sometimes the event is not wrong, only early: the | ||
| machine just is not ready for it yet. A state can **defer** such events. They are kept in a | ||
| queue instead of being discarded, and sent again as soon as the machine reaches a | ||
| configuration that no longer defers them. | ||
|
|
||
| This is the UML statechart notion of a *deferred event*. | ||
|
|
||
| ## Deferring events in a state | ||
|
|
||
| Pass the ids of the events to the `defer` parameter of {ref}`State`: | ||
|
|
||
| ```py | ||
| >>> from statemachine import State, StateChart | ||
|
|
||
| >>> class Council(StateChart): | ||
| ... gathering = State(initial=True, defer=["speak"]) | ||
| ... deliberating = State() | ||
| ... decided = State(final=True) | ||
| ... | ||
| ... convene = gathering.to(deliberating) | ||
| ... speak = deliberating.to(decided) | ||
|
|
||
| >>> sm = Council() | ||
| >>> sm.send("speak") | ||
| >>> sm.configuration_values | ||
| OrderedSet(['gathering']) | ||
|
|
||
| >>> sm.pending_deferred_event_ids | ||
| ['speak'] | ||
|
|
||
| ``` | ||
|
|
||
| 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 | ||
| like any other: | ||
|
|
||
| ```py | ||
| >>> sm.send("convene") | ||
| >>> sm.configuration_values | ||
| OrderedSet(['decided']) | ||
|
|
||
| >>> sm.pending_deferred_event_ids | ||
| [] | ||
|
|
||
| ``` | ||
|
|
||
| Some details worth knowing: | ||
|
|
||
| - An event is only deferred when **no transition is enabled** for it. If the active state | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. question: A transition on an ancestor always beats a |
||
| has a transition for the event, the transition runs as usual. | ||
| - Deferred events are replayed in the order they were sent (first in, first out), with the | ||
| arguments they were sent with. | ||
| - An event that is replayed into a configuration that still defers it stays in the queue. | ||
| - Deferral takes precedence over `allow_event_without_transition`: a deferred event is queued | ||
| whether the machine would otherwise ignore it or raise `TransitionNotAllowed`. | ||
| - The `defer` list does not need to name events declared in the class (see | ||
| {ref}`validate_deferred_events <validating-deferred-events>`). | ||
| - A final state cannot defer events, since the machine does not process events after it | ||
| terminates. The queue is cleared at that point. | ||
|
|
||
| ## Deferral in compound and parallel states | ||
|
|
||
| A compound state defers events for all of its descendants. Use the `defer` class keyword: | ||
|
|
||
| ```py | ||
| >>> class Journey(StateChart): | ||
| ... class mordor(State.Compound, defer=["rest"]): | ||
| ... gates = State(initial=True) | ||
| ... mount_doom = State() | ||
| ... | ||
| ... climb = gates.to(mount_doom) | ||
| ... | ||
| ... shire = State() | ||
| ... done = State(final=True) | ||
| ... | ||
| ... return_home = mordor.to(shire) | ||
| ... rest = shire.to(done) | ||
|
|
||
| >>> sm = Journey() | ||
| >>> sm.send("rest") | ||
| >>> sm.send("climb") | ||
| >>> sm.pending_deferred_event_ids | ||
| ['rest'] | ||
|
|
||
| >>> sm.send("return_home") | ||
| >>> sm.configuration_values | ||
| OrderedSet(['done']) | ||
|
|
||
| ``` | ||
|
|
||
| A state defers an event if it lists the event or any of its ancestors does. | ||
| `State.deferred_events` holds what a state declares itself, and | ||
| `State.deferred_events_recursive` adds the ids declared by its ancestors. | ||
|
|
||
| In a parallel state there are several active atomic states at once, and the event is deferred | ||
| **only if all of them defer it**. If any region can still react to the event, it is processed | ||
| as usual and nothing is queued. | ||
|
|
||
| ## Callbacks | ||
|
|
||
| Two optional callbacks follow the same pattern as `on_enter` and `on_exit`: | ||
|
|
||
| | Callback | When | | ||
| |---|---| | ||
| | `on_defer` | An event was added to the deferred queue. | | ||
| | `on_replay` | An event left the queue to be processed again. | | ||
|
|
||
| Both can be given inline to the state, or found by naming convention: `on_defer_event()` and | ||
| `on_replay_event()` for every state, `on_defer_<state_id>()` and `on_replay_<state_id>()` for a | ||
| specific one. | ||
|
|
||
| ```py | ||
| >>> class Messenger(StateChart): | ||
| ... riding = State(initial=True, defer=["deliver"]) | ||
| ... arrived = State() | ||
| ... delivered = State(final=True) | ||
| ... | ||
| ... arrive = riding.to(arrived) | ||
| ... deliver = arrived.to(delivered) | ||
| ... | ||
| ... def on_defer_riding(self, event): | ||
| ... print(f"{event} has to wait") | ||
| ... | ||
| ... def on_replay_event(self, event, state): | ||
| ... print(f"{event} is back while in {state.id}") | ||
|
|
||
| >>> sm = Messenger() | ||
| >>> sm.send("deliver") | ||
| deliver has to wait | ||
|
|
||
| >>> sm.send("arrive") | ||
| deliver is back while in arrived | ||
|
|
||
| ``` | ||
|
|
||
| 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. | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nitpick (non-blocking): |
||
|
|
||
| ## Inspecting the queue | ||
|
|
||
| The {ref}`StateChart` instance exposes the queue: | ||
|
|
||
| - `deferred_events`: a copy of the queue, as a list of `TriggerData`. | ||
| - `pending_deferred_event_ids`: the ids of the queued events, in order. | ||
| - `is_event_deferred(event_id)`: whether an event would be deferred in the current | ||
| configuration. | ||
| - `clear_deferred_queue()`: discards the queued events without replaying them. | ||
|
|
||
| ```py | ||
| >>> sm = Council() | ||
| >>> sm.is_event_deferred("speak") | ||
| True | ||
|
|
||
| >>> sm.send("speak") | ||
| >>> [str(trigger.event) for trigger in sm.deferred_events] | ||
| ['speak'] | ||
|
|
||
| >>> sm.clear_deferred_queue() | ||
| >>> sm.send("convene") | ||
| >>> sm.configuration_values | ||
| OrderedSet(['deliberating']) | ||
|
|
||
| ``` | ||
|
|
||
| The queue is runtime state: it is not part of the serialized machine, so a pickled or copied | ||
| machine starts with an empty queue. | ||
|
|
||
| (validating-deferred-events)= | ||
|
|
||
| ## Validating deferred events | ||
|
|
||
| A typo in a `defer` list is silent by default, because the event might be sent by code that is | ||
| not declared in the class. Set `validate_deferred_events = True` to require every deferred id | ||
| to be a declared event: | ||
|
|
||
| ```py | ||
| >>> from statemachine.exceptions import InvalidDefinition | ||
|
|
||
| >>> try: | ||
| ... class Lookout(StateChart): | ||
| ... validate_deferred_events = True | ||
| ... watching = State(initial=True, defer=["relieved"]) | ||
| ... relieved = State(final=True) | ||
| ... relieve = watching.to(relieved) | ||
| ... except InvalidDefinition as e: | ||
| ... print(e) | ||
| State 'watching' defers unknown event(s): ['relieved']. Declared events are: ['relieve'] | ||
|
|
||
| ``` | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,6 +32,7 @@ guards | |
|
|
||
| statechart | ||
| processing_model | ||
| deferral | ||
| error_handling | ||
| async | ||
| listeners | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| # StateChart 3.3.0 | ||
|
|
||
| *Not released yet* | ||
|
|
||
| ## What's new in 3.3.0 | ||
|
|
||
| ### Event deferral | ||
|
|
||
| A state can now **defer** events: when an event arrives and no transition handles it, the | ||
| engine keeps it in a queue instead of discarding it, and sends it again once the machine | ||
| reaches a configuration that no longer defers it. This is the UML statechart *deferred event*. | ||
|
|
||
| ```py | ||
| >>> from statemachine import State, StateChart | ||
|
|
||
| >>> class Council(StateChart): | ||
| ... gathering = State(initial=True, defer=["speak"]) | ||
| ... deliberating = State() | ||
| ... decided = State(final=True) | ||
| ... | ||
| ... convene = gathering.to(deliberating) | ||
| ... speak = deliberating.to(decided) | ||
|
|
||
| >>> sm = Council() | ||
| >>> sm.send("speak") | ||
| >>> sm.pending_deferred_event_ids | ||
| ['speak'] | ||
|
|
||
| >>> sm.send("convene") | ||
| >>> sm.configuration_values | ||
| OrderedSet(['decided']) | ||
|
|
||
| ``` | ||
|
|
||
| Compound states defer events for their children (`class mordor(State.Compound, defer=["rest"])`), | ||
| and in parallel states an event is deferred only when every active atomic state defers it. | ||
| 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`. | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nitpick (non-blocking): The other release notes end each entry with the PR link, like |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,6 +16,7 @@ Requires Python 3.10+. | |
| ```{toctree} | ||
| :maxdepth: 2 | ||
|
|
||
| 3.3.0 | ||
| 3.2.2 | ||
| 3.2.1 | ||
| 3.2.0 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -279,8 +279,34 @@ async def _enter_states( # noqa: C901 | |
| if target.final: | ||
| self._handle_final_state(target, on_entry_result) | ||
|
|
||
| await self._replay_deferred_events() | ||
| return result | ||
|
|
||
| async def _defer_event(self, trigger_data: TriggerData): | ||
| self._debug("%s Deferring event '%s'", self._log_id, trigger_data.event) | ||
| self._deferred_queue.append(trigger_data) | ||
| on_error = self._on_error_handler() | ||
| for state in self.sm.configuration: | ||
| if state.on_defer.key in self.sm._callbacks: | ||
| await self.sm._callbacks.async_call( | ||
| state.on_defer.key, | ||
| *trigger_data.args, | ||
| on_error=on_error, | ||
| **self._deferral_callback_kwargs(trigger_data, state), | ||
| ) | ||
|
|
||
| async def _replay_deferred_events(self): | ||
| on_error = self._on_error_handler() | ||
| for trigger_data in self._release_deferred_events(): | ||
| for state in self.sm.configuration: | ||
| if state.on_replay.key in self.sm._callbacks: | ||
| await self.sm._callbacks.async_call( | ||
| state.on_replay.key, | ||
| *trigger_data.args, | ||
| on_error=on_error, | ||
| **self._deferral_callback_kwargs(trigger_data, state), | ||
| ) | ||
|
|
||
| async def microstep(self, transitions: "list[Transition]", trigger_data: TriggerData): | ||
| self._microstep_count += 1 | ||
| self._debug( | ||
|
|
@@ -471,15 +497,18 @@ async def processing_loop( # noqa: C901 | |
| if first_result is self._sentinel: | ||
| first_result = result | ||
| else: | ||
| 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) | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. praise: Resolving the future at deferral time, not at recall, is what keeps |
||
| elif not self.sm.allow_event_without_transition: | ||
| tna = TransitionNotAllowed( | ||
| external_event.event, self.sm.configuration | ||
| ) | ||
| self._reject_future(event_future, tna) | ||
| self._reject_pending_futures(tna) | ||
| raise tna | ||
| # Event allowed but no transition — resolve with None | ||
| self._resolve_future(event_future, None) | ||
| else: | ||
| self._resolve_future(event_future, None) | ||
| except Exception as exc: | ||
| self._reject_future(event_future, exc) | ||
| self._reject_pending_futures(exc) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion (non-blocking): The external queue is a
PriorityQueuekeyed onexecution_time, and the releasedTriggerDatakeeps its original stamp, so a recalled event runs ahead of anything sent after it, not at the end:adeferred, thengowhoseon_entersendsc, processesabeforec. 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.