◐ ai-teacher

🔀 Pipelines & Orchestration · Chapter 4

Scheduling, dependencies and the backfill problem

The run stamped 14 March starts on 15 March. Until that sentence feels obvious, every Airflow schedule, cross-DAG sensor and backfill you write will be off by one.

Study this lesson free in the app →

The basics

Scheduling in Airflow means deciding which slice of time each run is responsible for, and when it is allowed to start. Dependencies decide what must finish first — inside a DAG and across DAGs. The backfill problem is re-running many past slices without corrupting data or crushing your systems.

The problem it solves: pipelines process periods — yesterday's rides, the last hour's payments. A run can only process a period after it has ended, and re-runs must hit exactly the same period. Get this wrong and you double-count, miss days, or wait forever for an upstream run that will never match.

The mental model: each run is a labelled envelope. The label says "rides for 14 March" (the data interval — 14 Mar 00:00 to 15 Mar 00:00). You can only fill the envelope once 14 March is over, so it is opened on 15 March. The label never changes, no matter when you re-open it.

Words you must know 5

Data interval
[data_interval_start, data_interval_end) the run covers
Logical date (ds) — the label; for normal schedules it equals the interval start
Catchup
create runs for every missed interval since start_date
ExternalTaskSensor
wait for a task in another DAG
Dataset
data-aware trigger: run when an upstream task updates a dataset

Explained

Meghalaya Cabs' daily_revenue DAG runs at 00:00. The run that starts at 00:00 on 15 March has ds = 2026-03-14.

Not a bug. It's the most important convention in Airflow — and the source of most of its off-by-one incidents.

The newspaper's date

The newspaper delivered on the morning of the 15th reports what happened on the 14th. The editors couldn't print it on the 14th — the day wasn't over.

If the printing press breaks and the paper is reprinted on the 17th, it still covers the 14th. Nobody prints the 17th's news on the 14th's edition.

And the sports supplement can't go out until the main edition is printed — but if the main edition is printed at midnight and the supplement checks at 2 am for an edition labelled 2 am, it waits forever. Labels have to match, not clocks.

Intervals, labels and start times

schedule='@daily', start_date=2026-03-01 (Asia/Kolkata)

data interval              ds / logical_date   run starts at
─────────────────────────────────────────────────────────────
[03-01 00:00, 03-02 00:00)   2026-03-01        03-02 00:00
[03-02 00:00, 03-03 00:00)   2026-03-02        03-03 00:00
[03-03 00:00, 03-04 00:00)   2026-03-03        03-04 00:00
[03-04 00:00, 03-05 00:00)   2026-03-04        03-05 00:00

Deployed 03-05 10:00:
  catchup=True  → runs for 03-01, 03-02, 03-03, 03-04 queued now
  catchup=False → only 03-04 (latest finished interval), then onward

The first run happens one full interval after start_date. Your task should read the window from data_interval_start / data_interval_end, never compute it.

Precisely (Airflow 2.x)

Three ways to depend on another DAG

Sensor = loose coupling but schedules must line up. Trigger = producer knows its consumers. Dataset = neither knows the other's schedule (renamed assets in Airflow 3).

Cross-DAG dependencies in code

# 1. Pull: wait for upstream task with the *same logical date*
wait = ExternalTaskSensor(
    task_id='wait_rides_ingest',
    external_dag_id='rides_ingest', external_task_id='publish_silver',
    # upstream runs at 00:00, we run at 02:00 → shift to match ITS label
    execution_delta=timedelta(hours=2),
    mode='reschedule', timeout=4 * 3600)

# 2. Data-aware: producer declares what it updates
SILVER_RIDES = Dataset('s3://mc-lake/silver/rides')

@task(outlets=[SILVER_RIDES])
def publish_silver(): ...

# consumer DAG runs whenever that task succeeds
@dag(schedule=[SILVER_RIDES], start_date=pendulum.datetime(2026, 3, 1, tz='Asia/Kolkata'))
def daily_revenue(): ...

execution_delta is subtracted from this run's logical date to find the upstream one. Forget it when schedules differ and the sensor waits for a run that doesn't exist.

The six-month backfill

Task: a bug in fare rounding affected fact_rides from 1 Sep to 28 Feb — 181 days. Downstream: daily_revenue, driver_payouts, and an ML feature table.

