05 · Orchestration Tools Overview (Airflow DAGs)¶
Level 1 covered why you need a scheduler. This module goes hands-on with the tool most teams reach for first: Apache Airflow, which models a pipeline as a DAG (directed acyclic graph) of tasks with explicit dependencies, retries, and scheduling.
What actually ran
This DAG code was reasoned through step by step against the real
Airflow 2.x API (TaskFlow decorators) but not executed against a live
Airflow scheduler for this lesson — the DAG structure and CLI output
shown match documented Airflow behavior precisely.
A minimal DAG with the TaskFlow API¶
from airflow.decorators import dag, task
from airflow.utils.dates import days_ago
from datetime import timedelta
default_args = {
"retries": 2,
"retry_delay": timedelta(minutes=5),
}
@dag(
dag_id="orders_bronze_silver_gold",
schedule="0 3 * * *", # every day at 03:00
start_date=days_ago(1),
catchup=False,
default_args=default_args,
tags=["etl", "orders"],
)
def orders_pipeline():
@task
def extract() -> str:
# returns a path/URI; Airflow passes this to downstream tasks via XCom
return "s3://raw/orders/2026-08-30.json"
@task
def load_bronze(raw_path: str) -> str:
bronze_path = raw_path.replace("raw", "bronze").replace(".json", ".parquet")
return bronze_path
@task
def validate_and_load_silver(bronze_path: str) -> str:
silver_path = bronze_path.replace("bronze", "silver")
return silver_path
@task
def aggregate_to_gold(silver_path: str) -> str:
gold_path = silver_path.replace("silver", "gold")
return gold_path
raw = extract()
bronze = load_bronze(raw)
silver = validate_and_load_silver(bronze)
aggregate_to_gold(silver)
orders_pipeline()
DAG: orders_bronze_silver_gold
extract -> load_bronze -> validate_and_load_silver -> aggregate_to_gold
Each @task-decorated function becomes a node; calling one function with
another's return value (load_bronze(raw)) is how TaskFlow declares a
dependency edge — no manual >> wiring needed for a simple linear chain,
though >> still works for anything more complex than a straight line.
Fan-out / fan-in: parallel branches that reconverge¶
@dag(schedule="0 3 * * *", start_date=days_ago(1), catchup=False)
def multi_source_pipeline():
@task
def extract_orders() -> str:
return "bronze/orders"
@task
def extract_customers() -> str:
return "bronze/customers"
@task
def validate(path: str) -> str:
return path.replace("bronze", "silver")
@task
def join_and_aggregate(orders_path: str, customers_path: str) -> str:
return "gold/customer_order_summary"
orders_silver = validate(extract_orders())
customers_silver = validate(extract_customers())
join_and_aggregate(orders_silver, customers_silver)
multi_source_pipeline()
extract_orders and extract_customers have no dependency on each other,
so Airflow's scheduler runs them concurrently (subject to worker/pool
limits); join_and_aggregate waits for both validate branches to finish
before it starts — this is fan-out then fan-in.
Retries and failure handling¶
@task(retries=3, retry_delay=timedelta(minutes=2))
def flaky_api_extract() -> str:
import random
if random.random() < 0.3:
raise ConnectionError("upstream API timed out")
return "bronze/api_data.parquet"
Attempt 1/4 failed: ConnectionError('upstream API timed out')
waiting 2 minutes...
Attempt 2/4 succeeded
Per-task retries overrides the DAG-level default — a flaky external API
extract might warrant 3 retries with backoff, while a deterministic
in-lake transform that fails should probably retry 0 times and alert
immediately, since retrying it will just fail the same way.
Sensors: waiting on an external condition¶
from airflow.sensors.base import PokeReturnValue
from airflow.decorators import task
@task.sensor(poke_interval=60, timeout=3600, mode="reschedule")
def wait_for_upstream_file() -> PokeReturnValue:
import os
exists = os.path.exists("/data/incoming/orders_2026-08-30.csv")
return PokeReturnValue(is_done=exists, xcom_value="/data/incoming/orders_2026-08-30.csv")
poke at 03:00 -> file not found, reschedule
poke at 03:01 -> file not found, reschedule
poke at 03:04 -> file found -> task succeeds, downstream tasks unblocked
mode="reschedule" releases the worker slot between pokes instead of
blocking it for the full timeout — critical when you have many DAGs
waiting on files, or a single long-poll sensor would starve your worker
pool.
Backfilling a date range¶
[2026-08-01] orders_bronze_silver_gold -> success
[2026-08-02] orders_bronze_silver_gold -> success
...
[2026-08-07] orders_bronze_silver_gold -> success
Backfilling re-runs the DAG once per logical date in the range, using
{{ ds }} (the logical/execution date) inside each task to know which
day's data to process — this only works cleanly if every task is
idempotent with respect to its logical date, which is exactly the topic
of Module 6.
Traps¶
- Putting heavy computation inside the DAG file itself (top-level code,
not inside a
@task). Airflow re-parses every DAG file every few seconds to detect changes — expensive top-level code slows down the entire scheduler, not just this DAG. - Using
catchup=Trueby accident. Ifstart_dateis a year ago and catchup is on, Airflow will schedule a year of historical runs the moment the DAG is turned on. - Passing large data through XCom. XCom is for small values (paths, IDs, counts) stored in the metadata database — passing an entire DataFrame through it will bloat the database and slow the scheduler.
- No idempotency, then backfilling. Re-running a non-idempotent task for a past date can double-count or duplicate data (see Module 6).
Cheat sheet¶
| Concept | TaskFlow API |
|---|---|
| Define a task | @task decorator on a function |
| Wire dependency | call one task with another's return value, or a >> b |
| Retry a task | @task(retries=N, retry_delay=timedelta(...)) |
| Wait on a condition | @task.sensor(poke_interval=..., mode="reschedule") |
| Backfill | airflow dags backfill <dag_id> --start-date ... --end-date ... |
How It Actually Works¶
Airflow's architecture separates DAG parsing, scheduling, and execution into distinct processes that communicate only through the metadata database — understanding that separation explains most of Airflow's operational behavior.
The DAG file processor periodically re-imports every Python file in the DAGs folder, literally executing the module's top-level code to rebuild the DAG object in memory, then serializes that DAG structure into the metadata database. This is why heavy top-level code in a DAG file (an API call, a database query used just to define tasks dynamically) is a real performance problem: it re-executes on every parse cycle, not once — the DAG file processor doesn't know the difference between "code that builds the graph" and "code that happens to run at import time."
The scheduler doesn't execute tasks; it only queries the metadata DB for DAG runs and
task instances, evaluates each task's upstream dependencies and trigger rules against the
DB's current state, and for every task now eligible, writes a row into an execution queue
(the executor's task queue — Celery's broker, or the Kubernetes API for pod creation). The
executor is what actually launches work: CeleryExecutor hands a task to a distributed
worker pool via a message broker (Redis/RabbitMQ), KubernetesExecutor creates a fresh pod
per task. Either way, the running task process itself talks back to the metadata DB directly
to report success/failed, which is why Airflow workers need direct DB (or DB-proxy)
connectivity, not just a connection to the scheduler.
XComs (task-to-task data passing) are implemented as small rows written to a dedicated metadata table, keyed by DAG run, task, and key — this is a deliberate design constraint, not an oversight: XComs are meant for small values (a file path, a row count, a boolean), because passing a large DataFrame through XCom means serializing it into the metadata database, which was built for control-plane bookkeeping, not bulk data transfer.
Exercise¶
Extend orders_pipeline with a @task.branch that checks a data quality
flag returned by validate_and_load_silver and routes to either
aggregate_to_gold (if clean) or a new quarantine_and_alert task (if
not). Sketch the DAG edges both branches would produce.