Build the pipeline

Today we build the pipeline designed in Lesson 14.1 and run every stage on our cluster: a MapReduce Streaming job that cleans and deduplicates the raw orders, a Spark job that writes silver Parquet and the gold tables the owner asked for, a UPI reconciliation, and the acceptance checks that decide whether any number may be shown to her.

Module 14 · Capstone project⏱ 34 minIntermediateFull pipeline on our cluster10/10 acceptance checksRuns in your browser (Python)8 practice problems
1 The story

Ten checks, ten PASS

The plan from Lesson 14.1 is on the whiteboard: bronze, clean, silver, gold, checks. Today Kabir builds it on the lab cluster, one stage at a time, and refuses to show Mrs. Shinde a single number until check.py prints ten PASS lines. By evening the pipeline has also found three things her accountant never saw.

clean (MapReduce) 104 maps · 3 reduces 4 min 39 s silver + gold (Spark) 26 day partitions 1 min 32 s check.py against truth.json 10/10 PASS Findings for Mrs. Shinde (September) Panvel till still charges ₹15 for chai: 11,711 orders undercharged, ₹43,710 lost Kharghar sells maggi, missing from the menu: 1,619 orders, ₹60,300 not in any category Nerul, 22 Sept 13:00–14:00: 201 UPI orders (₹6,854) never confirmed by the gateway revenue ₹1,15,24,009 peak hour 13:00 UPI net ₹80,37,797 myphatshala.in
Riya

Why two engines? Couldn't Spark do everything?

Arjun Sir

It could. We use MapReduce for cleaning on purpose: it is the clearest way to see deduplication as "group by key", and it shows you can still work with the classic Hadoop jobs most companies run. Spark then does what it is best at: typed columns, Parquet, joins and many aggregations in one job.

Kabir

And the checks? We already tested locally.

Arjun Sir

Locally we tested the rules. On the cluster we test the whole pipeline: shipping files with -files, three reducers, Parquet types, the join with payments. Any of those can break a number.

2 Definition

A batch pipeline, stage by stage

Formal definition (write this in exams)

A batch data pipeline is a sequence of jobs that turns raw input into trusted outputs. Each stage reads the previous stage's output and writes its own: bronze (raw, immutable), a staging or cleansing step that applies data quality rules and deduplication, silver (cleaned, typed, conformed, partitioned by business date) and gold (aggregated tables for consumers). A production pipeline is idempotent (re-running a stage for the same input gives the same output), observable (counters and logs for every rule) and verified by automated acceptance checks before results are published.

In simple words

Each stage does one job and leaves its result in its own folder. If something is wrong you re-run that stage, it overwrites its old output, and the checks tell you whether the numbers are right.

Words you'll meet in this lesson

StageOne job with one input folder and one output folder.
CounterA number a MapReduce task adds to (Lesson 9.4); we use one per rule.
-filesShips scripts and the menu to every task's working directory.
Partition columndt: each day of silver is its own folder.
Dynamic overwriteSpark replaces only the partitions it writes, not the whole table.
ReconciliationPOS UPI amounts vs gateway confirmations, per canteen.
Gold tableA small table that answers one business question.
BackfillRe-running a stage for past dates after a fix.

Architecture at a glance

What ran, where it ran, and the real sizes at each stage.

HDFS /capstone bronze/ orders: 104 CSV 20.9 MB payments: 26 JSONL 28.8 MB MapReduce Streaming clean_mapper.py ×104 shuffle by order_id clean_reducer.py ×3 8 counters stage/clean/ 3 part files (TSV) 29.7 MB 354,135 OK 296 BAD Spark on YARN: silver_gold.py cast types · split OK/BAD · read payments JSON write Parquet by dt · 5 gold aggregations silver/ orders: dt=…, 26 days 7.2 MB Parquet payments 5.5 MB gold/ daily_college · item_mix hourly · upi_recon upi_gaps quarantine/ 296 raw lines + file day, JSON for the till vendor check.py: 10 acceptance tests vs truth.json → 10/10 PASS · then (and only then) publish gold Lab: Hadoop 3.5.0 (3 workers, 2 GB / 2 vcores each for YARN) · Spark 4.0.4, 3 executors × 768 MB myphatshala.in

From our lab (the full run)

Clean job104 splits → 104 map tasks (all data-local) + 3 reducers; 357,332 lines in, 357,215 map outputs, 354,136 reduce groups, 354,431 lines out; 4 min 39 s
CountersCLEAN 354,135 · DUPLICATE 2,784 · LATE 2,100 · MALFORMED 296 · TEST_ORDER 13 · UNKNOWN_ITEM 1,619 · PRICE_MISMATCH 11,711 · UNDERCHARGED_RS 43,710
Spark jobsilver orders 7.2 MB Parquet in 26 day folders (from 29.7 MB of text); payments 5.5 MB; 5 gold tables; 1 min 32 s
UPIPOS says ₹80,76,462 by UPI; gateway SUCCESS ₹80,69,608; refunds ₹31,811 (987); 2,381 failed attempts later paid; gap ₹6,854 at Nerul only
Checks10/10 PASS against truth.json
Idempotencybackfilling 14 Sept twice: same totals, still 3 files in the partition; the same day written twice with append: 13,007 → 26,014 orders

The pipeline at three levels

Pick your level. New to this? Start at Beginner and climb.

Beginner What each stage does

  • Clean: reads every raw line, throws out broken and test lines (but counts them), and keeps one copy of each order.
  • Silver: stores the clean orders in a compact, typed format, one folder per day.
  • Gold: small summary tables: money per canteen per day, item sales, busy hours, UPI check.

Intermediate How the clean job works

  • The mapper parses each line; a good line becomes order_id TAB file_day TAB raw; a broken line gets the key ~BAD.
  • The shuffle brings every copy of an order_id to the same reducer, sorted by key (Lesson 8.4).
  • The reducer keeps the copy from the earliest file, adds the category and price check from menu.csv, and writes OK lines; ~BAD lines pass through as BAD.
  • Every rule increments a counter by printing reporter:counter:capstone,NAME,n to stderr (Lesson 9.8).

