Timeouts and Deadlines

Copy Markdown View Source

Timeouts let you react to a workflow being stuck in a state. They're useful for reminders, escalations, SLA enforcement, and offer expirations.

How timeouts work

Each timeout becomes an Oban trigger that polls on a cron schedule (default: every minute). The trigger's where clause checks both the state and a datetime field (default: state_entered_at):

where: state == :step_name and <field> <= ago(duration)

Once the condition is met, the trigger fires the timeout's action. When the workflow leaves the state (via a manual transition or another timeout), the where clause stops matching and the trigger naturally stops firing. The field can be overridden per timeout — see Data-driven deadlines below.

Action timeouts

Action timeouts run an Ash action without changing state. Use them for reminders, notifications, or logging:

step :awaiting_response do
  transition :respond, to: :next_step

  timeout :reminder, fire_after: {3, :days}, action: :send_reminder
end

You define the action with your notification logic:

actions do
  update :send_reminder do
    accept []
    change MyApp.Changes.SendReminderEmail
  end
end

Transition timeouts

Transition timeouts force the workflow into a new state. Use them for escalations, expirations, or SLA breaches:

step :awaiting_review do
  transition :approve, to: :approved

  timeout :escalation, fire_after: {7, :days}, transition_to: :escalated
end

step :escalated, terminal: true

The extension generates a hidden update action (:__timeout_review_escalation__timeout_<step>_<name>) that performs the state transition. You don't need to define this action yourself. Because the name includes the step, several steps can each declare an :escalation timeout of their own.

Repeating timeouts

By default, action timeouts fire once. Set repeat: true to keep firing on every scheduler cycle while the workflow remains in that state:

step :awaiting_response do
  transition :respond, to: :next_step

  # Fires once after 3 days
  timeout :reminder, fire_after: {3, :days}, action: :send_reminder

  # Fires after 3 days, then on every check_interval while still waiting
  timeout :follow_up do
    fire_after {3, :days}
    action :send_follow_up
    repeat true
  end
end

When a repeating timeout fires, the extension resets state_entered_at to the current time. This restarts the duration window — so fire_after: {3, :days} means the action fires every 3 days, not every scheduler cycle.

This reset is why state_entered_at is a timer anchor rather than a reliable "when did we enter this state" fact — a workflow that's been waiting for nine days with reminders every two reports state_entered_at as two days ago. If you need the honest answer, see Workflow history, which adds an entered_current_state_at calculation that ignores repeat resets.

Non-repeating timeouts (the default) use Oban's trigger_once? to prevent re-firing after the action completes, and they leave state_entered_at alone. Resetting it would push every other deadline on the same step back by the same amount, so a timeout :warn, fire_after: {30, :minutes}, action: :warn cannot delay the timeout :breach, fire_after: {1, :hours}, transition_to: :escalated beside it. Transition timeouts (with transition_to) don't need either mechanism since the state change naturally prevents re-firing.

Data-driven deadlines with field

By default, timeouts measure duration against state_entered_at — when the workflow entered its current state. The field option lets you measure against any datetime attribute or calculation instead:

step :active do
  transition :deactivate, to: :inactive

  # Fires 3 months after the worker's last session, not after entering :active
  timeout :inactivity do
    fire_after {3, :days}
    field :last_session_date
    transition_to :inactive_review
  end
end

The generated Oban trigger checks last_session_date <= ago(3, :day) instead of state_entered_at <= ago(3, :day). The field must be an existing attribute or calculation on the resource — a compile-time error is raised if it doesn't exist.

Use cases include:

  • Worker inactivity: field: :last_session_date — timeout based on actual activity
  • Document expiry: field: :earliest_cert_expiry — notify before certs expire
  • SLA tracking: field: :committed_by_date — alert when a deadline approaches

repeat: true is not supported with custom fields

Repeating timeouts reset state_entered_at to restart the duration window. With a custom field, this reset would need to update that field to "now" — but that's semantically wrong. If field: :last_session_date, resetting it to "now" would falsely claim a session occurred. The extension rejects this combination at compile time.

A non-repeating timeout against a custom field is not a periodic check. Its trigger keeps matching while the condition holds, but trigger_once? stops the action running a second time for the same record, so the reminder fires once. For a genuinely periodic check, add the cadence to the field itself — advance :next_check_at in the timeout action — so the condition stops matching until the next window opens.

Duration units

Supported units: :seconds, :minutes, :hours, :days.

timeout :hourly_ping, fire_after: {1, :hours}, action: :send_ping
timeout :weekly_expire, fire_after: {7, :days}, transition_to: :expired

Shorter than a poll interval

