The problem and the dataset

The capstone ties the whole course into one project. In this first part we do what experienced data engineers do before writing a pipeline: pin down the questions, take an inventory of the files, profile them in HDFS, list every data problem with a rule and a counter, and write the acceptance tests the finished pipeline must pass.

Module 14 · Capstone project⏱ 30 minIntermediateReal dataset on our clusterKnown right answers (truth.json)Runs in your browser (Python)8 practice problems
1 The story

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?"

September 2026 · Kadak Canteens KHR till · Kharghar VSH till · Vashi PNL till · Panvel NRL till · Nerul 104 order CSVs 4 tills × 26 open days 26 UPI files gateway, one per day The owner's four questions 1. Revenue per canteen per day? 2. What sells, and where? 3. When are we busiest? 4. Did every UPI rupee arrive? accountant: 4 days in Excel, totals never match Our dataset: 130 files, 52 MB, generated by make_capstone.py with a fixed seed, so you get the same files. myphatshala.in
Kabir

Fifty megabytes? My laptop can open that in Excel. Why would I need Hadoop?

Arjun Sir

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.

Riya

Then the first job is to write code?

Arjun Sir

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.

Kabir

How can we prove it? Nobody knows the right answer.

Arjun Sir

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.

2 Definition

A capstone: one real problem, end to end

Formal definition (write this in exams)

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.

In simple words

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

RequirementA question the business needs answered, e.g. revenue per canteen per day.
Source inventoryA list of every input: format, size, how often it arrives, key, owner.
ProfilingMeasuring the data as it really is before trusting it.
GrainWhat one row means: here, one order (not one item, not one day).
Business keyThe column that identifies a row: order_id.
Late recordA row for 12 September that arrives in the file of 14 September.
QuarantineA place for rows that cannot be parsed, kept for inspection, never silently dropped.
Acceptance testA check with an expected number; the pipeline is done when all pass.

Architecture at a glance

The plan we agree today and build in Lesson 14.2. Every box uses something from an earlier module.

pen drive 104 orders_*.csv 26 upi_*.jsonl menu.csv truth.json (the right answers) bronze HDFS, as received college=KHR/… payments/ Modules 5–6 clean MapReduce Streaming (Python) dedupe, flag, count Modules 8–9 silver + gold Spark on YARN Parquet by day KPI tables Modules 10, 11, 13 answers revenue/day item mix peak hours UPI reconciliation acceptance checks: pipeline numbers = truth.json (orders, revenue, duplicates, late, UPI …) Cluster: our Module 8 lab (Hadoop 3.5.0, 3 workers with DataNode + NodeManager) + Spark 4.0.4 from Lesson 13.2 myphatshala.in

From our lab (the dataset in numbers)

Files104 order CSVs (4 canteens × 26 open days, closed on Sundays) + 26 UPI JSON-lines files + menu.csv; 52,084,844 bytes in HDFS
Lines357,332 lines in the order files: 104 headers + 357,228 data lines
True answer354,135 real orders, revenue ₹11,524,009 (KHR ₹38,60,438 · VSH ₹32,70,741 · PNL ₹25,17,725 · NRL ₹18,75,105)
Planted problems2,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 HDFS130 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 checkthe 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

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

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; same order_id twice → keep one; item not in menu → keep, category unknown; 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_id across all files. That is a shuffle: MapReduce keyed by order_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.

3 Types and ways to use it

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.

104 files · 20.9 MB

Payments (UPI JSON lines)

One JSON object per gateway event: txn_id, order_id, amount, status, ts. Status is SUCCESS, FAILED or REFUNDED.

26 files · 28.8 MB

Menu (reference)

Item, category and price with valid_from / valid_to: chai went from ₹15 to ₹18 on 16 September.

9 rows

truth.json

Written by the generator: the counts and totals a correct pipeline must reproduce. Our acceptance tests read it.

the referee

The data quality catalogue

ProblemHow it looks in the filesRuleExpected count
DuplicateSame row twice; the till re-sent it after a network drop, sometimes in the next day's fileKeep one per order_id (earliest file)2,784
Malformed…,dosa (5 fields), forty five as amount, date 2026-09-31Quarantine with the raw line296
Test orderstudent_id = TEST at Vashi on 3 Sept (a technician testing the till)Drop and count13
LateAn order of the 12th inside the file of the 14th (Sunday in between)Count on its own day; flag late2,100
Unknown itemmaggi at Kharghar from 21 Sept; nobody added it to menu.csvKeep revenue; category unknown; report1,619
Price mismatchPanvel charged chai at ₹15 after the change to ₹18Keep amount charged; flag and sum the gap11,711 orders · ₹43,710
Unconfirmed UPIUPI orders at Nerul, 22 Sept, 13:00–13:59, with no SUCCESS from the gatewayReport in reconciliation201 orders · ₹6,854

Animated example: how one till creates dirty data

