Lesson 5 - Guided Project: Branching and Retries Together

Welcome to the Guided Project

Lesson 3 wired a real branch. Lesson 4 made a real task fail and retry. This project puts both in one DAG — the same branching zone-and-payment shape Lesson 3 built, with its final load_report step now deliberately unreliable the way Lesson 4’s flaky_ingest was — and verifies the whole thing end to end.

By the end of this project, you will be able to:

  • Combine a real branching dependency structure with a real retried task in a single DAG
  • Predict, and then confirm, that a retry on one downstream task doesn’t disturb the branch tasks that already finished upstream
  • Read a real “Task Tries” panel in the Airflow UI as direct evidence of a retry history, the same fact Lesson 4 confirmed via the REST API

One DAG: A Real Branch, Ending in a Real Retry

# gate: skip
"""Module 3's guided project: one real DAG combining genuine branching
dependencies (ingest/validate feeding two independent summaries that merge
back together) with a deliberately retried final task (load_report fails
its first attempt, succeeds on the second) -- the two ideas Lessons 3 and 4
each demonstrated alone, now together in a single run."""
from datetime import datetime, timedelta

from airflow.decorators import dag, task

WORKDIR = "/opt/airflow/dags/data/work/m3_guided_project"
RAW_CSV = "/opt/airflow/dags/data/yellow_tripdata_sample.csv"
LOAD_MARKER = f"{WORKDIR}/load_report_marker.txt"


@dag(
    dag_id="cityflow_guided_project_authoring",
    schedule=None,
    start_date=datetime(2026, 1, 1),
    catchup=False,
    tags=["cityflow", "module-3", "guided-project"],
)
def cityflow_guided_project_authoring_dag():
    @task
    def ingest_taxi_data() -> str:
        import os

        import pandas as pd

        os.makedirs(WORKDIR, exist_ok=True)
        raw = pd.read_csv(RAW_CSV)
        out_path = f"{WORKDIR}/ingested.csv"
        raw.to_csv(out_path, index=False)
        print(f"ingested {len(raw):,} raw trips -> {out_path}")
        return out_path

    @task
    def validate_trips(ingested_path: str) -> str:
        import pandas as pd

        raw = pd.read_csv(ingested_path)
        valid = raw[
            (raw["passenger_count"] > 0)
            & (raw["trip_distance"] > 0)
            & (raw["fare_amount"] > 0)
        ]
        out_path = f"{WORKDIR}/validated.csv"
        valid.to_csv(out_path, index=False)
        print(f"kept {len(valid):,} of {len(raw):,} rows -> {out_path}")
        return out_path

    @task
    def compute_by_payment_type(validated_path: str) -> list:
        import pandas as pd

        valid = pd.read_csv(validated_path)
        summary = (
            valid.groupby("payment_type")
            .agg(trips=("fare_amount", "size"), avg_fare=("fare_amount", "mean"))
            .round(2)
            .reset_index()
        )
        print(summary.to_string(index=False))
        return summary.to_dict(orient="records")

    @task
    def compute_by_passenger_count(validated_path: str) -> list:
        import pandas as pd

        valid = pd.read_csv(validated_path)
        summary = (
            valid.groupby("passenger_count")
            .agg(trips=("fare_amount", "size"), avg_distance=("trip_distance", "mean"))
            .round(2)
            .reset_index()
        )
        print(summary.to_string(index=False))
        return summary.to_dict(orient="records")

    @task
    def merge_summaries(by_payment: list, by_passengers: list) -> dict:
        return {"by_payment_type": by_payment, "by_passenger_count": by_passengers}

    @task(retries=1, retry_delay=timedelta(seconds=20))
    def load_report(merged: dict) -> None:
        """The deliberately-retried task: fails its first real attempt (no
        marker file present yet), succeeds on the second."""
        import json
        import os

        if not os.path.exists(LOAD_MARKER):
            with open(LOAD_MARKER, "w") as f:
                f.write("attempt-1-failed")
            raise RuntimeError(
                "simulated transient failure writing the report -- Airflow should retry"
            )
        os.remove(LOAD_MARKER)

        out_path = f"{WORKDIR}/guided_project_report.json"
        with open(out_path, "w") as f:
            json.dump(merged, f, indent=2)
        print(f"wrote {out_path} on retry (attempt 2)")
        print(json.dumps(merged, indent=2))

    ingested = ingest_taxi_data()
    validated = validate_trips(ingested)
    by_payment = compute_by_payment_type(validated)
    by_passengers = compute_by_passenger_count(validated)
    merged = merge_summaries(by_payment, by_passengers)
    load_report(merged)


cityflow_guided_project_authoring_dag()

