Durable Timers
ctx.sleep() suspends a run for a duration and frees the worker slot. A timer loop
re-queues the run when its wake time comes around, so a one-hour sleep doesn’t tie up
a worker for an hour.
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(2026, 6, 1, 9, 0, tzinfo=UTC)) # absolute UTCctx.sleep(duration) uses ctx.now() internally to compute the absolute wake time, so the
computed wake time is recorded in the event stream. Pass a datetime computed from ctx.now()
(not datetime.now()) when using sleep_until directly, or the wake time diverges on replay.
If the wake time is already past on a replay, the sleep is skipped.
What you’ll see in the stream
Section titled “What you’ll see in the stream”Suspending emits run:sleeping and sets status to sleeping. Waking emits
run:woken and execution continues from the line after sleep.
in_progress → run:sleeping (worker freed) → [wake] → run:woken → in_progressEach sleep in a run has its own replay key, so on recovery the ones that already
fired are skipped and later ones still suspend.
Crashes
Section titled “Crashes”If a worker dies mid-sleep, the timer loop on another worker re-queues the run. A sleeping run has a known wake time, so orphan recovery leaves it alone. Wake time is approximate: a run wakes within one timer poll interval of its target.