Skip to content

Commit cb3bc41

Browse files
fix: keep event order, measure delays on the monotonic clock, cancel by marking (#665)
Three fixes to how the engines order, time and cancel events, and a refactor that removes every suppression from the modules they touch. Events due at the same moment kept their order only by chance: both queues are PriorityQueues ordered by execution_time alone, so triggers stamped alike came out in heap order. TriggerData now numbers each trigger as it is created and compares that number after execution_time. Delays were measured on the wall clock, so a step of time.time() held or hastened every due time. Stamps and due checks now use time.monotonic(). Cancelling a delayed event filtered the list behind the heap, which is not always a heap afterwards, and dropped the trigger's future, so a coroutine awaiting it on the async engine was never answered. cancel_event() now marks each trigger sent with the send_id as cancelled and answers its future with None; the queue drops cancelled triggers from its head. EventQueue.pop_due() pops the head only once it is due, under the queue's mutex, so a trigger waiting on its delay never leaves the queue and a cancel from another thread always finds it. No type: ignore, noqa, pragma: no cover or pragma: no branch is left in engines/base.py, engines/sync.py, engines/async_.py or statemachine.py. The methods only the sync engine implements synchronously moved from BaseEngine to SyncEngine, each processing loop is split into its macrostep, the external events and one external event, and the code the pragmas hid is either removed as unreachable or covered by a test. Release notes in docs/releases/3.2.2.md. Co-authored-by: Fernando Macedo <fgmacedo@gmail.com> Signed-off-by: Fernando Macedo <fgmacedo@gmail.com>
1 parent fe5f070 commit cb3bc41

13 files changed

Lines changed: 1222 additions & 493 deletions

‎docs/releases/3.2.2.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,43 @@ Reported by [@Dolecor](https://github.com/Dolecor).
7676

7777
[#643](https://github.com/fgmacedo/python-statemachine/issues/643).
7878

79+
### Events due at the same moment kept their order only by chance
80+
81+
Both event queues are priority queues ordered by each trigger's due time, and triggers due at
82+
the same time came out in no particular order. A clock coarse enough to stamp two events alike
83+
could run them out of the order they were sent or raised: twenty events raised in one callback,
84+
all stamped with the same time, ran as `0, 2, 6, 14, 19, 18, ...`. Each trigger now also holds
85+
the order it was created in, and triggers due at the same moment run in that order, on both
86+
engines and both queues.
87+
88+
[#665](https://github.com/fgmacedo/python-statemachine/pull/665).
89+
90+
### Cancelling a delayed event could disorder the others and strand its caller
91+
92+
`cancel_event()` removed a trigger by filtering the list behind the queue's heap, and a filtered
93+
heap is not always a heap. The triggers left could then run out of due order: of ten delayed
94+
events due from 10 to 90 ms, cancelling the first left the one due at 50 ms running before the
95+
one due at 40 ms. The trigger's future went with it, so on the async engine a coroutine
96+
awaiting the cancelled event's `send()` was never answered.
97+
98+
A cancelled trigger now stays where it is, marked as cancelled, and is dropped when it reaches
99+
the head of the queue, without the processing loop waiting for its due time. A coroutine
100+
awaiting it gets `None` as soon as it is cancelled. A trigger waiting on its delay also stays
101+
in the queue while the loop checks the clock, so a `cancel_event()` from another thread always
102+
finds it.
103+
104+
[#665](https://github.com/fgmacedo/python-statemachine/pull/665).
105+
106+
### Delays measured on the wall clock
107+
108+
Every trigger was stamped with `time.time()` plus its delay, and the processing loop held a
109+
trigger until `time.time()` reached its stamp. A step of the wall clock, from a time sync or a
110+
change of the system time, moved every due time with it: stepped back, it held even an event
111+
sent with no delay until the clock caught up; stepped forward, it ran delayed events early.
112+
Delays are now measured on `time.monotonic()`, which no setting of the wall clock moves.
113+
114+
[#665](https://github.com/fgmacedo/python-statemachine/pull/665).
115+
79116
### `delay` and `internal` dropped from an explicit `Event`
80117

81118
`Event(dark.to(lit), delay=50)` rebuilt the event without its `delay`, so

‎docs/statechart.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ sm.send("timeout", delay=5000, send_id="my_timeout")
113113
sm.cancel_event("my_timeout")
114114
```
115115

116+
On the async engine, a coroutine awaiting the `send()` of a cancelled event gets `None`.
117+
116118
```{note}
117119
The delay is **blocking** in the sync engine — the processing loop sleeps until the
118120
delay elapses, holding the calling thread. In the async engine, delays are scheduled

‎statemachine/engines/async_.py‎

Lines changed: 118 additions & 134 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)