Advanced Production details that matter

  • Schema on read, explicitly: Spark reads the TSV with a 14-column string schema. Without it Spark guesses the column count from a sample, and a file that starts with a 3-column BAD line breaks the table.
  • Dynamic partition overwrite: spark.sql.sources.partitionOverwriteMode=dynamic lets silver_gold.py 2026-09-14 replace only that day.
  • Left join for reconciliation: UPI orders LEFT JOIN gateway SUCCESS; rows with no match are the gaps. An inner join would hide exactly the problem we look for.
  • Gold is small: coalesce(1) writes one file per gold table, so consumers don't read 200 tiny files.

Exam tip: For "explain a MapReduce job you wrote", describe key and value: "Mapper emits order_id as key so all copies of an order meet in one reducer; the reducer keeps the earliest copy (dedupe) and counts DUPLICATE, LATE and other rules with counters. 104 maps, 3 reducers, 357,215 records shuffled, 354,135 orders out."

3 Types and ways to use it

Choices we made, and the alternatives

DecisionWhat we didAlternativeWhy ours (here)
Cleaning engineMapReduce Streaming, PythonSpark dropDuplicates + windowShows dedupe as a shuffle by key; runs on any Hadoop cluster
Which copy winsEarliest file dayLatest copy / any copyEarliest = first arrival, which defines "late"
Silver formatParquet, partitioned by dtORC, Avro, Iceberg (Lesson 13.4)Columnar and compressed: 29.7 MB → 7.2 MB
Re-runsDynamic partition overwriteAppend, or rewrite the whole tableBackfill one day without touching the others
Bad rowsQuarantine JSON with file dayDrop with a log messageAuditable; the till vendor can fix the bug
ChecksSeparate check.py jobAsserts inside the main jobSame checks run after any job or backfill

The five gold tables

daily_college

Orders and revenue per day per canteen: the owner's question 1. 26 × 4 rows.

revenue

item_mix

Orders and revenue per item and category per canteen; shows maggi as unknown.

what sells

hourly

Orders per hour of day: 13:00 (52,866) beats 08:00 (49,660).

staffing

upi_recon + upi_gaps

POS UPI vs gateway per canteen, and the exact day and hour of every unconfirmed order.

money check

Animated example: three copies of one order meet in a reducer

Deduplication as a shuffle 6 steps
Animated example map: file 09-12 map: file 09-12 map: file 09-14 …00377 · 09-12 …00377 · 09-12…00377 · 09-14 shuffle hash(order_id) mod 3 reducer 1 of 3 09-12 …0037709-12 …0037709-14 …00377 keep min: 09-12DUPLICATE += 2 a mapper emits key = order_id other mappers emit the same key same key → same reducer, always sorted: the copies arrive next to each other one OK line out, counter +2 lab: 357,215 records in, 354,136 groups myphatshala.in

Test the shuffle logic with a tiny file that contains the hard cases (two copies in one file, a copy in the next file, a broken line) and know the expected output by heart. When the big run disagrees, run the tiny file first.

4 Step by step

Building and verifying the pipeline

Run inside the namenode container in /tmp/capstone, after Lesson 14.1's landing. Full scripts and outputs are in "Run on your PC".

  1. Submit the clean job

    mapred streaming -D mapreduce.job.reduces=3 \
      -files clean_mapper.py,clean_reducer.py,capstone_raw/menu.csv \
      -input '/capstone/bronze/orders/college=*' -output /capstone/stage/clean \
      -mapper clean_mapper.py -reducer clean_reducer.py
    INFO FileInputFormat - Total input files to process : 104 INFO JobSubmitter - number of splits:104 INFO Job - Job job_1792730826465_0001 completed successfully Launched map tasks=104 Launched reduce tasks=3 Data-local map tasks=104
    One split per small file, so 104 map tasks, each starting a JVM and a Python process for about 4,000 lines. Remember this for Lesson 14.3.
  2. Read the counters

    Map input records=357332      Map output records=357215
    Reduce input groups=354136    Reduce output records=354431
    capstone CLEAN=354135 DUPLICATE=2784 LATE=2100 MALFORMED=296 PRICE_MISMATCH=11711 TEST_ORDER=13 UNDERCHARGED_RS=43710 UNKNOWN_ITEM=1619
    The framework counters prove conservation: 357,332 − 104 headers − 13 test = 357,215 emitted; 354,135 OK + 296 BAD = 354,431 written. The custom counters equal the local test and truth.json.
  3. Look at the cleaned lines

    hdfs dfs -cat /capstone/stage/clean/part-00000 | head -2
    hdfs dfs -cat '/capstone/stage/clean/part-*' | grep -m1 '^BAD'
    OK KHR-0901-00002 2026-09-01 2026-09-01 10:55:57 KHR S05910 coffee drink 2 40 CASH 0 0 0 OK KHR-0901-00005 2026-09-01 2026-09-01 16:32:16 KHR S01467 chai drink 2 30 UPI 0 0 0 BAD 2026-09-30 VSH-BAD-2026-09-30-0,2026-09-31 10:00:00,VSH,S00042,idli,1,30,CASH
    The last three columns are the flags: late, unknown item, rupees undercharged. BAD lines keep the raw text and the day of the file they came in.
  4. Build silver and gold with Spark

    spark-submit --master yarn --num-executors 3 --executor-memory 768m silver_gold.py
    +------+--------+----+----+-------+------------+ |orders| revenue|days|late|unknown|undercharged| +------+--------+----+----+-------+------------+ |354135|11524009| 26|2100| 1619| 43710| +------+--------+----+----+-------+------------+ real 1m31.628s
    Silver orders: 26 dt= folders, 7.2 MB of Parquet instead of 29.7 MB of text. Types are now real: timestamps, dates, integers, booleans.
  5. Reconcile UPI

    == gold.upi_recon ==            == gold.upi_gaps ==
    |college|pos_upi|gateway_success|refunds|failed_attempts| gap| | KHR|2707150| 2707150| -11240| 766| 0| | NRL|1319845| 1312991| -4500| 351|6854| | PNL|1759014| 1759014| -7378| 542| 0| | VSH|2290453| 2290453| -8693| 722| 0| |2026-09-22| NRL| 13| 201| 6854|
    Only Nerul has a gap, and upi_gaps pins it to one hour: the gateway's outage window. Failed attempts are not a gap: those students paid again.
  6. Run the acceptance checks

    spark-submit --master yarn check.py
    PASS orders = truth got 354,135 expected 354,135 PASS revenue = truth got 11,524,009 expected 11,524,009 PASS order_id is unique got 354,135 expected 354,135 … PASS UPI gap = unconfirmed got 6,854 expected 6,854 10/10 checks passed
    Only now are the gold tables "published". In production the scheduler runs check.py as the last task and fails the run on any FAIL.
  7. Prove idempotency

    spark-submit … silver_gold.py 2026-09-14     # twice
    spark-submit … append_demo.py
    files in dt=2026-09-14 before: 3 files in dt=2026-09-14 after two backfills: 3 append run 1: orders 13,007 revenue 417,578 append run 2: orders 26,014 revenue 835,156 overwrite run 1: orders 13,007 revenue 417,578 overwrite run 2: orders 13,007 revenue 417,578
    The backfill replaced one day and left the month's totals at 354,135 / ₹11,524,009. The append demo shows the bug that a retry would cause without overwrite.

