Lesson 05 Scheduling & Backfills
Theme:
Lesson 05 · Slide 1

Lesson 05 — Scheduling & Backfills

Schedules, catchup, and the data-interval-aware DAG pattern.

Data Engineering Module 2 Orchestration
Lesson 05 · Slide 2

Why scheduling is harder than it looks

A correctly written task processes the data interval it was given, not the wall-clock time it runs at.

Lesson 05 · Slide 3

The two clocks

Logical date / data interval

The data we are processing. The start of the interval is fixed — a backfill run for hour 12 always has data_interval_start = 2026-05-01T12:00:00, whether it runs today or next year.

Physical execution time

The wall-clock time the run actually fired. This changes on every replay. Never use it to derive which data to process — it breaks backfills.

Lesson 05 · Slide 4

Schedule expressions

ExpressionInterval lengthNotes
"@hourly"1 hourFires at the top of every hour (UTC)
"@daily"24 hoursFires at midnight UTC; first run covers the previous day
"0 * * * *"1 hourEquivalent cron; more flexible for offset schedules
timedelta(hours=1)1 hourPython object; useful when the interval is computed
Dataset triggerevent-drivenFires when an upstream DAG produces a named Dataset (Airflow 2.4+)
Lesson 05 · Slide 5

Catchup, explained

When you set start_date=2026-05-01 and the scheduler starts on 2026-05-26 with catchup=True, Airflow queues every missed interval from start_date to now — approximately 600 hourly runs in that example. With catchup=False, Airflow skips straight to the most recent interval.

catchup=True

All missed intervals are queued. Right choice when historical completeness matters (e.g., loading a data warehouse from day one).

catchup=False

Skip the backlog, start from now. Right choice for alerting pipelines or dashboards where old data is irrelevant.

Choose deliberately — the default in Airflow is True.

Lesson 05 · Slide 6

The data-interval-aware DAG

# L04 (hardcoded — breaks on backfill):
def _extract(**context):
    from extract.gharchive import fetch_hour
    fetch_hour(hour="2026-05-01-12")

# L05 (data-interval-aware — safe to replay):
def _extract(**context):
    from extract.gharchive import fetch_hour
    hour = context["data_interval_start"].strftime("%Y-%m-%d-%-H")
    fetch_hour(hour=hour)

Now every run processes its own hour. Replaying the same interval always fetches the same data.

Lesson 05 · Slide 7

What's in context

def _extract(**context):
    print(context["data_interval_start"])  # pendulum DateTime
    print(context["run_id"])               # 'scheduled__2026-05-01T12:00:00+00:00'
Lesson 05 · Slide 8

Triggering a backfill

airflow dags backfill gharchive_hourly \
    --start-date 2026-05-01 \
    --end-date 2026-05-01T06:00:00

This enqueues 6 DagRuns, one per hourly interval (hours 0–5). Watch them parallel up to max_active_runs.

Each backfill run gets its own data_interval_start. Because our DAG is data-interval-aware, every run downloads exactly the right hour — no code changes needed to backfill.

Lesson 05 · Slide 9

max_active_runs and concurrency

with DAG(
    dag_id="gharchive_hourly",
    max_active_runs=2,      # at most 2 concurrent runs — polite to gharchive.org
    max_active_tasks=8,
    ...
) as dag: ...
Lesson 05 · Slide 10

The catchup gotcha

"If you set catchup=True with start_date six months ago and schedule='@hourly', you just told Airflow to queue 4,300 runs. Then your extract starts hammering gharchive.org. Always pair high-catchup DAGs with max_active_runs ≤ 2."

Lesson 05 · Slide 11

Dataset triggers (one-paragraph teaser)

Airflow 2.4+ added Datasets: declare that a DAG produces Dataset("s3://bucket/path") and another DAG consumes it, and the second DAG auto-triggers when the first finishes writing to that path. This replaces fragile ExternalTaskSensor patterns for cross-DAG dependencies.

from airflow.datasets import Dataset

HOURLY_OUT = Dataset("file:///opt/airflow/sample-pipeline/data/")

# Producer DAG
with DAG(..., schedule="@hourly") as hourly_dag:
    PythonOperator(..., outlets=[HOURLY_OUT])

# Consumer DAG — fires when HOURLY_OUT is updated
with DAG(..., schedule=[HOURLY_OUT]) as rollup_dag:
    ...

Not used in this course's sample pipeline, but worth knowing for production setups.

Lesson 05 · Slide 12

The full L05 DAG

def _hour_key(context) -> str:
    """Format data_interval_start as YYYY-MM-DD-H (gharchive filename shape)."""
    return context["data_interval_start"].strftime("%Y-%m-%d-%-H")

def _extract(**context) -> None:
    from extract.gharchive import fetch_hour
    hour = _hour_key(context)
    fetch_hour(hour=hour)

def _transform(**context) -> None:
    from transform.events import count_by_type
    hour = _hour_key(context)
    count_by_type(
        input_path=f"data/raw/{hour}.json.gz",
        output_path=f"data/out/{hour}.csv",
    )

with DAG(
    dag_id="gharchive_hourly",
    start_date=datetime(2026, 5, 1),
    schedule="@hourly",     # ← L05: was None
    catchup=True,           # ← L05: was False
    max_active_runs=2,
    tags=["data-engineering", "module-2"],
) as dag:
    extract = PythonOperator(task_id="extract", python_callable=_extract)
    transform = PythonOperator(task_id="transform", python_callable=_transform)
    extract >> transform
Lesson 05 · Slide 13

Common gotchas

Lesson 05 · Slide 14

Recap & what's next

Coming up: Lesson 06 — what happens when you re-run the same interval twice (idempotency & retries).
1 / ?