A Saturday at the Kharghar till 6 steps
Animated example till KHR · Sat 12 Sept files received …00101 chai 1 15 UPI…00102 dosa 1 45 CASH wifi down 13:10–13:40…00377 thali 1 80 UPI re-send: …00377 again orders_KHR_2026-09-12.csv …00377 ×2 (duplicate) orders_KHR_2026-09-14.csv …0912-00380 (late) …,dosa (broken line) order_id finds both copies,ts gives the right day normal rows: one line per order the till keeps billing offline on reconnect it re-sends: same order_id twice rows still unsent at closing reach Monday's file a crash mid-write leaves a cut-off line rules: dedupe on key, date from ts, quarantine myphatshala.in

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.

4 Step by step

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.

  1. Generate the dataset

    python3 make_capstone.py
    132 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,854
    random.seed(14) makes the files identical on every computer, so your counts match ours. The script also writes truth.json with these numbers.
  2. Look at the raw data

    hdfs dfs -cat /capstone/bronze/orders/college=KHR/orders_KHR_2026-09-14.csv | head -4
    order_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,UPI
    Rows are not in time order and every file repeats the header. Note the grain (one order) and the key (order_id) now; both drive the design.
  3. Land it in bronze, exactly as received

    bash land.sh
    5 104 20.9 M /capstone/bronze/orders 1 26 28.8 M /capstone/bronze/payments 1 1 389 /capstone/ref
    Bronze keeps the original bytes, one folder per canteen (college=KHR). If a rule is wrong next month, we can rebuild everything from here.
  4. Profile storage

    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: 3
    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.
  5. Test the cleaning rules on your PC first

    bash local_test.sh      # mapper | sort | reducer, no cluster
    CLEAN 354135 DUPLICATE 2784 LATE 2100 MALFORMED 296 PRICE_MISMATCH 11711 TEST_ORDER 13 UNDERCHARGED_RS 43710 UNKNOWN_ITEM 1619
    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 equals truth.json.
  6. Write the acceptance tests

    ("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"]),
    Ten checks, written before the pipeline exists. In 14.2 the build is finished only when all ten print PASS.
5 See it happen

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.

