Approach: pre-aggregate events to user-day to reduce volume, join to acquisitions, compute day-deltas up to 90 days, pivot/aggregate to produce retention matrix per acquisition_date. Partition by acquisition_date and event_date; handle skewed heavy users with salting and separate processing. Validate with unit and scale tests.Code (PySpark):python
from pyspark.sql import functions as F, Window
# 1) load tables
users = spark.table("users") # contains user_id, first_touch_date
events = spark.table("events") # large clickstream: user_id, event_ts
# 2) pre-aggregate events to user-day to dedupe
events_day = (events
.withColumn("event_date", F.to_date("event_ts"))
.groupBy("user_id", "event_date")
.agg(F.count("*").alias("events_count")))
# 3) join to acquisitions (only users with acquisition date)
joined = (users.select("user_id", F.to_date("first_touch_date").alias("acq_date"))
.join(events_day, on="user_id", how="left")
.withColumn("days_after", F.datediff("event_date", "acq_date"))
.filter((F.col("days_after") >= 0) & (F.col("days_after") < 90)))
# 4) mark active (1 per user-day)
user_day_active = joined.select("user_id", "acq_date", "event_date", "days_after") \
.withColumn("active", F.lit(1))
# 5) per acquisition_date and cohort_day compute unique users active
retention = (user_day_active
.groupBy("acq_date", "days_after")
.agg(F.countDistinct("user_id").alias("active_users")))
# 6) get cohort sizes and assemble retention table
cohort_sizes = users.groupBy(F.to_date("first_touch_date").alias("acq_date")) \
.agg(F.countDistinct("user_id").alias("cohort_size"))
retention_table = (retention.join(cohort_sizes, on="acq_date")
.withColumn("retention_rate", F.col("active_users") / F.col("cohort_size"))
.orderBy("acq_date", "days_after"))
Partitioning strategy:- Read/write tables partitioned by acq_date (date) and event_date for events_day to prune scans.- When writing results, partition by acq_date to make dashboard queries fast.- Use bucketing by user_id for joins if supported to avoid shuffle.Handling skew:- Detect top N heavy users (top 0.1%) by event counts and process separately: compute their retention independently to avoid single-key explosion.- Salt join keys for remaining users: add random salt (e.g., 0..9) to user_id before joins and replicate small table keys accordingly, then remove salt post-aggregation.- Use map-side aggregations (combineByKey) via spark aggregations and increase shuffle partitions.Testing correctness at scale:- Unit tests: small deterministic sample with known retention matrix.- Property tests: counts are bounded: sum(active_users for day=0) == cohort_size for users with any event on day0; retention monotonically non-increasing per cohort.- Sampling: run full pipeline on a 1%, 10% sample and compare proportions to full run.- Data validation: assert no negative days_after, and retention_rate in [0,1].- Perf tests: run on representative cluster, monitor shuffle read/write, skew metrics, and job stages; iterate partition/salt settings.