Our first version read the cleaned TSV without a schema. Spark takes the number of columns from the first line it samples; if that line is a 3-field BAD line, every OK line is cut to 3 columns. Always give an explicit schema for text inputs that mix record types, or split them into separate outputs.

5 See it happen

One late order through the whole pipeline

Order KHR-0912-04461 (one idli, ₹30, UPI), a real row from our data: sold on Saturday 12 September at 18:25, arrived in Monday's file. Left: the row as you would see it at each stage. Right: where the work happens on the cluster.

From raw line to gold 6 steps
What you see bronze · orders_KHR_2026-09-14.csvKHR-0912-04461,2026-09-12 18:25:23,KHR,S07371,idli,1,30,UPI map output (key TAB value)KHR-0912-04461 ⇥ 2026-09-14 ⇥KHR-0912-04461,2026-09-12 18:25:23,… stage/clean/part-0000xOK KHR-0912-04461 2026-09-12 …idli breakfast 1 30 UPI 1 0 0late = 1 (file 14th, sold 12th) silver/orders/dt=2026-09-12/part-….parquetdt 2026-09-12 · ts timestampqty 1 (int) · amount 30 (int) · late true gold/daily_college2026-09-12 KHR orders +1 revenue +30gold/upi_reconKHR pos_upi +30 · gateway SUCCESS +30 check.pyPASS late orders kept 2,100PASS gold revenue = silver10/10 checks passed myphatshala.in
Behind the screen DataNodes YARN containers Spark executors block of orders_KHR_2026-09-14.csv: 1 block,3 replicas (worker1, worker2, worker3)the map task is scheduled on a worker holding itData-local map tasks = 104 map container: JVM → python3 clean_mapper.pyenv mapreduce_map_input_file = …09-14.csvoutput sorted into 3 partitions on local diskpartition = hash(order_id) mod 3 reduce container fetches its partitionfrom all 104 maps (312 shuffles in total)menu.csv in its working dir via -fileswrites part-0000x to HDFS 3 executors × 768 MB read the TSV partscast columns, split OK / BADpartitionBy("dt") → one folder per daydynamic overwrite: only days written groupBy(dt, college) → shuffle (6 partitions)orders LEFT JOIN gateway SUCCESScoalesce(1): one small file per gold table5 gold tables, a few KB each check.py reads silver + gold + quarantineand truth.json from the local diskany FAIL → stop before publishing10/10 PASS MapReduce: 4 min 39 s (104 maps + 3 reduces) Spark: 1 min 32 s for silver + gold checks: a separate small Spark job myphatshala.in
6 Real-world use

The same pipeline shape at work

orders vssettlements

1. Food delivery: restaurant payouts

Orders from the app are deduplicated, matched with payment-gateway settlements and refunds, and turned into a weekly payout per restaurant. A left join finds orders with no settlement before money is paid out.

Orders + gateway files→dedupe, reconcile→Payout file
meter readslate, repeated

2. Electricity: smart-meter billing

Meters re-send readings after network outages. The pipeline dedupes on meter ID + reading time, bills on the reading date, and quarantines impossible values for field engineers.

Meter batches→dedupe on key, event time→Monthly bills
price listvs billed

3. Retail: price-compliance reports

Every sale is compared with the price valid that day. Stores billing old prices (like our Panvel till) appear in a gold table with the rupee gap, and the store systems team gets a ticket.

Sales + price history→date-range join, flag→Compliance table

4. Ed-tech: daily learning dashboards

App events are cleaned, stored by day in Parquet and aggregated into lessons watched per course per day. A backfill after a tracking bug overwrites only the affected days.

App events→silver by dt, overwrite→Dashboards
10/10 PASSthen publish

5. Banking: regulatory returns

Before a report goes to the regulator, automated checks compare its totals with the general ledger. A single FAIL stops publication, the same gate as our check.py.

Transactions→aggregate + reconcile + checks→Filed report

Simplified; organisations differ in tools and policies. Kadak Canteens and its data are fictional.

7 Run the code

The whole pipeline in 40 lines

A miniature of our pipeline: the mapper, the shuffle (sorted), the reducer and one gold table, on eight raw lines from two files. The rules are the real ones.