From pen drive to bronze 6 steps
What you see terminal (your PC → namenode container) python3 make_capstone.py132 files, 52,085,748 bytesclean orders 354,135 hdfs dfs -mkdir -p …/college=KHRhdfs dfs -put …/KHR/*.csv …(26 files per canteen) hdfs dfs -count -h /capstone/bronze/…5 104 20.9 M …/orders1 26 28.8 M …/payments hdfs dfs -du -s -h …/college=*7.0 M 20.9 M college=KHR3.4 M 10.2 M college=NRL hdfs fsck /capstone/bronzeTotal files: 130avg. block size 400652 B bash local_test.shCLEAN 354135 DUPLICATE 2784LATE 2100 MALFORMED 296 myphatshala.in
Behind the screen NameNode (RAM) nothing yet: files areon the local disk only /capstone/bronze/… + 26 entries (KHR) 130 file entries130 block entries ~260 objects for 52 MB(1 file = 1 block here) 3 DataNodes every worker holds a replicaof every block (rf = 3) disk used = 3 × 52 MB bronze = the bytes exactly as received one folder per canteen: college=KHR, …Spark and Hive read it as a column later 0.4 MB blocks in 128 MB slots104 order files → 104 map tasks later no cluster used in step 6:mapper | sort | reducer on one PC myphatshala.in
6 Real-world use

The same first week, in other companies

300 storesPOS files

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.

Store tills→inventory + profiling + rules→Trusted sales tables
ledger vsstatement

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.

Orders + bank files→match on transaction key→Exceptions report
toll plazalate uploads

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.

Lane batches→event time, dedupe on read ID→Daily revenue per plaza
pharmacymaster data

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.

Branch sales→join with master, flag unknown→Fix the master
migrationtruth = old system

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).

Old reports→acceptance tests→Cut-over

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

7 Run the code

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))
rows 12 broken 2 test 1 duplicate 1 unknown_item 1 price_gap 1 good orders : 8
  • 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/)
132 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,854

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.sh
5 104 20.9 M /capstone/bronze/orders 1 26 28.8 M /capstone/bronze/payments 1 1 389 /capstone/ref real 0m47.052s

Each 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"
Found 4 items drwxr-xr-x - hadoop supergroup 0 2026-10-23 05:01 /capstone/bronze/orders/college=KHR drwxr-xr-x - hadoop supergroup 0 2026-10-23 05:01 /capstone/bronze/orders/college=NRL drwxr-xr-x - hadoop supergroup 0 2026-10-23 05:01 /capstone/bronze/orders/college=PNL drwxr-xr-x - hadoop supergroup 0 2026-10-23 05:01 /capstone/bronze/orders/college=VSH Found 26 items -rw-r--r-- 3 hadoop supergroup 171626 2026-10-23 05:01 /capstone/bronze/orders/college=PNL/orders_PNL_2026-09-01.csv -rw-r--r-- 3 hadoop supergroup 202755 2026-10-23 05:01 /capstone/bronze/orders/college=PNL/orders_PNL_2026-09-02.csv -rw-r--r-- 3 hadoop supergroup 207358 2026-10-23 05:01 /capstone/bronze/orders/college=PNL/orders_PNL_2026-09-03.csv order_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,UPI {"txn_id": "T8596062784", "order_id": "KHR-0914-00001", "amount": 50, "status": "SUCCESS", "ts": "2026-09-14 15:01:29"} {"txn_id": "T2583005334", "order_id": "KHR-0914-00002", "amount": 20, "status": "SUCCESS", "ts": "2026-09-14 09:27:06"} 7.0 M 20.9 M /capstone/bronze/orders/college=KHR 3.4 M 10.2 M /capstone/bronze/orders/college=NRL 4.6 M 13.9 M /capstone/bronze/orders/college=PNL 5.9 M 17.8 M /capstone/bronze/orders/college=VSH 28.8 M 86.3 M /capstone/bronze/payments Total size: 52084844 B Total files: 130 Total blocks (validated): 130 (avg. block size 400652 B) Minimally replicated blocks: 130 (100.0 %) Default replication factor: 3 Average block replication: 3.0

"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.tsv
CLEAN 354135 DUPLICATE 2784 LATE 2100 MALFORMED 296 PRICE_MISMATCH 11711 TEST_ORDER 13 UNDERCHARGED_RS 43710 UNKNOWN_ITEM 1619 354431 local_clean_local.tsv

The 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.

8 Line by line

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.

Example 1BeginnerProfiling · volume

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.

Input
KHR 117,937 · VSH 100,373 · PNL 78,438 · NRL 57,387 · 26 days
Expected output
KHR 4536 orders/day
VSH 3860 orders/day
PNL 3017 orders/day
NRL 2207 orders/day

Approach

  1. Divide each canteen's month by the open days.
  2. Round to whole orders.
  3. 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.

Example 2IntermediateProfiling · quality

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.

Input
duplicates 2,784 · malformed 296 · test 13 · late 2,100
Expected output
duplicate   2784  0.78%
malformed    296  0.08%
test          13  0.00%
late        2100  0.59%
lines that are not clean orders: 3093

Approach

  1. Share = count ÷ data lines.
  2. Duplicates add revenue twice; late rows move revenue to the wrong day.
  3. 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.

Example 3AdvancedSizing · next year

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?

Input
104 files = 20.9 MB per month for 4 canteens · 312 open days a year
Expected output
files/year: 12480
MB/year: 2508
map tasks for one month: 1040

Approach

  1. Files per year = canteens × open days.
  2. Bytes scale with canteens.
  3. 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.

9 Quiz and revision

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 from ts, 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.

P1Easy

Do the lines add up?

Check that clean orders + duplicates + malformed + test orders equals the data lines in the order files.

Input
354,135 · 2,784 · 296 · 13 · 357,228
Expected output
357228 357228
Show solution and explanation
print(354135 + 2784 + 296 + 13, 357228)
357228 357228

How it works: Both are 357,228. An equation like this belongs in every pipeline review: nothing appeared or vanished.

P2Easy

Headers in the files

The order files had 357,332 lines in total. How many were headers?

Input
357,332 lines · 357,228 data lines
Expected output
104 headers, one per file
Show solution and explanation
print(357332 - 357228, "headers, one per file")
104 headers, one per file

How it works: 104 headers = 104 files. Forgetting to skip them would create 104 "malformed" rows.

P3Easy

Average order value

Revenue ₹11,524,009 from 354,135 orders: what is the average order value?

Input
₹11,524,009 · 354,135
Expected output
32.54
Show solution and explanation
print(round(11524009 / 354135, 2))
32.54

How it works: About ₹32.54 per order.

P4Medium

Replicated size

Bronze holds 52,084,844 bytes with replication 3. How many MB of disk does it use across the cluster?

Input
52,084,844 B · rf 3
Expected output
149.0 MB
Show solution and explanation
print(round(52084844 * 3 / 1024 / 1024, 1), "MB")
149.0 MB

How it works: About 149 MB (du showed 20.9 + 10.2 + 13.9 + 17.8 + 86.3 MB with replicas).

P5Medium

Share of revenue per canteen

Print each canteen's share of total revenue.

Input
by_college revenue from truth.json
Expected output
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%}")
KHR 33.5% VSH 28.4% PNL 21.8% NRL 16.3%

How it works: Kharghar earns a third of the money; Nerul about a sixth.

P6Medium

Find duplicate keys

Given a list of order_ids, print the ones that appear more than once and how often.

Input
a list of ids
Expected output
{'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})
{'KHR-1': 3, 'VSH-7': 2}

How it works: KHR-1 three times, VSH-7 twice: 3 extra rows to remove.

P7Business problem

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)?

Input
₹43,710 · 13 open days
Expected output
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")
3362 Rs/day, 1049040 Rs/year

How it works: About ₹3,362 a day, ₹10.5 lakh a year: a data pipeline that pays for itself.

P8Advanced

Write an acceptance test

Write a function that compares pipeline results with truth values and prints PASS or FAIL for each, plus a summary.

Input
two dictionaries
Expected output
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)
PASS orders 354135 354135 PASS revenue 11524009 11524009 FAIL late 2101 2100 2/3 passed

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.

Project start · processFresher

"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.
Data quality · judgementFresher

"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.
Design · late dataExperienced

"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.
Testing · trustExperienced

"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.