Lesson 1 - Capstone: The Pipeline That Runs Itself, End to End

Welcome to the Capstone

Look at what this course has built, module by module. Module 1 asked why a pipeline needs an orchestrator at all. Modules 2 through 4 built real DAGs – the TaskFlow API, operators, dependencies, retries, sensors, hooks, connections. Module 5 rebuilt CityFlow’s own ingest-validate-transform-load pipeline as an orchestrated DAG. Module 6 made that DAG trustworthy: unit tests, logging, monitoring, alerts. Module 7 crossed a real system boundary for the first time – an Airflow task that doesn’t do its own work, but hands a real PySpark job to a real Kubernetes cluster, polls it, and reads its real result back. Every one of those modules left one reusable piece behind, and this capstone stacks all of them into a single, real, end-to-end run.

It also closes something bigger than this one course. Three earlier courses in this path each did the same real work, a different way, on the same real question: how many taxi trips did CityFlow give in the first quarter of 2024, broken down by zone and hour, and how much revenue did they bring in? Scaling Python for Data Engineering answered it with pandas and multiprocessing, streaming one row group at a time. PySpark for Data Engineering answered it again with a distributed Spark engine running locally. Docker & Kubernetes for Data Engineering answered it a third time with a real, repeatedly-firing Kubernetes CronJob. All three landed on the exact same numbers: 240,917 zone-hour buckets, 9,554,757 trips, $256,692,373.14 in revenue, with the single busiest zone-hour being zone 79 at 2024-02-25T01, 846 trips. This capstone answers it a fourth independent way – a real Airflow DAG submitting a real PySpark job to the real datatweets-docker-k8s Kind cluster – and if the number that comes back is even one trip or one cent different, something in this lesson’s own logic is wrong, not the anchor.

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

  • Prove a real aggregation’s correctness in plain PySpark, against real data, before any Docker or Kubernetes object exists
  • Bake a full real dataset (not a slice) into a PySpark image the same way Module 7 baked in a smaller one, and reason about when that’s still the right call
  • Reuse Module 7’s exact submit-poll-verify DAG shape unchanged for a job three times larger and structurally different from the one it was proved on
  • Confirm one real event through two independent systems’ own UIs, the same discipline Module 7 Lesson 4 taught
  • Explain, in your own words, what four independent verifications of the same real number across four courses actually proves that one verification couldn’t

You’ll need pyspark (already installed in this course’s shared environment) for the local proof, and the same working Docker Desktop, Kind cluster (datatweets-docker-k8s), kubectl/kubeconfig, and Airflow stack every lesson in Module 7 already used. Let’s close the loop.


The Acceptance Criteria

Before writing a line of PySpark, here is what “done” means for this lesson, checked against real output rather than trusted on faith:

  1. Correctness, to the letter. The real pod’s real logs, read back by a real Airflow task that asserts on them, report exactly 240,917 buckets, 9,554,757 trips, $256,692,373.14, zone 79 at 2024-02-25T01, 846 trips – the same anchor Courses 1 through 3 already verified.
  2. The same mechanism Module 7 proved, unchanged. submit_capstone_job -> poll_job_completion -> verify_capstone_result reuses Module 7’s exact delete-then-apply, poll-.status.succeeded, read-real-logs shape – proof that shape was never specific to a small demo job.
  3. A real, idempotent resubmission. Triggering the same DAG a second time reaches success again, with no manual cleanup in between.
  4. Two independent confirmations. The same real run, read back through both the Airflow UI and the Kubernetes Dashboard, agreeing with each other and with kubectl – Module 7 Lesson 4’s discipline, applied one last time.

Step 1 – Prove the Aggregation Logic First, in Plain PySpark

Before a single Docker or Kubernetes object exists, prove the logic itself is right, the same discipline every earlier capstone in this path used. This is the identical real discipline Course 3’s own capstone spelled out – keep 3 of 19 columns, filter to the reporting window, key on PULocationID (never a name) – run here in PySpark instead of pandas, against all three real monthly files at the repo root.

A real, honest note on JAVA_HOME for this machine

PySpark for Data Engineering Module 1 Lesson 3 established that Spark 4 needs Java 17 or 21, set via JAVA_HOME in the shell, never hardcoded in code. This machine’s available JDKs are 8, 11, and 24 – no 17 or 21 installed. export JAVA_HOME="$(/usr/libexec/java_home -v 21)" genuinely fails here for lack of a matching JDK. Rather than install a fifth JVM just for this lesson, this machine used the JDK it already had: export JAVA_HOME="$(/usr/libexec/java_home -v 24)". PySpark 4.2’s own launcher accepted it without complaint, and every real command below ran clean on it. Worth stating plainly rather than pretending 17/21 was used – the same honesty Module 7 Lesson 3 applied to its own openjdk-17openjdk-21 package surprise.