What a careful engineer does:

  1. Confirms every affected task is idempotent per interval (overwrite partition / MERGE)
  2. Runs airflow dags backfill -s 2025-09-01 -e 2026-02-28 rides_model with max_active_runs at 4 so the warehouse and replica survive
  3. Checks for cumulative tables — driver_lifetime_earnings built as yesterday + today must be rebuilt in order, or rebuilt from scratch
  4. Cascades downstream: clears dependent DAGs for the same range (or ExternalTaskMarker clears them with --include-downstream)
  5. Verifies counts and ₹ totals per day before and after, against the payment gateway

At scale: the backfill problem

Throughput vs blast radius. 181 days × 30 tasks = 5,430 task instances. Unthrottled, they fill every worker slot and starve tonight's production runs. Use max_active_runs, a dedicated pool, or run backfills on a separate queue.

Order matters only for state. Partition-overwrite tasks can run in any order, in parallel. Anything cumulative (running balances, SCD2 dimensions, depends_on_past) must go sequentially — or be redesigned to recompute from raw.

Late-arriving data is a permanent mini-backfill. If 2% of rides arrive up to 3 days late, design each run to reprocess a trailing window (the last 3 intervals) — or use a lookback in incremental models.

Backfill the cheapest layer. If raw is correct, re-run model and serve only. Re-extracting six months from a partner API may be rate-limited or impossible.

Words to know

Data intervalThe time window [start, end) a DAG run is responsible for processing.
Logical dateThe run's identity label; equals the data interval start for cron and timedelta schedules.
CatchupCreating runs for all intervals between start_date (or the last run) and now.
ExternalTaskSensorA sensor that waits for a task in another DAG with a matching logical date.
Dataset (data-aware scheduling)A URI a task declares it updates; DAGs scheduled on it run when it's updated.
depends_on_pastA task setting requiring the same task's previous run to have succeeded.
max_active_runsThe maximum number of concurrent runs of one DAG; the main backfill throttle.

In two minutes

Test yourself

1. A @daily DAG's run has ds = 2026-03-14. When does it normally start?

  1. 2026-03-14 00:00
  2. 2026-03-15 00:00, when the 14 March interval ends
  3. 2026-03-14 23:59
  4. Whenever a worker is free on the 14th
Show the answer

2026-03-15 00:00, when the 14 March interval ends — A run is created after its data interval closes. The label is the interval start; the start time is the interval end.

2. For a cron-scheduled DAG in Airflow 2, the logical date equals…

  1. data_interval_start
  2. data_interval_end
  3. The wall-clock time the task started
  4. The DAG's start_date always
Show the answer

data_interval_start — For interval-based timetables the logical date is the start of the data interval. (CronTriggerTimetable is the exception: logical date = trigger time.)

3. A DAG with start_date = 2026-03-01 (no catchup override, Airflow 2 defaults) is deployed on 2026-03-10. What happens?

  1. Only today's run is created
  2. Nothing until 11 March
  3. Runs for 1–9 March are queued, because catchup defaults to True
  4. Only the 1 March run
Show the answer

Runs for 1–9 March are queued, because catchup defaults to True — catchup_by_default is True in Airflow 2, so every missed interval is scheduled. Airflow 3 changed this default to False.

4. Same DAG, but catchup=False. Which run is created on deploy?

  1. None
  2. All nine
  3. Only 1 March
  4. Only the most recent completed interval (9 March)
Show the answer

Only the most recent completed interval (9 March) — With catchup disabled the scheduler creates the latest finished interval and continues from there.

5. rides_ingest runs @daily at 00:00. daily_revenue runs at 02:00 and uses ExternalTaskSensor on it with no other settings. What happens?

  1. It waits for an upstream run with logical date at 02:00 that never exists, until timeout
  2. It works — sensors match by date only
  3. It triggers rides_ingest
  4. It runs immediately
Show the answer

It waits for an upstream run with logical date at 02:00 that never exists, until timeout — The sensor looks for the same logical date. Upstream labels are at 00:00, so set execution_delta=timedelta(hours=2) (or execution_date_fn).

6. Meghalaya Cabs wants daily_revenue to run whenever silver.rides is published, regardless of the producer's schedule. Most direct Airflow 2.4+ feature?

  1. depends_on_past
  2. Datasets: outlets on the producer task, schedule=[Dataset] on the consumer
  3. A cron schedule 10 minutes later
  4. catchup=True
Show the answer

Datasets: outlets on the producer task, schedule=[Dataset] on the consumer — Data-aware scheduling decouples schedules: the consumer is triggered by the dataset update event.

There are 10 questions, 3 puzzles and 5 recall cards for this lesson in the app — the recall cards come back on later days, just before you would forget.

Learn it so it stays. Read it, test yourself, then recall it on two different days. The app schedules that for you, free.

Start this lesson →