Data Engineering Module 8 – Orchestration: running pipelines on time
Orchestration concepts: DAGs, schedules, backfills
Model a pipeline as a DAG, choose how each run starts, read its data interval, set retries and timeouts, and decide when cron is enough.
What you will learn
- Model a pipeline as a directed acyclic graph of tasks
- Distinguish time-based, data-aware and event-driven scheduling
- Work out the data interval a scheduled run must process
- Explain retries, timeouts, SLAs and alerting
- Decide when cron and a script are enough
Before you start
What you’ll learn · Before you start
What you will learn
- Model a pipeline as a directed acyclic graph of tasks
- Distinguish time-based, data-aware and event-driven scheduling
- Work out the data interval a scheduled run must process
- Explain retries, timeouts, SLAs and alerting
- Decide when cron and a script are enough
Before you start
On this page
Seven scripts, one crontab and an email to ops at 08:00: that is how many teams run their first data pipeline: A sequence of jobs that moves data from sources to destinations, with transformations along the way. More in “What data engineering is, and what it is not”. The scripts start at fixed minutes and trust that the one before has finished, so on the morning the menu export lands 25 minutes late, the totals come from an empty file, and nobody can say which day to run again, or whether running it twice is safe.
An orchestrator replaces that trust with rules, built here on a canteen chain’s seven-task pipeline. By the end you can order a pipeline’s tasks as a graph, choose what starts each run, work out the slice of time a run must process, set retries, timeouts and alerts with numbers, and decide when one cron line is still enough.
A pipeline is a DAG of tasks, run on a schedule
A canteen chain with kitchens in Pune, Indore and Kochi wants one summary each morning, and seven tasks make it.
extract_orders and extract_menu read the day’s two files. validate prices each order from the menu, so it needs
both. aggregate_by_city adds up what is left and write_partition stores the totals under the day’s date. Then
send_summary emails ops and archive_source moves the files away.
Draw each task as a box and each “needs” as an arrow from the task that must finish first, and you have a directed acyclic graph: A graph of tasks whose edges have a direction and never form a cycle. The orchestrator can order its tasks and detect a dependency loop., the structure Airflow names its pipelines after: a Dag holds the schedule, the tasks and their dependencies, and leaves what happens inside a task to the task (Airflow: Dags). Acyclic means no path of arrows leads from a task back to itself, and that is what makes an order possible: a complete topological order, in which every task comes after all the tasks it depends on, exists exactly when the graph has no directed cycle (Python: graphlib).
Step through one day’s run and watch which tasks are ready. A task becomes ready only when every arrow into it starts at a task that has finished.
The drawing shows the whole scene; its 9 steps are listed below.
Every step as text
- Seven tasks, six edges. This is the canteen pipeline for one day of orders. Each arrow points from a task to a task that waits for it, and no path of arrows ever comes back to a task it has passed, so the graph has no cycle.
- Two tasks are ready at once. Extract orders and extract menu wait for nothing, so the scheduler can start both together. Every other task still has an arrow coming in from a task that has not finished.
- The orders land first. Extract orders reads the day's file and finishes with 6 rows. Validate still waits, because one of the two tasks it depends on is still running.
- The last input arrives. Extract menu finishes with 4 items. Both arrows into validate now start at finished tasks, so validate becomes ready. That is the only rule the scheduler applies.
- Validate rejects one row. Validate prices each order from the menu and keeps 5 of the 6 rows. Order 106 asks for 0 idli, so it is rejected. Aggregate by city waits only for validate, so it is ready now.
- One total per city. Aggregate by city adds up the priced orders, Pune 320, Indore 240 and Kochi 160, which is 720 in all. Write partition needs nothing but these three totals.
- The day's partition is stored. Write partition stores the 3 rows under the path of the run's own day. Two tasks wait for it and for nothing else, so both become ready in the same moment.
- The last two tasks run. Send summary emails ops a total of 720 from 5 orders, and archive source moves the 2 source files away. Neither waits for the other, so either may finish first.
- A topological order. The run took the tasks in this order, extract orders, extract menu, validate, aggregate by city, write partition, send summary and archive source. Each task comes after all the tasks it waits for, which is what makes the order valid.
The two extracts started together, and so did the last two tasks. That one rule is all a
scheduler: The part of an orchestrator that decides when each run of each DAG starts and queues its tasks. needs. The program below applies it by hand: it counts the upstream tasks each
task still waits for, and a task joins ready when its count reaches 0.
Program · topo_order.py
# The order in which a scheduler can start the canteen tasks
downstream = { # each task, and the tasks that wait for it
"extract_orders": ["validate"],
"extract_menu": ["validate"],
"validate": ["aggregate_by_city"],
"aggregate_by_city": ["write_partition"],
"write_partition": ["send_summary", "archive_source"],
"send_summary": [],
"archive_source": [],
}
waiting = dict.fromkeys(downstream, 0) # upstream tasks not yet done
for task in downstream:
for nxt in downstream[task]:
waiting[nxt] += 1
ready = [t for t in downstream if waiting[t] == 0]
order = []
while ready:
task = ready.pop(0)
order.append(task)
for nxt in downstream[task]:
waiting[nxt] -= 1
if waiting[nxt] == 0:
ready.append(nxt)
print(len(order), "of", len(downstream), "tasks ordered:")
print(" -> ".join(order)) Variables
- just taken by the loop
- changed in this step
- passed by the loop
- read next
- where the loop stopped
Printed so far
Nothing printed yet7 of 7 tasks ordered: extract_orders -> extract_menu -> validate -> aggregate_by_city -> write_partition -> send_summary -> archive_source
Every step was recorded from a run with Python 3.14.8 on macOS 26 arm64.
The drawing shows the variables when the program ends, and the program's 94 steps are listed below.
Change the program and run it here. Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Every step as text
- Start. No line has run yet, so there are no variables. Python starts with line 2.
- Line 2.
downstream = { # each task, and the tasks that wait for it "extract_orders": ["validate"], "extract_menu": ["validate"], "validate": ["aggregate_by_city"], "aggregate_by_city": ["write_partition"], "write_partition": ["send_summary", "archive_source"], "send_summary": [], "archive_source": [], }runs. - Line 2.
downstream = { # each task, and the tasks that wait for it "extract_orders": ["validate"], "extract_menu": ["validate"], "validate": ["aggregate_by_city"], "aggregate_by_city": ["write_partition"], "write_partition": ["send_summary", "archive_source"], "send_summary": [], "archive_source": [], }makesdownstreama dict with 7 keys. - Line 11.
waiting = dict.fromkeys(downstream, 0)setswaitingto{'extract_orders': 0, 'extract_menu': 0, 'validate': 0, 'aggregate_by_city': 0, 'write_partition': 0, 'send_summary': 0, 'archive_source': 0}.waitingwill hold, for each task, how many upstream tasks it is still waiting for. Every count starts at 0. - Line 12.
for task in downstream:takes the first key ofdownstream:taskis'extract_orders'. - Line 13.
for nxt in downstream[task]:takes the item at position 0 ofdownstream[task]:nxtis'validate'. - Line 14.
waiting[nxt] += 1changeswaiting['validate']from0to1. - Line 13.
for nxt in downstream[task]:finds no item at position 1 ofdownstream[task](it has 1 item), so the loop ends. - Line 12.
for task in downstream:takes the second key ofdownstream:taskis'extract_menu'. - Line 13.
for nxt in downstream[task]:takes the item at position 0 ofdownstream[task]:nxtis'validate'. - Line 14.
waiting[nxt] += 1changeswaiting['validate']from1to2. - Line 13.
for nxt in downstream[task]:finds no item at position 1 ofdownstream[task](it has 1 item), so the loop ends. - Line 12.
for task in downstream:takes the third key ofdownstream:taskis'validate'. - Line 13.
for nxt in downstream[task]:takes the item at position 0 ofdownstream[task]:nxtis'aggregate_by_city'. - Line 14.
waiting[nxt] += 1changeswaiting['aggregate_by_city']from0to1. - Line 13.
for nxt in downstream[task]:finds no item at position 1 ofdownstream[task](it has 1 item), so the loop ends. - Line 12.
for task in downstream:takes the fourth key ofdownstream:taskis'aggregate_by_city'. - Line 13.
for nxt in downstream[task]:takes the item at position 0 ofdownstream[task]:nxtis'write_partition'. - Line 14.
waiting[nxt] += 1changeswaiting['write_partition']from0to1. - Line 13.
for nxt in downstream[task]:finds no item at position 1 ofdownstream[task](it has 1 item), so the loop ends. - Line 12.
for task in downstream:takes the fifth key ofdownstream:taskis'write_partition'. - Line 13.
for nxt in downstream[task]:takes the item at position 0 ofdownstream[task]:nxtis'send_summary'. - Line 14.
waiting[nxt] += 1changeswaiting['send_summary']from0to1. - Line 13.
for nxt in downstream[task]:takes the item at position 1 ofdownstream[task]:nxtis'archive_source'. - Line 14.
waiting[nxt] += 1changeswaiting['archive_source']from0to1. All six edges are counted.validatewaits for 2 tasks, the two extracts for none, and every other task for exactly 1. - Line 13.
for nxt in downstream[task]:finds no item at position 2 ofdownstream[task](it has 2 items), so the loop ends. - Line 12.
for task in downstream:takes the sixth key ofdownstream:taskis'send_summary'. - Line 13.
for nxt in downstream[task]:has nothing to go through, so its body never runs and the loop is over at once. - Line 12.
for task in downstream:takes the seventh key ofdownstream:taskis'archive_source'. - Line 13.
for nxt in downstream[task]:has nothing to go through, so its body never runs and the loop is over at once. - Line 12.
for task in downstream:has read every key ofdownstream, so the loop ends. - Line 15.
ready = [t for t in downstream if waiting[t] == 0]runs. - Line 15.
ready = [t for t in downstream if waiting[t] == 0]runs. - Line 15.
ready = [t for t in downstream if waiting[t] == 0]runs. - Line 15.
ready = [t for t in downstream if waiting[t] == 0]runs. - Line 15.
ready = [t for t in downstream if waiting[t] == 0]runs. - Line 15.
ready = [t for t in downstream if waiting[t] == 0]runs. - Line 15.
ready = [t for t in downstream if waiting[t] == 0]runs. - Line 15.
ready = [t for t in downstream if waiting[t] == 0]setsreadyto['extract_orders', 'extract_menu']. Key pointreadyholds the two tasks whose count is 0, the extracts. A scheduler could start both of them at the same moment. - Line 16.
order = []makesordera list of 0 items. - Line 17.
while ready:tests its condition: it is true (readyis['extract_orders', 'extract_menu']), so the body runs. - Line 18.
task = ready.pop(0)setstaskto'extract_orders'(it was'archive_source'). - Line 19.
order.append(task)adds'extract_orders'to the end oforder. - Line 20.
for nxt in downstream[task]:takes the item at position 0 ofdownstream[task]:nxtis'validate'. - Line 21.
waiting[nxt] -= 1changeswaiting['validate']from2to1.extract_ordersis done, so the count ofvalidatefalls from 2 to 1. It is not ready yet:extract_menuhas not finished. - Line 22.
waiting[nxt] == 0is false (waitingis{'extract_orders': 0, 'extract_menu': 0, 'validate': 1, 'aggregate_by_city': 1, 'write_partition': 1, 'send_summary': 1, 'archive_source': 1}andnxtis'validate'), so Python skips the block underif waiting[nxt] == 0:. - Line 20.
for nxt in downstream[task]:finds no item at position 1 ofdownstream[task](it has 1 item), so the loop ends. - Line 17.
while ready:tests its condition: it is true (readyis['extract_menu']), so the body runs again (pass 2). - Line 18.
task = ready.pop(0)setstaskto'extract_menu'(it was'extract_orders'). - Line 19.
order.append(task)adds'extract_menu'to the end oforder. - Line 20.
for nxt in downstream[task]:takes the item at position 0 ofdownstream[task]:nxtis'validate'. - Line 21.
waiting[nxt] -= 1changeswaiting['validate']from1to0. - Line 22.
waiting[nxt] == 0is true (waitingis{'extract_orders': 0, 'extract_menu': 0, 'validate': 0, 'aggregate_by_city': 1, 'write_partition': 1, 'send_summary': 1, 'archive_source': 1}andnxtis'validate'), so the block underif waiting[nxt] == 0:runs. - Line 23.
ready.append(nxt)adds'validate'to the end ofready. Key pointvalidatereaches 0 only after both extracts are done, and only then joinsready. That one rule keeps a task from starting before its inputs exist. - Line 20.
for nxt in downstream[task]:finds no item at position 1 ofdownstream[task](it has 1 item), so the loop ends. - Line 17.
while ready:tests its condition: it is true (readyis['validate']), so the body runs again (pass 3). - Line 18.
task = ready.pop(0)setstaskto'validate'(it was'extract_menu'). - Line 19.
order.append(task)adds'validate'to the end oforder. - Line 20.
for nxt in downstream[task]:takes the item at position 0 ofdownstream[task]:nxtis'aggregate_by_city'. - Line 21.
waiting[nxt] -= 1changeswaiting['aggregate_by_city']from1to0. - Line 22.
waiting[nxt] == 0is true (waitingis{'extract_orders': 0, 'extract_menu': 0, 'validate': 0, 'aggregate_by_city': 0, 'write_partition': 1, 'send_summary': 1, 'archive_source': 1}andnxtis'aggregate_by_city'), so the block underif waiting[nxt] == 0:runs. - Line 23.
ready.append(nxt)adds'aggregate_by_city'to the end ofready. - Line 20.
for nxt in downstream[task]:finds no item at position 1 ofdownstream[task](it has 1 item), so the loop ends. - Line 17.
while ready:tests its condition: it is true (readyis['aggregate_by_city']), so the body runs again (pass 4). - Line 18.
task = ready.pop(0)setstaskto'aggregate_by_city'(it was'validate'). - Line 19.
order.append(task)adds'aggregate_by_city'to the end oforder. - Line 20.
for nxt in downstream[task]:takes the item at position 0 ofdownstream[task]:nxtis'write_partition'. - Line 21.
waiting[nxt] -= 1changeswaiting['write_partition']from1to0. - Line 22.
waiting[nxt] == 0is true (waitingis{'extract_orders': 0, 'extract_menu': 0, 'validate': 0, 'aggregate_by_city': 0, 'write_partition': 0, 'send_summary': 1, 'archive_source': 1}andnxtis'write_partition'), so the block underif waiting[nxt] == 0:runs. - Line 23.
ready.append(nxt)adds'write_partition'to the end ofready. - Line 20.
for nxt in downstream[task]:finds no item at position 1 ofdownstream[task](it has 1 item), so the loop ends. - Line 17.
while ready:tests its condition: it is true (readyis['write_partition']), so the body runs again (pass 5). - Line 18.
task = ready.pop(0)setstaskto'write_partition'(it was'aggregate_by_city'). - Line 19.
order.append(task)adds'write_partition'to the end oforder. - Line 20.
for nxt in downstream[task]:takes the item at position 0 ofdownstream[task]:nxtis'send_summary'. - Line 21.
waiting[nxt] -= 1changeswaiting['send_summary']from1to0. - Line 22.
waiting[nxt] == 0is true (waitingis{'extract_orders': 0, 'extract_menu': 0, 'validate': 0, 'aggregate_by_city': 0, 'write_partition': 0, 'send_summary': 0, 'archive_source': 1}andnxtis'send_summary'), so the block underif waiting[nxt] == 0:runs. - Line 23.
ready.append(nxt)adds'send_summary'to the end ofready. - Line 20.
for nxt in downstream[task]:takes the item at position 1 ofdownstream[task]:nxtis'archive_source'. - Line 21.
waiting[nxt] -= 1changeswaiting['archive_source']from1to0. - Line 22.
waiting[nxt] == 0is true (waitingis{'extract_orders': 0, 'extract_menu': 0, 'validate': 0, 'aggregate_by_city': 0, 'write_partition': 0, 'send_summary': 0, 'archive_source': 0}andnxtis'archive_source'), so the block underif waiting[nxt] == 0:runs. - Line 23.
ready.append(nxt)adds'archive_source'to the end ofready.write_partitionhad two tasks waiting on it. Both reached 0 on the same pass of the loop, so they could run side by side. - Line 20.
for nxt in downstream[task]:finds no item at position 2 ofdownstream[task](it has 2 items), so the loop ends. - Line 17.
while ready:tests its condition: it is true (readyis['send_summary', 'archive_source']), so the body runs again (pass 6). - Line 18.
task = ready.pop(0)setstaskto'send_summary'(it was'write_partition'). - Line 19.
order.append(task)adds'send_summary'to the end oforder. - Line 20.
for nxt in downstream[task]:has nothing to go through, so its body never runs and the loop is over at once. - Line 17.
while ready:tests its condition: it is true (readyis['archive_source']), so the body runs again (pass 7). - Line 18.
task = ready.pop(0)setstaskto'archive_source'(it was'send_summary'). - Line 19.
order.append(task)adds'archive_source'to the end oforder. - Line 20.
for nxt in downstream[task]:has nothing to go through, so its body never runs and the loop is over at once. - Line 17.
while ready:tests its condition: it is false (readyis[]), so the loop ends. - Line 24.
print(len(order), "of", len(downstream), "tasks ordered:")prints7 of 7 tasks ordered:. - Line 25.
print(" -> ".join(order))printsextract_orders -> extract_menu -> validate -> aggregate_by_city -> write_partition -> send_summary -> archive_source. That was the last line, so the program ends. Every task comes after all the tasks it waits for. With a cycle, the tasks on it would never reach 0, soorderwould hold fewer than 7 tasks.
The next program is a small engine for the whole pipeline. It starts a task only after every task it waits for has succeeded, and it runs three times: on a normal day, on a day without the menu export, and after an edit that adds one arrow too many.
"""The canteen pipeline as a DAG, run by a tiny scheduler: a task starts only
when every task it waits for has succeeded. Standard library only."""
from graphlib import CycleError, TopologicalSorter
DAY = "2026-10-16" # the run's data interval: this whole day, in UTC
ORDERS = [ # (order_id, city, item, quantity)
(101, "Pune", "thali", 2),
(102, "Pune", "dosa", 1),
(103, "Indore", "thali", 1),
(104, "Indore", "poha", 3),
(105, "Kochi", "dosa", 2),
(106, "Kochi", "idli", 0),
]
MENU = {"thali": 120, "dosa": 80, "poha": 40, "idli": 50}
FILES = {f"orders_{DAY}.csv": ORDERS, f"menu_{DAY}.csv": MENU}
TASKS = {}
def task(fn):
TASKS[fn.__name__] = fn
return fn
def read(name):
if name not in FILES:
raise FileNotFoundError(f"{name} not found")
return FILES[name]
@task
def extract_orders(out):
rows = read(f"orders_{DAY}.csv")
return rows, f"{len(rows)} rows"
@task
def extract_menu(out):
menu = read(f"menu_{DAY}.csv")
return menu, f"{len(menu)} items"
@task
def validate(out):
menu, rows = out["extract_menu"], out["extract_orders"]
kept = [(o, c, q * menu[i]) for o, c, i, q in rows if i in menu and q > 0]
return kept, f"{len(kept)} rows kept, {len(rows) - len(kept)} rejected"
@task
def aggregate_by_city(out):
totals = {}
for _, city, amount in out["validate"]:
totals[city] = totals.get(city, 0) + amount
return totals, ", ".join(f"{c} {t}" for c, t in totals.items())
@task
def write_partition(out):
path, rows = f"orders_daily/date={DAY}", len(out["aggregate_by_city"])
return path, f"{path}, {rows} rows"
@task
def send_summary(out):
total = sum(out["aggregate_by_city"].values())
return None, f"email to ops: {total} from {len(out['validate'])} orders"
@task
def archive_source(out):
return None, f"{len(FILES)} source files moved to archive/"
AFTER = { # each task, and the tasks it waits for (the DAG's edges)
"extract_orders": [],
"extract_menu": [],
"validate": ["extract_orders", "extract_menu"],
"aggregate_by_city": ["validate"],
"write_partition": ["aggregate_by_city"],
"send_summary": ["write_partition"],
"archive_source": ["write_partition"],
}
def run(after):
sorter = TopologicalSorter(after)
try:
sorter.prepare() # checks the whole graph before any task runs
except CycleError as err:
print(f" refused to start, {err.args[0]}: {' -> '.join(err.args[1])}")
return
out, failed, ready = {}, [], list(sorter.get_ready())
while ready:
name = ready.pop(0)
try:
out[name], note = TASKS[name](out)
except Exception as err: # any error fails the task
failed.append(name)
print(f" FAILED {name:<18} {err}")
continue # a failed task is never marked done
print(f" ok {name:<18} {note}")
sorter.done(name) # may make the tasks that wait for it ready
ready += sorter.get_ready()
for name in after:
if name not in out and name not in failed:
print(f" not run {name:<18} an upstream task failed")
print(f" {len(out)} of {len(after)} tasks ok")
print(f"Run 1: the run for {DAY}")
run(AFTER)
print("Run 2: the same day, but the menu export has not landed")
del FILES[f"menu_{DAY}.csv"]
run(AFTER)
print("Run 3: someone makes validate wait for aggregate_by_city too")
run({**AFTER, "validate": AFTER["validate"] + ["aggregate_by_city"]}) Output
Run 1: the run for 2026-10-16 ok extract_orders 6 rows ok extract_menu 4 items ok validate 5 rows kept, 1 rejected ok aggregate_by_city Pune 320, Indore 240, Kochi 160 ok write_partition orders_daily/date=2026-10-16, 3 rows ok send_summary email to ops: 720 from 5 orders ok archive_source 2 source files moved to archive/ 7 of 7 tasks ok Run 2: the same day, but the menu export has not landed ok extract_orders 6 rows FAILED extract_menu menu_2026-10-16.csv not found not run validate an upstream task failed not run aggregate_by_city an upstream task failed not run write_partition an upstream task failed not run send_summary an upstream task failed not run archive_source an upstream task failed 1 of 7 tasks ok Run 3: someone makes validate wait for aggregate_by_city too refused to start, nodes are in a cycle: validate -> aggregate_by_city -> validate
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pipeline_dag.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Run 1 ends with 7 of 7 tasks ok and 720 in the email. In run 2, extract_menu fails and is never marked done, so the 5
tasks below it never start and no wrong email goes out. Airflow gives such tasks the state upstream_failed, and fails
the whole pipeline run: One execution of a pipeline for one data interval or one event. The orchestrator keeps a record of its state, logs and outputs. when any task with nothing after it ends failed or
upstream_failed (Airflow: Tasks;
Dag Runs).
An arrow that closes a loop
Wrong: make validate also wait for aggregate_by_city, to compare the new totals with the old ones. Each would then
wait for the other and neither could start, and run 3 refuses to begin:
nodes are in a cycle: validate -> aggregate_by_city -> validate. Right: give the comparison its own task after
aggregate_by_city, and make the email wait for it.
Checkpoint
Check: Tasks and edges as a DAG
A run starts on the clock, on new data or on a message
Something has to start each run: a time on the clock, an update to the data the pipeline reads, or a message from another system.
A clock. A cron expression: A schedule written as five fields, minute, hour, day of month, month and day of week, such as 30 2 * * * for 02:30 every day. has five fields, and POSIX fixes their order and their
ranges: minutes run from 0 to 59, hours from 0 to 23, days of the month from 1 to 31, months from 1 to 12, and days of
the week from 0 to 6, counted from Sunday as 0. Each field is * for every value, a number, a range such as 1-5, or a
comma-separated list of those
(POSIX crontab, IEEE Std 1003.1-2024).
Two schedules, field by field:
| Field | Values | 30 2 * * * |
15 3 * * 1-5 |
|---|---|---|---|
| Minute | 0 to 59 | 30 |
15 |
| Hour | 0 to 23 | 2 |
3 |
| Day of the month | 1 to 31 | every | every |
| Month | 1 to 12 | every | every |
| Day of the week | 0 to 6, Sunday is 0 | every | Monday to Friday |
| Runs at | 02:30 daily | 03:15 on weekdays |
Two details catch people out. With both day fields set, a day that matches either one runs the job: the standard’s
0 0 1,15 * 1 fires at midnight on the 1st and the 15th of every month, and also on each Monday. And the step form
*/15 is not among
the forms POSIX defines, so check what your cron accepts.
Read both day fields at once
Paste 0 0 1,15 * 1 into MySmartCoPilot’s cron expression generator and read
its next runs: some fall on a Monday, the others on the first or the fifteenth.
A calendar. A timetable generalises the clock. Airflow turns every cron string into one, and the timetable also
decides each run’s data interval
(Airflow: Timetables).
It can carry a time zone, as CronTriggerTimetable("0 10 * * 1-5", timezone="Asia/Kolkata") does for 10:00 India
time on weekdays. Airflow keeps times in UTC and lets such a schedule follow its zone’s clock changes
(Airflow: Time Zones).
An update to the data. A data-aware trigger starts a run when its input changes. In Airflow a producing task
declares outlets=[Asset("s3://canteen/orders/")] and the consuming Dag uses that asset as its schedule. The asset
counts as updated only when the producing task succeeds, and a Dag that names several assets runs once all of them have
been updated since its last run
(Airflow: Asset-Aware Scheduling).
A message. An event-driven trigger: A rule that starts a run when a message or webhook arrives, instead of on a clock. Useful when the data's arrival is irregular. starts a run when an outside system sends
word, such as a queue message or a webhook. Airflow 3.0 added an AssetWatcher that watches a source such as a message
queue and updates an asset when an event arrives
(Airflow: Event-driven scheduling).
A queue with at-least-once delivery: A delivery guarantee under which no message is lost but some can arrive more than once, because a sender that gets no acknowledgement sends again; the receiver has to recognise and drop the repeats. may hand over a message twice, so such a
run must be safe to start twice.
Answer the two questions in the table about your own input. Each answer rules options out or adds a caveat, with the reason:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Start a run with | Predictable start time | Waits for late input | Records who feeds whom |
|---|---|---|---|
| A cron expression Starts on: the clock: five time fields | Good fires at the same minute every time: 30 2 * * * is 02:30 every day | Poor fires whether or not the input has landed, so a late file gives a failed or wrong run | Poor two jobs are linked only by their start times, such as 02:00 and 02:30 |
| A timetable Starts on: a calendar rule in a time zone | Good fires at 10:00 Asia/Kolkata on weekdays, and the rule says which days it skips | Poor like cron, it starts on time even when the input is late | Poor knows the calendar, not which pipeline produced its input |
| A data-aware trigger Starts on: an upstream dataset being updated | Mixed starts when the producer task succeeds, so the start moves with the upstream run | Good no successful update upstream means no run, so it never reads missing input | Good the dataset names the producer and the consumer, so the link can be shown |
| An event-driven trigger Starts on: a message or a webhook | Poor starts whenever a message arrives, at any minute of the day | Mixed reacts in seconds, but a burst of messages needs a cap on how many runs start at once | Mixed the message says that something happened, not which pipeline wrote the data |
How the answers decide
- “Should a run start at a time on the clock, or when its input is ready?” At a fixed time on the clock: rules out A data-aware trigger and An event-driven trigger. A data-aware or event-driven run starts whenever its input turns up, which can be any time; only cron and timetables promise a start on the clock.
- “Should a run start at a time on the clock, or when its input is ready?” When the upstream data is updated: adds a caveat to A cron expression and A timetable. A clock schedule can wait for data only through an extra task that polls for the file before the real work starts, and that task can time out.
- “Should a run start at a time on the clock, or when its input is ready?” When a message or webhook arrives: rules out A cron expression and A timetable. A clock schedule fires at its own times and never hears the message; to react within minutes it would have to fire very often and find nothing most times.
- “Should a run start at a time on the clock, or when its input is ready?” When a message or webhook arrives: adds a caveat to A data-aware trigger. A dataset update comes from a task inside the orchestrator, so an outside message has to be turned into such an update first.
- “How often does an input fail to arrive by the time the run needs it?” Rarely: it lands well before the run: When the input is almost always there in time, a fixed start is the simplest promise to readers, who know when the numbers appear.
- “How often does an input fail to arrive by the time the run needs it?” Some days it is late or missing: adds a caveat to A cron expression and A timetable. On a late day a clock-started run reads a missing or half-written file, so it needs a check that fails the run loudly and a retry budget.
- “How often does an input fail to arrive by the time the run needs it?” It arrives at no fixed time at all: rules out A cron expression and A timetable. Input with no fixed arrival time makes every fixed start wrong on most days, either too early with nothing to read or long after the data was ready.
In short: a clock for input that is reliably there in time, a data-aware trigger for another pipeline’s output, and an event for input that arrives from outside on a timetable of its own.
Every run processes a defined data interval, not “now”
The run that fires at 00:00 UTC as 2026-10-17 begins must total the orders of 2026-10-16, but at that moment the
clock proves nothing: a run can start late, be retried at noon or be started by hand next week. So each run gets a
data interval: The slice of time a run processes, such as one day from midnight to the next midnight with the end excluded. Reading it instead of the clock keeps re-runs and backfills correct., the slice of time it processes. Airflow’s documentation describes a daily
interval as running from one midnight to the next, with the run scheduled after it ends, so the day’s data is complete
(Airflow: Dag Runs). The interval
selects orders by event time: The time an event happened at its source, stamped by the system that produced it, such as a payment's capture time; totals by event time put each row on the day it happened., when they were placed.
The program works out one scheduled run’s interval. Then it replays three runs on one afternoon, first reading each
run’s interval and then now() minus one day, as a task that ignores its interval would:
"""Which day of orders does a run process? Its data interval, not the clock."""
from datetime import UTC, datetime, timedelta
ONE_DAY = timedelta(days=1)
def daily_interval(fired_at):
"""A daily schedule at 00:00 UTC: a run covers the whole day before it fires."""
end = fired_at.replace(hour=0, minute=0)
return end - ONE_DAY, end
def hhmm(t):
return t.strftime("%Y-%m-%d %H:%M")
fired = datetime(2026, 10, 17, 0, 0, tzinfo=UTC)
start, end = daily_interval(fired)
print(f"The scheduled run fired at {hhmm(fired)} UTC reads")
print(f" [{hhmm(start)}, {hhmm(end)}) UTC, end excluded")
print(f" day by its interval: {start:%Y-%m-%d}")
print(f" day by now() - 1 day: {fired - ONE_DAY:%Y-%m-%d} (the same)")
now = datetime(2026, 10, 17, 15, 40, tzinfo=UTC) # someone starts a replay
print(f"\nA replay of 3 runs, all started by hand at {hhmm(now)} UTC")
print(f"{'run':<5}{'data interval it was given':<38}{'by interval':<13}now() - 1 day")
days = {"by interval": [], "by now() - 1 day": []}
for n, d in enumerate((15, 16, 17), start=1):
start, end = daily_interval(datetime(2026, 10, d, tzinfo=UTC))
by_interval, by_clock = f"{start:%Y-%m-%d}", f"{now - ONE_DAY:%Y-%m-%d}"
days["by interval"].append(by_interval)
days["by now() - 1 day"].append(by_clock)
print(f"{n:<5}[{hhmm(start)}, {hhmm(end)}) {by_interval} {by_clock}")
wanted = set(days["by interval"])
for rule, got in days.items():
wrote, missed = len(set(got)), len(wanted - set(got))
unit = "day" if wrote == 1 else "days"
print(f"{rule:<17} 3 runs wrote {wrote} {unit}, missed {missed} of 3") Output
The scheduled run fired at 2026-10-17 00:00 UTC reads [2026-10-16 00:00, 2026-10-17 00:00) UTC, end excluded day by its interval: 2026-10-16 day by now() - 1 day: 2026-10-16 (the same) A replay of 3 runs, all started by hand at 2026-10-17 15:40 UTC run data interval it was given by interval now() - 1 day 1 [2026-10-14 00:00, 2026-10-15 00:00) 2026-10-14 2026-10-16 2 [2026-10-15 00:00, 2026-10-16 00:00) 2026-10-15 2026-10-16 3 [2026-10-16 00:00, 2026-10-17 00:00) 2026-10-16 2026-10-16 by interval 3 runs wrote 3 days, missed 0 of 3 by now() - 1 day 3 runs wrote 1 day, missed 2 of 3
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 cron_vs_interval.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The scheduled run reads [2026-10-16 00:00, 2026-10-17 00:00), end excluded, and on its own night now() minus a
day picks the same day, which is why the bug hides. The replay exposes it: by interval, 3 runs wrote 3 days; by the
clock, they wrote 1 day and missed 2 of 3. A backfill: Running a pipeline over past intervals to fill a gap or to reprocess data after a bug fix. A backfill is only safe when every task is idempotent. is that replay done on purpose:
Airflow can run a copy of a Dag for each day of the past three months at once, each looking only at its own interval
(Airflow: Dags). Its best-practice
guide asks tasks to read and write one partition, such as one keyed by data_interval_start, and never to use now()
for critical work (Airflow: Best Practices).
Airflow 3 gives a bare cron string no interval
In Airflow 3, schedule="0 0 * * *" becomes a trigger timetable whose data_interval_start and data_interval_end
are both the trigger time; Airflow 2 made a full interval by default ([scheduler] create_cron_data_intervals, False
in Airflow 3). For a run that covers a whole day, use CronDataIntervalTimetable or give the trigger timetable an
interval (Airflow: Timetables).
Intervals also tell the scheduler what is missing after a late start or a pause. With catchup: The scheduler's behaviour of creating a run for every interval missed since the start date or a pause. With it off, the scheduler creates a run only for the latest interval. on, it creates a run for every interval since the start date that has not run yet; Airflow’s default is off, which creates a run only for the latest interval (Airflow: Dag Runs).
Clearing the clock's day instead of the interval's
Wrong: DELETE FROM orders_daily WHERE order_date = CURRENT_DATE - 1 before the task writes the day. Replayed later,
it clears the day before the replay, not the run’s day. Right, on a data-interval timetable:
DELETE FROM orders_daily WHERE order_date = '{{ data_interval_start | ds }}'. Airflow’s templates reference says
logical_date, which {{ ds }} prints, only identifies the run, and points to data_interval_start and
data_interval_end for selecting rows by time
(Airflow: Templates reference).
Checkpoint
Check: The data interval of a run
Retries absorb brief failures, and timeouts cap how long a task can block
extract_orders calls the canteen’s order service, which now and then answers HTTP 503 for a minute or hangs on a slow
query. A retry absorbs the first kind of failure and a timeout ends the second. Airflow sets both per task
(Airflow Task SDK: API reference;
Airflow: Tasks):
| Airflow setting | What it does | Canteen extracts |
|---|---|---|
retries |
Tries a failed task again, this many times | 3, so 4 attempts at most |
retry_delay |
The wait before a retry; 300 s if unset | 30 s |
retry_exponential_backoff |
Multiplies the wait at each retry | 2.0: 30, 60, 120 s, then spread |
max_retry_delay |
Caps a growing wait | 5 min |
execution_timeout |
Stops a longer attempt with AirflowTaskTimeout |
5 min |
on_retry_callback |
Runs your code at each retry | A message to ops |
Doubling the wait is exponential backoff: Waiting longer before each retry of a failed request, usually by doubling a ceiling from a base wait up to a cap, so that a struggling service gets time to recover., which gives a struggling service time to
recover; jitter: Randomness in retry waits, such as a wait drawn between zero and the backoff ceiling, so that clients that failed together do not all retry at the same moment. keeps many clients from retrying in step. The program works out how long these
settings can hold up validate, with every attempt running into its timeout, then reads a week of runs:
"""How long can extract_orders hold up validate, and which alert sees a retry?"""
TIMEOUT_S = 5 * 60 # execution_timeout: each attempt is stopped after 5 minutes
RETRIES = 3 # so up to 4 attempts in all
BASE_DELAY_S = 30 # retry_delay before the first retry
MAX_DELAY_S = 5 * 60 # max_retry_delay: no wait is longer than this
def worst_case(waits):
"""Every attempt runs into the timeout, and each retry waits first."""
return (len(waits) + 1) * TIMEOUT_S + sum(waits)
doubled = [min(BASE_DELAY_S * 2**k, MAX_DELAY_S) for k in range(RETRIES)]
plans = {
"fixed 30 s": [BASE_DELAY_S] * RETRIES,
"doubling": doubled,
# Airflow 3.3.2 spreads a doubled wait d over d to 2d - 1 seconds
"doubling, spread": [min(2 * d - 1, MAX_DELAY_S) for d in doubled],
}
head = f"{RETRIES + 1} attempts of up to {TIMEOUT_S // 60} min"
print(f"Worst case before validate can start ({head})")
for name, waits in plans.items():
total = worst_case(waits)
print(f" {name:<16} waits {waits} s -> {total // 60} min {total % 60:02d} s")
WEEK = { # what each attempt of extract_orders did, one run a day
"Mon": ["ok"],
"Tue": ["timeout", "ok"],
"Wed": ["ok"],
"Thu": ["HTTP 503", "HTTP 503", "ok"],
"Fri": ["ok"],
"Sat": ["ok"],
"Sun": ["timeout", "ok"],
}
print("\nOne week of runs")
for day, attempts in WEEK.items():
print(f" {day} {' -> '.join(attempts)}")
failed = sum(attempts[-1] != "ok" for attempts in WEEK.values())
retries = sum(len(attempts) - 1 for attempts in WEEK.values())
days = sum(len(attempts) > 1 for attempts in WEEK.values())
print(f"{len(WEEK) - failed} of {len(WEEK)} runs succeeded")
print(f" alert on failure only: {failed} alerts")
print(f" alert on every retry: {retries} alerts, on {days} of {len(WEEK)} days") Output
Worst case before validate can start (4 attempts of up to 5 min) fixed 30 s waits [30, 30, 30] s -> 21 min 30 s doubling waits [30, 60, 120] s -> 23 min 30 s doubling, spread waits [59, 119, 239] s -> 26 min 57 s One week of runs Mon ok Tue timeout -> ok Wed ok Thu HTTP 503 -> HTTP 503 -> ok Fri ok Sat ok Sun timeout -> ok 7 of 7 runs succeeded alert on failure only: 0 alerts alert on every retry: 4 alerts, on 3 of 7 days
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 retry_budget.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
With a fixed 30 s wait the worst case is 4 attempts of 5 minutes plus 3 waits, 21 min 30 s, and doubling the wait gives
23 min 30 s. Airflow 3.3.2 adds jitter of its own: its code spreads each doubled wait over a range that starts at that
value and ends one second short of twice it, from a hash of the task, its run and the try, so the spread line’s
26 min 57 s is the real bound (Airflow 3.3.2 source).
All three fit well inside the hours before the 08:00 email. Set RETRIES to 5 and run it again: the bound grows, and
the 5-minute cap stops the last waits from growing further.
The week shows why failure alerts are not enough. All 7 runs succeeded, so none fired, yet the task needed 4 retries on 3 days. A source that needs retries that often is degrading, and on the day it needs one attempt more than the budget, the run fails without warning. Alert on every retry, and alert on lateness too: a run can miss the 08:00 email with no failed task at all, just by being slow. That 08:00 is a service-level objective: A target for a measured property of a service, with how often it must hold, such as a file published by 06:00 on at least 29 of 30 days. A promise to customers with a consequence for missing it is a service level agreement. More in “Pipeline requirements: SLAs, freshness, sizing” on data freshness: How old data is when someone reads it, or how soon after an event it can be read. Written as a target, for example that the oldest record is no older than a stated time. More in “Pipeline requirements: SLAs, freshness, sizing”, and once the canteen promises it to someone outside the team, with a consequence when it is missed, it is a service level agreement (SLA). Airflow 3.0 removed the SLA feature of Airflow 2, and 3.1 added Deadline Alerts, still experimental, which run a callback when a run has not finished within a set time of a reference point such as its queueing (Airflow: Tasks; Deadline Alerts). A deadline turns either kind of promise into an alert.
Retrying a task that appends
Wrong: a task inserts the day’s rows, then fails on its last step, and the retry inserts them again: the totals
double without one failed run. Retries are safe only for an idempotent task: A task whose outputs are the same when it is retried or re-run on the same inputs. The run's interval is one such input; datetime.now() is not. More in “Idempotent tasks, retries and backfills” task. Right:
replace the day’s partition or upsert by order id, as Airflow’s best-practice guide asks
(Best Practices). For files, write under
a temporary name in the same folder and rename into place: a successful os.replace is atomic, a POSIX requirement
(Python: os.replace).
When cron is enough, and when it is not
Some pipelines never need more than 30 2 * * * /opt/canteen/daily.sh: one script on one machine writing one report
for one reader. Give it a log, make it exit with an error status when anything fails, and let errors reach a person:
cron mails a job’s output and errors to its owner unless the job redirects them
(POSIX crontab). That is a schedule, a log
and an alert; an orchestrator would add a server, a database and upgrades to keep three lines of shell running.
Cron stops being enough when jobs need each other. It knows start times only, so a dependency becomes a guess (“the menu job is always done by 02:30”), and it records nothing about which day a run processed, so a backfill is a loop someone writes by hand. Each row below is a point where an orchestrator starts to pay:
| When you need | Cron and a script | An orchestrator |
|---|---|---|
| Tasks that wait for tasks | Start times spaced out, and hope | Edges; a failure stops what comes after |
| Past days run again | A loop over dates, by hand | A backfill, one run per interval |
| Retries and timeouts | Code in each script, if any | Settings on each task |
| Many pipelines, several on call | Crontabs on many machines | One view of every run and its log |
The orchestrator’s job is the order, not the work: Airflow’s documentation says a Dag cares how its tasks run, in what order, with how many retries and which timeouts, not what they do (Airflow: Dags). Heavy processing belongs to the engines tasks call, such as a warehouse or Spark. Orchestrators differ in what they add on top: Dagster, for one, calls itself a data orchestrator with integrated lineage and observability (Dagster documentation).
Key idea
A pipeline is a DAG with a schedule and a recovery plan. Cron gives you the schedule; move to an orchestrator when dependencies, reruns of past days or a shared on-call need the graph and the plan too.
Next, Writing a TaskFlow DAG and testing it with airflow dags test
turns the canteen pipeline into an Airflow 3 Dag;
Idempotent tasks and backfills makes its writes safe to
repeat and backfills a date range with airflow backfill create; and
Dagster, Prefect, Kestra and cron compared runs the same pipeline in
other tools.
Checkpoint
Check: Cron or an orchestrator
Practise: write the canteen DAG as YAML
Every orchestrator file starts by writing the graph down. In the exercise you write the seven canteen tasks as YAML, each with the tasks it waits for and its retry rules, in an order a scheduler could follow from the top. The sample tests check the tasks, the order, the six edges and the extracts’ retries.
Exercise · Easy · Python
Write the canteen pipeline as a YAML DAG
The canteen pipeline of this lesson has seven tasks: extract_orders, extract_menu, validate, aggregate_by_city, write_partition, send_summary and archive_source. In canteen_dag.py they are listed in A-to-Z order with nothing filled in. Rewrite DAG_YAML so that a scheduler could run the pipeline from it, and leave parse_dag as it is (the tests read your YAML with it).
Under each task, after lists the tasks it waits for, retries says how many times a failed attempt is tried again, retry_delay_s how many seconds to wait before each retry and timeout_s after how many seconds an attempt is stopped.
Your YAML must keep these rules:
- The seven tasks, each once, in a runnable order. Reading the YAML from the top, every task comes after all the tasks in its
afterlist. That makes the order of the text a topological order. - Exactly the six dependencies of the lesson's pipeline.
validatewaits for both extracts,aggregate_by_cityforvalidate,write_partitionforaggregate_by_city, andsend_summaryandarchive_sourceeach forwrite_partition. Add no other: an extra arrow makes a task wait for no reason. Every name in anafterlist must be one of the seven tasks, and no task waits for itself. - The extracts retry, within limits.
extract_ordersandextract_menuread files that are sometimes late: give eachretries: 3,retry_delay_s: 30andtimeout_s: 300. - The summary email is never retried. A retry after a send that half worked would email ops twice, so
send_summaryhasretries: 0. - Every task can be stopped. Give every task a
timeout_sabove 0. The values forvalidate,aggregate_by_city,write_partitionandarchive_sourceare yours to choose.
The sample tests check the seven tasks, the order, the six dependencies and the extracts' retry rules. Run them, then check the other rules yourself against the list above.
Starter code · canteen_dag.py
"""Write the canteen pipeline as a YAML DAG that a scheduler can run in order.
Fill in DAG_YAML below and leave parse_dag as it is: the tests read your YAML
with parse_dag, then check the order, the dependencies and the retry rules.
"""
# Each task is a key at the left edge. Under it, indented by two spaces:
# after the tasks it waits for, as a list: [task_a, task_b]
# retries how many times a failed attempt is tried again
# retry_delay_s seconds to wait before each retry
# timeout_s seconds after which an attempt is stopped
# The tasks are listed here in A-to-Z order, with nothing filled in yet.
DAG_YAML = """
aggregate_by_city:
after: []
retries: 0
retry_delay_s: 0
timeout_s: 0
archive_source:
after: []
retries: 0
retry_delay_s: 0
timeout_s: 0
extract_menu:
after: []
retries: 0
retry_delay_s: 0
timeout_s: 0
extract_orders:
after: []
retries: 0
retry_delay_s: 0
timeout_s: 0
send_summary:
after: []
retries: 0
retry_delay_s: 0
timeout_s: 0
validate:
after: []
retries: 0
retry_delay_s: 0
timeout_s: 0
write_partition:
after: []
retries: 0
retry_delay_s: 0
timeout_s: 0
"""
def parse_dag(text):
"""Reads the YAML above into {task: {"after": [...], "retries": 3, ...}},
keeping the tasks in the order they are written."""
dag, task = {}, None
for raw in text.splitlines():
line = raw.split("#")[0].rstrip()
if not line:
continue
if not line.startswith(" "):
task = line.rstrip(":").strip()
dag[task] = {}
continue
if task is None:
raise ValueError(f"{line.strip()!r} is indented but has no task above it")
key, _, value = (part.strip() for part in line.partition(":"))
if value.startswith("[") and value.endswith("]"):
dag[task][key] = [x.strip() for x in value[1:-1].split(",") if x.strip()]
elif value.isdigit():
dag[task][key] = int(value)
else:
raise ValueError(f"{task}: {key} must be a [list] or a whole number")
return dag The sample tests · test_canteen_dag.py
from canteen_dag import DAG_YAML, parse_dag
TASKS = {
"extract_orders",
"extract_menu",
"validate",
"aggregate_by_city",
"write_partition",
"send_summary",
"archive_source",
}
NEEDED = {
("extract_orders", "validate"),
("extract_menu", "validate"),
("validate", "aggregate_by_city"),
("aggregate_by_city", "write_partition"),
("write_partition", "send_summary"),
("write_partition", "archive_source"),
}
def edges(dag):
"""Every (upstream, task) pair the YAML declares."""
return {(up, task) for task, spec in dag.items() for up in spec.get("after", [])}
def test_the_seven_tasks_are_all_listed():
"""the YAML holds the seven canteen tasks and no others."""
assert set(parse_dag(DAG_YAML)) == TASKS
def test_every_task_is_listed_after_the_tasks_it_waits_for():
"""reading the YAML from the top gives a topological order."""
seen = []
for task, spec in parse_dag(DAG_YAML).items():
for up in spec.get("after", []):
assert up in seen, f"{task} is listed before {up}, which it waits for"
seen.append(task)
def test_no_dependency_is_missing():
"""the six arrows of the lesson's pipeline are all there."""
missing = NEEDED - edges(parse_dag(DAG_YAML))
assert not missing, f"missing (upstream, task) pairs: {sorted(missing)}"
def test_the_extracts_retry_3_times_30_s_apart_with_a_5_minute_timeout():
"""both extract tasks: retries 3, retry_delay_s 30, timeout_s 300."""
dag = parse_dag(DAG_YAML)
for task in ("extract_orders", "extract_menu"):
spec = dag[task]
got = (spec.get("retries"), spec.get("retry_delay_s"), spec.get("timeout_s"))
assert got == (3, 30, 300), f"{task} has {got}" A hint
Start with the tasks that wait for nothing: the two extracts have after: [], so they can go first, in either order. Then repeat one step: pick a task whose after list names only tasks you have already written above it, and write it next. validate can come only once both extracts are above it; send_summary and archive_source come last, in either order, because each waits only for write_partition. If you get stuck, step through the scene "One run of the canteen pipeline, task by task" in the lesson: it starts the tasks in an order that works.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then practise all five ideas; each round starts with the one your answers show you know least.
Practice that adapts to you
Practise this lesson
11 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Two warm-ups first, answered in full. Say your own answer aloud before you read each one, then compare.
Warm-up: how would you explain a DAG to a developer who has only used cron?
Cron answers one question per line: when does this command start? A DAG answers a second one: what has to finish
before it starts? Draw each step as a box and each “needs” as an arrow, and the arrows give an order. In the canteen
pipeline, validate needs both extracts, so it starts when both are done, not at 02:30 because they usually are by
then. Acyclic means no chain of arrows returns to where it began, so an order always exists, and a tool can check the
graph before the first run. The scheduler starts each task as soon as its upstream tasks succeed, runs independent
tasks side by side, and stops everything downstream of a failure. With cron, a late file means the next job runs on
bad input; with a DAG, that job waits, or the run fails where everyone can see it.
Warm-up: what is a data interval, and why does an orchestrator need it?
A data interval is the slice of time one run is responsible for, such as a day from midnight to the next midnight with the end excluded. The scheduler hands it to each run, and every task reads and writes that slice instead of asking the clock. It is needed because runs rarely happen when their data happened: a run starts after its day has ended, a retry can start hours later, and a backfill starts today for days long past. A task that reads the clock processes the wrong day in each of those cases. A task that reads its interval processes the same day every time, so a re-run replaces the same partition. Intervals also show the scheduler what is missing: with catchup on, Airflow creates a run for every interval since the start date that has not run yet.
The next two need a full interview answer. Each shows what it tests and a first step; plan yours around the canteen pipeline before you open the model answer.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
The canteen team runs its seven tasks as seven crontab lines that start between 02:00 and 02:50, and it wants one Airflow DAG instead without losing or doubling a single day. Plan the move: what changes in the scripts, how you prove the DAG gives the same results, and how you switch over.
-
Mid to senior
Ops in Pune wants the canteen summary at 10:00 India time on weekdays only, each one covering "the previous day" in India time. Today the pipeline runs on a daily 00:00 UTC schedule. Design the schedule and the interval each run processes, Monday's included.
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What does it mean for a pipeline to be a DAG of tasks?
A task starts only when every task it waits for has succeeded, so a missing input file stops every task below it, and a cycle is refused before anything runs.
-
A validation step must compare new city totals with the old ones. How do you add it without a cycle?
Give the comparison its own task after the aggregation and make the email wait for it. Making validate also wait for the aggregation that waits for validate is a cycle, and the run refuses to begin.
-
Which three things can start a pipeline run?
The clock, as a five-field cron expression or a timetable in a time zone; an update to the run's input; or an outside message. The way the input arrives decides which fits.
-
Why must each run process its own data interval instead of reading the clock?
Reruns and backfills replay old runs. In the lesson's replay, runs that read the clock wrote 1 day and missed 2 of the 3 they were meant to cover.
-
What is wrong with deleting order_date = CURRENT_DATE - 1 before a task writes its day?
Replayed later, it clears the day before the replay rather than the run's own day. Use the start of the run's data interval as the day to replace.
-
With 3 retries 30 seconds apart and a 5-minute timeout, how long can one task hold up the next?
21 minutes 30 seconds at most: four attempts of up to 5 minutes each, plus three waits of 30 seconds.
-
When is it safe to let an orchestrator retry a task?
Only when the task is idempotent. A task that inserts its rows and then fails doubles them on the retry; replace the day's partition, or upsert by order id, instead.
-
When is cron with a script enough, and when does an orchestrator pay off?
Cron is enough for one job on one machine with a log and an alert. An orchestrator pays once jobs depend on each other, past days must run again, or several people share the on-call.
Key takeaways
- A pipeline is a directed acyclic graph of tasks: a task starts only when every task it waits for has succeeded, so a missing menu file stops the five tasks below it and a cycle is refused before anything runs.
- A run starts on the clock (a five-field cron expression, or a timetable in a time zone), on an update to its input, or on an outside message, and the way the input arrives decides which fits.
- Each run processes its own data interval; in the replay, runs that read the clock instead wrote 1 day and missed 2 of 3, which is why reruns and backfills depend on intervals.
- Retries absorb brief failures and timeouts cap how long a task blocks: 3 retries 30 s apart with a 5-minute timeout hold up the next task 21 min 30 s at most; an alert on every retry sees a source getting worse, and a deadline on the run reports a slow run that threatens the 08:00 target of an SLO or an SLA.
- Cron and a script are enough for one job on one machine with a log and an alert; an orchestrator pays once jobs depend on each other, past days must run again, or several people share the on-call.
Terms in this lesson
- backfill
- Running a pipeline over past intervals to fill a gap or to reprocess data after a bug fix. A backfill is only safe when every task is idempotent.
- catchup
- The scheduler's behaviour of creating a run for every interval missed since the start date or a pause. With it off, the scheduler creates a run only for the latest interval.
- cron expression
- A schedule written as five fields, minute, hour, day of month, month and day of week, such as 30 2 * * * for 02:30 every day.
- data interval
- The slice of time a run processes, such as one day from midnight to the next midnight with the end excluded. Reading it instead of the clock keeps re-runs and backfills correct.
- directed acyclic graph
- A graph of tasks whose edges have a direction and never form a cycle. The orchestrator can order its tasks and detect a dependency loop.
- event-driven trigger
- A rule that starts a run when a message or webhook arrives, instead of on a clock. Useful when the data's arrival is irregular.
- pipeline run
- One execution of a pipeline for one data interval or one event. The orchestrator keeps a record of its state, logs and outputs.
- scheduler
- The part of an orchestrator that decides when each run of each DAG starts and queues its tasks.
References
- Apache Airflow 3.3.2 documentation: Dags (The Apache Software Foundation)
- Apache Airflow 3.3.2 documentation: Dag Runs (The Apache Software Foundation)
- Apache Airflow 3.3.2 documentation: Tasks (The Apache Software Foundation)
- Apache Airflow 3.3.2 documentation: Asset-Aware Scheduling (The Apache Software Foundation)
- Apache Airflow 3.3.2 documentation: Event-driven scheduling (The Apache Software Foundation)
- Apache Airflow 3.3.2 documentation: Timetables (The Apache Software Foundation)
- Apache Airflow 3.3.2 documentation: Time Zones (The Apache Software Foundation)
- Apache Airflow 3.3.2 documentation: Templates reference (The Apache Software Foundation)
- Apache Airflow 3.3.2 documentation: Best Practices (The Apache Software Foundation)
- Apache Airflow 3.3.2 documentation: Deadline Alerts (The Apache Software Foundation)
- Apache Airflow Task SDK 1.3.2: airflow.sdk API reference (The Apache Software Foundation)
- Apache Airflow 3.3.2 source: airflow-core/src/airflow/models/taskinstance.py (The Apache Software Foundation)
- crontab: The Open Group Base Specifications Issue 8 (IEEE Std 1003.1-2024) (The IEEE and The Open Group)
- Python 3.14 documentation: graphlib, functionality to operate with graph-like structures (Python Software Foundation)
- Python 3.14 documentation: os.replace (Python Software Foundation)
- Dagster documentation: Overview (Dagster Labs)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress