Timers And Events¶
Three runs in one file. One sleeps for eight hours. One blocks until somebody sends it an event. One waits for an event that never arrives and takes its escalation branch instead.
The thing to watch is what the sleeping workflow costs while it waits, which is nothing. It does not hold a coroutine open for eight hours. It records a timer, parks, and is released from memory. A worker re-drives it when the deadline comes due.
Source: examples/timers_events_demo.py
Get It And Run It¶
pip install 'satay[studio]'
curl -fsSL -O https://raw.githubusercontent.com/leejianrong/satay-runtime/v0.1.0/examples/timers_events_demo.py
SATAY_DATA_DIR=.satay-demo python timers_events_demo.py
The Three Workflows¶
@satay.workflow
async def overnight_restock(sku: str) -> str:
"""Count stock, wait out the 8-hour settlement window, then reorder."""
on_hand = await count_stock(sku)
await satay.sleep(8 * _HOUR) # durable: recorded as a timer, the run parks here
return await reorder(sku, on_hand)
@satay.workflow
async def await_shipment(order_id: str) -> str:
"""Block until a ``ShipmentArrived`` event lands on ``order_id``."""
arrival = await satay.wait_for_event(ShipmentArrived, key=order_id)
return f"{arrival.crates} crates received via {arrival.carrier}"
@satay.workflow
async def await_shipment_or_escalate(order_id: str) -> str:
"""The same wait, bounded. A timed-out wait resolves to ``None`` — not an error."""
arrival = await satay.wait_for_event(ShipmentArrived, key=order_id, timeout=6 * _HOUR)
if arrival is None:
return f"escalated: nothing arrived for {order_id} within 6h"
return f"{arrival.crates} crates received via {arrival.carrier}"
The event is a plain frozen dataclass:
wait_for_event(ShipmentArrived, ...) uses that class to rehydrate the delivered payload, so
what comes back on the other side of the wait is a ShipmentArrived, not a dict. The codec tags
and round-trips dataclasses, enums, datetimes, and timedeltas. There is no pickle involved
anywhere.
What It Printed¶
$ SATAY_DATA_DIR=.satay-demo python timers_events_demo.py
Satay — durable sleep, events, and the timeout path
data dir: …/.satay-demo
1) overnight_restock sleeps 8h — run dfda89e0f6ea4ce29ba925360cb88c1c
first drive returned <parked> — status waiting (parked)
count_stock executions: 1 reorder: not yet reached
worker tick with nothing due: 0 run(s) woken
after advancing 8h, worker tick: 1 run(s) woken
result: ordered 28 units of SKU-42 status: completed
count_stock executions: 1 (reused on the wake, not re-run) reorder: 1
note: a graceful wake from a park is not an interruption — no ⚡ below
Run dfda89e0f6ea4ce29ba925360cb88c1c — 11 event(s)
1 2026-01-01T00:00:00+00:00 WorkflowCreated workflow=overnight_restock code_version=git:4d22d57c0a914532d987bc7df2af0f65530cdce6
2 2026-01-01T00:00:00+00:00 TaskScheduled task=count_stock ordinal=0
3 2026-01-01T00:00:00+00:00 TaskAttemptStarted task=count_stock ordinal=0 attempt=1
4 2026-01-01T00:00:00+00:00 TaskCompleted task=count_stock ordinal=0
5 2026-01-01T00:00:00+00:00 TimerCreated
6 2026-01-01T00:00:00+00:00 WorkflowWaiting
7 2026-01-01T08:00:00+00:00 TimerFired
8 2026-01-01T08:00:00+00:00 TaskScheduled task=reorder ordinal=0
9 2026-01-01T08:00:00+00:00 TaskAttemptStarted task=reorder ordinal=0 attempt=1
10 2026-01-01T08:00:00+00:00 TaskCompleted task=reorder ordinal=0
11 2026-01-01T08:00:00+00:00 WorkflowCompleted
2) await_shipment blocks on an event — run 2ea2c3cb195a4d36aaf4a99a9c4f97b8
first drive returned <parked> — status waiting
sent ShipmentArrived(key='order-7') — it sits in the durable inbox
worker tick: 1 run(s) woken
result: 4 crates received via DHL status: completed
(an event that arrives *before* the wait is matched from the inbox too)
Run 2ea2c3cb195a4d36aaf4a99a9c4f97b8 — 5 event(s)
1 2026-01-01T08:00:00+00:00 WorkflowCreated workflow=await_shipment code_version=git:4d22d57c0a914532d987bc7df2af0f65530cdce6
2 2026-01-01T08:00:00+00:00 EventWaitStarted
3 2026-01-01T08:00:00+00:00 WorkflowWaiting
4 2026-01-01T08:00:00+00:00 ExternalEventReceived
5 2026-01-01T08:00:00+00:00 WorkflowCompleted
3) await_shipment_or_escalate times out — run 10b6f4ba03f94e9b8d28435ae6ea235f
first drive returned <parked> — status waiting
after advancing 6h, worker tick: 1 run(s) woken
result: escalated: nothing arrived for order-8 within 6h status: completed
the wait resolved to None and the workflow chose its own escalation branch
Run 10b6f4ba03f94e9b8d28435ae6ea235f — 6 event(s)
1 2026-01-01T08:00:00+00:00 WorkflowCreated workflow=await_shipment_or_escalate code_version=git:4d22d57c0a914532d987bc7df2af0f65530cdce6
2 2026-01-01T08:00:00+00:00 TimerCreated
3 2026-01-01T08:00:00+00:00 EventWaitStarted
4 2026-01-01T08:00:00+00:00 WorkflowWaiting
5 2026-01-01T14:00:00+00:00 TimerFired
6 2026-01-01T14:00:00+00:00 WorkflowCompleted
journal kept in …/.satay-demo
all three runs are on the run list: satay dev --data-dir …/.satay-demo
Parking Is The Whole Feature¶
Section 1's first line is the surprising one:
await handle.result() returned satay.PARKED and the run is not finished. That is not an
error. The workflow reached satay.sleep(8 * _HOUR), the runtime wrote TimerCreated and
WorkflowWaiting, and the drive returned. The coroutine is gone. Nothing about this run
lives in memory any more; all of it is three rows in SQLite.
PARKED is a sentinel with no other meaning, which is the point: this demo drives the worker by
hand, so None here would have been indistinguishable from a workflow that finished and returned
None. Test for it with is satay.PARKED, never == None.
Then when the deadline comes due, a worker re-drives the run from the top. count_stock answers
from the journal, satay.sleep sees a fired timer and returns immediately, and reorder runs
for real. count_stock executions: 1 on the far side of the wake is the proof.
This is why satay.sleep exists as a primitive rather than being asyncio.sleep. An
asyncio.sleep(8 * 3600) in a workflow body pins a coroutine to one process for eight hours and
loses the workflow entirely if that process restarts. A durable sleep survives a restart because
there is nothing to lose.
No ⚡ here, and that is deliberate
A graceful wake from a park appends no WorkflowResumed, so nothing on this timeline is
marked as an interruption. ⚡ means the process died. If parking looked like crashing you
would have no way to tell a healthy overnight wait from a worker that keeps falling over.
Events Land In A Durable Inbox¶
Section 2 sends the event while the run is parked:
shipment = satay.start(await_shipment, "order-7", store=store, clock=clock)
await satay.send_event(ShipmentArrived(carrier="DHL", crates=4), key="order-7", store=store)
print(f" worker tick: {await worker.tick()} run(s) woken")
send_event does not deliver into a coroutine. It writes to a persistent inbox keyed by
key="order-7". Delivery happens on the next worker tick, which finds a parked run whose
outstanding wait matches an inbox entry and re-drives it.
Because the inbox is durable, ordering does not have to be lucky. An event that arrives before
the workflow reaches its wait_for_event is matched out of the inbox when the wait finally
happens. You do not have to sequence your senders against your workflow's progress.
key= is what routes it. Use the correlation id of the thing you are waiting on: an order id, a
review id, a ticket number. Buffered matches for the same key are consumed FIFO by arrival time.
A Timeout Is A Value, Not An Exception¶
Section 3 is the branch most people get wrong on the first try:
arrival = await satay.wait_for_event(ShipmentArrived, key=order_id, timeout=6 * _HOUR)
if arrival is None:
return f"escalated: nothing arrived for {order_id} within 6h"
A timed-out wait resolves to None. It does not raise. The workflow decides what a timeout
means, and the run completes normally through its own escalation path. Notice the status in the
output: completed, not failed. Nothing went wrong; a business rule fired.
Its timeline shows both mechanisms armed at once. TimerCreated for the deadline,
EventWaitStarted for the wait, then WorkflowWaiting. Whichever resolves first ends the wait.
A delivered event beats a simultaneously-due timeout
The worker's poll loop delivers events before it fires due timers. So when an event
arrives in the same tick that the timeout comes due, the event wins and your workflow gets
the payload rather than None. A timeout timer whose wait has already been resolved by an
event is discarded rather than fired. That ordering is decided in the runtime, not left to
chance.
Where The Worker Comes From¶
The example drives the poll loop by hand, one tick at a time:
clock = ManualClock()
worker = TimerEventWorker(store=store, clock=clock)
...
print(f" worker tick with nothing due: {await worker.tick()} run(s) woken")
clock.advance(8 * 3600) # 8 hours later, as far as the runtime is concerned
print(f" after advancing 8h, worker tick: {await worker.tick()} run(s) woken")
tick() returns how many runs it woke, which is why the first tick prints 0 and the one after
the clock advance prints 1. Nothing was due yet, then something was.
In an application you do not write this loop. async with satay.run_app() as store: starts the
same worker against a real clock and stops it on the way out, and satay dev does it for a whole
process with Studio attached. Both are the same class doing the same job. The only reason it
appears by hand here is that a demo cannot wait eight real hours, and ManualClock plus an
explicit tick() is how you compress that to a microsecond.
The trade is visible in what result() answers. With a loop running, await handle.result() on a
parked run waits for the wake and gives you the outcome. Driving ticks by hand there is no loop, so
it returns satay.PARKED — the sentinel that means "no outcome yet", distinct from a workflow that
returned None on purpose. See The Five Primitives.
Which is also how you should test this. clock.advance(...) then await worker.tick() is the
whole pattern for asserting on a workflow that sleeps overnight or waits three days for a human.
See Testing workflows.
Open It In Studio¶
Open the printed URL with its ?token= query string. All three runs are in the list, all
completed.
Two things are worth clicking for. On run 1, the gap between WorkflowWaiting and TimerFired
is eight hours of journal time with nothing in between, which is what "it costs nothing to wait"
looks like on a timeline. On run 3, TimerCreated and EventWaitStarted sit next to each other,
so you can see both mechanisms armed and watch which one resolved the wait.
satay dev can wake your parked runs too
These three runs have finished, so there is nothing left to wake. But if you park a run of
your own and quit the script, satay dev --app your.module picks it up: the poll loop wakes
any run in the journal whose timer is due, no matter which process parked it. The journal is
the only state that matters. The Studio tour shows that end of it.
Recap¶
satay.sleeprecords a timer and parks the run. The coroutine is released; only SQLite rows remain. An eight-hour wait costs nothing to hold.- With no poll loop running, a parked run's first drive returns
satay.PARKEDwith statuswaiting. That is the normal shape, not a failure. Insideasync with satay.run_app()the loop is there, andresult()waits for the wake instead. - Waking from a park is graceful. No
WorkflowResumed, no⚡. That marker stays reserved for actual interruptions. send_eventwrites to a durable inbox keyed bykey=. An event that arrives before its wait is still matched, so senders and workflows do not have to be sequenced.- A timed-out
wait_for_eventreturnsNonerather than raising, and the workflow chooses what to do about it. - A delivered event beats a simultaneously-due timeout, because the poll loop delivers before it fires timers.
Next: Fan-Out With Crash Recovery, the demo that tends to convince people.