The shortest deadline you can declare comes from the scheduler you selected. AshWorkflow.Scheduler.Oban is the default and polls on a cron interval, and cron cannot poll more often than once a minute, so fire_after: {30, :seconds} would fire up to 60 seconds late — an error larger than the deadline itself. AshWorkflow.Verifiers.ValidateTimeoutPrecision rejects it at compile time rather than making a promise the scheduler cannot keep.

For a deadline shorter than a minute, select the scheduler that arms a timer per deadline instead of polling:

workflow do
  scheduler AshWorkflow.Scheduler.Precise

  step :awaiting_confirmation do
    # Fires 30 seconds later, not up to 60 seconds after that
    timeout :quick_check, fire_after: {30, :seconds}, action: :check_status
  end
end

Then start the process that holds the timers, listing the resources it recovers deadlines for:

children = [
  {AshWorkflow.Scheduler.Precise.Timeline, resources: [MyApp.Order]}
]

See AshWorkflow.Scheduler.Precise for what that trades away. A timer lives in memory, so a deadline held only by a timer is lost when the node dies and is recovered by the look-ahead sweep on whichever node leads next. A workflow measured in days wants Oban's durability more than an exact instant.

If you would rather keep the polling scheduler and drive one timeout yourself, self_scheduled?: true asserts that something else invokes it at the resolution the deadline needs:

# Nothing polls for this one. Call AshOban.schedule/2, or drive it from your
# own process, as often as the deadline requires.
timeout :quick_check do
  fire_after {30, :seconds}
  action :check_status
  self_scheduled? true
end

Polling interval and precision

Timeouts are not scheduled jobs waiting to fire at a particular time. Each one is an Oban cron scheduler that wakes on an interval, queries for records whose deadline has passed, and enqueues work for the ones it finds. The interval is check_interval, and it defaults to every minute ("* * * * *").

Set it once for the whole resource, and override individual timeouts that need a different cadence:

workflow do
  # Every trigger on this resource polls hourly instead of every minute
  check_interval "0 * * * *"

  step :awaiting_review do
    transition :approve, to: :approved

    # Inherits the hourly interval above
    timeout :nudge, fire_after: {2, :days}, action: :send_nudge

    # Overrides it: checked once a day at 9am
    timeout :daily_report do
      fire_after {3, :days}
      action :generate_report
      check_interval "0 9 * * *"
    end
  end
end

What polling costs

The workflow-level check_interval applies to automatic step triggers as well as timeouts, and every trigger gets its own scheduler. That means the cost multiplies with the size of the workflow, not with the number of records:

Triggers on the resourceDefault intervalScheduler queries per hour
4 automatic steps + 4 timeouts"* * * * *"480
4 automatic steps + 4 timeouts"0 * * * *"8

Each of those queries is a filtered read against the resource's table, so they are individually cheap and well served by an index on state. But they run whether or not any record is actually waiting, on every resource that uses AshWorkflow, forever.

The default of every minute suits deadlines measured in minutes or hours. For workflows measured in days — most approval and onboarding flows — an hourly or daily interval gives the same user-visible behaviour for a fraction of the queries. Match the interval to the precision the deadline actually needs:

# A 14-day offer expiry does not need minute precision
workflow do
  check_interval "0 * * * *"
  # ...
end

Timeouts are not precise to the second

A timeout fires on the first scheduler cycle after the duration has elapsed. With the default every-minute cron, a {2, :days} timeout fires somewhere between exactly 2 days and 2 days + 1 minute after state_entered_at. If you set check_interval: "0 * * * *" (hourly), the window is up to 1 hour.

Do not try to close that window with a faster cron. AshWorkflow.Verifiers.ValidateTimeoutPrecision rejects a deadline shorter than a minute on the polling scheduler, because cron does not poll below that. For sub-minute precision select AshWorkflow.Scheduler.Precise, or set self_scheduled?: true on the one timeout you drive yourself. See Shorter than a poll interval above.

Oban queue configuration

By default, all workflow triggers use the :workflow queue. You can override this at the workflow level:

workflow do
  queue :hiring_pipeline

  step :process, action: :run_processing, on_success: :review
  # ...
end

All generated triggers (automatic steps and timeouts) will use the specified queue.

Queue must be configured in Oban

The queue name must match a queue in your Oban configuration. If the queue isn't configured, jobs will be inserted but never executed — they'll sit in the oban_jobs table indefinitely with no error. This is validated at runtime by Oban, not at compile time.

config :my_app, Oban,
  repo: MyApp.Repo,
  queues: [default: 10, hiring_pipeline: 5]

The state_entered_at attribute

The extension auto-adds a state_entered_at (utc_datetime_usec) attribute to the resource. It's set when the record is created and updated every time the state changes on manual transitions and automatic step completions. Timeout durations are calculated from this timestamp.

If you need to define this attribute yourself (e.g., with a custom default or source), the extension skips adding it.