# Durable Timers

> ctx.sleep suspends a run and frees the worker. A timer loop re-queues it at the wake time.

Source: https://docs.pyrula.io/workflows/guides/timers/

`ctx.sleep()` suspends the run and frees the worker slot; a timer loop re-queues it at wake time.

```python
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"
```

## Relative vs absolute

```python
from datetime import UTC, datetime, timedelta

await ctx.sleep(timedelta(hours=1))
await ctx.sleep(3600)                                          # seconds
await ctx.sleep_until(datetime(2027, 6, 1, 9, 0, tzinfo=UTC))  # absolute UTC
```

`ctx.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](https://docs.pyrula.io/workflows/guides/client.md#starting-a-run-later)). That wait is journaled
under its own scope, so the body's sleeps are unaffected.

## Stream events

```
in_progress → run:sleeping (worker freed) → [wake] → run:woken → in_progress
```

Each `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

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

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](https://docs.pyrula.io/workflows/guides/client.md#cancellation).