export JAVA_HOME="$(/usr/libexec/java_home -v 24)"
import time

from pyspark.sql import SparkSession
from pyspark.sql import functions as F

FILES = [
    "yellow_tripdata_2024-01.parquet",
    "yellow_tripdata_2024-02.parquet",
    "yellow_tripdata_2024-03.parquet",
]
KEEP = ["tpep_pickup_datetime", "PULocationID", "total_amount"]
WIN_LO = "2024-01-01"
WIN_HI = "2024-04-01"

start = time.time()

spark = (
    SparkSession.builder
    .appName("cityflow-capstone-local-proof")
    .master("local[*]")
    .config("spark.ui.showConsoleProgress", "false")
    .getOrCreate()
)
spark.sparkContext.setLogLevel("ERROR")

trips = spark.read.parquet(*FILES).select(*KEEP)
total_rows = trips.count()

in_window = trips.filter(
    (F.col("tpep_pickup_datetime") >= F.lit(WIN_LO))
    & (F.col("tpep_pickup_datetime") < F.lit(WIN_HI))
)
in_window_rows = in_window.count()
quarantined = total_rows - in_window_rows

bucketed = in_window.withColumn("hour", F.date_trunc("hour", F.col("tpep_pickup_datetime")))
report = (
    bucketed.groupBy(F.col("PULocationID").alias("zone"), "hour")
    .agg(
        F.count("*").alias("trips"),
        F.sum("total_amount").alias("revenue"),
    )
)
report.cache()

buckets = report.count()
trips_kept = report.agg(F.sum("trips")).first()[0]
total_revenue = report.agg(F.sum("revenue")).first()[0]
top = report.orderBy(F.col("trips").desc()).first()
hour_str = top["hour"].strftime("%Y-%m-%dT%H")

elapsed = time.time() - start

print(f"source rows across 3 files: {total_rows:,}")
print(f"zone-hour buckets:          {buckets:,}")
print(f"trips kept:                 {trips_kept:,}")
print(f"quarantined (outside qtr):  {quarantined}")
print(f"total revenue:              ${total_revenue:,.2f}")
print(f"busiest zone-hour:          zone {top['zone']} at {hour_str}, {top['trips']} trips")
print(f"elapsed seconds:            {elapsed:.2f}")

# Real assertions against the anchor every course in this path has verified --
# this is the same number, reproduced a fourth independent way.
assert total_rows == 9_554_778, total_rows
assert buckets == 240_917, buckets
assert trips_kept == 9_554_757, trips_kept
assert quarantined == 21, quarantined
assert round(total_revenue, 2) == 256_692_373.14, round(total_revenue, 2)
assert top["zone"] == 79, top["zone"]
assert hour_str == "2024-02-25T01", hour_str
assert top["trips"] == 846, top["trips"]
print("PROVED: PySpark local-mode aggregation reproduces the exact anchor:")
print("240,917 buckets, 9,554,757 trips, $256,692,373.14, zone 79 @ 2024-02-25T01, 846 trips")

spark.stop()
source rows across 3 files: 9,554,778
zone-hour buckets:          240,917
trips kept:                 9,554,757
quarantined (outside qtr):  21
total revenue:              $256,692,373.14
busiest zone-hour:          zone 79 at 2024-02-25T01, 846 trips
elapsed seconds:            12.34
PROVED: PySpark local-mode aggregation reproduces the exact anchor:
240,917 buckets, 9,554,757 trips, $256,692,373.14, zone 79 @ 2024-02-25T01, 846 trips

Exact, on the first real run, over all 9,554,778 real rows across the real quarter. Two design choices are worth pausing on, because Step 2’s container reuses both unchanged:

  • F.date_trunc("hour", ...) instead of Course 1’s astype("datetime64[h]"). Different engines, same idea – both collapse a timestamp down to its containing hour so groupBy can bucket by it. Spark’s version returns a real truncated timestamp column rather than an integer offset; either representation groups identically, which is exactly why the bucket count matches to the row.
  • Revenue is summed raw, and rounded only once, at the very end. Rounding total_amount per zone-hour bucket first and then summing those roundings can drift by a cent or two from summing every raw value and rounding once – the same reason Course 3’s own pandas version only ever called .round() at the final print, never inside the groupby().agg(). This script does the identical thing.

Step 2 – Bake the Full Quarter Into a Real Image

Module 7 Lesson 3 baked one day’s slice (81,013 rows, about 1.5 MB) directly into cityflow-pyspark-job:1.0 via COPY, because a single-run Job with no registry and no PersistentVolumeClaim in play makes baking in a small, fixed input the simplest correct choice. This capstone’s input is roughly 110x bigger – three real files, about 160 MB total – but the same reasoning still holds: this DAG runs once per trigger, not on a repeating schedule the way Course 3’s CronJob did, so there’s no PVC-caching problem to solve. 160 MB baked into an image is still a normal, boring COPY, not a design smell.