files = {"09-12": ["K-0912-1,2026-09-12 08:10:00,KHR,S1,chai,1,15,UPI",
                   "K-0912-2,2026-09-12 13:05:00,KHR,S2,thali,1,80,UPI",
                   "K-0912-2,2026-09-12 13:05:00,KHR,S2,thali,1,80,UPI",
                   "K-0912-3,2026-09-12 13:40:00,KHR,S3,dosa"],
         "09-14": ["K-0912-4,2026-09-12 17:55:00,KHR,S4,vada pav,2,40,UPI",
                   "K-0914-1,2026-09-14 08:02:00,KHR,S5,maggi,1,30,CASH",
                   "K-0914-2,2026-09-14 09:15:00,KHR,TEST,idli,1,30,CASH",
                   "K-0912-2,2026-09-12 13:05:00,KHR,S2,thali,1,80,UPI"]}
menu = {"chai": "drink", "thali": "meal", "dosa": "meal", "vada pav": "snack", "idli": "breakfast"}
count = {}

def mapper(file_day, line):
    f = line.split(",")
    if len(f) != 8:
        count["MALFORMED"] = count.get("MALFORMED", 0) + 1
        return []
    if f[3] == "TEST":
        count["TEST_ORDER"] = count.get("TEST_ORDER", 0) + 1
        return []
    return [(f[0], file_day, line)]

pairs = [p for day, lines in files.items() for line in lines for p in mapper(day, line)]
pairs.sort()                                   # the shuffle and sort
clean, i = [], 0
while i < len(pairs):                          # the reducer: one group per order_id
    j = i
    while j < len(pairs) and pairs[j][0] == pairs[i][0]:
        j += 1
    file_day, line = min((d, l) for _, d, l in pairs[i:j])
    count["DUPLICATE"] = count.get("DUPLICATE", 0) + (j - i - 1)
    f = line.split(",")
    late = file_day > f[1][5:10]
    clean.append((f[1][:10], f[4], menu.get(f[4], "unknown"), int(f[6]), late))
    i = j

gold = {}
for dt, item, cat, amount, late in clean:
    gold[dt] = gold.get(dt, 0) + amount
print("counters:", count)
print("clean   :", clean)
print("gold    :", gold)
counters: {'MALFORMED': 1, 'TEST_ORDER': 1, 'DUPLICATE': 2} clean : [('2026-09-12', 'chai', 'drink', 15, False), ('2026-09-12', 'thali', 'meal', 80, False), ('2026-09-12', 'vada pav', 'snack', 40, True), ('2026-09-14', 'maggi', 'unknown', 30, False)] gold : {'2026-09-12': 135, '2026-09-14': 30}
  • return [(f[0], file_day, line)]Key = order_id, plus the file day: exactly what clean_mapper.py prints, as a tuple.
  • pairs.sort() # the shuffle and sortSorting puts every copy of an order next to the others, like Hadoop's shuffle.
  • file_day, line = min((d, l) for _, d, l in pairs[i:j])Earliest file wins; the other copies are counted as DUPLICATE.
  • late = file_day > f[1][5:10]Sold on the 12th, first seen in the 14th's file: late, but counted on the 12th.

Run it on your PC: the full pipeline

Continue in the namenode container in /tmp/capstone after landing (Lesson 14.1). Spark 4.0.4 is unpacked as in Lesson 13.2 (/tmp/opt/spark-4.0.4-bin-hadoop3). Outputs are real; progress lines, Java stack traces and Spark INFO/WARN lines are removed.

1 · Clean and deduplicate (MapReduce Streaming)

# clean_job.sh: bronze -> cleaned text with Hadoop Streaming (3 reducers)
hdfs dfs -rm -r -f -skipTrash /capstone/stage/clean
mapred streaming \
  -D mapreduce.job.name=capstone-clean \
  -D mapreduce.job.reduces=3 \
  -files clean_mapper.py,clean_reducer.py,capstone_raw/menu.csv \
  -input '/capstone/bronze/orders/college=*' \
  -output /capstone/stage/clean \
  -mapper clean_mapper.py -reducer clean_reducer.py

# time bash clean_job.sh      (clean_mapper.py and clean_reducer.py: see Lesson 14.1)
05:02:20 FileInputFormat - Total input files to process : 104 05:02:20 JobSubmitter - number of splits:104 05:02:22 Job - Running job: job_1792730826465_0001 05:06:53 Job - Job job_1792730826465_0001 completed successfully File System Counters FILE: Number of bytes read=31926105 FILE: Number of bytes written=98301112 HDFS: Number of bytes read=21946938 HDFS: Number of bytes written=31169366 Job Counters Killed map tasks=1 Launched map tasks=104 Launched reduce tasks=3 Data-local map tasks=104 Total time spent by all map tasks (ms)=2049842 Total time spent by all reduce tasks (ms)=560387 Map-Reduce Framework Map input records=357332 Map output records=357215 Map output bytes=31211657 Map output materialized bytes=31927959 Reduce input groups=354136 Reduce shuffle bytes=31927959 Reduce input records=357215 Reduce output records=354431 Shuffled Maps =312 capstone CLEAN=354135 DUPLICATE=2784 LATE=2100 MALFORMED=296 PRICE_MISMATCH=11711 TEST_ORDER=13 UNDERCHARGED_RS=43710 UNKNOWN_ITEM=1619 File Input Format Counters Bytes Read=21933626 File Output Format Counters Bytes Written=31169366 05:06:53 StreamJob - Output directory: /capstone/stage/clean real 4m38.788s user 0m16.239s sys 0m1.780s

104 splits for 104 small files. The custom "capstone" counter group is printed with the framework counters (some framework counters removed here).

2 · Silver and gold (Spark on YARN)

# silver_gold.py: cleaned text -> silver Parquet (partitioned by day) -> gold KPI tables
import sys
from pyspark.sql import SparkSession, functions as F

spark = (SparkSession.builder.appName("capstone-silver-gold")
         .config("spark.sql.sources.partitionOverwriteMode", "dynamic")   # re-runs replace only their days
         .config("spark.sql.shuffle.partitions", "6").getOrCreate())
spark.sparkContext.setLogLevel("ERROR")
B = "hdfs://namenode/capstone"
cols = ["status", "order_id", "dt", "ts", "college", "student_id", "item", "category", "qty", "amount",
        "pay_mode", "late", "unknown_item", "undercharged"]

