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 a worker process
COMPLETEDTask finished successfully
FAILEDTask failed (error returned or exception)
CANCELLEDTask was cancelled before execution
EXPIREDTask was never claimed before good_until deadline passed

Terminal states: COMPLETED, FAILED, CANCELLED, EXPIRED.

TaskStatus values:

EnumValueTerminal
PENDING"PENDING"No
CLAIMED"CLAIMED"No
RUNNING"RUNNING"No
COMPLETED"COMPLETED"Yes
FAILED"FAILED"Yes
CANCELLED"CANCELLED"Yes
EXPIRED"EXPIRED"Yes

Use is_terminal or TASK_TERMINAL_STATES to check terminal status programmatically:

from horsies import TaskStatus, TASK_TERMINAL_STATES
status = TaskStatus.FAILED
status.is_terminal # True
TaskStatus.RUNNING.is_terminal # False
# Frozenset for use in queries or filters
TASK_TERMINAL_STATES # frozenset({TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED, TaskStatus.EXPIRED})
┌──────────────┐
┌─────│ PENDING │─────┐
│ └──────┬───────┘ │
│ │ Worker │ good_until
│ │ claims │ passed
│ ▼ ▼
│ ┌──────────────┐ ┌──────────┐
timeout │ │ CLAIMED │ │ EXPIRED │
(requeue)◄─┼─────┤ │ └──────────┘
│ └──────┬───────┘
│ │ 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 to worker’s process pool
  • Child process sets status=RUNNING, started_at=NOW()
  • Heartbeat thread begins sending runner heartbeats
  • Task returns TaskResult(ok=value)
  • Worker stores serialized result
  • Sets completed_at=NOW()

COMPLETED means the task succeeded (returned TaskResult.ok). Execution that ends with TaskResult.err or an exception is FAILED, not COMPLETED.

  • Task returns TaskResult(err=TaskError(...)) or
  • Task raises unhandled exception or
  • Worker process 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 without being claimed
  • Reaper transitions to EXPIRED with TASK_EXPIRED outcome code
  • No attempt row is written (task never started)
  • 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. Captured inside the TaskSendResult flow before enqueue.
enqueued_atWhen task becomes eligible for claiming (updated on retry)
claimed_atWorker claims task
started_atExecution begins in child process
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: Child process sends for RUNNING tasks

Missing heartbeats trigger automatic recovery. See Heartbeats & Recovery.

Tasks can have a good_until deadline:

  • If task isn’t claimed before good_until, it becomes unclaimable
  • The reaper periodically transitions unclaimed expired tasks to EXPIRED with a TASK_EXPIRED result
  • Useful for time-sensitive operations
from datetime import datetime, timedelta, timezone
@app.task("urgent_task")
def urgent_task() -> TaskResult[str, TaskError]:
...
# Compute deadline at send time, not module load time
urgent_task.send(good_until=datetime.now(timezone.utc) + timedelta(minutes=5))