Runs and States (The Traffic Lights of a Run)
One Lesson, Many Tries
A job is like "maths" on the timetable. A run is one maths lesson on one day: Monday's maths. If the projector breaks and the lesson starts again, that is a second attempt at the same lesson. And the teacher's logbook, "9:02 Alice paused the lesson", is a list of events.
Every run has an id made of three parts joined by dots:
The number counts runs of that job on that run date, starting at 1. Job
names can contain - and _ but never ., so the id always splits back
cleanly.
The Traffic Lights
A run moves through states, a bit like traffic lights telling it when it may go:
scheduled ──> waiting ──> queued ──> running ──> succeeded
│ ▲ ▲ │ └──> retry_wait ──> queued
│ │ │ └──────> failed
▼ │ │
blocked held (from waiting / queued / retry_wait)
skipped, cancelled (operator, or on: failure rules)
- scheduled: the run exists; its time has come.
- waiting: red light. It is waiting for the jobs it depends on, or for its own previous run to finish.
- blocked: a job it needs ended badly, so it cannot go.
- queued: amber. Ready to go, waiting for a free slot.
- running: green. An attempt is running right now.
- retry_wait: the attempt failed, but it may try again after a pause.
- held: an operator pressed pause.
held_fromremembers where it was, so "release" puts it back. - succeeded, failed, skipped, cancelled: the journey is
over. These are the final states (
FINAL_STATES).
Each run also remembers its trigger (what made it: schedule,
dependency, manual, backfill or catch_up), and each attempt its
outcome (succeeded, failed, timeout, killed, lost or
start_error).
Frozen Records
Runs, attempts and events are frozen: nobody changes one in place.
To move a run on, the engine makes a changed copy with
dataclasses.replace and saves that. A copy someone already holds never
changes under their feet.
import dataclasses
from datetime import UTC, date, datetime
from pyscheduler.engine.model import Run, RunState, Trigger, make_run_id, parse_run_id
now = datetime(2026, 10, 17, 9, tzinfo=UTC)
run = Run(
run_id=make_run_id("extract-trades", date(2026, 10, 17), 1),
job="extract-trades",
run_date=date(2026, 10, 17),
scheduled_at=now,
state=RunState.SCHEDULED,
reason="",
attempt=0,
rerun=0,
trigger=Trigger.SCHEDULE,
config_version=1,
created_at=now,
updated_at=now,
)
done = dataclasses.replace(run, state=RunState.SUCCEEDED)
print(run.run_id, done.is_final) # extract-trades.2026-10-17.1 True
print(parse_run_id(run.run_id)) # ('extract-trades', datetime.date(2026, 10, 17), 1)