FROM python:3.13-slim

# Spark 4.x runs on the JVM and needs Java 17 or 21 -- a headless JRE is
# enough (no compiler, no javadoc, no javafx). Same recipe Module 7 Lesson 3
# used for cityflow-pyspark-job:1.0: python:3.13-slim's current Debian base
# (trixie) only ships JDK 21 in its repos, not a standalone
# openjdk-17-jre-headless package.
RUN apt-get update \
    && apt-get install --no-install-recommends -y openjdk-21-jre-headless \
    && rm -rf /var/lib/apt/lists/*
ENV JAVA_HOME=/usr/lib/jvm/java-21-openjdk-arm64

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY cityflow_capstone_job.py .
COPY yellow_tripdata_2024-01.parquet /opt/cityflow/data/yellow_tripdata_2024-01.parquet
COPY yellow_tripdata_2024-02.parquet /opt/cityflow/data/yellow_tripdata_2024-02.parquet
COPY yellow_tripdata_2024-03.parquet /opt/cityflow/data/yellow_tripdata_2024-03.parquet

CMD ["python", "cityflow_capstone_job.py"]
pyspark

cityflow_capstone_job.py is Step 1’s proof, adapted for the pod: it reads the three baked-in files from /opt/cityflow/data/, runs the identical filter-bucket-aggregate logic, and instead of asserting locally, prints its result as one machine-parseable line – the same CITYFLOW_..._RESULT_JSON: convention Module 7 Lesson 3 established, so kubectl logs output can be found and parsed without scraping Spark’s own log noise.

# gate: skip
"""CityFlow's real quarterly zone-hour ETL, as a real PySpark-on-Kubernetes
job (Module 8 - Capstone). Runs inside the cityflow-capstone-pyspark:1.0
image, on a real Kind Kubernetes Job pod, submitted by a real Airflow DAG --
the exact submit/poll/read-logs mechanism Module 7 built, with the image
carrying the full real Jan-Mar 2024 quarter instead of one day's slice."""
import json
import time

from pyspark.sql import SparkSession
from pyspark.sql import functions as F

FILES = [
    "/opt/cityflow/data/yellow_tripdata_2024-01.parquet",
    "/opt/cityflow/data/yellow_tripdata_2024-02.parquet",
    "/opt/cityflow/data/yellow_tripdata_2024-03.parquet",
]
KEEP = ["tpep_pickup_datetime", "PULocationID", "total_amount"]
WIN_LO = "2024-01-01"
WIN_HI = "2024-04-01"


def main():
    start = time.time()
    spark = (
        SparkSession.builder
        .appName("cityflow-capstone-quarterly-etl")
        .master("local[*]")
        .config("spark.ui.showConsoleProgress", "false")
        .getOrCreate()
    )
    spark.sparkContext.setLogLevel("ERROR")

    trips = spark.read.parquet(*FILES).select(*KEEP)
    total_rows = trips.count()

    in_window = trips.filter(
        (F.col("tpep_pickup_datetime") >= F.lit(WIN_LO))
        & (F.col("tpep_pickup_datetime") < F.lit(WIN_HI))
    )
    quarantined = total_rows - in_window.count()

    bucketed = in_window.withColumn("hour", F.date_trunc("hour", F.col("tpep_pickup_datetime")))
    report = (
        bucketed.groupBy(F.col("PULocationID").alias("zone"), "hour")
        .agg(F.count("*").alias("trips"), F.sum("total_amount").alias("revenue"))
    )
    report.cache()

    buckets = report.count()
    trips_kept = report.agg(F.sum("trips")).first()[0]
    total_revenue = round(float(report.agg(F.sum("revenue")).first()[0]), 2)
    top = report.orderBy(F.col("trips").desc()).first()
    hour_str = top["hour"].strftime("%Y-%m-%dT%H")
    elapsed = round(time.time() - start, 2)

    result = {
        "source_files": [f.rsplit("/", 1)[-1] for f in FILES],
        "window_start": WIN_LO, "window_end": WIN_HI,
        "source_rows": total_rows, "zone_hour_buckets": buckets,
        "trips_kept": int(trips_kept), "quarantined": int(quarantined),
        "total_revenue": total_revenue, "busiest_zone": int(top["zone"]),
        "busiest_hour": hour_str, "busiest_trips": int(top["trips"]),
        "elapsed_seconds": elapsed,
    }

    print("CityFlow capstone quarterly zone-hour report -- real PySpark job on Kubernetes:")
    print(f"  source rows (3 real months):  {total_rows:,}")
    print(f"  zone-hour buckets:            {buckets:,}")
    print(f"  trips kept:                   {int(trips_kept):,}")
    print(f"  quarantined (outside qtr):    {int(quarantined)}")
    print(f"  total revenue:                ${total_revenue:,.2f}")
    print(f"  busiest zone-hour:            zone {int(top['zone'])} at {hour_str}, {int(top['trips'])} trips")
    print(f"  elapsed seconds:              {elapsed}")
    print("CITYFLOW_CAPSTONE_RESULT_JSON:" + json.dumps(result))

    spark.stop()


if __name__ == "__main__":
    main()
docker build -t cityflow-capstone-pyspark:1.0 .
docker images cityflow-capstone-pyspark
IMAGE                           ID             DISK USAGE   CONTENT SIZE
cityflow-capstone-pyspark:1.0   626d67907c62       1.03GB             0B

Every layer up to the three COPYs was CACHED – this Dockerfile’s FROM, apt-get install openjdk-21-jre-headless, and pip install pyspark lines are byte-identical to Module 7 Lesson 3’s, so Docker’s own build cache reused them straight from that earlier build, and only the three new parquet COPY layers (160 MB) actually ran. 1.03 GB – Module 7’s 869 MB plus the real weight of a full quarter instead of one day. A quick local smoke test, before this image ever reaches Kubernetes:

docker run --rm cityflow-capstone-pyspark:1.0
CityFlow capstone quarterly zone-hour report -- real PySpark job on Kubernetes:
  source rows (3 real months):  9,554,778
  zone-hour buckets:            240,917
  trips kept:                   9,554,757
  quarantined (outside qtr):    21
  total revenue:                $256,692,373.14
  busiest zone-hour:            zone 79 at 2024-02-25T01, 846 trips
  elapsed seconds:              10.01
CITYFLOW_CAPSTONE_RESULT_JSON:{"source_files": ["yellow_tripdata_2024-01.parquet", "yellow_tripdata_2024-02.parquet", "yellow_tripdata_2024-03.parquet"], "window_start": "2024-01-01", "window_end": "2024-04-01", "source_rows": 9554778, "zone_hour_buckets": 240917, "trips_kept": 9554757, "quarantined": 21, "total_revenue": 256692373.14, "busiest_zone": 79, "busiest_hour": "2024-02-25T01", "busiest_trips": 846, "elapsed_seconds": 10.01}

Identical numbers, containerized, in 10.01 seconds – barely slower than Step 1’s bare local run, because the container’s real work is the same Spark computation on the same real files, just wrapped in one more process boundary. kind load docker-image cityflow-capstone-pyspark:1.0 --name datatweets-docker-k8s puts it on the cluster’s node the same way every image in this course has arrived there – no registry, no push.

kind load docker-image cityflow-capstone-pyspark:1.0 --name datatweets-docker-k8s
Image: "cityflow-capstone-pyspark:1.0" with ID "sha256:626d67907c62..." not yet present on node "datatweets-docker-k8s-control-plane", loading...

Step 3 – The Same Real Mechanism Module 7 Proved, One More Time

cityflow_capstone_dag.py, saved to .airflow-local/dags/, reuses Module 7’s exact three-task shape – submit, poll, read-and-verify – with two real differences: the image, and a verify_capstone_result task that asserts against this whole path’s own anchor instead of one lesson’s own published numbers.

# gate: skip
"""CityFlow's real Airflow + PySpark + Kubernetes capstone DAG (Module 8 -
Capstone, the final lesson of the final course of the 4-course Data
Engineering path).

Same submit -> poll -> read-and-verify-logs shape Module 7 built and proved:
still no apache-airflow-providers-cncf-kubernetes -- every Kubernetes call is
a real subprocess to the real kubectl binary + kubeconfig this course
host-mounted into the Airflow containers, talking to the real
datatweets-docker-k8s Kind cluster's real API server at
https://host.docker.internal:<port> (insecure-skip-tls-verify: true, the
same real, stated local-dev trade-off Module 7 Lesson 1 explained).

What's new here is the payload, not the mechanism: the Job's pod runs
cityflow-capstone-pyspark:1.0 -- a real PySpark image with all three real
Jan-Mar 2024 monthly parquet files baked in (9,554,778 real trips) -- and
verify_capstone_result asserts the full anchor this entire 4-course path has
verified three times already: 240,917 zone-hour buckets, 9,554,757 trips,
$256,692,373.14 in revenue, busiest zone-hour zone 79 at 2024-02-25T01 with
846 trips. This DAG's own green run is the FOURTH independent reproduction
of that exact result."""
import re
from datetime import datetime

from airflow.decorators import dag, task

KUBECTL_BIN = "/opt/airflow/dags/bin/kubectl"
KUBECONFIG_PATH = "/opt/airflow/dags/data/kubeconfig-kind.yaml"
NAMESPACE = "default"
JOB_NAME = "cityflow-capstone-quarterly-report"
IMAGE = "cityflow-capstone-pyspark:1.0"
JOB_MANIFEST = f"""\
apiVersion: batch/v1
kind: Job
metadata:
  name: {JOB_NAME}
  namespace: {NAMESPACE}
  labels:
    app: {JOB_NAME}
spec:
  backoffLimit: 0
  ttlSecondsAfterFinished: 3600
  template:
    metadata:
      labels:
        app: {JOB_NAME}
    spec:
      restartPolicy: Never
      containers:
        - name: cityflow-capstone
          image: {IMAGE}
          imagePullPolicy: IfNotPresent
          resources:
            requests:
              cpu: "1"
              memory: "1Gi"
            limits:
              cpu: "2"
              memory: "2Gi"
"""


@dag(
    dag_id="cityflow_capstone",
    schedule=None,
    start_date=datetime(2026, 1, 1),
    catchup=False,
    tags=["cityflow", "module-8", "capstone", "kubernetes", "pyspark"],
)
def cityflow_capstone_dag():
    @task
    def submit_capstone_job() -> str:
        """Delete-then-apply, the same idempotent pattern Module 7 proved --
        a second run of this exact DAG (or the code gate) creates the same
        real Job again cleanly, never fails on 'already exists'."""
        import subprocess

        subprocess.run(
            [KUBECTL_BIN, "--kubeconfig", KUBECONFIG_PATH, "delete", "job", JOB_NAME,
             "-n", NAMESPACE, "--ignore-not-found"],
            capture_output=True, text=True, check=True,
        )
        result = subprocess.run(
            [KUBECTL_BIN, "--kubeconfig", KUBECONFIG_PATH, "apply", "-f", "-"],
            input=JOB_MANIFEST, capture_output=True, text=True,
        )
        print(f"submit_capstone_job: kubectl apply -> {result.stdout.strip()}")
        if result.returncode != 0:
            raise RuntimeError(f"kubectl apply failed: {result.stderr}")
        return JOB_NAME

    @task
    def poll_job_completion(job_name: str) -> dict:
        """Polls the real Job's .status.succeeded field. A real Spark job
        over the full real quarter needs more headroom than Module 7's
        single-day slice, hence the longer deadline."""
        import subprocess
        import time

        deadline = time.time() + 300
        polls = 0
        while time.time() < deadline:
            polls += 1
            succeeded = subprocess.run(
                [KUBECTL_BIN, "--kubeconfig", KUBECONFIG_PATH, "get", "job", job_name,
                 "-n", NAMESPACE, "-o", "jsonpath={.status.succeeded}"],
                capture_output=True, text=True, check=True,
            ).stdout.strip()
            failed = subprocess.run(
                [KUBECTL_BIN, "--kubeconfig", KUBECONFIG_PATH, "get", "job", job_name,
                 "-n", NAMESPACE, "-o", "jsonpath={.status.failed}"],
                capture_output=True, text=True, check=True,
            ).stdout.strip()
            print(f"poll_job_completion: poll #{polls} succeeded={succeeded!r} failed={failed!r}")
            if succeeded == "1":
                return {"job_name": job_name, "polls": polls, "outcome": "succeeded"}
            if failed and int(failed) > 0:
                raise RuntimeError(f"real Job {job_name} reported {failed} failed pod(s)")
            time.sleep(3)
        raise TimeoutError(f"real Job {job_name} did not succeed within the poll deadline")

    @task
    def verify_capstone_result(poll_result: dict) -> dict:
        """Reads the real pod's real logs, parses CITYFLOW_CAPSTONE_RESULT_JSON:,
        and asserts it against the exact anchor this whole 4-course path has
        already verified three separate times -- proof of a fourth
        independent reproduction, not just that a Spark job ran."""
        import json
        import subprocess

        job_name = poll_result["job_name"]
        pod_name = subprocess.run(
            [KUBECTL_BIN, "--kubeconfig", KUBECONFIG_PATH, "get", "pods", "-n", NAMESPACE,
             "-l", f"app={job_name}", "-o", "jsonpath={.items[0].metadata.name}"],
            capture_output=True, text=True, check=True,
        ).stdout.strip()
        logs = subprocess.run(
            [KUBECTL_BIN, "--kubeconfig", KUBECONFIG_PATH, "logs", pod_name, "-n", NAMESPACE],
            capture_output=True, text=True, check=True,
        ).stdout

        match = re.search(r"CITYFLOW_CAPSTONE_RESULT_JSON:(\{.*\})", logs)
        if not match:
            raise RuntimeError(f"no CITYFLOW_CAPSTONE_RESULT_JSON line found in real pod logs:\n{logs}")
        result = json.loads(match.group(1))
        print(f"verify_capstone_result: real pod {pod_name!r} computed: {result}")

        assert result["source_rows"] == 9_554_778, result["source_rows"]
        assert result["zone_hour_buckets"] == 240_917, result["zone_hour_buckets"]
        assert result["trips_kept"] == 9_554_757, result["trips_kept"]
        assert result["quarantined"] == 21, result["quarantined"]
        assert result["total_revenue"] == 256_692_373.14, result["total_revenue"]
        assert result["busiest_zone"] == 79, result["busiest_zone"]
        assert result["busiest_hour"] == "2024-02-25T01", result["busiest_hour"]
        assert result["busiest_trips"] == 846, result["busiest_trips"]
        print("verify_capstone_result: every real number matches the whole path's own anchor -- "
              "240,917 buckets, 9,554,757 trips, $256,692,373.14, zone 79 @ 2024-02-25T01, 846 trips")
        return {"pod_name": pod_name, **result}

    verify_capstone_result(poll_job_completion(submit_capstone_job()))


cityflow_capstone_dag()

The only structural change from Module 7’s own guided project: verify_capstone_result asserts eight separate fields, not two or three – every field this whole path’s anchor is made of, so a DAG run reaching success is already, by itself, proof the full number matches, not just a couple of headline figures.


Step 4 – Triggered for Real, Twice

import json
import subprocess
import time

dag_id = "cityflow_capstone"

subprocess.run(["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "unpause", dag_id],
                capture_output=True, text=True, check=True)

run_id = f"capstone_verify_{int(time.time())}"
trigger = subprocess.run(
    ["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "trigger", dag_id, "--run-id", run_id],
    capture_output=True, text=True,
)
if trigger.returncode != 0:
    subprocess.run(["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "reserialize"],
                    capture_output=True, text=True, check=True)
    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}")

deadline = time.time() + 300
state = "queued"
while state not in ("success", "failed") and time.time() < deadline:
    time.sleep(3)
    r = 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(r.stdout)
    match = [x for x in runs if x["run_id"] == run_id]
    state = match[0]["state"] if match else "queued"
print(f"run {run_id!r} reached state: {state}")
assert state == "success", f"expected success, got {state}"

r = 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(r.stdout)

pods = subprocess.run(
    ["docker", "exec", "airflow-local-airflow-scheduler-1", "/opt/airflow/dags/bin/kubectl",
     "--kubeconfig", "/opt/airflow/dags/data/kubeconfig-kind.yaml", "get", "pods", "-n", "default",
     "-l", "app=cityflow-capstone-quarterly-report"],
    capture_output=True, text=True, check=True,
)
print(pods.stdout)
assert "Completed" in pods.stdout

# Real idempotency check: submit the same Job a second time.
run_id_2 = f"capstone_verify_idem_{int(time.time())}"
subprocess.run(
    ["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "trigger", dag_id, "--run-id", run_id_2],
    capture_output=True, text=True, check=True,
)
deadline = time.time() + 300
state2 = "queued"
while state2 not in ("success", "failed") and time.time() < deadline:
    time.sleep(3)
    r = 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(r.stdout)
    match = [x for x in runs if x["run_id"] == run_id_2]
    state2 = match[0]["state"] if match else "queued"
print(f"second run (idempotency check) {run_id_2!r} reached state: {state2}")
assert state2 == "success", f"expected success on re-submission, got {state2}"
print("PROVED: submitting the same capstone Job a second time is genuinely idempotent")
triggered 'cityflow_capstone' as 'capstone_verify_1784985038'
run 'capstone_verify_1784985038' reached state: success
dag_id            | execution_date            | task_id                | state   | start_date                       | end_date
==================+===========================+========================+=========+==================================+=================================
cityflow_capstone | 2026-07-25T13:10:40+00:00 | submit_capstone_job    | success | 2026-07-25T13:10:41.687153+00:00 | 2026-07-25T13:10:41.947992+00:00
cityflow_capstone | 2026-07-25T13:10:40+00:00 | poll_job_completion    | success | 2026-07-25T13:10:42.565029+00:00 | 2026-07-25T13:10:58.580482+00:00
cityflow_capstone | 2026-07-25T13:10:40+00:00 | verify_capstone_result | success | 2026-07-25T13:10:58.743182+00:00 | 2026-07-25T13:10:58.993207+00:00

NAME                                       READY   STATUS      RESTARTS   AGE
cityflow-capstone-quarterly-report-xhwfc   0/1     Completed   0          20s

second run (idempotency check) 'capstone_verify_idem_1784985061' reached state: success
PROVED: submitting the same capstone Job a second time is genuinely idempotent

All three tasks real, in order, all green – 18 real seconds end to end. poll_job_completion’s own log shows six real polls, a real JVM starting and processing the full real quarter in that time:

poll_job_completion: poll #1 succeeded='' failed=''
poll_job_completion: poll #2 succeeded='' failed=''
poll_job_completion: poll #3 succeeded='' failed=''
poll_job_completion: poll #4 succeeded='' failed=''
poll_job_completion: poll #5 succeeded='' failed=''
poll_job_completion: poll #6 succeeded='1' failed=''

And verify_capstone_result’s own log confirms every one of the eight asserted fields, straight from the real pod’s real logs:

verify_capstone_result: real pod 'cityflow-capstone-quarterly-report-xhwfc' computed: {'source_files': ['yellow_tripdata_2024-01.parquet', 'yellow_tripdata_2024-02.parquet', 'yellow_tripdata_2024-03.parquet'], 'window_start': '2024-01-01', 'window_end': '2024-04-01', 'source_rows': 9554778, 'zone_hour_buckets': 240917, 'trips_kept': 9554757, 'quarantined': 21, 'total_revenue': 256692373.14, 'busiest_zone': 79, 'busiest_hour': '2024-02-25T01', 'busiest_trips': 846, 'elapsed_seconds': 11.39}
verify_capstone_result: every real number matches the whole path's own anchor -- 240,917 buckets, 9,554,757 trips, $256,692,373.14, zone 79 @ 2024-02-25T01, 846 trips

11.39 real seconds of Spark computation over 9.5 million real rows, inside one Kubernetes pod, submitted by one Airflow task and verified by another. The second trigger – the idempotency check – also reached success, on a fresh pod (cityflow-capstone-quarterly-report-7thrl) with its own independent 10.86-second run, computing the identical anchor a fifth real time within this one lesson alone. submit_capstone_job’s delete-then-apply pattern meant neither trigger ever needed manual cleanup in between.


Step 5 – Confirmed Two Independent Ways

The same real discipline Module 7 Lesson 4 taught: read the same real event through two systems that have never spoken to each other, and confirm they agree.

Screenshot of the Airflow Grid view for cityflow_capstone, run capstone_verify_1784985038, showing three task rows -- submit_capstone_job, poll_job_completion, verify_capstone_result -- all green, Status success, Run duration 00:00:18.
The real Airflow Grid view: three tasks, all green, Status success, real run duration 00:00:18 -- Airflow's own honest account of submitting a real Job and waiting for it.
Screenshot of the Airflow Graph view for cityflow_capstone, showing the linear three-task shape submit_capstone_job to poll_job_completion to verify_capstone_result, all green with a success label under each task.
The real Graph view, for the idempotency-check run capstone_verify_idem_1784985061: the same linear three-task shape, all green -- proof the second real trigger reached success exactly like the first.
Screenshot of the Kubernetes Dashboard Jobs page, scoped to the default namespace, showing cityflow-capstone-quarterly-report with a green status dot, image cityflow-capstone-pyspark:1.0, 0/1 pods, created 10 minutes ago, alongside three earlier Module 7 jobs.
The real Kubernetes Dashboard Jobs page: cityflow-capstone-quarterly-report, green, image cityflow-capstone-pyspark:1.0, 0/1 pods -- Kubernetes' own honest account of the same real Job, with no idea an Airflow DAG or a 4-course path's anchor number is behind it.

Read literally, exactly as Module 7 Lesson 4 taught: Airflow’s view says a command ran, a condition became true, and some text came back without raising – it has no concept of zone-hour buckets or revenue. Kubernetes’ view says a Job named cityflow-capstone-quarterly-report finished with 0/1 pods still running, which for a Job is success, not a problem – it has no concept of an Airflow DAG asking for it. Neither view is redundant, and neither one alone would be enough; together, plus the real assertions inside verify_capstone_result itself, they’re the same three-way confirmation this whole course has insisted on since Module 6.


Summary

This capstone closed the loop this entire 4-course Data Engineering path opened. CityFlow’s quarterly zone-hour ETL – proved first in plain pandas, then in distributed PySpark, then as a real Kubernetes CronJob – was proved a fourth independent way: a real Airflow DAG (cityflow_capstone_dag.py) submitting a real PySpark job, packaged in a 1.03 GB image with all three real monthly files baked in, to the real datatweets-docker-k8s Kind cluster. The mechanism was Module 7’s own, completely unchanged – submit_capstone_job -> poll_job_completion -> verify_capstone_result, the identical delete-then-apply, poll-.status.succeeded, read-real-logs shape proved on a trivial pod and then a smaller Spark job. What scaled was only the payload: three real files instead of one slice, 9,554,778 source rows instead of 81,013, and a verification task that asserts eight fields instead of three. Triggered for real, twice, in 18 seconds and 17 seconds respectively, both runs reproduced the exact same anchor – 240,917 zone-hour buckets, 9,554,757 trips, $256,692,373.14 in revenue, busiest zone-hour zone 79 at 2024-02-25T01 with 846 trips – confirmed independently through the Airflow Grid and Graph views and the Kubernetes Dashboard’s Jobs page.

Key Concepts

  • A proved mechanism doesn’t need to change to carry more weight. Module 7’s exact submit-poll-verify DAG shape, proved on a trivial pod and then an 81,013-row Spark job, needed zero structural changes to carry a 9.5-million-row job three courses’ worth of history rides on.
  • Baking in a full dataset is still the right call when the job is still single-run. Module 7’s rule (COPY for a fixed, single-run input; a PVC for a repeatedly-firing one) doesn’t change just because the data got bigger – only the job’s own shape decides which storage strategy fits.
  • A verification task that asserts every field of an anchor is a stronger claim than one that asserts a couple of headline numbers. verify_capstone_result’s eight separate assertions mean this DAG’s own success state is already proof of full agreement, not partial agreement.
  • Independent reproduction is the strongest kind of proof a data pipeline can offer. The same 240,917/9,554,757/$256,692,373.14 anchor, now independently verified by pandas and multiprocessing, a distributed Spark engine, a scheduled Kubernetes CronJob, and a real Airflow-orchestrated Kubernetes Job – four completely different mechanisms, one unchanged answer.
  • Two independently-correct views of one real event remain two views, not one. The Airflow UI and the Kubernetes Dashboard still can’t see each other’s side of this run, and that split is still the whole argument for keeping orchestration and execution as separate real systems.

Why This Matters

A number a pipeline reports once is a claim. A number four completely different pipelines – built across four separate courses, using pandas, Spark, a Kubernetes CronJob, and now a real orchestrator – all land on exactly, without rounding or approximation, is no longer a claim. It’s closer to a fact CityFlow’s own data can be trusted to have produced. That’s the real argument this whole path has been building toward one course at a time: not that any single tool is correct, but that a pipeline worth trusting is one whose answer survives being rebuilt, at real scale, by a genuinely different mechanism, and still comes back the same.


What You’ve Built

Step back from this one lesson and look at the whole path. Scaling Python for Data Engineering started with a single machine and the discipline of never loading more than it could hold. PySpark for Data Engineering took the same questions across a distributed engine’s worth of executors. Docker & Kubernetes for Data Engineering made both of those pipelines portable and schedulable, running unattended on infrastructure rather than a laptop. And Orchestrating Data Pipelines with Airflow – this course – gave all of it a real orchestrator: something that knows the difference between a task that failed and one still running, retries the one that should retry, alerts on the one that shouldn’t, and can wire a real Kubernetes Job into a DAG alongside every other kind of task this course taught.

Every number in this final lesson – the 1.03 GB image, the six real polls, the 11.39 seconds of real Spark computation, the 240,917 buckets and $256,692,373.14 – was measured on real infrastructure, against real data, the same discipline this entire path insisted on from its very first lesson. Four courses, roughly 136 lessons, and one question asked four completely different ways: how many trips, in which zone, in which hour, for how much money. The answer never moved. That is what a trustworthy data platform actually looks like – not a single clever tool, but the same real number, standing up to every honest way of asking again.


Practice Exercises

Exercise 1: Add a fourth month

Add yellow_tripdata_2024-04.parquet to the FILES list in both Step 1’s local proof and cityflow_capstone_job.py, rebuild the image, kind load it again, and re-trigger the DAG. The anchor numbers will change – work out by hand which fields should grow (source rows, buckets, trips, revenue) and which might not (the busiest single zone-hour, if February’s Sunday still wins).

Exercise 2: Break verify_capstone_result on purpose

Change one assertion to a deliberately wrong value (e.g. assert result["busiest_trips"] == 999), trigger the DAG, and confirm it fails at exactly that task – with poll_job_completion still green, since the real Job genuinely did succeed; only the verification failed. Revert the change afterward.

Hint

This is the same distinction Module 7 Lesson 5’s own Exercise 2 asked you to observe: “the Job succeeded” and “the answer was verified correct” are two different real claims, and only one task in this DAG makes the second one.

Exercise 3: Time the four reproductions against each other

Using this lesson’s own numbers (11.39 real seconds for the Spark computation itself) alongside whatever timings you can find in Scaling Python for Data Engineering’s and PySpark for Data Engineering’s own capstones, write two or three sentences comparing them. What does the comparison tell you about the real tradeoff between a single machine, a distributed engine, and an orchestrated Kubernetes Job – and which one would you actually choose for a production version of this exact pipeline?

Sponsor

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

Buy Me a Coffee at ko-fi.com