Four canteens, 130 files and no answer
Kadak Canteens runs four college canteens in Kharghar, Vashi, Panvel and Nerul. Every evening each till (POS machine) saves a CSV file of the day's orders, and every night the UPI gateway sends a file of payments. Mrs. Shinde, the owner, hands Kabir a pen drive with all of September and a question her accountant could not answer in four days of Excel: "How much did each canteen really earn, what sells, when are we busiest, and did all the UPI money reach my account?"
Fifty megabytes? My laptop can open that in Excel. Why would I need Hadoop?
Fair question, and you should ask it in every project. Today it is one month and four canteens. Mrs. Shinde wants forty canteens and three years of history next year: that is thousands of files a month and gigabytes a day. We build the pipeline now, on a size we can check by hand, and it scales by adding nodes.
Then the first job is to write code?
No. The first job is to understand the problem and the data: what exactly is asked, what is wrong with the files, and how we will prove our answer is right. That is today's lesson. Tomorrow we build.
How can we prove it? Nobody knows the right answer.
For this capstone we do. Our generator writes truth.json, the correct totals, alongside the messy files. In a company the same role is played by a trusted source: a bank statement, the finance ledger, the source database's row counts.
A capstone: one real problem, end to end
A capstone project applies the whole course to one realistic problem. In data engineering it starts with requirements (the business questions and the deliverables), a source inventory (each dataset's format, frequency, volume, keys and owner), data profiling (measuring the actual content: row counts, distinct keys, nulls, invalid values, duplicates and late records) and a set of data quality rules that say how each problem is handled. It ends with acceptance criteria: measurable checks, ideally reconciled against an independent source of truth, that the delivered tables must pass.
Before building anything, write down what the business wants, look hard at the files you were given, list everything that is wrong with them and decide what to do about each problem, and agree on the numbers that will prove you got it right.
Words you'll meet in this lesson
order_id.Architecture at a glance
The plan we agree today and build in Lesson 14.2. Every box uses something from an earlier module.
From our lab (the dataset in numbers)
| Files | 104 order CSVs (4 canteens × 26 open days, closed on Sundays) + 26 UPI JSON-lines files + menu.csv; 52,084,844 bytes in HDFS |
|---|---|
| Lines | 357,332 lines in the order files: 104 headers + 357,228 data lines |
| True answer | 354,135 real orders, revenue ₹11,524,009 (KHR ₹38,60,438 · VSH ₹32,70,741 · PNL ₹25,17,725 · NRL ₹18,75,105) |
| Planted problems | 2,784 duplicate rows · 296 broken lines · 13 test orders · 2,100 late rows · 1,619 orders of an item missing from the menu · 11,711 undercharged orders (₹43,710) · 201 UPI orders without a gateway confirmation (₹6,854) |
| In HDFS | 130 files = 130 blocks, average 400,652 bytes per block (0.3% of a 128 MB block): the small files problem from Lesson 7.5 |
| Local check | the cleaning logic run as a Linux pipe on a PC already reproduces every planted count before we touch the cluster |
The capstone at three levels
Beginner What we are building
- A small business has messy daily files from four shops and a payments company.
- We load them into Hadoop, clean them, and produce four tables that answer the owner's four questions.
- We check our answers against known correct totals, so we are not guessing.
Intermediate Profiling and quality rules
- Grain and key: one row = one order, key
order_id(e.g.KHR-0914-00610= canteen, month-day, counter). Duplicates are rows with the same key. - Rules: unparsable line → quarantine;
student_id = TEST→ drop; sameorder_idtwice → keep one; item not in menu → keep, categoryunknown; amount ≠ qty × menu price → keep, flag and measure the gap. - Business date comes from the order's timestamp, not from the file name: 2,100 orders arrive in a later file and must still count on their own day.
Advanced Design decisions you must defend
- Deduplication needs all copies in one place: a copy can sit in the next day's file, so the dedupe key must be
order_idacross all files. That is a shuffle: MapReduce keyed byorder_id(Module 8) does exactly this. - Keep the earliest copy: it tells us when the order first arrived, which decides "late".
- Never fix money silently: Panvel's till charged old prices. Revenue stays what was actually charged; the ₹ gap is reported as a finding, not "corrected".
- Partition silver by business date so a re-run of one day overwrites only that day (idempotency, Lesson 13.4).
Exam tip: For "Describe a big data project you did", start with the problem and the data, not the tools: "4 canteens, 130 daily files, 3.5 lakh orders with duplicates, late rows and price errors; the goal was daily revenue, item mix, peak hours and UPI reconciliation, verified against bank totals." Then the architecture, then results.
The sources, and every kind of dirt in them
Orders (POS CSV)
order_id,ts,college,student_id,item,qty,amount,pay_mode. One file per till per open day, about 4,000 rows, header in every file.
Payments (UPI JSON lines)
One JSON object per gateway event: txn_id, order_id, amount, status, ts. Status is SUCCESS, FAILED or REFUNDED.
Menu (reference)
Item, category and price with valid_from / valid_to: chai went from ₹15 to ₹18 on 16 September.
truth.json
Written by the generator: the counts and totals a correct pipeline must reproduce. Our acceptance tests read it.
the refereeThe data quality catalogue
| Problem | How it looks in the files | Rule | Expected count |
|---|---|---|---|
| Duplicate | Same row twice; the till re-sent it after a network drop, sometimes in the next day's file | Keep one per order_id (earliest file) | 2,784 |
| Malformed | …,dosa (5 fields), forty five as amount, date 2026-09-31 | Quarantine with the raw line | 296 |
| Test order | student_id = TEST at Vashi on 3 Sept (a technician testing the till) | Drop and count | 13 |
| Late | An order of the 12th inside the file of the 14th (Sunday in between) | Count on its own day; flag late | 2,100 |
| Unknown item | maggi at Kharghar from 21 Sept; nobody added it to menu.csv | Keep revenue; category unknown; report | 1,619 |
| Price mismatch | Panvel charged chai at ₹15 after the change to ₹18 | Keep amount charged; flag and sum the gap | 11,711 orders · ₹43,710 |
| Unconfirmed UPI | UPI orders at Nerul, 22 Sept, 13:00–13:59, with no SUCCESS from the gateway | Report in reconciliation | 201 orders · ₹6,854 |
Animated example: how one till creates dirty data
- On a normal day the till writes one line per order into that day's file.
- The canteen's wifi drops; the till keeps billing and stores orders locally.
- When the network returns it re-sends recent orders, so order 00377 appears twice.
- Orders still unsent at closing go out with the next file. The canteen is shut on Sunday, so they land in Monday's file.
- If the till crashes while writing, a line is cut off half-way.
- None of this is rare. The rules handle it: one row per order_id, business date from the timestamp, broken lines to quarantine. (Illustrative; our generator plants the same problems at known rates.)
Write the rules down before you write code, and give each rule a counter. "We dropped some bad rows" is not acceptable in a review; "296 lines quarantined, here they are" is.
From a pen drive to profiled data in HDFS
All on our Module 8 lab cluster. The full scripts are in "Run on your PC" below.
Generate the dataset
python3 make_capstone.py132 files, 52,085,748 bytes in capstone_raw/ clean orders 354,135, revenue Rs 11,524,009 duplicates 2,784 malformed 296 test_orders 13 late 2,100 unknown_item 1,619 price_mismatch 11,711 undercharged 43,710 refunds 987 upi_unconfirmed 6,854random.seed(14)makes the files identical on every computer, so your counts match ours. The script also writestruth.jsonwith these numbers.Look at the raw data
Rows are not in time order and every file repeats the header. Note the grain (one order) and the key (hdfs dfs -cat /capstone/bronze/orders/college=KHR/orders_KHR_2026-09-14.csv | head -4order_id,ts,college,student_id,item,qty,amount,pay_mode KHR-0914-00449,2026-09-14 10:42:17,KHR,S07344,samosa,1,18,UPI KHR-0914-01282,2026-09-14 12:30:28,KHR,S07543,idli,3,90,UPI KHR-0914-01489,2026-09-14 10:01:47,KHR,S06912,idli,1,30,UPIorder_id) now; both drive the design.Land it in bronze, exactly as received
Bronze keeps the original bytes, one folder per canteen (bash land.sh5 104 20.9 M /capstone/bronze/orders 1 26 28.8 M /capstone/bronze/payments 1 1 389 /capstone/refcollege=KHR). If a rule is wrong next month, we can rebuild everything from here.Profile storage
130 files, 130 blocks, 390 replicas, each block 0.4 MB. Every file costs a NameNode entry and, later, one map task. We will pay for this in Lesson 14.3.hdfs fsck /capstone/bronze | grep -E "Total (files|blocks)|Average block"Total files: 130 Total blocks (validated): 130 (avg. block size 400652 B) Default replication factor: 3Test the cleaning rules on your PC first
The same trick as Lesson 9.8: a Streaming job is just a mapper, a sort and a reducer, so a Linux pipe tests it in 25 seconds. Every counter already equalsbash local_test.sh # mapper | sort | reducer, no clusterCLEAN 354135 DUPLICATE 2784 LATE 2100 MALFORMED 296 PRICE_MISMATCH 11711 TEST_ORDER 13 UNDERCHARGED_RS 43710 UNKNOWN_ITEM 1619truth.json.Write the acceptance tests
Ten checks, written before the pipeline exists. In 14.2 the build is finished only when all ten print PASS.("orders = truth", s[0], t["clean_orders"]), ("order_id is unique", s[2], s[0]), ("quarantined lines", quarantine_count, t["malformed"]), ("UPI gap = unconfirmed", recon_gap, t["upi_unconfirmed"]),
Landing 130 files: the terminal and the NameNode
On the left what you type and see; on the right what HDFS stores and what it costs.
- The generator writes 132 files (130 data files plus menu.csv and truth.json).
- land.sh creates one folder per canteen and copies its 26 daily files.
- hdfs dfs -count: 104 order files and 26 payment files in bronze.
- du shows logical size and size with 3 replicas: 7.0 MB of Kharghar data uses 20.9 MB of disk.
- fsck: 130 files in 130 blocks, each only about 0.4 MB.
- The cleaning rules, run locally, reproduce every number in truth.json.
- Until we put them, the files exist only on the local disk; HDFS knows nothing.
- Each -put asks the NameNode for a block and streams it through a pipeline of DataNodes (Lesson 6.1).
- After landing, the NameNode holds 130 file and 130 block entries in memory.
- With replication 3 on 3 workers, every worker stores a copy of every block: 52 MB of data uses about 156 MB of disk.
- Each block is about 0.4 MB. Small files waste NameNode memory and create one map task each.
- The local pipe test runs the same Python on one machine: it checks the rules, not the cluster. (Block squares are symbolic.)
The same first week, in other companies
1. Retail chain: store sales
A supermarket chain receives nightly POS files from hundreds of stores. Before building dashboards, the team profiles them and finds re-sent batches, test transactions and stores with outdated price lists, exactly our dirt at a larger scale.
2. Payments: settlement reconciliation
A payment aggregator matches merchant orders with bank and UPI switch files every day. The acceptance test is the bank statement: every rupee must be matched, refunded or explained.
3. Highways: toll transactions
Toll lanes upload FASTag reads in batches; lanes that lose connectivity upload hours later. Reports must use the time of passage, not upload time, the same "business date from the timestamp" rule.
4. Hospitals: pharmacy sales
A hospital group finds medicines sold that are missing from the central drug master. Like our maggi, they are kept and flagged, and the master-data team is told, instead of silently losing revenue from reports.
5. Any migration
When a company moves pipelines to a new platform, the old system's output is the truth file. The new pipeline is accepted only when counts and totals match, day by day (Lesson 13.4).
Simplified; organisations differ in tools and policies. Kadak Canteens and its data are fictional.
Profile a sample before you trust it
Twelve lines from our files, with the same kinds of problems. The profiler counts what is wrong without fixing anything yet: that is profiling.
lines = [
"KHR-0912-00101,2026-09-12 08:14:02,KHR,S00412,samosa,1,18,UPI",
"KHR-0912-00102,2026-09-12 08:15:40,KHR,S03311,dosa,1,45,CASH",
"KHR-0912-00377,2026-09-12 13:12:09,KHR,S07102,thali,1,80,UPI",
"KHR-0912-00377,2026-09-12 13:12:09,KHR,S07102,thali,1,80,UPI",
"KHR-BAD-2026-09-12-0,2026-09-12 10:00:00,KHR,S00042,dosa",
"VSH-0903-00250,2026-09-03 09:40:11,VSH,TEST,idli,1,30,CASH",
"KHR-0921-00009,2026-09-21 13:30:44,KHR,S02211,maggi,1,30,UPI",
"PNL-0916-00031,2026-09-16 08:05:19,PNL,S05120,chai,2,30,UPI",
"NRL-0914-00007,2026-09-14 16:02:55,NRL,S00777,samosa,1,18,CASH",
"VSH-0915-00410,2026-09-15 13:01:00,VSH,S01010,poha,1,forty five,UPI",
"KHR-0912-00380,2026-09-12 17:55:31,KHR,S04404,vada pav,2,40,UPI",
"NRL-0914-00008,2026-09-14 13:20:02,NRL,S08008,thali,1,80,UPI",
]
menu = {"chai": 18, "dosa": 45, "thali": 80, "idli": 30, "samosa": 18, "poha": 25, "vada pav": 20}
seen, report = set(), {"rows": 0, "broken": 0, "test": 0, "duplicate": 0, "unknown_item": 0, "price_gap": 0}
for line in lines:
report["rows"] += 1
f = line.split(",")
if len(f) != 8 or not f[6].isdigit():
report["broken"] += 1
continue
if f[3] == "TEST":
report["test"] += 1
continue
if f[0] in seen:
report["duplicate"] += 1
continue
seen.add(f[0])
if f[4] not in menu:
report["unknown_item"] += 1
elif int(f[6]) != int(f[5]) * menu[f[4]]:
report["price_gap"] += 1
for k, v in report.items():
print(f"{k:<13}{v}")
print("good orders :", len(seen))if len(f) != 8 or not f[6].isdigit():Broken lines: too few fields, or an amount that is not a number. In the real job they go to quarantine.if f[0] in seen:A duplicate is a second row with the same order_id, the business key.elif int(f[6]) != int(f[5]) * menu[f[4]]:Panvel charged ₹15 for chai after the price became ₹18: 2 × 18 = 36, but the row says 30.
Run it on your PC: generate, land and profile the capstone data
Start the Module 8 lab (docker compose -p lab8 up -d in its folder). Generate the files on your PC (Python 3, about a minute), copy the folder into the namenode container, and run the scripts there as user hadoop. All outputs below are real.
1 · Generate the dataset (on your PC)
# make_capstone.py: the capstone dataset for "Kadak Canteens", 4 college canteens in Navi Mumbai (September 2026)
# Raw POS files per canteen per day, UPI gateway files per day, the menu, and truth.json
# (the right answers, so your pipeline can be checked). Same files on every computer.
import csv, json, os, random
from datetime import date, datetime, timedelta
random.seed(14)
OUT = "capstone_raw"
COLLEGES = {"KHR": 4500, "VSH": 3800, "PNL": 3000, "NRL": 2200} # orders on a normal day
MENU = [ # item, category, price, valid_from, valid_to
("chai", "drink", 15, "2026-09-01", "2026-09-15"), ("chai", "drink", 18, "2026-09-16", "2026-09-30"),
("coffee", "drink", 20, "2026-09-01", "2026-09-30"), ("samosa", "snack", 18, "2026-09-01", "2026-09-30"),
("vada pav", "snack", 20, "2026-09-01", "2026-09-30"), ("poha", "breakfast", 25, "2026-09-01", "2026-09-30"),
("idli", "breakfast", 30, "2026-09-01", "2026-09-30"), ("dosa", "meal", 45, "2026-09-01", "2026-09-30"),
("thali", "meal", 80, "2026-09-01", "2026-09-30")]
WEIGHT = {"chai": 30, "coffee": 8, "samosa": 14, "vada pav": 14, "poha": 9, "idli": 9, "dosa": 10, "thali": 6}
HOURS = [(8, 14), (9, 12), (10, 7), (11, 6), (12, 9), (13, 15), (14, 9), (15, 6), (16, 10), (17, 7), (18, 5)]
def next_open(d): # the next day the canteen is open
return d + timedelta(2 if d.weekday() == 5 else 1)
def price(item, day):
for it, cat, p, f, t in MENU:
if it == item and f <= day <= t:
return p
os.makedirs(f"{OUT}/payments", exist_ok=True)
with open(f"{OUT}/menu.csv", "w", newline="") as f:
csv.writer(f).writerows([("item", "category", "price", "valid_from", "valid_to")] + MENU)
files = {} # (college, file_day) -> list of raw lines
pays = {} # day -> list of gateway events
truth = {"clean_orders": 0, "revenue": 0, "duplicates": 0, "malformed": 0, "test_orders": 0,
"late": 0, "unknown_item": 0, "price_mismatch": 0, "undercharged": 0, "by_college": {}, "upi_net": 0, "refunds": 0, "upi_unconfirmed": 0}
days = [date(2026, 9, 1) + timedelta(d) for d in range(30)]
for c, base in COLLEGES.items():
truth["by_college"][c] = {"orders": 0, "revenue": 0}
for d in days:
if d.weekday() == 6: # canteens are closed on Sundays
continue
ds = d.isoformat()
n = int(base * random.uniform(0.85, 1.15))
for k in range(1, n + 1):
oid = f"{c}-{d:%m%d}-{k:05d}"
h = random.choices([h for h, w in HOURS], [w for h, w in HOURS])[0]
ts = f"{ds} {h:02d}:{random.randint(0, 59):02d}:{random.randint(0, 59):02d}"
item = random.choices(list(WEIGHT), list(WEIGHT.values()))[0]
if c == "KHR" and ds >= "2026-09-21" and random.random() < 0.04:
item = "maggi" # new dish, nobody added it to menu.csv
qty = random.choices([1, 2, 3], [80, 15, 5])[0]
p = 30 if item == "maggi" else price(item, ds)
if c == "PNL" and item == "chai" and ds >= "2026-09-16":
p = 15 # Panvel's POS never got the new chai price
student = f"S{random.randint(1, 9000):05d}"
if c == "VSH" and ds == "2026-09-03" and k % 250 == 0:
student = "TEST" # a POS engineer testing the till
mode = random.choices(["UPI", "CASH", "CARD"], [70, 25, 5])[0]
row = f"{oid},{ts},{c},{student},{item},{qty},{qty * p},{mode}"
fday = ds
if random.random() < 0.006: # POS offline: row arrives in the next day's file
fday = next_open(d).isoformat()
if fday > "2026-09-30":
fday = ds
files.setdefault((c, fday), []).append(row)
if student == "TEST":
truth["test_orders"] += 1
continue
truth["clean_orders"] += 1; truth["revenue"] += qty * p
truth["by_college"][c]["orders"] += 1; truth["by_college"][c]["revenue"] += qty * p
truth["late"] += fday != ds
truth["unknown_item"] += item == "maggi"
if item != "maggi" and p != price(item, ds):
truth["price_mismatch"] += 1; truth["undercharged"] += qty * (price(item, ds) - p)
if random.random() < 0.008: # POS re-sent the row (same or next file)
again = fday if random.random() < 0.7 else min(next_open(date.fromisoformat(fday)).isoformat(), "2026-09-30")
files.setdefault((c, again), []).append(row)
truth["duplicates"] += 1
if mode == "UPI":
txn = f"T{random.randint(10**9, 10**10 - 1)}"
ev = pays.setdefault(ds, [])
outage = c == "NRL" and ds == "2026-09-22" and h == 13 # gateway down: no confirmations
if random.random() < 0.01: # first attempt failed, student paid again
ev.append({"txn_id": txn + "F", "order_id": oid, "amount": qty * p, "status": "FAILED", "ts": ts})
if outage:
truth["upi_unconfirmed"] += qty * p
else:
ev.append({"txn_id": txn, "order_id": oid, "amount": qty * p, "status": "SUCCESS", "ts": ts})
truth["upi_net"] += qty * p
if random.random() < 0.004 and not outage: # refunded later (cold food, wrong item)
ev.append({"txn_id": txn + "R", "order_id": oid, "amount": -qty * p, "status": "REFUNDED", "ts": ts})
truth["upi_net"] -= qty * p; truth["refunds"] += 1
bad = ["{oid},{ts},{c},S00042,dosa", "{oid},{ts},{c},S00042,dosa,1,forty five,UPI", "{oid},2026-09-31 10:00:00,{c},S00042,idli,1,30,CASH"]
for (c, ds), rows in sorted(files.items()):
random.shuffle(rows)
for j in range(random.randint(0, 6)): # a few broken lines per file
rows.insert(random.randrange(len(rows)), random.choice(bad).format(oid=f"{c}-BAD-{ds}-{j}", ts=ds + " 10:00:00", c=c))
truth["malformed"] += 1
os.makedirs(f"{OUT}/orders/{c}", exist_ok=True)
with open(f"{OUT}/orders/{c}/orders_{c}_{ds}.csv", "w") as f:
f.write("order_id,ts,college,student_id,item,qty,amount,pay_mode\n" + "\n".join(rows) + "\n")
for ds, ev in sorted(pays.items()):
with open(f"{OUT}/payments/upi_{ds}.jsonl", "w") as f:
f.write("".join(json.dumps(e) + "\n" for e in ev))
json.dump(truth, open(f"{OUT}/truth.json", "w"), indent=1)
size = sum(os.path.getsize(os.path.join(r, x)) for r, _, fs in os.walk(OUT) for x in fs)
nfiles = sum(len(fs) for _, _, fs in os.walk(OUT))
print(f"{nfiles} files, {size:,} bytes in {OUT}/")
print(f"clean orders {truth['clean_orders']:,}, revenue Rs {truth['revenue']:,}")
for k in ["duplicates", "malformed", "test_orders", "late", "unknown_item", "price_mismatch", "undercharged", "refunds", "upi_unconfirmed"]:
print(f" {k:<15}{truth[k]:>7,}")
# python3 make_capstone.py
# docker cp . lab8-namenode-1:/tmp/capstone (run inside the folder with the scripts and capstone_raw/)The seed fixes every random choice, so your files and numbers are identical to ours. truth.json holds these totals.
2 · Land in bronze
# land.sh: copy the raw files into the bronze layer of HDFS, exactly as received
hdfs dfs -rm -r -f -skipTrash /capstone
hdfs dfs -mkdir -p /capstone/bronze/payments /capstone/ref
for c in KHR VSH PNL NRL; do
hdfs dfs -mkdir -p /capstone/bronze/orders/college=$c
hdfs dfs -put capstone_raw/orders/$c/*.csv /capstone/bronze/orders/college=$c/
done
hdfs dfs -put capstone_raw/payments/*.jsonl /capstone/bronze/payments/
hdfs dfs -put capstone_raw/menu.csv /capstone/ref/
hdfs dfs -count -h /capstone/bronze/orders /capstone/bronze/payments /capstone/ref
# docker exec -it -w /tmp/capstone lab8-namenode-1 bash land.shEach hdfs dfs command starts a JVM, so landing 130 files took 47 seconds. Columns: directories, files, size.
3 · Profile what landed
# profile.sh: first look at the bronze data (what arrived, how big, how many files and blocks)
hdfs dfs -ls /capstone/bronze/orders
hdfs dfs -ls /capstone/bronze/orders/college=PNL | head -4
hdfs dfs -cat /capstone/bronze/orders/college=KHR/orders_KHR_2026-09-14.csv | head -4
hdfs dfs -cat /capstone/bronze/payments/upi_2026-09-14.jsonl | head -2
hdfs dfs -du -s -h /capstone/bronze/orders/* /capstone/bronze/payments
hdfs fsck /capstone/bronze 2>/dev/null | grep -E "Total (files|blocks|size)|Average block|Minimally|Default replication""head" closes the pipe early, so hdfs dfs -cat prints a harmless "Unable to write to output stream"; we removed those lines and fsck's erasure-coding section. du shows size and size × 3 replicas.
4 · Test the cleaning rules locally (no cluster)
#!/usr/bin/env python3
# clean_mapper.py: parse raw POS lines; key every good row by order_id so copies meet in one reducer
import os, sys
from datetime import datetime
path = os.environ.get("mapreduce_map_input_file", "orders_XXX_0000-00-00.csv")
fday = path.rsplit("_", 1)[-1][:10] # date of the file the line arrived in
def count(name):
print(f"reporter:counter:capstone,{name},1", file=sys.stderr)
for line in sys.stdin:
line = line.rstrip("\n")
if not line or line.startswith("order_id,"):
continue # header of each file
f = line.split(",")
try:
if len(f) != 8:
raise ValueError("fields")
datetime.strptime(f[1], "%Y-%m-%d %H:%M:%S") # rejects 2026-09-31
int(f[5]), int(f[6])
except ValueError:
count("MALFORMED")
print(f"~BAD\t{fday}\t{line}") # quarantine, keep the raw line
continue
if f[3] == "TEST":
count("TEST_ORDER")
continue
print(f"{f[0]}\t{fday}\t{line}")
#!/usr/bin/env python3
# clean_reducer.py: one line per order_id (keep the earliest copy), add category, flag problems
import csv, sys
menu = {} # item -> [(from, to, price, category)]
for r in csv.DictReader(open("menu.csv")):
menu.setdefault(r["item"], []).append((r["valid_from"], r["valid_to"], int(r["price"]), r["category"]))
def count(name, k=1):
print(f"reporter:counter:capstone,{name},{k}", file=sys.stderr)
def finish(copies):
fday, line = min(copies) # earliest file wins
count("DUPLICATE", len(copies) - 1)
oid, ts, college, student, item, qty, amount, mode = line.split(",")
dt, qty, amount = ts[:10], int(qty), int(amount)
late = fday > dt
price = next(((p, c) for a, b, p, c in menu.get(item, []) if a <= dt <= b), None)
category = price[1] if price else "unknown"
under = qty * price[0] - amount if price else 0
count("CLEAN")
if late: count("LATE")
if not price: count("UNKNOWN_ITEM")
if under: count("PRICE_MISMATCH"); count("UNDERCHARGED_RS", under)
print("\t".join(map(str, ["OK", oid, dt, ts, college, student, item, category, qty, amount, mode,
int(late), int(not price), under])))
key, copies = None, []
for line in sys.stdin:
k, fday, raw = line.rstrip("\n").split("\t", 2)
if k == "~BAD":
print(f"BAD\t{fday}\t{raw}")
continue
if k != key and copies:
finish(copies); copies = []
key = k
copies.append((fday, raw))
if copies:
finish(copies)
# run mapper | sort | reducer on your PC first (no cluster needed)
for f in capstone_raw/orders/*/*.csv; do mapreduce_map_input_file=$f python3 clean_mapper.py < $f; done 2>local_mapcnt \
| LC_ALL=C sort -t$'\t' -k1,1 | (cd capstone_raw && python3 ../clean_reducer.py) 2>local_redcnt > local_clean_local.tsv
sort local_mapcnt local_redcnt | awk -F, '{s[$2]+=$3} END {for (k in s) print k, s[k]}' | sort
wc -l local_clean_local.tsvThe mapper and reducer are explained line by line in Lesson 14.2. Here they serve as a profiler: every counter equals truth.json before the cluster is involved.
Keep the earliest copy of each order
Three copies of rows arrive in different files. The dictionary keeps, for each order_id, the copy from the earliest file. That decides both "duplicate" and "late".
Worked examples: real problems, solved and traced in memory
Three calculations every capstone review asks for: how much data, how dirty, and how big next year.
Orders per canteen per open day
Problem statement
From the true totals, how many orders does each canteen serve on an average open day? The canteens were open 26 days in September.
KHR 117,937 · VSH 100,373 · PNL 78,438 · NRL 57,387 · 26 days
KHR 4536 orders/day VSH 3860 orders/day PNL 3017 orders/day NRL 2207 orders/day
Approach
- Divide each canteen's month by the open days.
- Round to whole orders.
- Print them busiest first.
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 dirty is the data?
Problem statement
Express each problem as a share of the 357,228 data lines, and say which ones change revenue if ignored.
duplicates 2,784 · malformed 296 · test 13 · late 2,100
duplicate 2784 0.78% malformed 296 0.08% test 13 0.00% late 2100 0.59% lines that are not clean orders: 3093
Approach
- Share = count ÷ data lines.
- Duplicates add revenue twice; late rows move revenue to the wrong day.
- Print as percentages.
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.
What if Kadak grows to 40 canteens?
Problem statement
The owner plans 40 canteens. Keep today's average file size and daily volume per canteen. How many order files and bytes per year, and how many map tasks would one month need if we keep one file per canteen per day?
104 files = 20.9 MB per month for 4 canteens · 312 open days a year
files/year: 12480 MB/year: 2508 map tasks for one month: 1040
Approach
- Files per year = canteens × open days.
- Bytes scale with canteens.
- One map task per small file for a month.
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.1 in one screen
- Capstone = one realistic problem end to end: requirements → inventory → profiling → rules → pipeline → acceptance tests.
- Kadak Canteens: 4 canteens, 104 POS CSVs + 26 UPI files + menu = 130 files, 52 MB; 354,135 true orders, ₹11,524,009.
- Grain = one order; business key =
order_id; business date fromts, not the file name. - Planted dirt: 2,784 duplicates, 296 malformed, 13 test, 2,100 late, 1,619 unknown item, 11,711 undercharged (₹43,710), 201 unconfirmed UPI (₹6,854).
- Rules: quarantine broken lines, drop test rows, keep the earliest copy, keep but flag unknown items and price gaps; never fix money silently.
- Bronze keeps raw bytes; fsck showed 130 blocks of 0.4 MB: small files.
- A local pipe test (mapper | sort | reducer) reproduced every counter before using the cluster.
Interview and viva questions
How do you start a data engineering project?
By writing down the business questions and deliverables, listing every source (format, frequency, volume, key, owner), profiling the real data for counts, keys, nulls, invalid values, duplicates and late records, agreeing rules for each problem, and agreeing acceptance criteria against a trusted source before building the pipeline.
What is data profiling?
Measuring what the data really contains before using it: row counts per source and day, distinct and duplicate keys, null and invalid values, value ranges, unexpected categories and arrival delays. Profiling turns assumptions ("order_id is unique") into facts ("2,784 order_ids appear twice").
What is the grain of a table and why does it matter?
The grain is what one row represents, for example one order. Every aggregation, join and deduplication rule depends on it: if you think a row is an order but it is really an order line, revenue and counts are wrong.
How do you handle late-arriving data?
Use the event time (when the order happened) for business dates, not the arrival or file date; partition by event date; and make loads idempotent so a late row can be added by re-running that day without double counting. Flag late rows so delays can be monitored.
Why keep bad rows instead of deleting them?
Because someone must be able to see what was rejected and why: a quarantine table with the raw line and a reason makes the pipeline auditable, lets source teams fix their systems, and proves that the totals add up (clean + quarantined + dropped = received).
Practice problems
Profiling and planning exercises on the capstone dataset.
Do the lines add up?
Check that clean orders + duplicates + malformed + test orders equals the data lines in the order files.
354,135 · 2,784 · 296 · 13 · 357,228
357228 357228
Show solution and explanation
print(354135 + 2784 + 296 + 13, 357228)How it works: Both are 357,228. An equation like this belongs in every pipeline review: nothing appeared or vanished.
Headers in the files
The order files had 357,332 lines in total. How many were headers?
357,332 lines · 357,228 data lines
104 headers, one per file
Show solution and explanation
print(357332 - 357228, "headers, one per file")How it works: 104 headers = 104 files. Forgetting to skip them would create 104 "malformed" rows.
Average order value
Revenue ₹11,524,009 from 354,135 orders: what is the average order value?
₹11,524,009 · 354,135
32.54
Show solution and explanation
print(round(11524009 / 354135, 2))How it works: About ₹32.54 per order.
Replicated size
Bronze holds 52,084,844 bytes with replication 3. How many MB of disk does it use across the cluster?
52,084,844 B · rf 3
149.0 MB
Show solution and explanation
print(round(52084844 * 3 / 1024 / 1024, 1), "MB")How it works: About 149 MB (du showed 20.9 + 10.2 + 13.9 + 17.8 + 86.3 MB with replicas).
Share of revenue per canteen
Print each canteen's share of total revenue.
by_college revenue from truth.json
KHR 33.5% VSH 28.4% PNL 21.8% NRL 16.3%
Show solution and explanation
rev = {"KHR": 3860438, "VSH": 3270741, "PNL": 2517725, "NRL": 1875105}
total = sum(rev.values())
for c, r in rev.items():
print(c, f"{r / total:.1%}")How it works: Kharghar earns a third of the money; Nerul about a sixth.
Find duplicate keys
Given a list of order_ids, print the ones that appear more than once and how often.
a list of ids
{'KHR-1': 3, 'VSH-7': 2}Show solution and explanation
ids = ["KHR-1", "KHR-2", "KHR-1", "VSH-7", "KHR-1", "VSH-7", "PNL-3"]
counts = {}
for i in ids:
counts[i] = counts.get(i, 0) + 1
print({k: v for k, v in counts.items() if v > 1})How it works: KHR-1 three times, VSH-7 twice: 3 extra rows to remove.
Cost of the Panvel price bug
Panvel sold chai at ₹15 instead of ₹18 for 15 open days (16–30 Sept, minus Sundays). The lab found ₹43,710 undercharged. What is that per open day, and per year if never fixed (312 open days)?
₹43,710 · 13 open days
3362 Rs/day, 1049040 Rs/year
Show solution and explanation
days = 13 # 16-30 Sept has 15 days, 2 of them Sundays
per_day = 43710 / days
print(round(per_day), "Rs/day,", round(per_day * 312), "Rs/year")How it works: About ₹3,362 a day, ₹10.5 lakh a year: a data pipeline that pays for itself.
Write an acceptance test
Write a function that compares pipeline results with truth values and prints PASS or FAIL for each, plus a summary.
two dictionaries
PASS orders 354135 354135 PASS revenue 11524009 11524009 FAIL late 2101 2100 2/3 passed
Show solution and explanation
truth = {"orders": 354135, "revenue": 11524009, "late": 2100}
result = {"orders": 354135, "revenue": 11524009, "late": 2101}
def check(result, truth):
ok = 0
for k in truth:
good = result.get(k) == truth[k]
ok += good
print("PASS" if good else "FAIL", k, result.get(k), truth[k])
print(f"{ok}/{len(truth)} passed")
check(result, truth)How it works: One wrong number fails the run; in the real check.py this is how a broken optimisation was caught in Lesson 14.3.
Real-world scenario questions (asked in company interviews)
Interviewers love "your first week on a project" questions. These are the common ones.
"You get a pen drive of files and a vague request. What do you do first?"
A small business wants "reports" from its till and payment files.
The interviewer is checking: that you clarify before you code.
Show a strong answer
- Turn "reports" into concrete questions with the owner (revenue per canteen per day, item mix, peak hours, UPI reconciliation) and agree who uses them and how often.
- Inventory the sources: format, frequency, volume, key, owner, and find a trusted total to check against (bank statement).
- Profile, list the problems with counts, agree rules, then design the pipeline.
"One shop charged the old price for two weeks. Do you correct the revenue?"
The menu says ₹18; the till charged ₹15.
The interviewer is checking: that you don't change financial facts silently.
Show a strong answer
- No. Revenue is what customers actually paid; it must match the cash and UPI received.
- Flag the rows, compute the gap (₹43,710 in our data) and report it to the business with the shop and dates, so they fix the till.
- If finance wants a "list price" view, add it as a separate column, never overwrite the amount.
"Some shops upload yesterday's sales today. How do you design for that?"
Daily reports were already sent when the late rows arrive.
The interviewer is checking: event time vs arrival time, idempotency.
Show a strong answer
- Partition by event date from the timestamp; keep the arrival file and time as lineage columns and flag late rows.
- Re-process the affected event dates (a small backfill) with overwrite-by-partition, so re-runs never double count.
- Measure lateness; agree a cut-off (e.g. reports final after 2 days) and re-issue changed reports.
"How do you know your pipeline's numbers are right?"
The business has been burned by wrong dashboards before.
The interviewer is checking: reconciliation and automated checks.
Show a strong answer
- Reconcile against an independent source: bank or gateway totals, source-system row counts, the old report during migration.
- Prove conservation: clean + quarantined + dropped = received, per day and source.
- Automate it: acceptance checks run after every load and fail the pipeline before gold is published.