The shape is Lesson 3’s exactly: validate_trips feeds two independent summary tasks (TaskFlow’s nested calls create the same branch >> to a list did — compute_by_payment_type(validated) and compute_by_passenger_count(validated) both take validated as their only argument, so neither depends on the other). The one new idea is load_report’s retries=1 and its marker-file logic, lifted directly from Lesson 4’s flaky_ingest — the deliberate failure now lives at the very end of the chain, after the branch has already merged.


Triggered End to End

import subprocess
import time
import json

dag_id = "cityflow_guided_project_authoring"

deadline = time.time() + 320
found = False
while time.time() < deadline:
    result = subprocess.run(
        ["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "details", dag_id],
        capture_output=True, text=True,
    )
    if result.returncode == 0:
        found = True
        break
    time.sleep(5)
assert found, f"{dag_id} was never picked up by the scheduler"

subprocess.run(
    ["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "unpause", dag_id],
    capture_output=True, text=True, check=True,
)
run_id = f"lesson5_verify_{int(time.time())}"
subprocess.run(
    ["docker", "exec", "airflow-local-airflow-scheduler-1",
     "airflow", "dags", "trigger", dag_id, "--run-id", run_id],
    capture_output=True, text=True, check=True,
)
print(f"triggered {dag_id!r} as {run_id!r}")

# retry_delay=20s on load_report means this run genuinely takes >20s
deadline = time.time() + 90
state = "queued"
while state not in ("success", "failed") and time.time() < deadline:
    time.sleep(3)
    result = subprocess.run(
        ["docker", "exec", "airflow-local-airflow-scheduler-1",
         "airflow", "dags", "list-runs", "-d", dag_id, "--output", "json"],
        capture_output=True, text=True, check=True,
    )
    runs = json.loads(result.stdout)
    state = next(r["state"] for r in runs if r["run_id"] == run_id)

print(f"run {run_id!r} reached state: {state}")
assert state == "success", f"expected success, got {state}"

states = subprocess.run(
    ["docker", "exec", "airflow-local-airflow-scheduler-1",
     "airflow", "tasks", "states-for-dag-run", dag_id, run_id],
    capture_output=True, text=True, check=True,
)
print(states.stdout)
triggered 'cityflow_guided_project_authoring' as 'lesson5_verify_1784885780'
run 'lesson5_verify_1784885780' reached state: success
dag_id                             | task_id                    | state   | start_date                       | end_date
===================================+============================+=========+==================================+=================================
cityflow_guided_project_authoring | ingest_taxi_data           | success | 2026-07-24T09:36:22.716764+00:00 | 2026-07-24T09:36:23.835079+00:00
cityflow_guided_project_authoring | validate_trips             | success | 2026-07-24T09:36:24.810784+00:00 | 2026-07-24T09:36:26.087645+00:00
cityflow_guided_project_authoring | compute_by_payment_type    | success | 2026-07-24T09:36:26.250526+00:00 | 2026-07-24T09:36:26.914275+00:00
cityflow_guided_project_authoring | compute_by_passenger_count | success | 2026-07-24T09:36:26.248857+00:00 | 2026-07-24T09:36:26.914258+00:00
cityflow_guided_project_authoring | merge_summaries            | success | 2026-07-24T09:36:27.750872+00:00 | 2026-07-24T09:36:27.864773+00:00
cityflow_guided_project_authoring | load_report                | success | 2026-07-24T09:36:49.241209+00:00 | 2026-07-24T09:36:49.396302+00:00

Two real facts jump out of these timestamps. First, the branch: compute_by_payment_type started at 26.250526, compute_by_passenger_count at 26.2488571.6 milliseconds apart, both genuinely parallel. Second, the retry: merge_summaries finished at 27.864773, but load_report didn’t start until 49.241209 — a real ~21-second gap matching retry_delay=timedelta(seconds=20), the same signature Lesson 4’s retry left behind.

Confirm the retry directly, the same way Lesson 4 did:

import requests

resp = requests.get(
    "http://localhost:8080/api/v1/dags/cityflow_guided_project_authoring/dagRuns/"
    "lesson5_verify_1784885780/taskInstances/load_report",
    auth=("airflow", "airflow"),
)
info = resp.json()
print("load_report state:", info["state"])
print("load_report try_number:", info["try_number"])
assert info["try_number"] == 2, f"expected try_number 2, got {info['try_number']}"
print("confirmed: load_report needed 2 tries to succeed, same as Lesson 4's flaky_ingest")
load_report state: success
load_report try_number: 2
confirmed: load_report needed 2 tries to succeed, same as Lesson 4's flaky_ingest

The real merged report, written only once load_report finally succeeded:

{
  "by_payment_type": [
    {"payment_type": 1, "trips": 153384, "avg_fare": 18.39},
    {"payment_type": 2, "trips": 28056,  "avg_fare": 18.46},
    {"payment_type": 3, "trips": 676,    "avg_fare": 16.55},
    {"payment_type": 4, "trips": 1517,   "avg_fare": 19.27}
  ],
  "by_passenger_count": [
    {"passenger_count": 1.0, "trips": 143638, "avg_distance": 3.15},
    {"passenger_count": 2.0, "trips": 27038,  "avg_distance": 3.84},
    {"passenger_count": 3.0, "trips": 5912,   "avg_distance": 3.67},
    {"passenger_count": 4.0, "trips": 3304,   "avg_distance": 3.99},
    {"passenger_count": 5.0, "trips": 2263,   "avg_distance": 3.00},
    {"passenger_count": 6.0, "trips": 1475,   "avg_distance": 2.87},
    {"passenger_count": 8.0, "trips": 3,      "avg_distance": 0.05}
  ]
}

Identical numbers to Lesson 3’s branch_report.json — same real data, same real grouping logic — proving the retry at the end changed when the report was written, not what it contained.


Verified Two More Ways: Real Screenshots

The Graph view: the branch, visibly

Screenshot of the Airflow Graph view for cityflow_guided_project_authoring, showing ingest_taxi_data flowing into validate_trips, which branches into two parallel boxes compute_by_payment_type and compute_by_passenger_count, both flowing into merge_summaries, then load_report. All six boxes are green with a success label.
The Graph view for the guided project's real run: the exact same branch shape as Lesson 3, now ending in `load_report` -- the task that actually needed two tries to get here.

The Task Tries panel: the retry, visibly

Screenshot of the Airflow task instance detail page for load_report, showing a Task Tries section with two buttons: '1' in red (failed) and '2' in blue/green (selected, success), and a Status field reading success for the selected try.
`load_report`'s real Task Tries panel: try **1** in red (the deliberate failure) and try **2** in green (the real success) -- Airflow's own UI account of the identical retry the REST API's `try_number: 2` and the ~21-second timestamp gap already proved.

Three independent readings of the same fact — real elapsed time, the REST API’s try_number, and the UI’s own Task Tries panel — all agree: load_report failed once and succeeded on retry, without disturbing anything upstream of it.


What This Project Actually Proved

Lesson 3 proved a real branch runs its independent tasks in parallel. Lesson 4 proved a real retry is something Airflow’s scheduler does on its own once configured, not something you simulate by hand. This project proved those two facts compose: a branch merging into a task that then fails and retries behaves exactly as each lesson predicted alone — the branch tasks’ timestamps are untouched by the fact that something three steps later needed a second attempt, because nothing about a downstream task’s retry reaches back to redo work that already succeeded.


Key Takeaways

  • Branching dependencies and task retries are independent features that compose cleanly — this DAG’s branch tasks succeeded on their first attempt regardless of load_report’s retry three steps later.
  • A ~21-second gap between merge_summaries finishing and load_report starting is real evidence of a retry_delay=timedelta(seconds=20) retry, the same signature Lesson 4 taught you to look for.
  • The REST API’s try_number field and the UI’s Task Tries panel are two independent readings of the exact same retry history stored in the metadata database — this project’s real try_number: 2 and the screenshot’s red-1/green-2 buttons agree because they’re the same fact, read two ways.
  • A retried task’s eventual output (here, guided_project_report.json) is identical to what a first-try success would have produced — retrying changes when work completes, never what the successful attempt computes.

Exercises

Exercise 1 — Open the real Grid view for cityflow_guided_project_authoring in your own browser and click the load_report cell for this run. Confirm you can see the same Task Tries panel this lesson screenshotted, and that clicking try 1 shows the real RuntimeError in its Logs tab while try 2 shows the real wrote ... on retry line.

Exercise 2 — Add retries=1 to compute_by_payment_type as well (with its own separate marker file) so that both a branch task and the final load task retry in the same run. Trigger it and confirm, via try_number on each, that both retries happened independently — one retrying doesn’t force the other to.

Exercise 3 — Using this run’s real task timestamps, calculate how much of the DAG’s ~27-second total wall-clock time was spent purely waiting on load_report’s retry_delay, versus how much was spent on tasks actually doing work. What does that suggest about setting retry_delay too high on a task in a time-sensitive DAG?

Sponsor

Keep DATATWEETS free. Help fund practical data, AI, and engineering lessons for learners worldwide.

Buy Me a Coffee at ko-fi.com