schema = ", ".join(c + " STRING" for c in cols)          # read everything as text first, then cast
raw = spark.read.schema(schema).option("sep", "\t").csv(f"{B}/stage/clean")   # BAD rows fill 3 columns
orders = (raw.where("status = 'OK'").drop("status")
          .withColumn("ts", F.to_timestamp("ts")).withColumn("dt", F.to_date("dt"))
          .withColumn("qty", F.col("qty").cast("int")).withColumn("amount", F.col("amount").cast("int"))
          .withColumn("late", F.col("late") == "1").withColumn("unknown_item", F.col("unknown_item") == "1")
          .withColumn("undercharged", F.col("undercharged").cast("int")))
days = sys.argv[1:]                                    # optional: only these days (a backfill)
if days:
    orders = orders.where(F.col("dt").isin(days))
orders.write.mode("overwrite").partitionBy("dt").parquet(f"{B}/silver/orders")
raw.where("status = 'BAD'").select("order_id", "dt").toDF("file_day", "raw_line") \
   .write.mode("overwrite").json(f"{B}/quarantine/orders")

pay = spark.read.json(f"{B}/bronze/payments").withColumn("dt", F.to_date(F.substring("ts", 1, 10)))
pay.write.mode("overwrite").partitionBy("dt").parquet(f"{B}/silver/payments")

o = spark.read.parquet(f"{B}/silver/orders")
p = spark.read.parquet(f"{B}/silver/payments")
ok = p.where("status = 'SUCCESS'").select("order_id", F.col("amount").alias("paid"))
upi = o.where("pay_mode = 'UPI'").join(ok, "order_id", "left")          # every UPI order, paid or not
gold = {
  "daily_college": o.groupBy("dt", "college").agg(F.count("*").alias("orders"), F.sum("amount").alias("revenue")),
  "item_mix": o.groupBy("college", "item", "category").agg(F.count("*").alias("orders"), F.sum("amount").alias("revenue")),
  "hourly": o.groupBy(F.hour("ts").alias("hour")).agg(F.count("*").alias("orders")),
  "upi_recon": (o.where("pay_mode = 'UPI'").groupBy("college").agg(F.sum("amount").alias("pos_upi"))
                .join(p.join(o.select("order_id", "college"), "order_id").groupBy("college").agg(
                      F.sum(F.when(F.col("status") == "SUCCESS", F.col("amount")).otherwise(0)).alias("gateway_success"),
                      F.sum(F.when(F.col("status") == "REFUNDED", F.col("amount")).otherwise(0)).alias("refunds"),
                      F.sum(F.when(F.col("status") == "FAILED", 1).otherwise(0)).alias("failed_attempts")), "college")
                .withColumn("gap", F.col("pos_upi") - F.col("gateway_success"))),
  "upi_gaps": (upi.where("paid IS NULL").groupBy("dt", "college", F.hour("ts").alias("hour"))
               .agg(F.count("*").alias("orders"), F.sum("amount").alias("unconfirmed"))),
}
for name, df in gold.items():
    df.coalesce(1).write.mode("overwrite").parquet(f"{B}/gold/{name}")

print("== silver.orders ==")
o.agg(F.count("*").alias("orders"), F.sum("amount").alias("revenue"), F.countDistinct("dt").alias("days"),
      F.sum(F.col("late").cast("int")).alias("late"), F.sum(F.col("unknown_item").cast("int")).alias("unknown"),
      F.sum("undercharged").alias("undercharged")).show()
print("== gold.daily_college: month by canteen ==")
spark.read.parquet(f"{B}/gold/daily_college").groupBy("college").agg(F.sum("orders").alias("orders"),
      F.sum("revenue").alias("revenue")).orderBy(F.desc("revenue")).show()
print("== gold.upi_recon ==")
spark.read.parquet(f"{B}/gold/upi_recon").orderBy("college").show()
print("== gold.upi_gaps: UPI orders with no SUCCESS from the gateway ==")
spark.read.parquet(f"{B}/gold/upi_gaps").orderBy("dt", "college", "hour").show()
print("== gold.hourly: top 3 hours ==")
spark.read.parquet(f"{B}/gold/hourly").orderBy(F.desc("orders")).show(3)
print("== gold.item_mix: maggi and Panvel chai ==")
spark.read.parquet(f"{B}/gold/item_mix").where("item = 'maggi' or (college = 'PNL' and item = 'chai')").show()

# spark_job.sh: run silver_gold.py on YARN (pass days to backfill only those days)
export SPARK_HOME=/tmp/opt/spark-4.0.4-bin-hadoop3 HADOOP_CONF_DIR=/opt/hadoop/etc/hadoop
$SPARK_HOME/bin/spark-submit --master yarn --deploy-mode client \
  --num-executors 3 --executor-memory 768m --executor-cores 1 --driver-memory 768m \
  silver_gold.py "$@"
== silver.orders == +------+--------+----+----+-------+------------+ |orders| revenue|days|late|unknown|undercharged| +------+--------+----+----+-------+------------+ |354135|11524009| 26|2100| 1619| 43710| +------+--------+----+----+-------+------------+ == gold.daily_college: month by canteen == +-------+------+-------+ |college|orders|revenue| +-------+------+-------+ | KHR|117937|3860438| | VSH|100373|3270741| | PNL| 78438|2517725| | NRL| 57387|1875105| +-------+------+-------+ == gold.upi_recon == +-------+-------+---------------+-------+---------------+----+ |college|pos_upi|gateway_success|refunds|failed_attempts| gap| +-------+-------+---------------+-------+---------------+----+ | KHR|2707150| 2707150| -11240| 766| 0| | NRL|1319845| 1312991| -4500| 351|6854| | PNL|1759014| 1759014| -7378| 542| 0| | VSH|2290453| 2290453| -8693| 722| 0| +-------+-------+---------------+-------+---------------+----+ == gold.upi_gaps: UPI orders with no SUCCESS from the gateway == +----------+-------+----+------+-----------+ | dt|college|hour|orders|unconfirmed| +----------+-------+----+------+-----------+ |2026-09-22| NRL| 13| 201| 6854| +----------+-------+----+------+-----------+ == gold.hourly: top 3 hours == +----+------+ |hour|orders| +----+------+ | 13| 52866| | 8| 49660| | 9| 42761| +----+------+ only showing top 3 rows == gold.item_mix: maggi and Panvel chai == +-------+-----+--------+------+-------+ |college| item|category|orders|revenue| +-------+-----+--------+------+-------+ | PNL| chai| drink| 23474| 441480| | KHR|maggi| unknown| 1619| 60300| +-------+-----+--------+------+-------+ real 1m31.628s user 1m11.919s sys 0m9.942s

