This exercise applies every concept from Module 1 by designing and implementing a complete DAG for the IPL daily ETL workflow — from raw API extraction through transformation, quality checks, and warehouse loading. You will build the DAG using the pure-Python `DAG` class from Lesson 1, implement idempotent task functions scoped to a logical date, configure retry with exponential backoff, add event-driven S3 file detection via a mock sensor, and verify the topological sort produces the correct execution order before running any task.
The pipeline processes one day of IPL match data: extract match metadata from a REST API and delivery records from a database, wait for the official scorecard file to land in S3, transform and merge both sources, run data quality checks, load to the analytics warehouse, and trigger a downstream dbt model run. The exercise ends with an idempotency verification: the full pipeline runs twice for the same logical date and the warehouse row count assertion confirms no duplication occurred.
Step 1 — DAG Structure and Task Functions
Build the full DAG with eight tasks: `wait_for_scorecard` (sensor), `extract_api_matches`, `extract_db_deliveries`, `validate_sources`, `transform_and_merge`, `check_data_quality`, `load_to_warehouse`, and `trigger_dbt`. Define all task functions with the `logical_date` parameter as their idempotency key, and assign sensible retry counts to extract tasks. Verify the topological sort produces a valid execution order respecting all declared dependencies by running a dry run before executing any actual task logic.
# exercise_dag_design.py — Step 1: DAG structure and task functions
from datetime import date, datetime, timezone
from collections import defaultdict, deque
from typing import Callable, Any, Optional
import time
import random
import pandas as pd
import numpy as np
np.random.seed(42)
random.seed(42)
# ── Reuse the DAG + Task classes from Lesson 1 ───────────────────────────────
class Task:
def __init__(self, task_id: str, fn: Callable,
retries: int = 0, retry_delay_s: float = 1.0,
trigger_rule: str = "all_success"):
self.task_id = task_id
self.fn = fn
self.retries = retries
self.retry_delay_s = retry_delay_s
self.trigger_rule = trigger_rule
self.upstream: list['Task'] = []
def __rshift__(self, other: 'Task') -> 'Task':
other.upstream.append(self)
return other
class DAGRunner:
def __init__(self, tasks: list[Task]):
self.tasks = {t.task_id: t for t in tasks}
self.results: dict[str, dict] = {}
def _topo_order(self) -> list[Task]:
in_degree = {tid: 0 for tid in self.tasks}
for t in self.tasks.values():
for u in t.upstream:
in_degree[t.task_id] += 1
q = deque(tid for tid, d in in_degree.items() if d == 0)
order = []
while q:
tid = q.popleft()
order.append(self.tasks[tid])
for t in self.tasks.values():
if self.tasks[tid] in t.upstream:
in_degree[t.task_id] -= 1
if in_degree[t.task_id] == 0:
q.append(t.task_id)
assert len(order) == len(self.tasks), "Cycle detected"
return order
def run(self, logical_date: date, dry_run: bool = False) -> dict:
print(f"\n{'DRY RUN — ' if dry_run else ''}Running DAG for {logical_date}")
print("-" * 55)
for task in self._topo_order():
# Check trigger rule
upstream_states = [
self.results.get(u.task_id, {}).get("state", "pending")
for u in task.upstream
]
skip = (
task.trigger_rule == "all_success"
and any(s != "success" for s in upstream_states)
)
if skip:
self.results[task.task_id] = {"state": "upstream_failed", "output": None}
print(f" ⊘ {task.task_id:<30} [upstream_failed]")
continue
if dry_run:
self.results[task.task_id] = {"state": "success", "output": f"<dry_{task.task_id}>"}
print(f" ~ {task.task_id:<30} [dry_run]")
continue
# Run with retry
upstream_ctx = {
u.task_id: self.results[u.task_id]["output"]
for u in task.upstream
}
for attempt in range(1, task.retries + 2):
try:
output = task.fn(logical_date=logical_date, **upstream_ctx)
self.results[task.task_id] = {"state": "success", "output": output}
print(f" ✓ {task.task_id:<30} [success]")
break
except Exception as exc:
if attempt <= task.retries:
delay = task.retry_delay_s * (2 ** (attempt - 1))
print(f" ↻ {task.task_id:<30} [retry {attempt}/{task.retries}] — {exc}")
time.sleep(min(delay, 0.1)) # cap at 100ms for exercise speed
else:
self.results[task.task_id] = {"state": "failed", "output": None}
print(f" ✗ {task.task_id:<30} [failed] — {exc}")
return self.results
# ── Idempotent task function implementations ──────────────────────────────────
WAREHOUSE: dict[str, list[dict]] = defaultdict(list) # in-memory warehouse
def wait_for_scorecard(logical_date: date, **_) -> bool:
"""Mock sensor: immediately finds the scorecard file."""
print(f" Checking for scorecard: s3://ipl-data/scorecards/{logical_date}.csv")
return True # file found
def extract_api_matches(logical_date: date, **_) -> list[dict]:
matches = [
{"match_id": 10001, "venue": "Wankhede", "date": str(logical_date)},
{"match_id": 10002, "venue": "Chinnaswamy", "date": str(logical_date)},
]
return matches
def extract_db_deliveries(logical_date: date, **_) -> list[dict]:
np.random.seed(int(logical_date.strftime("%Y%m%d")))
return [
{"delivery_id": i, "match_id": 10001 + (i // 120),
"runs": int(np.random.choice([0,1,2,4,6]))}
for i in range(1, 241) # 2 matches × 120 deliveries
]
def validate_sources(logical_date: date,
wait_for_scorecard, extract_api_matches, extract_db_deliveries,
**_) -> dict:
assert wait_for_scorecard == True
assert len(extract_api_matches) > 0
assert len(extract_db_deliveries) > 0
return {"matches": len(extract_api_matches), "deliveries": len(extract_db_deliveries)}
def transform_and_merge(logical_date: date,
extract_api_matches, extract_db_deliveries, **_) -> pd.DataFrame:
matches_df = pd.DataFrame(extract_api_matches)
deliveries_df= pd.DataFrame(extract_db_deliveries)
merged = deliveries_df.merge(matches_df, on="match_id", how="left")
merged["logical_date"] = str(logical_date)
return merged
def check_data_quality(logical_date: date, transform_and_merge: pd.DataFrame, **_) -> dict:
assert transform_and_merge["runs"].between(0, 6).all(), "Invalid runs values"
assert transform_and_merge["venue"].notna().all(), "Null venues after join"
return {"rows": len(transform_and_merge), "quality": "pass"}
def load_to_warehouse(logical_date: date,
transform_and_merge: pd.DataFrame, check_data_quality, **_) -> int:
"""Idempotent: delete existing rows for this logical_date, then insert."""
WAREHOUSE[str(logical_date)] = transform_and_merge.to_dict(orient="records")
return len(WAREHOUSE[str(logical_date)])
def trigger_dbt(logical_date: date, load_to_warehouse, **_) -> str:
print(f" Triggering dbt run for {logical_date} ({load_to_warehouse} rows loaded)")
return "dbt_run_complete"
print("Task functions defined")Step 2 — Wire Dependencies and Run Idempotency Test
Wire all eight tasks with correct upstream dependencies, confirm the topological sort with a dry run, execute the full pipeline twice for the same logical date, and assert that the warehouse row count is identical after both runs — confirming idempotency. Simulate a transient failure on the first attempt of `extract_api_matches` and verify the retry logic recovers correctly before the DAG proceeds to downstream tasks.
# exercise_dag_design.py — Step 2: Wire dependencies and idempotency test
from datetime import date
LOGICAL_DATE = date(2024, 4, 20)
# Build tasks
t_sensor = Task("wait_for_scorecard", wait_for_scorecard, retries=2)
t_api = Task("extract_api_matches", extract_api_matches, retries=3, retry_delay_s=0.5)
t_db = Task("extract_db_deliveries",extract_db_deliveries,retries=2)
t_validate = Task("validate_sources", validate_sources)
t_transform= Task("transform_and_merge", transform_and_merge)
t_quality = Task("check_data_quality", check_data_quality)
t_load = Task("load_to_warehouse", load_to_warehouse)
t_dbt = Task("trigger_dbt", trigger_dbt)
# Wire dependencies
t_sensor >> t_api
t_sensor >> t_db
t_api >> t_validate
t_db >> t_validate
t_validate >> t_transform
t_transform>> t_quality
t_quality >> t_load
t_load >> t_dbt
all_tasks = [t_sensor, t_api, t_db, t_validate, t_transform, t_quality, t_load, t_dbt]
runner = DAGRunner(all_tasks)
# Dry run: verify topological order without executing
print("Topological order (dry run):")
dry_results = runner.run(LOGICAL_DATE, dry_run=True)
# Simulate transient failure on first attempt of extract_api_matches
call_count = [0]
original_extract = extract_api_matches
def flaky_extract(logical_date, **kw):
call_count[0] += 1
if call_count[0] == 1:
raise ConnectionError("Simulated transient API timeout")
return original_extract(logical_date=logical_date, **kw)
t_api.fn = flaky_extract
runner2 = DAGRunner(all_tasks)
# Run 1: first attempt fails, retry succeeds
results1 = runner2.run(LOGICAL_DATE)
assert results1["load_to_warehouse"]["state"] == "success"
wh_count_run1 = len(WAREHOUSE[str(LOGICAL_DATE)])
# Run 2: idempotency test — same logical date, re-run entire DAG
t_api.fn = extract_api_matches # restore non-flaky version
runner3 = DAGRunner(all_tasks)
results3 = runner3.run(LOGICAL_DATE)
assert results3["load_to_warehouse"]["state"] == "success"
wh_count_run2 = len(WAREHOUSE[str(LOGICAL_DATE)])
print(f"\nIdempotency test:")
print(f" Run 1 warehouse rows: {wh_count_run1}")
print(f" Run 2 warehouse rows: {wh_count_run2}")
assert wh_count_run1 == wh_count_run2, \
f"Idempotency failed: {wh_count_run1} → {wh_count_run2} rows after second run"
print(f" Row count unchanged ✓ — pipeline is idempotent")
# Final summary
print("\nFinal task states (Run 2):")
for task_id, result in results3.items():
icon = {"success":"✓", "failed":"✗", "upstream_failed":"⊘"}.get(result["state"],"?")
print(f" {icon} {task_id:<30} [{result['state']}]")
print("\nAll assertions passed. DAG design exercise complete.")Warning: The task functions in this exercise receive upstream task outputs as keyword arguments by task ID — `transform_and_merge` receives `extract_api_matches` and `extract_db_deliveries` as parameters. In production Airflow, task outputs are passed via XCom (small values) or shared storage paths (large DataFrames). Never pass a full DataFrame through XCom — it is serialised to the metadata database and can easily exceed the 48KB size limit, causing the task to fail at runtime. Pass file paths or storage references between tasks; read the data from storage inside each task.
Extension Challenge: Extend the pipeline with a branching task after `check_data_quality` that takes two paths: if the quality check reports more than 5% null venues (indicating a bad API response), it routes to a `quarantine_to_s3` task that writes the bad data to an S3 quarantine prefix and sends an alert; otherwise it routes to `load_to_warehouse`. Implement this as a conditional task that reads the quality check result and sets downstream task states accordingly. This is the orchestration equivalent of the Airflow `BranchPythonOperator`.