Skip to content

Task Lifecycle

StateDescription
PendingTask is queued, waiting to be claimed
ClaimedWorker has claimed the task, preparing to execute
RunningTask is actively executing in the worker
CompletedTask finished successfully
FailedTask failed (error returned or panic)
CancelledTask was cancelled via workflow cancellation
Expiredgood_until deadline passed before execution started

Terminal states: Completed, Failed, Cancelled, Expired.

pub enum TaskStatus {
Pending,
Claimed,
Running,
Completed,
Failed,
Cancelled,
Expired,
}
pub const TASK_TERMINAL_STATES: &[TaskStatus] = &[
TaskStatus::Completed,
TaskStatus::Failed,
TaskStatus::Cancelled,
TaskStatus::Expired,
];
┌──────────────┐
┌─────│ Pending │─────────┐
│ └──────┬───────┘ │
│ │ Worker │ good_until
│ │ claims │ passed
│ ▼ ▼
│ ┌──────────────┐ ┌──────────┐
timeout │ │ Claimed │ ► │ Expired │
(requeue)◄─┼─────┤ │ └──────────┘
│ └──────┬───────┘ good_until passed
│ │ Execution
│ ▼ starts
│ ┌──────────────┐
│ │ Running │
│ └──────┬───────┘
│ │
│ ┌──────────┼────────────┐
│ │ │ │
│ ▼ ▼ ▼
│ ┌──────────┐ ┌──────────┐ (retry)
│ │Completed │ │ Failed │────┐
│ └──────────┘ └──────────┘ │
│ │
└──────────────────────────────┘
  • Worker executes claim query with FOR UPDATE SKIP LOCKED
  • Sets claimed=TRUE, claimed_at=NOW(), claimed_by_worker_id
  • Task is reserved for this worker
  • Task dispatched via tokio::spawn (async) or spawn_blocking (blocking)
  • Sets status=Running, started_at=NOW()
  • Runner heartbeat loop begins
  • Task returns Ok(value)
  • Worker stores serialized result
  • Sets completed_at=NOW()

Completed means the task succeeded (returned Ok). Execution that ends with Err(TaskError) or a panic is Failed, not Completed.

  • Task returns Err(TaskError { ... }) or
  • Task panics (caught, wrapped as UnhandledError) or
  • Worker crashes (detected via missing heartbeats)
  • Sets failed_at=NOW(), stores error in result
  • Only if retry policy configured and retries remaining
  • Sets status=Pending, increments retry_count
  • Sets next_retry_at based on retry policy intervals
  • Task has good_until set and deadline has passed before it was claimed
  • Reaper transitions to Expired with TaskExpired outcome code
  • Worker claimed the task, but good_until passed before user code started
  • Worker transitions to Expired with TaskExpired
  • No attempt row is written because the task body did not run
  • Claimer heartbeat missing for claimed_stale_threshold_ms
  • Reaper automatically requeues (safe — user code never ran)
  • Sets claimed=FALSE, claimed_at=NULL

Each task records timing information:

FieldSet When
sent_atImmutable call-site timestamp — when .send() or .schedule() was called
enqueued_atWhen task becomes eligible for claiming (updated on retry)
claimed_atWorker claims task
started_atExecution begins
completed_atSuccessful completion
failed_atFailure
next_retry_atScheduled retry time

Two types of heartbeats track task health:

  1. Claimer heartbeat: Worker sends for Claimed tasks (task not yet running)
  2. Runner heartbeat: Worker sends for Running tasks

Missing heartbeats trigger automatic recovery. See Heartbeats & Recovery.

Tasks can have a good_until deadline:

  • If the task does not start before good_until, it becomes unclaimable
  • The reaper transitions unclaimed expired tasks to Expired
  • Workers also guard the Claimed → Running transition and expire tasks whose deadline passed while they were claimed
  • Useful for time-sensitive operations

Set good_until per send:

use chrono::{Utc, Duration};
use horsies::TaskSendOptions;
let deadline = Utc::now() + Duration::minutes(5);
let handle = urgent_task::with_options(
TaskSendOptions::new().good_until(deadline),
)
.send(input)
.await?;

For workflow tasks, use .good_until(deadline) on the workflow node while building the spec.