This exercise builds a complete Spark batch job that processes simulated IPL match clickstream data — fan engagement events from a mobile app recorded during live matches. The pipeline reads raw event Parquet files from HDFS, applies schema validation, enriches events with match metadata via a broadcast join, computes per-match and per-bowler engagement statistics using window functions, writes results to a Delta table with upsert semantics, and demonstrates partition-count tuning and skew detection. Every concept from Module 2 Lessons 1–5 is exercised in sequence.
The clickstream dataset models real-world event log characteristics: high volume, heterogeneous event types, a heavily skewed user distribution where a small number of power users generate a disproportionate share of events, and a match_id foreign key that joins to a small metadata table. These characteristics make it a realistic proxy for production Spark workloads — the skew, the broadcast join opportunity, and the Delta upsert requirement all arise naturally from the domain rather than being artificially constructed.
Step 1 — Data Generation and Schema Validation
Generate the synthetic clickstream dataset as a Parquet file with intentional schema characteristics: a heavy-tailed user distribution, event type variety, and a skewed match distribution where one match receives five times the normal event count. Apply explicit schema validation using `df.schema` comparison after reading, asserting all required columns are present and correctly typed before any transformation logic runs.
# exercise_spark_batch.py — Step 1: Data generation and schema validation
from pyspark.sql import SparkSession
from pyspark.sql import functions as F
from pyspark.sql.types import (
StructType, StructField, LongType, IntegerType,
StringType, TimestampType, BooleanType
)
import random
from datetime import datetime, timezone, timedelta
import pandas as pd
import numpy as np
random.seed(42)
np.random.seed(42)
spark = (
SparkSession.builder
.appName("IPL-Clickstream-Batch")
.master("local[4]")
.config("spark.sql.adaptive.enabled", "true")
.config("spark.sql.shuffle.partitions", "50")
.config("spark.sql.extensions",
"io.delta.sql.DeltaSparkSessionExtension")
.config("spark.sql.catalog.spark_catalog",
"org.apache.spark.sql.delta.catalog.DeltaCatalog")
.getOrCreate()
)
# Generate synthetic clickstream data
MATCH_IDS = [10001, 10002, 10003, 10004]
EVENT_TYPES= ["boundary_reaction","wicket_alert","score_refresh",
"live_chat","prediction_submit"]
N_EVENTS = 5000
# Heavy-tailed: match 10001 gets 5x normal events (skew)
match_weights = [0.50, 0.20, 0.15, 0.15] # 10001 is the final — huge skew
base_ts = datetime(2024, 4, 20, 19, 30, 0, tzinfo=timezone.utc)
events = pd.DataFrame({
"event_id": range(1, N_EVENTS+1),
"match_id": np.random.choice(MATCH_IDS, N_EVENTS, p=match_weights),
"user_id": np.random.zipf(1.5, N_EVENTS).clip(1, 500), # zipf = power-law
"event_type": np.random.choice(EVENT_TYPES, N_EVENTS),
"over": np.random.randint(1, 21, N_EVENTS),
"ts": [base_ts + timedelta(seconds=int(s))
for s in np.random.uniform(0, 7200, N_EVENTS)],
"is_premium": np.random.choice([True, False], N_EVENTS, p=[0.2, 0.8]),
})
# Write to local Parquet (simulates HDFS)
events_path = "/tmp/ipl_clickstream/events.parquet"
spark.createDataFrame(events).write.mode("overwrite").parquet(events_path)
print(f"Generated {N_EVENTS:,} events across {len(MATCH_IDS)} matches")
print(f"Match distribution: {dict(zip(MATCH_IDS, np.unique(events['match_id'], return_counts=True)[1]))}")
# Define and validate schema
EXPECTED_SCHEMA = StructType([
StructField("event_id", LongType(), False),
StructField("match_id", LongType(), False),
StructField("user_id", LongType(), False),
StructField("event_type", StringType(), False),
StructField("over", LongType(), False),
StructField("ts", TimestampType(), False),
StructField("is_premium", BooleanType(), False),
])
events_df = spark.read.schema(EXPECTED_SCHEMA).parquet(events_path)
required_cols = {f.name for f in EXPECTED_SCHEMA.fields}
actual_cols = {f.name for f in events_df.schema.fields}
assert required_cols.issubset(actual_cols), f"Missing columns: {required_cols - actual_cols}"
print(f"Schema validated: {len(actual_cols)} columns, {events_df.count():,} rows")Step 2 — Broadcast Join, Enrichment and Feature Engineering
Create the small match metadata DataFrame (four rows), force a broadcast join onto the events DataFrame to eliminate the shuffle, add derived columns for `phase`, `hour_of_day`, and `is_boundary_event`, and verify the physical plan confirms `BroadcastHashJoin` rather than `SortMergeJoin`. Assert that the enriched DataFrame has the same row count as the original events, confirming no rows were lost in the left join.
# exercise_spark_batch.py — Step 2: Broadcast join and feature engineering
# Match metadata — small table, perfect candidate for broadcast
match_meta = spark.createDataFrame([
(10001, "Wankhede Stadium", "Mumbai Indians", "CSK", "final"),
(10002, "Chinnaswamy Stadium","RCB", "KKR", "qualifier"),
(10003, "Eden Gardens", "KKR", "SRH", "league"),
(10004, "Chepauk", "CSK", "RCB", "league"),
], ["match_id", "venue", "home_team", "away_team", "match_type"])
# Broadcast join — eliminates shuffle, verified in physical plan
enriched = (
events_df
.join(F.broadcast(match_meta), on="match_id", how="left")
.withColumn("phase",
F.when(F.col("over") <= 6, "powerplay")
.when(F.col("over") <= 15, "middle")
.otherwise("death")
)
.withColumn("hour_of_day", F.hour(F.col("ts")))
.withColumn("is_boundary_event",
F.col("event_type").isin(["boundary_reaction"]))
.withColumn("is_wicket_event",
F.col("event_type") == "wicket_alert")
)
# Verify broadcast join in plan
print("Physical plan — should show BroadcastHashJoin:")
enriched.explain("formatted")
# Assert no rows lost
assert enriched.count() == N_EVENTS, "Row count changed after broadcast join"
print(f"\nEnriched: {enriched.count():,} rows — row count preserved ✓")
# Show skew in partition distribution
partition_dist = (
enriched
.withColumn("pid", F.spark_partition_id())
.groupBy("pid").count()
.orderBy("count", ascending=False)
)
print("\nPartition row count distribution (top 5):")
partition_dist.show(5)Step 3 — Window Functions, Aggregation and Delta Write
Compute engagement statistics at two levels: per-match summary (total events, unique users, peak hourly engagement) and per-phase breakdown (events and premium-user fraction per phase per match). Use window functions to rank matches by engagement. Write the per-match summary to a Delta table with MERGE semantics. Verify the Delta time travel feature by querying the table's version 0 after a second write updates two records, confirming the original values are still accessible.
# exercise_spark_batch.py — Step 3: Aggregation, window functions, Delta write
from pyspark.sql import Window
from delta import DeltaTable
DELTA_PATH = "/tmp/ipl_match_engagement/"
# --- Per-match engagement summary ---
match_summary = (
enriched
.groupBy("match_id", "venue", "match_type")
.agg(
F.count("event_id").alias("total_events"),
F.countDistinct("user_id").alias("unique_users"),
F.sum(F.col("is_premium").cast("int")).alias("premium_events"),
F.sum(F.col("is_boundary_event").cast("int")).alias("boundary_reactions"),
F.sum(F.col("is_wicket_event").cast("int")).alias("wicket_alerts"),
)
.withColumn("premium_pct",
F.round(F.col("premium_events") / F.col("total_events") * 100, 1))
)
# Window function: rank matches by total engagement
window_spec = Window.orderBy(F.col("total_events").desc())
match_ranked = match_summary.withColumn(
"engagement_rank", F.rank().over(window_spec)
)
print("Match engagement ranking:")
match_ranked.orderBy("engagement_rank").show(truncate=False)
# --- Per-phase breakdown ---
phase_stats = (
enriched
.groupBy("match_id", "phase")
.agg(
F.count("event_id").alias("events"),
F.countDistinct("user_id").alias("unique_users"),
F.round(F.avg(F.col("is_premium").cast("double")), 3).alias("premium_rate"),
)
.orderBy("match_id", "phase")
)
print("\nPer-phase engagement stats:")
phase_stats.show(truncate=False)
# --- Write to Delta with MERGE ---
match_ranked.select(
"match_id","venue","match_type","total_events",
"unique_users","premium_pct","engagement_rank"
).write.format("delta").mode("overwrite").save(DELTA_PATH)
print("\nInitial Delta write complete (version 0)")
# Simulate an update: match 10001 engagement revised after re-processing
updated = spark.createDataFrame([
(10001, "Wankhede Stadium", "final", 2650, 390, 21.5, 1),
], ["match_id","venue","match_type","total_events","unique_users","premium_pct","engagement_rank"])
delta_tbl = DeltaTable.forPath(spark, DELTA_PATH)
delta_tbl.alias("t").merge(
updated.alias("s"), "t.match_id = s.match_id"
).whenMatchedUpdateAll().whenNotMatchedInsertAll().execute()
print("MERGE applied — version 1 committed")
# Time travel: confirm version 0 still shows original value
v0 = spark.read.format("delta").option("versionAsOf", 0).load(DELTA_PATH)
v0_total = v0.filter(F.col("match_id") == 10001).select("total_events").collect()[0][0]
v1 = spark.read.format("delta").load(DELTA_PATH)
v1_total = v1.filter(F.col("match_id") == 10001).select("total_events").collect()[0][0]
print(f"\nTime travel verification:")
print(f" Version 0 match_id=10001 total_events: {v0_total}")
print(f" Version 1 match_id=10001 total_events: {v1_total}")
assert v1_total == 2650, f"MERGE did not update total_events: {v1_total}"
assert v0_total != v1_total, "Time travel failed: versions should differ"
print("All assertions passed. Spark batch job complete.")