Run with day arguments (bash spark_job.sh 2026-09-14) to backfill only those days.

3 · Acceptance checks

# check.py: acceptance tests. The pipeline's answers must equal truth.json from the generator.
import json
from pyspark.sql import SparkSession, functions as F

spark = SparkSession.builder.appName("capstone-check").getOrCreate()
spark.sparkContext.setLogLevel("ERROR")
B = "hdfs://namenode/capstone"
t = json.load(open("capstone_raw/truth.json"))
o = spark.read.parquet(f"{B}/silver/orders")
s = o.agg(F.count("*"), F.sum("amount"), F.countDistinct("order_id"), F.sum(F.col("late").cast("int")),
          F.sum(F.col("unknown_item").cast("int")), F.sum("undercharged")).first()
recon = spark.read.parquet(f"{B}/gold/upi_recon").agg(F.sum("gateway_success"), F.sum("refunds"), F.sum("gap")).first()
daily = spark.read.parquet(f"{B}/gold/daily_college").agg(F.sum("revenue")).first()[0]
tests = [
    ("orders = truth", s[0], t["clean_orders"]),
    ("revenue = truth", s[1], t["revenue"]),
    ("order_id is unique", s[2], s[0]),
    ("late orders kept", s[3], t["late"]),
    ("unknown items flagged", s[4], t["unknown_item"]),
    ("undercharged rupees", s[5], t["undercharged"]),
    ("quarantined lines", spark.read.json(f"{B}/quarantine/orders").count(), t["malformed"]),
    ("gold revenue = silver", daily, s[1]),
    ("UPI net = truth", recon[0] + recon[1], t["upi_net"]),
    ("UPI gap = unconfirmed", recon[2], t["upi_unconfirmed"]),
]
for name, got, want in tests:
    print(f"{'PASS' if got == want else 'FAIL'}  {name:<24} got {got:>10,}  expected {want:>10,}")
print(f"{sum(g == w for _, g, w in tests)}/{len(tests)} checks passed")

export SPARK_HOME=/tmp/opt/spark-4.0.4-bin-hadoop3 HADOOP_CONF_DIR=/opt/hadoop/etc/hadoop
$SPARK_HOME/bin/spark-submit --master yarn --num-executors 2 --executor-memory 768m --driver-memory 768m check.py
PASS orders = truth got 354,135 expected 354,135 PASS revenue = truth got 11,524,009 expected 11,524,009 PASS order_id is unique got 354,135 expected 354,135 PASS late orders kept got 2,100 expected 2,100 PASS unknown items flagged got 1,619 expected 1,619 PASS undercharged rupees got 43,710 expected 43,710 PASS quarantined lines got 296 expected 296 PASS gold revenue = silver got 11,524,009 expected 11,524,009 PASS UPI net = truth got 8,037,797 expected 8,037,797 PASS UPI gap = unconfirmed got 6,854 expected 6,854 10/10 checks passed

The pipeline is accepted only when every line says PASS.

4 · Backfill twice, and the append mistake

# idem.sh: backfill one day twice, and show what blind "append" would have done
export SPARK_HOME=/tmp/opt/spark-4.0.4-bin-hadoop3 HADOOP_CONF_DIR=/opt/hadoop/etc/hadoop
S="$SPARK_HOME/bin/spark-submit --master yarn --num-executors 3 --executor-memory 768m --driver-memory 768m"
q() { hdfs dfs -ls -R /capstone/silver/orders/dt=2026-09-14 | grep -c parquet; }
echo "files in dt=2026-09-14 before: $(q)"
$S silver_gold.py 2026-09-14 2>/dev/null | sed -n '/silver.orders/,/^$/p'
$S silver_gold.py 2026-09-14 2>/dev/null | sed -n '/silver.orders/,/^$/p'
echo "files in dt=2026-09-14 after two backfills: $(q)"
$S append_demo.py 2>/dev/null

# append_demo.py: the same day written twice with mode("append") vs mode("overwrite")
from pyspark.sql import SparkSession, functions as F
spark = SparkSession.builder.appName("capstone-append-demo").getOrCreate()
day = spark.read.parquet("hdfs://namenode/capstone/silver/orders").where("dt = '2026-09-14'")
for mode in ["append", "overwrite"]:
    path = f"hdfs://namenode/capstone/tmp/{mode}_demo"
    for run in (1, 2):
        day.write.mode(mode).parquet(path)
        r = spark.read.parquet(path).agg(F.count("*"), F.sum("amount")).first()
        print(f"{mode:<9} run {run}: orders {r[0]:,}  revenue {r[1]:,}")
files in dt=2026-09-14 before: 3 == silver.orders == +------+--------+----+----+-------+------------+ |orders| revenue|days|late|unknown|undercharged| +------+--------+----+----+-------+------------+ |354135|11524009| 26|2100| 1619| 43710| +------+--------+----+----+-------+------------+ == silver.orders == +------+--------+----+----+-------+------------+ |orders| revenue|days|late|unknown|undercharged| +------+--------+----+----+-------+------------+ |354135|11524009| 26|2100| 1619| 43710| +------+--------+----+----+-------+------------+ files in dt=2026-09-14 after two backfills: 3 append run 1: orders 13,007 revenue 417,578 append run 2: orders 26,014 revenue 835,156 overwrite run 1: orders 13,007 revenue 417,578 overwrite run 2: orders 13,007 revenue 417,578 real 4m50.738s user 4m6.420s sys 0m38.024s

Each backfill rewrote only dt=2026-09-14 (still 3 files) and the month's totals did not move. With append, a retry doubles the day.

8 Line by line

Inside the reducer: one group at a time

The reducer sees sorted lines, one at a time. It must notice where one order_id ends, then keep the earliest copy. Watch copies fill and empty.

Worked examples: real problems, solved and traced in memory

Three questions Mrs. Shinde asked after seeing the gold tables, answered from the lab numbers.

Example 1BeginnerReconciliation · UPI

Where did the UPI money go?

Problem statement

The tills say ₹80,76,462 was paid by UPI. The gateway confirmed ₹80,69,608 and refunded ₹31,811. How much should reach the bank, and how much is unexplained?

Input
POS ₹8,076,462 · SUCCESS ₹8,069,608 · refunds ₹31,811
Expected output
net to bank Rs 8,037,797  unexplained Rs 6,854

Approach

  1. Net to bank = confirmed − refunds.
  2. Unexplained = POS − confirmed (refunds are explained).
  3. Compare with upi_gaps.

Solution: run it live

What happens in memory, step by step

Recorded from a real Python run: press Next to execute one line at a time. Watch variables change in each frame, and lists and dictionaries (numbered #1, #2 …) fill up as objects.

Example 2IntermediateGold · staffing

How many counter staff at 13:00?

Problem statement

Across 26 days and 4 canteens, 13:00–14:00 had 52,866 orders. One staff member can serve about 90 orders an hour. How many people does an average canteen need at that hour?

Input
52,866 orders · 26 days · 4 canteens · 90 orders/person/hour
Expected output
13:00 (508, 6)
08:00 (478, 6)

Approach

  1. Orders per canteen in that hour on an average day.
  2. Divide by 90 and round up.
  3. Compare with 08:00 (49,660).

Solution: run it live

What happens in memory, step by step

Recorded from a real Python run: press Next to execute one line at a time. Watch variables change in each frame, and lists and dictionaries (numbered #1, #2 …) fill up as objects.

Example 3AdvancedOperations · backfill

How long to re-run the whole month?

Problem statement

The menu is corrected (maggi added at ₹30). The clean job must re-run on all 104 files (4 min 39 s) and Spark for all days (1 min 32 s), then checks. Later the canteens grow to 40: clean time grows with the number of files at the same per-map cost, Spark time roughly with data. Estimate both.

Input
measured: clean 279 s for 104 maps · Spark 92 s
Expected output
today      : 6.2 min
40 canteens: 61.8 min

Approach

  1. Today: add the measured times.
  2. At 40 canteens: 10 × files and 10 × data, same cluster.
  3. Notice which stage explodes.

Solution: run it live

What happens in memory, step by step

Recorded from a real Python run: press Next to execute one line at a time. Watch variables change in each frame, and lists and dictionaries (numbered #1, #2 …) fill up as objects.

9 Quiz and revision

Check yourself

Quick revision

Lesson 14.2 in one screen

  • Stages: bronze (104 CSV, 20.9 MB) → clean (MapReduce Streaming) → stage/clean (29.7 MB TSV) → silver (Parquet by dt, 7.2 MB) → gold (5 tables) → check.py.
  • Clean job: key = order_id; reducer keeps earliest file copy; counters per rule; 104 maps, 3 reduces, 4 min 39 s.
  • Conservation: 357,332 lines − 104 headers − 13 test = 357,215 mapped; 354,135 OK + 296 BAD = 354,431 out.
  • Spark: explicit schema for mixed TSV; partitionBy("dt"); dynamic overwrite; coalesce(1) for small gold tables; 1 min 32 s.
  • UPI: POS ₹80,76,462 vs SUCCESS ₹80,69,608 → gap ₹6,854 = 201 orders, Nerul, 22 Sept 13:00; refunds ₹31,811.
  • Findings: Panvel undercharged ₹43,710; maggi ₹60,300 uncategorised; peak hour 13:00 (52,866 orders).
  • 10/10 acceptance checks; backfill twice = same totals; append twice = 26,014 instead of 13,007.

Interview and viva questions

How did you remove duplicates in MapReduce?

The mapper emits order_id as the key with the file day and the raw line as the value. The shuffle sends all records with the same key to the same reducer, sorted, so the copies arrive together. The reducer keeps the copy from the earliest file and increments a DUPLICATE counter by the number of extra copies.

How do you make a Spark job idempotent?

Write output partitioned by business date and use overwrite with dynamic partition overwrite mode, so each run replaces exactly the partitions it produces. Never append blindly. Then a retry or backfill for a day gives the same result as the first run.

What is the difference between silver and gold?

Silver holds cleaned, typed, deduplicated records at the original grain (one row per order). Gold holds aggregated tables designed for specific consumers and questions, such as revenue per canteen per day.

How do you reconcile two datasets?

Agree a key and a measure, join from the system that should be complete (left join), and compare per group: counts and amounts. Explain every difference with a category (refunds, failed attempts) and report what is left as a gap with its exact keys, dates and amounts.

Why use counters instead of print statements?

Counters are aggregated by the framework across all tasks, keep only successful attempts, appear in the job output and history server, and can be checked automatically. Prints in hundreds of task logs cannot be summed reliably.

Practice problems

Pipeline exercises using the real outputs of our run.

P1Easy

Check conservation in the map phase

Map input records were 357,332, with 104 headers and 13 test orders skipped. Do the map output records (357,215) add up?

Input
357,332 · 104 · 13
Expected output
357215
Show solution and explanation
print(357332 - 104 - 13)
357215

How it works: 357,215: nothing vanished in the mappers.

P2Easy

Shuffles

How many map-output fetches does the shuffle make with 104 maps and 3 reducers?

Input
104 × 3
Expected output
312
Show solution and explanation
print(104 * 3)
312

How it works: 312, the "Shuffled Maps" counter: every reducer fetches its partition from every map.

P3Easy

Compression ratio

Cleaned text was 29.7 MB, silver Parquet 7.2 MB. What is the ratio?

Input
29.7 · 7.2
Expected output
4.1 x smaller
Show solution and explanation
print(round(29.7 / 7.2, 1), "x smaller")
4.1 x smaller

How it works: About 4 times smaller, with real column types.

P4Medium

Revenue per canteen per day

Aggregate a few clean rows into a daily revenue table like gold.daily_college.

Input
(dt, college, amount) rows
Expected output
('09-12', 'KHR') 120
('09-12', 'NRL') 30
('09-14', 'KHR') 15
Show solution and explanation
rows = [("09-12", "KHR", 40), ("09-12", "KHR", 80), ("09-12", "NRL", 30), ("09-14", "KHR", 15)]
gold = {}
for dt, c, amt in rows:
    gold[(dt, c)] = gold.get((dt, c), 0) + amt
for k in sorted(gold):
    print(k, gold[k])
('09-12', 'KHR') 120 ('09-12', 'NRL') 30 ('09-14', 'KHR') 15

How it works: A dictionary keyed by (day, canteen) is groupBy("dt", "college").agg(sum("amount")).

P5Medium

Find the gap hour

Given UPI orders and confirmed order_ids, print unconfirmed orders grouped by hour.

Input
orders with hours
Expected output
{13: (3, 95)}
Show solution and explanation
orders = [("N-1", 13, 40), ("N-2", 13, 30), ("N-3", 12, 18), ("N-4", 13, 25)]
confirmed = {"N-3"}
gaps = {}
for oid, hour, amt in orders:
    if oid not in confirmed:
        n, s = gaps.get(hour, (0, 0))
        gaps[hour] = (n + 1, s + amt)
print(gaps)
{13: (3, 95)}

How it works: Everything unconfirmed falls in 13:00: an outage, not random failures.

P6Medium

Average undercharge

11,711 Panvel orders were undercharged by ₹43,710 in total. What is the average per order, and what does that say about quantities?

Input
₹43,710 · 11,711
Expected output
3.73
Show solution and explanation
print(round(43710 / 11711, 2))
3.73

How it works: ₹3.73 per order: more than ₹3 means some orders had 2 or 3 chais.

P7Business problem

Maggi pricing

Maggi sold 1,619 orders for ₹60,300 at Kharghar from 21 Sept. If it is added to the menu at ₹30, are the amounts consistent?

Input
1,619 orders · ₹60,300
Expected output
average per order: 37.25 | qty per order: 1.24
Show solution and explanation
orders, revenue = 1619, 60300
print("average per order:", round(revenue / orders, 2), "| qty per order:", round(revenue / orders / 30, 2))
average per order: 37.25 | qty per order: 1.24

How it works: About 1.24 plates per order at ₹30: consistent, so the menu fix needs no correction of amounts.

P8Advanced

A conservation check function

Write a function that checks received = clean + duplicates + malformed + test, and prints the difference if not.

Input
counter dictionary
Expected output
OK
MISSING 1000 lines
Show solution and explanation
def conserved(c):
    total = c["CLEAN"] + c["DUPLICATE"] + c["MALFORMED"] + c["TEST_ORDER"]
    diff = c["RECEIVED"] - total
    print("OK" if diff == 0 else f"MISSING {diff} lines")
conserved({"RECEIVED": 357228, "CLEAN": 354135, "DUPLICATE": 2784, "MALFORMED": 296, "TEST_ORDER": 13})
conserved({"RECEIVED": 357228, "CLEAN": 353135, "DUPLICATE": 2784, "MALFORMED": 296, "TEST_ORDER": 13})
OK MISSING 1000 lines

How it works: Add it as a check after every clean run: a reducer that forgets its last group shows up immediately.

Real-world scenario questions (asked in company interviews)

Questions interviewers ask after you describe a pipeline like this one.

Debugging · dataFresher

"Your dashboard shows yesterday's revenue doubled. What happened?"

Nothing changed in the code; the scheduler retried a failed run.

The interviewer is checking: idempotency.

Show a strong answer
  • The retry appended the same day again: the first attempt had already written part of its output.
  • Fix the data by overwriting that day's partition; fix the job by writing per-day partitions with overwrite (dynamic mode) instead of append.
  • Add a check: today's row count within a range and no duplicate keys, so it fails before the dashboard.
MapReduce · designFresher

"How would you remove duplicates from 100 files where a copy can be in another file?"

Follow-up: "And which copy do you keep?"

The interviewer is checking: key choice and determinism.

Show a strong answer
  • Key by the business key (order_id); the shuffle groups all copies in one reducer whatever file they came from.
  • Keep a deterministic copy: earliest arrival (file day), so re-runs pick the same one and lateness is measured.
  • Count extra copies in a counter; in Spark the same is a window with row_number() over order_id ordered by file day.
Reconciliation · financeExperienced

"Finance says UPI collections don't match the bank. Investigate."

The difference is a few thousand rupees in a month.

The interviewer is checking: structured reconciliation.

Show a strong answer
  • Left join POS UPI orders to gateway records on order_id; classify: confirmed, failed-then-paid, refunded, unconfirmed.
  • Group the unconfirmed by day, shop and hour: one cluster (ours: 201 orders, Nerul, 22 Sept 13:00) means an outage or a file missing, not random loss.
  • Send the exact list to the gateway; add a daily gold table and alert so it is caught the next day, not at month-end.
Engineering · qualityExperienced

"How do you test a data pipeline?"

The team has unit tests for code but numbers still go wrong.

The interviewer is checking: testing data, not only code.

Show a strong answer
  • Unit-test the logic on tiny files with known edge cases (duplicate in the next file, broken line, unknown item); run mapper | sort | reducer locally.
  • On every run, check conservation (in = out + rejected), uniqueness of keys, and totals against an independent source.
  • Gate publication on those checks and keep the quarantine so failures are explainable.