This exercise builds Stage 3 of the capstone pipeline: the Glue-style ETL transformation from Bronze to Silver. Using DuckDB SQL to simulate the Glue PySpark transformation from Module 2 Lesson 8, you will implement the full Silver transformation — deduplication, type casting, null rejection, quality gate, and phase/boundary enrichment — and write the output to the Silver S3 path with an Iceberg snapshot. The exercise also simulates a Glue Job Bookmark by tracking which Bronze files have been processed and implements a schema evolution step adding a `wicket_phase` column.
The key correctness assertions for Silver are: row count is less than Bronze (duplicates and nulls removed), all Silver rows pass the quality gate (runs in [0,6], bowler not null), the Iceberg snapshot records an overwrite operation with the correct row count, and the schema evolution step adds `is_boundary` to existing rows with the correct default value. A moto-based S3 interaction test verifies that the Bronze path read and Silver path write work correctly using the AWS SDK mock.
Step 1 — Glue ETL Transformation and Iceberg Silver Snapshot
Read Bronze data from the simulated S3 store, apply the full Silver transformation SQL (deduplication + type casting + quality gate + enrichment) using DuckDB, write Silver to a new S3 path with Iceberg overwrite snapshot, implement the Glue Job Bookmark simulation (track processed Bronze files), and verify the Silver Iceberg snapshot records an overwrite operation. Assert Silver row count is less than Bronze, all quality checks pass, and the Job Bookmark correctly marks the Bronze file as processed.
# capstone_silver_transform.py — Step 1: Glue ETL and Iceberg Silver snapshot
import duckdb
import pandas as pd
import numpy as np
from datetime import datetime, timezone
# Reuse S3, ICEBERG_SNAPSHOTS from Lesson 32 (in full pipeline: passed as state)
# For standalone: reinitialise
np.random.seed(42)
# ── Read from Bronze S3 ───────────────────────────────────────────────────────
bronze_df = S3[BRONZE_PATH].copy()
assert len(bronze_df) == 252, f"Expected 252 bronze rows, got {len(bronze_df)}"
con = duckdb.connect(":memory:")
con.register("bronze_raw", bronze_df)
# ── Silver transformation (Glue PySpark equivalent in DuckDB SQL) ─────────────
SILVER_SQL = """
WITH deduplicated AS (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY delivery_id ORDER BY _ingested_at) AS _rn
FROM bronze_raw
),
type_cast AS (
SELECT
CAST(delivery_id AS BIGINT) AS delivery_id,
CAST(match_id AS INTEGER) AS match_id,
CAST(over AS INTEGER) AS over,
CAST(runs_scored AS INTEGER) AS runs_scored,
CAST(is_wicket AS BOOLEAN) AS is_wicket,
bowler,
batter,
CAST(_ingested_at AS TIMESTAMPTZ) AS ingested_at
FROM deduplicated
WHERE _rn = 1
AND bowler IS NOT NULL
AND CAST(runs_scored AS INTEGER) BETWEEN 0 AND 6
),
enriched AS (
SELECT *,
CASE
WHEN over <= 6 THEN 'powerplay'
WHEN over <= 15 THEN 'middle'
ELSE 'death'
END AS phase,
runs_scored >= 4 AS is_boundary
FROM type_cast
)
SELECT * FROM enriched
"""
silver_df = con.execute(SILVER_SQL).df()
# Write Silver to S3 (mutable — MERGE-based)
SILVER_PATH = "s3://ipl-lakehouse-bronze-prod/silver/deliveries/year=2024/month=04/day=20/silver.parquet"
S3[SILVER_PATH] = silver_df.copy()
# Iceberg overwrite snapshot for Silver
def iceberg_overwrite_snapshot(table: str, path: str, n_rows: int) -> dict:
snap = {
"snapshot_id": len(ICEBERG_SNAPSHOTS) + 1,
"operation": "overwrite",
"table": table,
"path": path,
"added_records": n_rows,
"deleted_records": 0,
"timestamp": datetime.now(timezone.utc).isoformat(),
}
ICEBERG_SNAPSHOTS.append(snap)
return snap
snap2 = iceberg_overwrite_snapshot("silver.deliveries", SILVER_PATH, len(silver_df))
# ── Assertions ────────────────────────────────────────────────────────────────
assert len(silver_df) < len(bronze_df), "Silver must be smaller than Bronze"
assert silver_df["bowler"].notna().all(), "No null bowlers in Silver"
assert silver_df["runs_scored"].between(0, 6).all(), "All runs in [0,6]"
assert "phase" in silver_df.columns
assert "is_boundary" in silver_df.columns
assert snap2["operation"] == "overwrite"
assert snap2["added_records"] == len(silver_df)
print(f" Silver: {len(silver_df)} rows (bronze={len(bronze_df)}, rejected={len(bronze_df)-len(silver_df)}) ✓")
print(f" Quality: no null bowlers, all runs in [0,6] ✓")
print(f" Iceberg snapshot {snap2['snapshot_id']}: overwrite, {snap2['added_records']} records ✓")
# ── Glue Job Bookmark simulation ──────────────────────────────────────────────
JOB_BOOKMARK: dict[str, str] = {} # source_path → processed_at
def bookmark_processed(path: str) -> None:
JOB_BOOKMARK[path] = datetime.now(timezone.utc).isoformat()
def is_already_processed(path: str) -> bool:
return path in JOB_BOOKMARK
bookmark_processed(BRONZE_PATH)
assert is_already_processed(BRONZE_PATH) == True
assert is_already_processed("other/path.parquet") == False
print(f" Job Bookmark: {BRONZE_PATH} marked as processed ✓")
print("Step 1 ✓: Glue ETL, Iceberg Silver snapshot, Job Bookmark complete")Step 2 — Schema Evolution and Idempotency
Simulate an Iceberg schema evolution event by adding a `wicket_phase` column to the Silver table — existing rows receive a default of `None`, new rows receive the computed phase value. Then run the full Silver transformation a second time for the same logical date and assert the Silver row count and aggregate runs sum are identical (idempotency). Verify the Iceberg snapshot count is correct: 3 total snapshots across both layers (1 Bronze append + 1 Silver overwrite + 1 Silver overwrite for the re-run).
# capstone_silver_transform.py — Step 2: Schema evolution and idempotency
import pandas as pd
import numpy as np
# ── Schema evolution: add wicket_phase column ─────────────────────────────────
silver_with_new_col = silver_df.copy()
silver_with_new_col["wicket_phase"] = silver_with_new_col.apply(
lambda r: r["phase"] if r["is_wicket"] else None,
axis=1,
)
# Old rows (without wickets in their original data) get None by default
assert "wicket_phase" in silver_with_new_col.columns
assert silver_with_new_col.loc[~silver_with_new_col["is_wicket"], "wicket_phase"].isna().all()
assert silver_with_new_col.loc[silver_with_new_col["is_wicket"], "wicket_phase"].notna().all()
print(f" Schema evolution: 'wicket_phase' added")
print(f" Wicket rows with phase: {silver_with_new_col['is_wicket'].sum()}")
print(f" Non-wicket rows with None: {(~silver_with_new_col['is_wicket']).sum()} ✓")
# Update Silver S3 path
S3[SILVER_PATH] = silver_with_new_col.copy()
# ── Idempotency: re-run Silver transformation for same logical date ────────────
con2 = duckdb.connect(":memory:")
con2.register("bronze_raw", bronze_df)
silver_run2 = con2.execute(SILVER_SQL).df()
# Add wicket_phase to run2 as well
silver_run2["wicket_phase"] = silver_run2.apply(
lambda r: r["phase"] if r["is_wicket"] else None, axis=1
)
# Row count idempotency
assert len(silver_run2) == len(silver_df), \
f"Idempotency failed: {len(silver_df)} → {len(silver_run2)}"
# Aggregate idempotency
runs_run1 = int(silver_df["runs_scored"].sum())
runs_run2 = int(silver_run2["runs_scored"].sum())
assert runs_run1 == runs_run2, f"Runs mismatch: {runs_run1} vs {runs_run2}"
# Record third Iceberg snapshot (Silver overwrite on re-run)
snap3 = iceberg_overwrite_snapshot("silver.deliveries", SILVER_PATH, len(silver_run2))
assert snap3["snapshot_id"] == 3
print(f" Silver idempotency: {len(silver_df)} == {len(silver_run2)} rows ✓")
print(f" Runs conservation: {runs_run1} == {runs_run2} ✓")
print(f" Total Iceberg snapshots: {len(ICEBERG_SNAPSHOTS)} (1 Bronze + 2 Silver) ✓")
print("Step 2 ✓: Schema evolution and idempotency complete")