Skip to content
Pyrula

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 timedelta
from 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"
from datetime import UTC, datetime, timedelta
await ctx.sleep(timedelta(hours=1))
await ctx.sleep(3600) # seconds
await ctx.sleep_until(datetime(2026, 6, 1, 9, 0, tzinfo=UTC)) # absolute UTC

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

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_progress

Each sleep in a run has its own replay key, so on recovery the ones that already fired are skipped and later ones still suspend.

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.