Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Beginner
  • 25 minutes
  • Examples run with Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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

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.

Interactive · step by step One run of the canteen pipeline, task by task Step through one day's run. A task starts only when every task with an arrow into it has finished; the line under each name says what it produced.
One run of the canteen pipeline, task by task, step 9 of 9. Showing Extract orders (6 rows), Extract menu (4 items), Validate (5 kept, 1 rejected), Aggregate by city (3 city totals), Write partition (3 rows stored), Send summary (emailed: 720), Archive source (2 files archived).Extract orders6 rowsExtract menu4 itemsValidate5 kept, 1 rejectedAggregate by city3 city totalsWrite partition3 rows storedSend summaryemailed: 720Archive source2 files archived7 of 7 tasks ok

The drawing shows the whole scene; its 9 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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.
  9. 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.

Interactive · line by line Finding an order the scheduler can follow Each step runs one line. Watch the counts in waiting fall as tasks finish; a task joins ready only 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

After line 25: downstream is {'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 is {'extract_orders': 0, 'extract_menu': 0, 'validate': 0, 'aggregate_by_city': 0, 'write_partition': 0, 'send_summary': 0, 'archive_source': 0}; task is 'archive_source'; nxt is 'archive_source'; ready is []; order is ['extract_orders', 'extract_menu', 'validate', 'aggregate_by_city', 'write_partition', 'send_summary', 'archive_source'].downstream'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'extract_orders':0'extract_menu':0'validate':0'aggregate_by_city':0'write_partition':0'send_summary':0'archive_source':0task'archive_source'nxt'archive_source'readyorder'extract_orders'0'extract_menu'1'validate'2'aggregate_by_city'3'write_partition'4'send_summary'5'archive_source'6
  • just taken by the loop
  • changed in this step
  • passed by the loop
  • read next
  • where the loop stopped

Printed so far

7 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.

Line run 25
Next line end
Loop pass –
Every step as text
  1. Start. No line has run yet, so there are no variables. Python starts with line 2.
  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.
  3. 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": [], } makes downstream a dict with 7 keys.
  4. Line 11. waiting = dict.fromkeys(downstream, 0) sets waiting to {'extract_orders': 0, 'extract_menu': 0, 'validate': 0, 'aggregate_by_city': 0, 'write_partition': 0, 'send_summary': 0, 'archive_source': 0}. waiting will hold, for each task, how many upstream tasks it is still waiting for. Every count starts at 0.
  5. Line 12. for task in downstream: takes the first key of downstream: task is 'extract_orders'.
  6. Line 13. for nxt in downstream[task]: takes the item at position 0 of downstream[task]: nxt is 'validate'.
  7. Line 14. waiting[nxt] += 1 changes waiting['validate'] from 0 to 1.
  8. Line 13. for nxt in downstream[task]: finds no item at position 1 of downstream[task] (it has 1 item), so the loop ends.
  9. Line 12. for task in downstream: takes the second key of downstream: task is 'extract_menu'.
  10. Line 13. for nxt in downstream[task]: takes the item at position 0 of downstream[task]: nxt is 'validate'.
  11. Line 14. waiting[nxt] += 1 changes waiting['validate'] from 1 to 2.
  12. Line 13. for nxt in downstream[task]: finds no item at position 1 of downstream[task] (it has 1 item), so the loop ends.
  13. Line 12. for task in downstream: takes the third key of downstream: task is 'validate'.
  14. Line 13. for nxt in downstream[task]: takes the item at position 0 of downstream[task]: nxt is 'aggregate_by_city'.
  15. Line 14. waiting[nxt] += 1 changes waiting['aggregate_by_city'] from 0 to 1.
  16. Line 13. for nxt in downstream[task]: finds no item at position 1 of downstream[task] (it has 1 item), so the loop ends.
  17. Line 12. for task in downstream: takes the fourth key of downstream: task is 'aggregate_by_city'.
  18. Line 13. for nxt in downstream[task]: takes the item at position 0 of downstream[task]: nxt is 'write_partition'.
  19. Line 14. waiting[nxt] += 1 changes waiting['write_partition'] from 0 to 1.
  20. Line 13. for nxt in downstream[task]: finds no item at position 1 of downstream[task] (it has 1 item), so the loop ends.
  21. Line 12. for task in downstream: takes the fifth key of downstream: task is 'write_partition'.
  22. Line 13. for nxt in downstream[task]: takes the item at position 0 of downstream[task]: nxt is 'send_summary'.
  23. Line 14. waiting[nxt] += 1 changes waiting['send_summary'] from 0 to 1.
  24. Line 13. for nxt in downstream[task]: takes the item at position 1 of downstream[task]: nxt is 'archive_source'.
  25. Line 14. waiting[nxt] += 1 changes waiting['archive_source'] from 0 to 1. All six edges are counted. validate waits for 2 tasks, the two extracts for none, and every other task for exactly 1.
  26. Line 13. for nxt in downstream[task]: finds no item at position 2 of downstream[task] (it has 2 items), so the loop ends.
  27. Line 12. for task in downstream: takes the sixth key of downstream: task is 'send_summary'.
  28. Line 13. for nxt in downstream[task]: has nothing to go through, so its body never runs and the loop is over at once.
  29. Line 12. for task in downstream: takes the seventh key of downstream: task is 'archive_source'.
  30. Line 13. for nxt in downstream[task]: has nothing to go through, so its body never runs and the loop is over at once.
  31. Line 12. for task in downstream: has read every key of downstream, so the loop ends.
  32. Line 15. ready = [t for t in downstream if waiting[t] == 0] runs.
  33. Line 15. ready = [t for t in downstream if waiting[t] == 0] runs.
  34. Line 15. ready = [t for t in downstream if waiting[t] == 0] runs.
  35. Line 15. ready = [t for t in downstream if waiting[t] == 0] runs.
  36. Line 15. ready = [t for t in downstream if waiting[t] == 0] runs.
  37. Line 15. ready = [t for t in downstream if waiting[t] == 0] runs.
  38. Line 15. ready = [t for t in downstream if waiting[t] == 0] runs.
  39. Line 15. ready = [t for t in downstream if waiting[t] == 0] sets ready to ['extract_orders', 'extract_menu']. Key point ready holds the two tasks whose count is 0, the extracts. A scheduler could start both of them at the same moment.
  40. Line 16. order = [] makes order a list of 0 items.
  41. Line 17. while ready: tests its condition: it is true (ready is ['extract_orders', 'extract_menu']), so the body runs.
  42. Line 18. task = ready.pop(0) sets task to 'extract_orders' (it was 'archive_source').
  43. Line 19. order.append(task) adds 'extract_orders' to the end of order.
  44. Line 20. for nxt in downstream[task]: takes the item at position 0 of downstream[task]: nxt is 'validate'.
  45. Line 21. waiting[nxt] -= 1 changes waiting['validate'] from 2 to 1. extract_orders is done, so the count of validate falls from 2 to 1. It is not ready yet: extract_menu has not finished.
  46. Line 22. waiting[nxt] == 0 is false (waiting is {'extract_orders': 0, 'extract_menu': 0, 'validate': 1, 'aggregate_by_city': 1, 'write_partition': 1, 'send_summary': 1, 'archive_source': 1} and nxt is 'validate'), so Python skips the block under if waiting[nxt] == 0:.
  47. Line 20. for nxt in downstream[task]: finds no item at position 1 of downstream[task] (it has 1 item), so the loop ends.
  48. Line 17. while ready: tests its condition: it is true (ready is ['extract_menu']), so the body runs again (pass 2).
  49. Line 18. task = ready.pop(0) sets task to 'extract_menu' (it was 'extract_orders').
  50. Line 19. order.append(task) adds 'extract_menu' to the end of order.
  51. Line 20. for nxt in downstream[task]: takes the item at position 0 of downstream[task]: nxt is 'validate'.
  52. Line 21. waiting[nxt] -= 1 changes waiting['validate'] from 1 to 0.
  53. Line 22. waiting[nxt] == 0 is true (waiting is {'extract_orders': 0, 'extract_menu': 0, 'validate': 0, 'aggregate_by_city': 1, 'write_partition': 1, 'send_summary': 1, 'archive_source': 1} and nxt is 'validate'), so the block under if waiting[nxt] == 0: runs.
  54. Line 23. ready.append(nxt) adds 'validate' to the end of ready. Key point validate reaches 0 only after both extracts are done, and only then joins ready. That one rule keeps a task from starting before its inputs exist.
  55. Line 20. for nxt in downstream[task]: finds no item at position 1 of downstream[task] (it has 1 item), so the loop ends.
  56. Line 17. while ready: tests its condition: it is true (ready is ['validate']), so the body runs again (pass 3).
  57. Line 18. task = ready.pop(0) sets task to 'validate' (it was 'extract_menu').
  58. Line 19. order.append(task) adds 'validate' to the end of order.
  59. Line 20. for nxt in downstream[task]: takes the item at position 0 of downstream[task]: nxt is 'aggregate_by_city'.
  60. Line 21. waiting[nxt] -= 1 changes waiting['aggregate_by_city'] from 1 to 0.
  61. Line 22. waiting[nxt] == 0 is true (waiting is {'extract_orders': 0, 'extract_menu': 0, 'validate': 0, 'aggregate_by_city': 0, 'write_partition': 1, 'send_summary': 1, 'archive_source': 1} and nxt is 'aggregate_by_city'), so the block under if waiting[nxt] == 0: runs.
  62. Line 23. ready.append(nxt) adds 'aggregate_by_city' to the end of ready.
  63. Line 20. for nxt in downstream[task]: finds no item at position 1 of downstream[task] (it has 1 item), so the loop ends.
  64. Line 17. while ready: tests its condition: it is true (ready is ['aggregate_by_city']), so the body runs again (pass 4).
  65. Line 18. task = ready.pop(0) sets task to 'aggregate_by_city' (it was 'validate').
  66. Line 19. order.append(task) adds 'aggregate_by_city' to the end of order.
  67. Line 20. for nxt in downstream[task]: takes the item at position 0 of downstream[task]: nxt is 'write_partition'.
  68. Line 21. waiting[nxt] -= 1 changes waiting['write_partition'] from 1 to 0.
  69. Line 22. waiting[nxt] == 0 is true (waiting is {'extract_orders': 0, 'extract_menu': 0, 'validate': 0, 'aggregate_by_city': 0, 'write_partition': 0, 'send_summary': 1, 'archive_source': 1} and nxt is 'write_partition'), so the block under if waiting[nxt] == 0: runs.
  70. Line 23. ready.append(nxt) adds 'write_partition' to the end of ready.
  71. Line 20. for nxt in downstream[task]: finds no item at position 1 of downstream[task] (it has 1 item), so the loop ends.
  72. Line 17. while ready: tests its condition: it is true (ready is ['write_partition']), so the body runs again (pass 5).
  73. Line 18. task = ready.pop(0) sets task to 'write_partition' (it was 'aggregate_by_city').
  74. Line 19. order.append(task) adds 'write_partition' to the end of order.
  75. Line 20. for nxt in downstream[task]: takes the item at position 0 of downstream[task]: nxt is 'send_summary'.
  76. Line 21. waiting[nxt] -= 1 changes waiting['send_summary'] from 1 to 0.
  77. Line 22. waiting[nxt] == 0 is true (waiting is {'extract_orders': 0, 'extract_menu': 0, 'validate': 0, 'aggregate_by_city': 0, 'write_partition': 0, 'send_summary': 0, 'archive_source': 1} and nxt is 'send_summary'), so the block under if waiting[nxt] == 0: runs.
  78. Line 23. ready.append(nxt) adds 'send_summary' to the end of ready.
  79. Line 20. for nxt in downstream[task]: takes the item at position 1 of downstream[task]: nxt is 'archive_source'.
  80. Line 21. waiting[nxt] -= 1 changes waiting['archive_source'] from 1 to 0.
  81. Line 22. waiting[nxt] == 0 is true (waiting is {'extract_orders': 0, 'extract_menu': 0, 'validate': 0, 'aggregate_by_city': 0, 'write_partition': 0, 'send_summary': 0, 'archive_source': 0} and nxt is 'archive_source'), so the block under if waiting[nxt] == 0: runs.
  82. Line 23. ready.append(nxt) adds 'archive_source' to the end of ready. write_partition had two tasks waiting on it. Both reached 0 on the same pass of the loop, so they could run side by side.
  83. Line 20. for nxt in downstream[task]: finds no item at position 2 of downstream[task] (it has 2 items), so the loop ends.
  84. Line 17. while ready: tests its condition: it is true (ready is ['send_summary', 'archive_source']), so the body runs again (pass 6).
  85. Line 18. task = ready.pop(0) sets task to 'send_summary' (it was 'write_partition').
  86. Line 19. order.append(task) adds 'send_summary' to the end of order.
  87. Line 20. for nxt in downstream[task]: has nothing to go through, so its body never runs and the loop is over at once.
  88. Line 17. while ready: tests its condition: it is true (ready is ['archive_source']), so the body runs again (pass 7).
  89. Line 18. task = ready.pop(0) sets task to 'archive_source' (it was 'send_summary').
  90. Line 19. order.append(task) adds 'archive_source' to the end of order.
  91. Line 20. for nxt in downstream[task]: has nothing to go through, so its body never runs and the loop is over at once.
  92. Line 17. while ready: tests its condition: it is false (ready is []), so the loop ends.
  93. Line 24. print(len(order), "of", len(downstream), "tasks ordered:") prints 7 of 7 tasks ordered:.
  94. Line 25. print(" -> ".join(order)) prints extract_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, so order would 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.

Seven tasks run as a DAG: a normal day, a missing file and a cycle Python · pipeline_dag.py
"""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

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

  1. Question 1 of 2 In the canteen DAG, extract_orders has just succeeded and extract_menu is still running. Which tasks may the scheduler start now?

    Choose one answer.

    A hint

    Look at every arrow that points into validate.

    Show the answer: Question 1 of 2

    Answer: None. validate still waits for extract_menu, and every later task waits for validate or a task after it.

    The scheduler applies one rule: start a task when every task it waits for has succeeded. validate waits for both extracts, so it stays waiting until extract_menu finishes, and the tasks after it wait in a chain behind it. In the scene, validate became ready on the step where the menu arrived.

  2. Question 2 of 2 Ops wants a task check_till that compares each city's total with the till count, and the summary email must not go out when they differ. Which edges add it without breaking the DAG?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: aggregate_by_city before check_till, and check_till before send_summary.

    A new task needs an arrow in from each task whose output it reads, and an arrow out to each task that must not run until it has passed: here aggregate_by_city, then check_till, then send_summary. An arrow back into validate would make a cycle, and a check that nothing waits for cannot stop anything.

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:

Interactive · answer and compare Which way should the canteen pipeline start? Answer two questions about your input. The table marks the ways to start a run that still fit, the ones a question rules out, and why.
Should a run start at a time on the clock, or when its input is ready?
How often does an input fail to arrive by the time the run needs it?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Four ways to start a run of the canteen pipeline, against three properties a team relies on
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:

The interval each run reads, and a 3-run replay by interval and by the clock Python · cron_vs_interval.py
"""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

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

  1. Question 1 of 2 A daily schedule with data intervals fires at 00:00 UTC. Which orders does the run that fires as Saturday begins, at Saturday 00:00 UTC, process?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The orders placed from Friday 00:00 to Saturday 00:00 UTC, the day that has just ended, with Saturday 00:00 itself excluded.

    The interval of a daily run is the day before it fires, from 00:00 to the next 00:00 with the end excluded. The program's scheduled run is this one: it fired as Saturday began and read Friday. The interval is fixed when the run is created, so a retry or a replay processes the same day.

  2. Question 2 of 2 Three missed daily runs are replayed by hand on one afternoon. A task in the pipeline clears and rewrites the partition of now() minus one day. What do the three runs leave behind?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: One partition, the day before the replay, rewritten three times; the other days the runs were for stay missing.

    A replay is a backfill done by hand, and it works only when each run reads the interval it was given. With now() minus one day the runs are blind to their intervals: at most one of them, the run whose day happens to be the day before the replay, writes its own day, and only by chance. Reading data_interval_start gives three different days.

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 retries can hold up validate, and what each alert rule sees Python · retry_budget.py
"""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

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

  1. Question 1 of 2 A clinic's IT team runs one script on one server at 01:00 every night. It exports the day's appointments to a single CSV report for one manager, logs to a file, and prints any error to standard error, which cron mails to the team. Should they move it to an orchestrator?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Not yet: one job with no dependencies, a log and an alert is what cron and a script do well.

    Cron and a script are enough for one script on one machine with a log and an alert. The team should look again when a second job must wait for this one, when a past night must be run again, or when several people share the on-call for many pipelines.

  2. Question 2 of 2 Which of these are reasons for the canteen team to move its crontab to an orchestrator? Choose every one that is.

    Choose every answer that is right.

    Show the answer: Question 2 of 2

    Answer:

    • Four people now share the on-call for eleven pipelines.
    • The aggregate job must not start until both extracts have finished.
    • A bug fix means the last 30 days must be computed again.

    Dependencies between jobs, reruns of past days, and many pipelines with several people on call are the points where an orchestrator starts to pay. Data volume is the processing engine's concern, and the scripts' language is nobody's.

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 after list. That makes the order of the text a topological order.
  • Exactly the six dependencies of the lesson's pipeline. validate waits for both extracts, aggregate_by_city for validate, write_partition for aggregate_by_city, and send_summary and archive_source each for write_partition. Add no other: an extra arrow makes a task wait for no reason. Every name in an after list must be one of the seven tasks, and no task waits for itself.
  • The extracts retry, within limits. extract_orders and extract_menu read files that are sometimes late: give each retries: 3, retry_delay_s: 30 and timeout_s: 300.
  • The summary email is never retried. A retry after a send that half worked would email ops twice, so send_summary has retries: 0.
  • Every task can be stopped. Give every task a timeout_s above 0. The values for validate, aggregate_by_city, write_partition and archive_source are 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.

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.

  1. Question 1 of 11 What does the word acyclic promise about a pipeline's graph?

    Choose one answer.

    Show the answer: Question 1 of 11

    Answer: No path of arrows leads from a task back to itself.

    In a directed acyclic graph every arrow has a direction and no chain of arrows comes back to where it started. That is exactly the condition under which a topological order exists, an order in which every task comes after all the tasks it depends on.

  2. Question 2 of 11 In run 2 of the pipeline program, the menu file has not landed and extract_menu fails. What happens to the other six tasks?

    Choose one answer.

    Show the answer: Question 2 of 11

    Answer: extract_orders succeeds, and the five tasks from validate on never start.

    A failure stops everything downstream of it and nothing else: extract_orders does not depend on the menu, so it runs, and the five tasks that depend on extract_menu directly or through other tasks are reported as not run. Airflow marks such tasks upstream_failed.

  3. Question 3 of 11 Which POSIX cron expression runs a job at 03:15 every Monday to Friday?

    Choose one answer.

    Show the answer: Question 3 of 11

    Answer: 15 3 * * 1-5

    The five fields are minute, hour, day of the month, month and day of the week, in that order. For 03:15 the minute is 15 and the hour is 3, and Monday to Friday is the range 1-5 in the last field, because POSIX numbers the days from Sunday, 0.

  4. Question 4 of 11 Another team's pipeline, in the same orchestrator, writes the orders table at no fixed time between 01:00 and 06:00. The canteen run should start within minutes of that write. Which start fits best?

    Choose one answer.

    Show the answer: Question 4 of 11

    Answer: A data-aware trigger on the orders table, so the run starts when the producing task succeeds.

    When the input is another pipeline's output inside the same orchestrator, a data-aware trigger is the natural start: Airflow marks an asset updated only when its producing task succeeds and then schedules the consumer. Clock starts either wait too long or poll, and an event needs an outside message the producer does not send.

  5. Question 5 of 11 A run's data interval is [Friday 00:00, Saturday 00:00) UTC. An order is placed at exactly Saturday 00:00 UTC. Which run processes it?

    Choose one answer.

    Show the answer: Question 5 of 11

    Answer: The next run, whose interval starts at Saturday 00:00.

    Daily intervals written as [start, end) cover time with no gap and no overlap: an order at midnight belongs to the interval that starts at midnight. That is why the program writes the interval with a square bracket at the start and a round one at the end.

  6. Question 6 of 11 A daily Dag whose start date is 10 days in the past is switched on with catchup turned on. What does the scheduler do?

    Choose one answer.

    Show the answer: Question 6 of 11

    Answer: It creates a run for each of the 10 past intervals, each processing its own day.

    Catchup turns the missing intervals into runs: 10 runs, one per day since the start date, each reading only its own day. That is safe only when every task reads its interval rather than the clock, and when each run can be repeated without duplicating rows.

  7. Question 7 of 11 A task has retries set to 3, a fixed retry_delay of 60 s and an execution_timeout of 5 minutes. In the worst case every attempt runs into the timeout. For how many minutes can it hold up the task after it?

    Type a number.

    A hint

    Count the attempts first, the first try and 3 retries, then the waits between them.

    Show the answer: Question 7 of 11

    Answer: 23 minutes

    There are 4 attempts of 5 minutes each, 20 minutes, and 3 waits of 1 minute between them, 3 minutes: 23 minutes in all. The program's fixed 30 s plan gives 21 min 30 s the same way.

  8. Question 8 of 11 For a week the canteen's extract_orders succeeded on every run, but needed 4 retries spread over 3 of the 7 days. Ops alerts on failed runs only. What does that alert setup miss?

    Choose one answer.

    Show the answer: Question 8 of 11

    Answer: A source that is getting less reliable: on the day it needs more attempts than the budget allows, the run fails with no warning before it.

    An alert on failures alone reports only the day the retry budget runs out. Retries on 3 of 7 days are a warning in themselves, which is why the lesson sends a message on every retry and puts a deadline on the result.

  9. Question 9 of 11 The canteen has promised an outside client the 08:00 summary, with a refund when it is late: a service level agreement (SLA). Which Airflow 3.3.2 feature can raise an alert when a run has not finished in time, even though no task has failed?

    Choose one answer.

    Show the answer: Question 9 of 11

    Answer: A Deadline Alert, which runs a callback when the run has not finished within a set time of a reference point such as its queueing.

    An SLA is a promise to someone outside the team, here a summary by 08:00, and a run can break it while every task succeeds, just by being slow. Airflow 3.0 removed the SLA feature of Airflow 2; since 3.1 a Deadline Alert counts from a reference point, such as the moment the run was queued, and runs a callback when the run has not finished by then.

  10. Question 10 of 11 Which of these does one POSIX crontab line give you by itself? Choose every one that does.

    Choose every answer that is right.

    Show the answer: Question 10 of 11

    Answer:

    • The command's output and errors mailed to its owner, when the command does not redirect them.
    • A start at the times its five fields describe.

    A crontab line is a schedule plus mailed output. Dependencies, a run history per interval and retries are what an orchestrator adds, which is why it starts to pay once jobs depend on each other.

  11. Question 11 of 11 The canteen grows to 300 kitchens, and aggregate_by_city now reads 40 GB of orders a day. Where should that aggregation run once the pipeline is in an orchestrator?

    Choose one answer.

    Show the answer: Question 11 of 11

    Answer: In an engine built for it, such as a warehouse query or a Spark job, which the task starts and waits for.

    An Airflow Dag cares how its tasks run, not what they do. Heavy data work belongs to an engine that a task calls, and the orchestrator keeps the order, the retries, the timeouts and the record of each run.

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.

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

  8. 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.