Task Lifecycle
Task States
Section titled “Task States”| State | Description |
|---|---|
PENDING | Task is queued, waiting to be claimed |
CLAIMED | Worker has claimed the task, preparing to execute |
RUNNING | Task is actively executing in a worker process |
COMPLETED | Task finished successfully |
FAILED | Task failed (error returned or exception) |
CANCELLED | Task was cancelled before execution |
EXPIRED | Task was never claimed before good_until deadline passed |
Terminal states: COMPLETED, FAILED, CANCELLED, EXPIRED.
Status Enum
Section titled “Status Enum”TaskStatus values:
| Enum | Value | Terminal |
|---|---|---|
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.FAILEDstatus.is_terminal # True
TaskStatus.RUNNING.is_terminal # False
# Frozenset for use in queries or filtersTASK_TERMINAL_STATES # frozenset({TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED, TaskStatus.EXPIRED})State Transitions
Section titled “State Transitions” ┌──────────────┐ ┌─────│ PENDING │─────┐ │ └──────┬───────┘ │ │ │ Worker │ good_until │ │ claims │ passed │ ▼ ▼ │ ┌──────────────┐ ┌──────────┐ timeout │ │ CLAIMED │ │ EXPIRED │ (requeue)◄─┼─────┤ │ └──────────┘ │ └──────┬───────┘ │ │ Execution starts │ ▼ │ ┌──────────────┐ │ │ RUNNING │ │ └──────┬───────┘ │ │ │ ┌──────────┼────────────┐ │ │ │ │ │ ▼ ▼ ▼ │ ┌──────────┐ ┌──────────┐ (retry) │ │COMPLETED │ │ FAILED │────┐ │ └──────────┘ └──────────┘ │ │ │ └──────────────────────────────┘Transition Details
Section titled “Transition Details”PENDING → CLAIMED
Section titled “PENDING → CLAIMED”- 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
CLAIMED → RUNNING
Section titled “CLAIMED → RUNNING”- Task dispatched to worker’s process pool
- Child process sets
status=RUNNING,started_at=NOW() - Heartbeat thread begins sending runner heartbeats
RUNNING → COMPLETED
Section titled “RUNNING → COMPLETED”- 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.
RUNNING → FAILED
Section titled “RUNNING → FAILED”- Task returns
TaskResult(err=TaskError(...))or - Task raises unhandled exception or
- Worker process crashes (detected via missing heartbeats)
- Sets
failed_at=NOW(), stores error inresult
RUNNING → PENDING (retry)
Section titled “RUNNING → PENDING (retry)”- Only if retry policy configured and retries remaining
- Sets
status=PENDING, incrementsretry_count - Sets
next_retry_atbased on retry policy intervals
PENDING → EXPIRED
Section titled “PENDING → EXPIRED”- Task has
good_untilset and deadline has passed without being claimed - Reaper transitions to EXPIRED with
TASK_EXPIREDoutcome code - No attempt row is written (task never started)
CLAIMED → PENDING (stale recovery)
Section titled “CLAIMED → PENDING (stale recovery)”- Claimer heartbeat missing for
claimed_stale_threshold_ms - Reaper automatically requeues (safe - user code never ran)
- Sets
claimed=FALSE,claimed_at=NULL
Timestamps
Section titled “Timestamps”Each task records timing information:
| Field | Set When |
|---|---|
sent_at | Immutable call-site timestamp — when .send() or .schedule() was called. Captured inside the TaskSendResult flow before enqueue. |
enqueued_at | When task becomes eligible for claiming (updated on retry) |
claimed_at | Worker claims task |
started_at | Execution begins in child process |
completed_at | Successful completion |
failed_at | Failure |
next_retry_at | Scheduled retry time |
Heartbeats
Section titled “Heartbeats”Two types of heartbeats track task health:
- Claimer heartbeat: Worker sends for CLAIMED tasks (task not yet running)
- Runner heartbeat: Child process sends for RUNNING tasks
Missing heartbeats trigger automatic recovery. See Heartbeats & Recovery.
Task Expiry
Section titled “Task Expiry”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
EXPIREDwith aTASK_EXPIREDresult - 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 timeurgent_task.send(good_until=datetime.now(timezone.utc) + timedelta(minutes=5))