Durable Timers
ctx.sleep() suspends the run and frees the worker slot; a timer loop re-queues it at wake time.
from datetime import timedeltafrom pyrula.workflows import workflow
@workflow(name="reminder")async def reminder(ctx, user_id: str, message: str, delay_seconds: float) -> str: await ctx.sleep(delay_seconds) # worker freed here await ctx.step( "send_notification", lambda: send_notification( user_id, message, idempotency_key=f"{ctx.run_id}:send_notification", ), ) return "sent"Relative vs absolute
Section titled “Relative vs absolute”from datetime import UTC, datetime, timedelta
await ctx.sleep(timedelta(hours=1))await ctx.sleep(3600) # secondsawait ctx.sleep_until(datetime(2027, 6, 1, 9, 0, tzinfo=UTC)) # absolute UTCctx.sleep computes the wake time from ctx.now() and records it. Derive a sleep_until
datetime from ctx.now(), not datetime.now(), or the wake time diverges on replay. A wake time
already past when first reached skips the sleep.
Pausing a run doesn’t stop the clock on a timed wait_for_signal or ctx.interrupt: the deadline
is recorded, and WaitTimeout raises once the run is resumed and runs again.
submit(delay=...) and submit(start_at=...) hold a run before it starts with the same machinery
(Starting a run later). That wait is journaled
under its own scope, so the body’s sleeps are unaffected.
Stream events
Section titled “Stream events”in_progress → run:sleeping (worker freed) → [wake] → run:woken → in_progressEach sleep has its own replay key; on recovery, sleeps that already fired are skipped.
run:sleeping only arms the sleep. If the run restarts while still asleep (an update arrives, a
manual requeue), the body replays to the sleep, applies the update, and sleeps until the original
wake time without a second run:sleeping. Cancellation closes the wait and queues normal cancelled
cleanup.
A sleep ends once
Section titled “A sleep ends once”A sleep ends exactly once: by its timer, or by the first attempt to reach it after the wake time
(an update, requeue or orphan takeover before the timer loop swept). That attempt closes the sleep
through the store under its own claim. run:woken names the sleep it ends, so a later timer firing
finds it closed and queues nothing; if the timer closes it first, the attempt stops and the run is
picked up again.
Either way the store writes run:woken and the sleep’s step:done before anything the next attempt
journals. Cancellation writes neither: it removes the timer and admits fenced terminal cleanup.
A run restarted while waiting on a signal journals step:pending and run:signal_waiting again,
so that pair can appear twice for one wait.
Crashes
Section titled “Crashes”When a worker dies mid-sleep, a timer loop on another worker re-queues the run (orphan recovery
leaves sleeping runs alone). Wake lands within one timer poll interval (timer_poll_interval_s,
5 s) of the target.
Cancelling a sleeping run still needs an available worker. Pause alone leaves a sleep armed; cancel wins when both exist. See Cancellation.