Skip to content

feat: add event deferral to states - #664

Open
SahilRajputdotexe wants to merge 1 commit into
fgmacedo:developfrom
SahilRajputdotexe:feat/event-deferral
Open

SahilRajputdotexe wants to merge 1 commit into
fgmacedo:developfrom
SahilRajputdotexe:feat/event-deferral

Conversation

@SahilRajputdotexe

Copy link
Copy Markdown

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 raising TransitionNotAllowed). 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.

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")    # queued, still in `gathering`
sm.send("convene")  # leaves `gathering`, `speak` is replayed -> `decided`

Details:

  • Compound states defer for their children (class mordor(State.Compound, defer=["rest"])). In parallel states the event is only deferred if all regions defer it.
  • on_defer / on_replay callbacks, inline or by naming convention (on_defer_event, on_defer_<state>, on_replay_event, on_replay_<state>).
  • StateChart gets deferred_events, pending_deferred_event_ids, is_event_deferred() and clear_deferred_queue().
  • Final states can't defer. The queue is cleared when the machine terminates and is not pickled.
  • validate_deferred_events = True (off by default) checks the ids against the declared events.
  • Works in both the sync and async engines.

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 through sm_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.

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>
@sonarqubecloud

sonarqubecloud Bot commented Oct 2, 2026

Copy link
Copy Markdown

@fgmacedo fgmacedo left a comment •

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 and TriggerData ordering; git merge-tree reports conflicts in engines/sync.py and engines/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_KEYS in io/native.py and io/schemas/statechart.schema.json were not extended, and _parse_state builds State(...) 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()

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread statemachine/factory.py
def _check_deferred_events(cls):
if not cls.validate_deferred_events:
return
known_event_ids = {str(e.id) for e in cls._events}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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'].

Suggested change
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()}

Comment thread docs/deferral.md

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

Copy link
Copy Markdown
Owner

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 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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread statemachine/state.py
return self._parallel

@property
def deferred_events(self) -> frozenset[str]:

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread docs/deferral.md
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.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread docs/releases/3.3.0.md
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`.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The 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 [#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)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The 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 await sm.send() from hanging forever when the recall never comes (machine terminates, queue cleared). Keep it.

@codecov

codecov Bot commented Oct 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (525bcdd) to head (656d39d).
⚠️ Report is 3 commits behind head on develop.

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     
Flag Coverage Δ
unittests 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants