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.
Why two engines? Couldn't Spark do everything?
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.
And the checks? We already tested locally.
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.
A batch pipeline, stage by stage
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.
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
dt: each day of silver is its own folder.Architecture at a glance
What ran, where it ran, and the real sizes at each stage.
From our lab (the full run)
| Clean job | 104 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 |
|---|---|
| Counters | CLEAN 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 job | silver 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 |
| UPI | POS 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 |
| Checks | 10/10 PASS against truth.json |
| Idempotency | backfilling 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
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_idto 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 writesOKlines;~BADlines pass through asBAD. - Every rule increments a counter by printing
reporter:counter:capstone,NAME,nto 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=dynamicletssilver_gold.py 2026-09-14replace 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."
Choices we made, and the alternatives
| Decision | What we did | Alternative | Why ours (here) |
|---|---|---|---|
| Cleaning engine | MapReduce Streaming, Python | Spark dropDuplicates + window | Shows dedupe as a shuffle by key; runs on any Hadoop cluster |
| Which copy wins | Earliest file day | Latest copy / any copy | Earliest = first arrival, which defines "late" |
| Silver format | Parquet, partitioned by dt | ORC, Avro, Iceberg (Lesson 13.4) | Columnar and compressed: 29.7 MB → 7.2 MB |
| Re-runs | Dynamic partition overwrite | Append, or rewrite the whole table | Backfill one day without touching the others |
| Bad rows | Quarantine JSON with file day | Drop with a log message | Auditable; the till vendor can fix the bug |
| Checks | Separate check.py job | Asserts inside the main job | Same 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.
revenueitem_mix
Orders and revenue per item and category per canteen; shows maggi as unknown.
hourly
Orders per hour of day: 13:00 (52,866) beats 08:00 (49,660).
staffingupi_recon + upi_gaps
POS UPI vs gateway per canteen, and the exact day and hour of every unconfirmed order.
money checkAnimated example: three copies of one order meet in a reducer
- A map task reading the file of 12 September emits order …00377 with its file day.
- The same order also sits in another split and in the file of 14 September, so two more copies are emitted.
- The partitioner sends every record with the same key to the same reducer, whichever mapper produced it.
- The reducer receives keys in sorted order, so all three copies come one after another.
- It keeps the copy with the earliest file day and adds 2 to DUPLICATE.
- On the full data: 357,215 records shuffled into 354,136 keys (354,135 orders plus the ~BAD key). (Simplified to one order; real splits hold thousands.)
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.
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".
Submit the clean job
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.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.pyINFO 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=104Read the counters
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.Map input records=357332 Map output records=357215 Reduce input groups=354136 Reduce output records=354431capstone CLEAN=354135 DUPLICATE=2784 LATE=2100 MALFORMED=296 PRICE_MISMATCH=11711 TEST_ORDER=13 UNDERCHARGED_RS=43710 UNKNOWN_ITEM=1619Look at the cleaned lines
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.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,CASHBuild silver and gold with Spark
Silver orders: 26spark-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.628sdt=folders, 7.2 MB of Parquet instead of 29.7 MB of text. Types are now real: timestamps, dates, integers, booleans.Reconcile UPI
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.== 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|Run the acceptance checks
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.spark-submit --master yarn check.pyPASS 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 passedProve idempotency
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.spark-submit … silver_gold.py 2026-09-14 # twice spark-submit … append_demo.pyfiles 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
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.
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.
- The raw line sits in Monday's Kharghar file, exactly as the till sent it.
- The mapper emits the order_id as key, plus the file day and the raw line.
- The reducer keeps it (no other copy), adds category breakfast, and flags it late because it arrived on the 14th.
- Spark writes it with real types into the folder of the day it was sold: dt=2026-09-12.
- Gold counts it on Saturday, not Monday, and matches its UPI payment.
- The checks confirm all 2,100 late orders were kept on their own days.
- The file is one small block with three replicas, so YARN can run its map task on any worker: all 104 maps were data-local.
- Each map container starts a JVM that pipes lines into our Python mapper; Hadoop tells it the file name through an environment variable.
- Each of the 3 reducers pulls its share from every map (104 × 3 = 312 shuffles) and writes one part file.
- Spark's executors read the parts, cast types and write one folder per business day.
- Aggregations shuffle by day and canteen; the UPI check is a left join; gold tables are tiny.
- The checks read everything back and compare with truth.json. Nothing is published on a FAIL.
The same pipeline shape at work
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.
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.
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.
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.
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.
Simplified; organisations differ in tools and policies. Kadak Canteens and its data are fictional.
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)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)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 "$@"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.pyThe 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]:,}")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.
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.
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?
POS ₹8,076,462 · SUCCESS ₹8,069,608 · refunds ₹31,811
net to bank Rs 8,037,797 unexplained Rs 6,854
Approach
- Net to bank = confirmed − refunds.
- Unexplained = POS − confirmed (refunds are explained).
- 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.
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?
52,866 orders · 26 days · 4 canteens · 90 orders/person/hour
13:00 (508, 6) 08:00 (478, 6)
Approach
- Orders per canteen in that hour on an average day.
- Divide by 90 and round up.
- 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.
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.
measured: clean 279 s for 104 maps · Spark 92 s
today : 6.2 min 40 canteens: 61.8 min
Approach
- Today: add the measured times.
- At 40 canteens: 10 × files and 10 × data, same cluster.
- 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.
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.
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?
357,332 · 104 · 13
357215
Show solution and explanation
print(357332 - 104 - 13)How it works: 357,215: nothing vanished in the mappers.
Shuffles
How many map-output fetches does the shuffle make with 104 maps and 3 reducers?
104 × 3
312
Show solution and explanation
print(104 * 3)How it works: 312, the "Shuffled Maps" counter: every reducer fetches its partition from every map.
Compression ratio
Cleaned text was 29.7 MB, silver Parquet 7.2 MB. What is the ratio?
29.7 · 7.2
4.1 x smaller
Show solution and explanation
print(round(29.7 / 7.2, 1), "x smaller")How it works: About 4 times smaller, with real column types.
Revenue per canteen per day
Aggregate a few clean rows into a daily revenue table like gold.daily_college.
(dt, college, amount) rows
('09-12', 'KHR') 120
('09-12', 'NRL') 30
('09-14', 'KHR') 15Show 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])How it works: A dictionary keyed by (day, canteen) is groupBy("dt", "college").agg(sum("amount")).
Find the gap hour
Given UPI orders and confirmed order_ids, print unconfirmed orders grouped by hour.
orders with hours
{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)How it works: Everything unconfirmed falls in 13:00: an outage, not random failures.
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?
₹43,710 · 11,711
3.73
Show solution and explanation
print(round(43710 / 11711, 2))How it works: ₹3.73 per order: more than ₹3 means some orders had 2 or 3 chais.
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?
1,619 orders · ₹60,300
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))How it works: About 1.24 plates per order at ₹30: consistent, so the menu fix needs no correction of amounts.
A conservation check function
Write a function that checks received = clean + duplicates + malformed + test, and prints the difference if not.
counter dictionary
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})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.
"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.
"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.
"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.
"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.