The sales job from the previous lesson worked, but every new question cost one more job: sales by producer and market was one, the ranking per market another, joining it with the catalogue a third, and between each of them the complete output went to HDFS and came back. Apache Spark was born at Berkeley (2009, Matei Zaharia) out of precisely that frustration: iterative machine learning algorithms and interactive queries took ten or a hundred times longer in MapReduce than the CPU justified, because the time went on writing to and reading from disk between phases. Spark keeps the essentials of the model (data in partitions, independent and re-runnable tasks, a shuffle to group by key) but expresses the whole computation as a DAG of operators that a scheduler turns into stages and that runs while keeping the intermediate data in memory. On that foundation it built a collections API (RDDs), then a tables API with an optimiser (DataFrames and Spark SQL), and on top of those, libraries for machine learning (MLlib), graphs (GraphX) and streams (Structured Streaming, which we will see in 05-04). Today it is the reference batch engine. In this lesson analytics rewrites the daily sales computation as services/analytics/daily_sales.py, with DataFrames and with RDDs for comparison, launches it locally and on a cluster from docker-compose.yml, reads the execution plan, and trains some recommendations with ALS on the clicks.

Contents

  1. Why Spark: an in-memory DAG versus phases on disk
  2. Architecture: driver, cluster manager, executors, tasks and stages
  3. RDDs: immutable collections, lazy transformations and lineage
  4. DataFrames and Spark SQL: Catalyst and columnar formats
  5. Narrow and wide operations, shuffle and stages
  6. Optimisations: cache, broadcast join, partitioning and skew
  7. MapReduce versus Spark
  8. MLlib: recommendations with ALS on the clicks
  9. Hands-on: services/analytics/daily_sales.py
  10. Common Mistakes and Tips
  11. Exercises
  12. Conclusion

  1. Why Spark: an in-memory DAG versus phases on disk

In MapReduce (05-02), a chain of three groupings is three jobs and six trips through disk: each job writes its output to HDFS (replicated three times) and the next one reads it. For an iterative algorithm that goes over the same data a hundred times, that is a hundred full reads from disk of a dataset that has not changed. Spark changes two things:

  • The whole computation is a single program, a DAG of operators (read, filter, explode, group, join, write) that the engine knows in full before running anything. With that global view it can chain into a single pass the operators that do not need to redistribute data, and it only materialises intermediate data at the points where there is a shuffle. This is the dataflow pattern from 05-01.
  • Intermediate data lives in the memory of the executors, and can be explicitly cached to be reused across several passes. A hundred iterations over a cached dataset read the disk once.

The price of not writing to disk is fault tolerance: if a node dies, its intermediate data in memory disappears. Spark's solution, and its original idea, is lineage (section 3): instead of saving the intermediate data, it saves the recipe for recomputing it from the input, and recomputes only the lost partitions. It is the deterministic re-execution of 05-01, applied to fragments of a computation rather than to whole tasks.

  1. Architecture: driver, cluster manager, executors, tasks and stages

flowchart TB
    subgraph D[Driver: daily_sales.py]
        SC[SparkSession / SparkContext<br/>DAG scheduler + task scheduler]
    end
    CM[Cluster manager<br/>standalone · YARN · Kubernetes]
    subgraph W1[Worker node 1]
        E1[Executor<br/>JVM with 4 cores and 8 GB]
        T1a[task] --- E1
        T1b[task] --- E1
    end
    subgraph W2[Worker node 2]
        E2[Executor]
        T2a[task] --- E2
        T2b[task] --- E2
    end
    SC -- "requests executors" --> CM
    CM -- "launches" --> E1
    CM -- "launches" --> E2
    SC -- "sends tasks, receives results" --> E1
    SC -- "sends tasks, receives results" --> E2
    E1 <-- "shuffle" --> E2
    E1 --> HDFS[(HDFS / MinIO)]
    E2 --> HDFS
  • Driver. The process that runs the main program (daily_sales.py). It holds the SparkSession (formerly SparkContext), builds the DAG, divides it into jobs, stages and tasks, sends them to the executors and collects results. It is the master of 05-02, one per application: if the driver dies, the application dies (on YARN in cluster mode, the driver is the ApplicationMaster and can be retried).
  • Cluster manager. It shares out resources among applications. Spark ships with its own (standalone, one master and workers: the one we will use in docker-compose.yml), and integrates with YARN (05-02: the driver requests containers from the ResourceManager) and with Kubernetes (07-05: each executor is a pod). The driver does not care which one it is; the API is the same.
  • Executor. A JVM process on a worker node, with N cores and M GB assigned, that lives for the whole application. It runs tasks in threads (one task per core at a time) and keeps cached partitions and shuffle data in memory. Unlike MapReduce, no JVM is started per task: the JVM is started once and runs thousands of tasks, which eliminates the fixed per-task cost.
  • Task. The unit of work: running a chain of operators over one partition. A stage with 200 partitions is 200 tasks.
  • Stage. A set of tasks that can run without a shuffle. The DAG is cut into stages at every wide operation (section 5); within a stage, the operators are chained (pipelining) and a row goes through all of them without touching disk.
  • Job. Everything triggered by an action (section 3): write, collect, count. A program may have several jobs; each job has one or more stages.

With PySpark, the driver is a Python process that talks to a JVM (via Py4J), and on the executors the Python code of RDD functions runs in auxiliary Python processes with round-trip serialisation; with DataFrames, by contrast, most operations are translated into JVM code and Python is not involved per row. That is the main reason the DataFrame API is the recommended one.

  1. RDDs: immutable collections, lazy transformations and lineage

The RDD (Resilient Distributed Dataset) is Spark's original abstraction: a collection of elements partitioned across the executors, immutable (it is not modified: another RDD is created from it) and resilient through lineage. You operate on it with two kinds of methods:

Kind What it does Examples Runs anything
Transformation Returns a new RDD defined from another map, filter, flatMap, reduceByKey, groupByKey, join, distinct, repartition No: it is lazy, it only adds a node to the DAG
Action Returns a result to the driver or writes to a sink collect, count, take, reduce, saveAsTextFile, foreach Yes: it launches a job that runs all the pending transformations

Laziness is what makes optimisation possible: when the program says filter and then map and then count, Spark does not run three passes; on reaching count it knows the whole chain and runs it in a single pass per partition. And lineage is the DAG of transformations that led to each RDD: if a partition of the sales RDD is lost because its executor died, Spark looks at the lineage (sales = lines.reduceByKey(...), lines = events.flatMap(...), events = sc.textFile(...)) and recomputes only that partition from the corresponding HDFS block. Nothing intermediate has to be saved to disk; the cost is recomputing, which is affordable as long as the lineage is short (for long, iterative lineages there is checkpoint(), which does materialise to HDFS and cuts the lineage).

The sales computation with RDDs, which will serve as a comparison in the hands-on part:

# Excerpt from services/analytics/daily_sales.py (RDD version, see section 9)
import json
from datetime import datetime, timezone

def day_of(ev):                                               # event time (01-05), not processing time
    return datetime.fromtimestamp(ev["timestamp_ms"] / 1000, tz=timezone.utc).strftime("%Y-%m-%d")

def sales_rdd(sc, input_path):
    events = sc.textFile(input_path)                          # RDD[str]: one partition per HDFS block
    orders = events.map(json.loads).filter(lambda e: e["type"] == "order.created")
    lines = orders.flatMap(lambda e: [                        # one row per order line
        ((day_of(e), e["data"]["market"], ln["producer"]), ln["quantity"] * ln["price"])
        for ln in e["data"]["lines"]])
    sales = lines.reduceByKey(lambda a, b: a + b)             # wide: shuffle by key; combines locally first
    return sales                                              # nothing has run yet

Up to this point not a single byte has been read: textFile, map, filter, flatMap and reduceByKey are transformations. sales.collect() or sales.saveAsTextFile(...) would launch the job. Two details that set a Spark user apart: reduceByKey does the reduction locally in each partition before the shuffle (the combiner of 05-02, automatically), whereas groupByKey followed by a sum moves all the values over the network; and lambda functions travel serialised to the executors, so they cannot capture non-serialisable objects (a database connection, for example).

  1. DataFrames and Spark SQL: Catalyst and columnar formats

An RDD is a collection of objects that are opaque to Spark: it does not know that ln["producer"] is a column, so it cannot optimise anything beyond chaining. A DataFrame is a table with a schema (named, typed columns), distributed in partitions like an RDD, on which you operate with a relational API (select, filter, groupBy, join, agg) or directly with SQL (spark.sql("SELECT ...")). Both produce the same logical plan, which goes through Catalyst, the optimiser:

  1. Analysis: resolving column names and types against the schema.
  2. Logical optimisation: rules such as pushing filters down towards the read (predicate pushdown: filtering type = 'order.created' while reading, not afterwards), reading only the columns needed (column pruning), simplifying expressions, reordering joins.
  3. Physical planning: choosing algorithms: a join is done by broadcast hash if one side is small, by sort-merge if not; an aggregation in two phases (partial in each partition, final after the shuffle).
  4. Code generation: Tungsten compiles the plan into specialised Java bytecode (whole-stage codegen) and manages memory off the JVM heap in a compact binary format. This is what makes PySpark with DataFrames as fast as Scala: Python only describes the plan.

Since Spark 3, Adaptive Query Execution (AQE) re-optimises the plan during execution with real statistics: it reduces the number of partitions after a small shuffle, turns a sort-merge join into a broadcast if it discovers that one side fits, and splits skewed partitions (section 6).

DataFrames go hand in hand with columnar formats. A Parquet file stores the data by columns instead of by rows: all the producer values together, all the amount values together, compressed with encodings suited to each type (dictionary for repeated strings, run-length for consecutive values) and with statistics (minimum, maximum, nulls) per row group. A query that only needs producer and amount reads those two columns and skips the rest of the file, and a filter day = '2026-09-14' skips entire row groups whose range does not contain it. Compared with JSONL, which forces you to read and parse every byte, Parquet cuts both the bytes read and the size on disk by an order of magnitude. That is why the lake of 04-02 starts out as JSONL (what Kafka produces) and the first step of the analytics converts it to Parquet, partitioned by day.

  1. Narrow and wide operations, shuffle and stages

The division of the DAG into stages depends on a single criterion: what dependency each output partition has on the input ones.

Narrow dependency Wide dependency
Each output partition depends on One input partition (or a fixed few) All (or many) of the input partitions
Operations map, filter, flatMap, select, withColumn, union, coalesce, broadcast join, join with identical partitioning groupBy/reduceByKey, distinct, sort-merge join, orderBy, repartition
Cost Chained within the same task, no network Shuffle: write, transfer, read; cuts the DAG into a new stage
Recovery after a failure Recompute the lost partition from its single input Recomputing may require re-reading many partitions (Spark keeps the shuffle files to avoid it)
flowchart LR
    subgraph S0[Stage 0: no shuffle]
        R[read json<br/>2 partitions] --> F[filter type] --> X[explode lines] --> P[partial agg<br/>by day, market, producer]
    end
    P == "shuffle<br/>hashpartitioning(day, market, producer)<br/>200 partitions" ==> S1
    subgraph S1[Stage 1]
        A[final agg] --> J[broadcast join<br/>with catalogue] --> W[write parquet]
    end
    C[read catalogue<br/>1 partition] -. broadcast to all executors .-> J

The sales DAG has exactly one shuffle, the groupBy, and therefore two stages. Everything else is chained: a JSON row is filtered, exploded and partially aggregated in the same task without anybody writing it out. The join with the catalogue, which in MapReduce would be a third job, is narrow thanks to the broadcast (section 6). In the Spark web UI (http://localhost:4040 during execution) each job appears with its stages, each stage with its tasks, and for each stage the shuffle write and shuffle read bytes: these are the counters of 05-02, and the diagnostic criterion is the same.

Spark's shuffle also writes to local disk (the shuffle files, served by the executors or by an external shuffle service), so "in memory" does not mean "no disk": it means that between narrow operations nothing is written, and that cached data is served from memory. With 200 shuffle partitions by default (spark.sql.shuffle.partitions), a small job produces 200 tiny tasks in the second stage; AQE merges them, but on old versions or with AQE disabled it is worth lowering the number.

  1. Optimisations: cache, broadcast join, partitioning and skew

cache() and persist(). They mark a DataFrame or RDD so that, the first time it is computed, its partitions are kept (in memory by default; persist(StorageLevel.MEMORY_AND_DISK) allows spilling to disk, DISK_ONLY, or serialised to save memory). It pays off when the same intermediate result is used in several actions: the lines DataFrame in the hands-on part feeds the sales by producer, a ranking and a quality check; without the cache, every action reads and parses the JSON from HDFS again. unpersist() releases it. Caching something that is used once is pure cost.

Broadcast join. Joining the sales (millions of rows, spread out) with the catalogue (a few dozen rows) should not move the sales. With F.broadcast(catalog), Spark sends a copy of the catalogue to every executor and the join is resolved locally, with no shuffle of the large side: it is the map-side join of 05-02, automatically. Spark does it on its own if it estimates that the small side takes up less than spark.sql.autoBroadcastJoinThreshold (10 MB by default); forcing it with broadcast() is worthwhile when the estimate fails (a CSV with no statistics). With two large sides, the plan is a sort-merge join: a shuffle of both by the key and a sorted merge.

repartition(n) and coalesce(n). The number of partitions governs the parallelism. repartition(n) does a full shuffle to obtain n balanced partitions (or repartition("day") to place each day in its own partition, useful before a partitioned write); coalesce(n) reduces the number without a shuffle, merging local partitions, and is the cheap way of not writing 200 Parquet files of 3 KB. As a guide: partitions of 100–200 MB in memory and between 2 and 4 tasks per available core.

Skew with salting. Artisan Cheese Week is back: when grouping by producer, the montblanc-dairy partition receives half the rows. With DataFrames, the partial aggregation per partition (which Catalyst always inserts) eases the case of sums, just as the combiner did; skew hurts when the operation cannot be pre-reduced (a sort-merge join, collect_list, windows). The technique from 05-01, now with columns:

from pyspark.sql import functions as F

N_SALT = 8
# 1) add a deterministic salt to the hot key (derived from the order id: re-runnable)
salted = lines.withColumn("salt", F.when(F.col("producer") == "montblanc-dairy",
                                         F.pmod(F.hash("order_id"), F.lit(N_SALT))).otherwise(F.lit(0)))
# 2) aggregate by (key, salt): the hot key is spread over 8 partitions
partial = salted.groupBy("day", "market", "producer", "salt").agg(F.sum("amount").alias("amount"))
# 3) second aggregation, now a small one, dropping the salt
sales = partial.groupBy("day", "market", "producer").agg(F.sum("amount").alias("amount"))

And since Spark 3, AQE with spark.sql.adaptive.skewJoin.enabled=true detects skewed join partitions (by size relative to the median) and splits them automatically, with no manual salt. For skewed aggregations with no possible pre-reduction, the salt is still needed.

  1. MapReduce versus Spark

MapReduce (Hadoop) Spark
Model Map and reduce; chains of jobs DAG of operators in one program
Intermediate data Local disk + HDFS between jobs Memory (and local disk only in the shuffle)
Fault tolerance Re-execution of tasks from disk Recomputation through lineage; shuffle files; optional checkpoint
Fixed cost A JVM per task; tens of seconds per job Persistent executors; millisecond tasks
API Java (Streaming for other languages); only map/reduce Scala, Java, Python, R, SQL; dozens of operators; DataFrames with an optimiser
Joins, iterations By hand, several jobs Native; cache for iterating
Iterative (ML, graphs) 10–100× slower because of re-reads Designed for it (MLlib, GraphX)
Interactive No Yes (spark-shell, notebooks, Spark SQL)
Streaming No Structured Streaming (05-04)
Memory required Modest Higher: the executors need RAM for cache and shuffle
Current status Historical foundation; YARN and HDFS still in use Reference batch engine

Spark's advantage on the sales job of 05-02 shows in the numbers: the same computation that took 52 s in MapReduce on the test cluster takes 6 s in Spark on YARN with the same resources, and chaining the ranking and the join with the catalogue adds not jobs but a stage. The trade-off is memory: a badly sized executor (too little memory for the shuffle or the cache) fails with an OutOfMemoryError where MapReduce simply wrote to disk.

  1. MLlib: recommendations with ALS on the clicks

The second case in Kilometre Zero's analytics, "recommended products for Anna", is an iterative problem: exactly the kind that was handled badly in MapReduce. MLlib comes with ready-made distributed algorithms, and for recommendation the classic is ALS (Alternating Least Squares), a factorisation of the customers × products matrix that learns one factor vector per customer and another per product from interactions (purchases, clicks) and predicts the interest of each unobserved pair. With clicks there is no "rating", only implicit signals (viewed, added to basket, purchased), and ALS has a mode for that.

The clicks in the lake, /km0/clicks/2026-09-14/hour=13/web-01.jsonl (04-02), look like this:

{"timestamp_ms":1789390812000,"customer":"anna","product":"aged-cheese","action":"viewed"}
{"timestamp_ms":1789390834000,"customer":"anna","product":"crianza-wine","action":"basket"}
{"timestamp_ms":1789390901000,"customer":"mark","product":"crianza-wine","action":"purchased"}
{"timestamp_ms":1789391010000,"customer":"lucy","product":"fresh-cheese","action":"viewed"}
{"timestamp_ms":1789391044000,"customer":"lucy","product":"zucchini","action":"purchased"}
# km0/services/analytics/recommendations_als.py
from pyspark.sql import SparkSession, functions as F
from pyspark.ml.feature import StringIndexer
from pyspark.ml.recommendation import ALS

spark = SparkSession.builder.appName("km0-recommendations").getOrCreate()
clicks = spark.read.json("hdfs://namenode:8020/km0/clicks/2026-09-*/")         # 7 days, every hour
weight = F.when(F.col("action") == "purchased", 5).when(F.col("action") == "basket", 2).otherwise(1)
interest = clicks.withColumn("weight", weight).groupBy("customer", "product").agg(F.sum("weight").alias("interest"))

# ALS needs integer ids: StringIndexer assigns one per distinct value
idx_cust = StringIndexer(inputCol="customer", outputCol="customer_id").fit(interest)
idx_prod = StringIndexer(inputCol="product", outputCol="product_id").fit(interest)
data = idx_prod.transform(idx_cust.transform(interest))

als = ALS(userCol="customer_id", itemCol="product_id", ratingCol="interest",
          implicitPrefs=True,        # the signals are implicit (clicks), not ratings
          rank=10, maxIter=10, regParam=0.1, coldStartStrategy="drop", seed=42)
model = als.fit(data)               # 10 iterations: each one alternates customer and product factors

recommendations = model.recommendForAllUsers(3)                                      # 3 products per customer
labels = spark.createDataFrame(enumerate(idx_prod.labels), ["product_id", "product"])
(recommendations.select("customer_id", F.explode("recommendations").alias("r"))
    .join(labels, F.col("r.product_id") == labels.product_id)
    .join(spark.createDataFrame(enumerate(idx_cust.labels), ["customer_id", "customer"]), "customer_id")
    .select("customer", "product", F.round("r.rating", 3).alias("affinity"))
    .write.mode("overwrite").parquet("hdfs://namenode:8020/km0/recommendations/2026-09-14"))

Each iteration of fit is a job with several stages over the same data, which MLlib caches internally; with 10 iterations in MapReduce it would be 20 jobs and 20 reads from HDFS. The result (anna → crianza-wine, lucy → aged-cheese...) is loaded by the pipeline of 05-05 into the catalog database for the website to display. What MLlib does not decide is the quality: choosing rank, regParam and the weights of the actions requires evaluation (RegressionEvaluator or ranking metrics over a held-out set), and that belongs in a machine learning course, not this one.

  1. Hands-on: services/analytics/daily_sales.py

9.1 Environment: Spark in docker-compose.yml

To the docker-compose.yml of 04-02 (HDFS) and 05-02 (YARN) we add a Spark standalone cluster, which is lighter than YARN for development. The driver will run on our machine or in the master's container:

# km0/docker-compose.yml (excerpt)
services:
  spark-master:
    image: bitnami/spark:3.5
    environment:
      - SPARK_MODE=master
    ports:
      - "8080:8080"        # standalone master web UI
      - "7077:7077"        # port that drivers and workers connect to
    volumes:
      - ./services/analytics:/app/analytics
      - ./events:/app/events
  spark-worker:
    image: bitnami/spark:3.5
    environment:
      - SPARK_MODE=worker
      - SPARK_MASTER_URL=spark://spark-master:7077
      - SPARK_WORKER_CORES=2
      - SPARK_WORKER_MEMORY=2G
    deploy:
      replicas: 2         # docker compose up --scale spark-worker=2
    volumes:
      - ./services/analytics:/app/analytics
      - ./events:/app/events

The two workers offer 4 cores in total, and HDFS is reachable as hdfs://namenode:8020 from the same Compose network. The catalogue is a small CSV, services/analytics/catalog.csv, which in production would come from a daily export of km0_catalog:

producer,producer_name,province
la-vega-farm,La Vega Farm,Girona
montblanc-dairy,Montblanc Dairy,Tarragona
roble-alto-winery,Roble Alto Winery,Lleida

9.2 The program with DataFrames

# km0/services/analytics/daily_sales.py
"""Sales by producer, market and day from the order.created events in the lake.

Usage: spark-submit daily_sales.py <input> <catalog.csv> <output> [--rdd]
  input:  hdfs://namenode:8020/km0/events/2026-09-14/orders.jsonl  (or a local path, or a glob)
  output: hdfs://namenode:8020/km0/aggregates/daily_sales           (Parquet partitioned by day)
"""
import sys
from pyspark.sql import SparkSession, functions as F, types as T

SCHEMA = T.StructType([                                   # declaring the schema avoids an inference pass
    T.StructField("event_id", T.StringType()),
    T.StructField("type", T.StringType()),
    T.StructField("version", T.IntegerType()),
    T.StructField("timestamp_ms", T.LongType()),
    T.StructField("source", T.StringType()),
    T.StructField("data", T.StructType([
        T.StructField("order_id", T.StringType()),
        T.StructField("customer", T.StringType()),
        T.StructField("market", T.StringType()),
        T.StructField("lines", T.ArrayType(T.StructType([
            T.StructField("product", T.StringType()),
            T.StructField("producer", T.StringType()),
            T.StructField("quantity", T.IntegerType()),
            T.StructField("price", T.DoubleType()),
        ]))),
    ])),
])


def order_lines(spark, input_path):
    """DataFrame with one row per order line: day, market, order_id, product, producer, quantity, amount."""
    events = spark.read.schema(SCHEMA).json(input_path)
    orders = events.filter(F.col("type") == "order.created")            # Catalyst pushes it down to the read
    return (orders
        .select(
            F.to_date(F.from_unixtime(F.col("timestamp_ms") / 1000)).alias("day"),   # event time
            F.col("data.market").alias("market"),
            F.col("data.order_id").alias("order_id"),
            F.explode("data.lines").alias("ln"))                       # one row per array element
        .select("day", "market", "order_id",
                F.col("ln.product").alias("product"),
                F.col("ln.producer").alias("producer"),
                F.col("ln.quantity").alias("quantity"),
                (F.col("ln.quantity") * F.col("ln.price")).alias("amount")))


def sales_dataframe(spark, input_path, catalog_path):
    lines = order_lines(spark, input_path).cache()                      # used in two actions (sales and check)
    catalog = spark.read.option("header", True).csv(catalog_path)       # 3 rows: a broadcast candidate

    sales = (lines
        .groupBy("day", "market", "producer")                           # ONE wide operation: one shuffle
        .agg(F.round(F.sum("amount"), 2).alias("amount"),
             F.sum("quantity").alias("units"),
             F.countDistinct("order_id").alias("orders"))
        .join(F.broadcast(catalog), "producer", "left")                 # narrow: the catalogue travels to every executor
        .select("day", "market", "producer", "producer_name", "province", "amount", "units", "orders"))

    check = lines.agg(F.countDistinct("order_id").alias("orders"), F.round(F.sum("amount"), 2).alias("total"))
    print("Check:", check.first().asDict())                             # action 1: uses the cache
    return sales                                                        # action 2 will be the write


if __name__ == "__main__":
    input_path, catalog_path, output_path = sys.argv[1:4]
    spark = (SparkSession.builder.appName("km0-daily-sales")
             .config("spark.sql.shuffle.partitions", "8")               # small job: 200 would be absurd
             .config("spark.sql.sources.partitionOverwriteMode", "dynamic")   # overwrite ONLY the partitions written
             .getOrCreate())
    if "--rdd" in sys.argv:
        from sales_rdd import sales_rdd                                 # RDD version from section 3
        for (day, market, producer), amount in sorted(sales_rdd(spark.sparkContext, input_path).collect()):
            print(f"{day} {market:10s} {producer:20s} {amount:12,.2f}")
    else:
        sales = sales_dataframe(spark, input_path, catalog_path)
        sales.explain()                                                 # physical plan (section 9.3)
        (sales.coalesce(1)                                              # one file per output partition
              .write.mode("overwrite")
              .partitionBy("day")                                       # output/day=2026-09-14/part-....parquet
              .parquet(output_path))
        sales.orderBy("day", "market", "producer").show(truncate=False)
    spark.stop()

Key points of the program:

  • Explicit schema. Without it, spark.read.json does a full pass just to infer types. With it, the read is one pass and the types are the ones we want (timestamp_ms as LongType, not double).
  • explode turns the lines array into rows, which is what the mapper's for ln in ev["data"]["lines"] loop did.
  • A single shuffle. The groupBy is the only wide operation. filter, select, explode and the broadcast join are chained in the same stage as the read. Catalyst inserts a partial aggregation before the shuffle (HashAggregate with partial_sum), so what crosses the network is partials by (day, market, producer) per partition: the combiner, without writing it.
  • cache() on lines because there are two actions (check.first() and the write); without it, the JSON would be read and parsed twice.
  • partitionBy("day") with partitionOverwriteMode=dynamic. The output is one directory per day (day=2026-09-14/), and overwrite in dynamic mode replaces only the days this job writes, leaving the rest untouched. Running the job for 14 September twice produces exactly the same output: it is the per-partition idempotency that the pipeline of 05-05 needs for reprocessing and backfilling.
  • coalesce(1) before writing, because the aggregate is about 12 rows per day and we do not want 8 Parquet files of 2 KB. With large aggregates you would remove or adjust it.

9.3 Launching and the execution plan

Locally, with four threads as simulated executors (local[4]), over the file generated in 05-01:

$ pip install pyspark==3.5.1
$ spark-submit --master 'local[4]' services/analytics/daily_sales.py \
    events/2026-09-14/orders.jsonl services/analytics/catalog.csv output/daily_sales
Check: {'orders': 400000, 'total': 3590252.0}
== Physical Plan ==
AdaptiveSparkPlan isFinalPlan=false
+- Project [day, market, producer, producer_name, province, amount, units, orders]
   +- BroadcastHashJoin [producer], [producer], LeftOuter, BuildRight
      :- HashAggregate(keys=[day, market, producer], functions=[sum(amount), sum(quantity), count(distinct order_id)])
      :  +- Exchange hashpartitioning(day, market, producer, 8)
      :     +- HashAggregate(keys=[day, market, producer], functions=[partial_sum(amount), partial_sum(quantity), ...])
      :        +- InMemoryTableScan [day, market, order_id, producer, quantity, amount]
      :              +- InMemoryRelation ... (lines cached)
      :                    +- Generate explode(data.lines) ...
      :                       +- Filter (type = order.created)
      :                          +- FileScan json [type, timestamp_ms, data] PushedFilters: [IsNotNull(type), EqualTo(type,order.created)]
      +- BroadcastExchange HashedRelationBroadcastMode
         +- FileScan csv [producer, producer_name, province]
+----------+---------+-----------------+-----------------+---------+----------+-----+------+
|day       |market   |producer         |producer_name    |province |amount    |units|orders|
+----------+---------+-----------------+-----------------+---------+----------+-----+------+
|2026-09-14|girona   |la-vega-farm     |La Vega Farm     |Girona   |178013.70 |58940|31502 |
|2026-09-14|girona   |montblanc-dairy  |Montblanc Dairy  |Tarragona|473402.10 |46330|41218 |
|2026-09-14|girona   |roble-alto-winery|Roble Alto Winery|Lleida   |246187.40 |25121|23967 |
...

The plan is read from the bottom up, and every line confirms a decision from the previous sections: FileScan json with PushedFilters (the filter pushed down to the read), Generate explode, InMemoryRelation (the cache), the HashAggregate with partial_sum before the Exchange hashpartitioning(..., 8) (partial aggregation, then the only shuffle with 8 partitions) and the final HashAggregate, and BroadcastHashJoin with a BroadcastExchange of the CSV (the catalogue travels, the sales do not). If SortMergeJoin with two Exchange nodes appeared instead of BroadcastHashJoin, we would know that the broadcast was not applied and that we are paying for one shuffle too many.

Against the Compose standalone cluster, with the file in HDFS:

$ docker compose exec spark-master spark-submit \
    --master spark://spark-master:7077 \
    --executor-memory 1G --executor-cores 2 --num-executors 2 \
    /app/analytics/daily_sales.py \
    hdfs://namenode:8020/km0/events/2026-09-14/orders.jsonl \
    /app/analytics/catalog.csv \
    hdfs://namenode:8020/km0/aggregates/daily_sales
$ docker compose exec namenode hdfs dfs -ls /km0/aggregates/daily_sales/
drwxr-xr-x   - spark supergroup  0  /km0/aggregates/daily_sales/day=2026-09-14
-rw-r--r--   2 spark supergroup  0  /km0/aggregates/daily_sales/_SUCCESS

With --master yarn and the Hadoop configuration in HADOOP_CONF_DIR, the same file would be launched on the YARN of 05-02, with the driver as the ApplicationMaster (--deploy-mode cluster), without changing a line of code. The master's UI (localhost:8080) shows the workers and the applications; the driver's (localhost:4040, while it is running) shows the jobs, stages and tasks, with their shuffle bytes.

9.4 The RDD version, for comparison

sales_rdd.py contains the function from section 3. Launched with --rdd, it produces the same figures, and two differences show up in the UI: the read stage is slower (every line goes through a Python process with json.loads, instead of the JVM's JSON parser) and there is no plan to read: Spark runs the lambdas as they are, without pushing down filters or pruning columns, because it does not know what they do. On the 130 MB file the difference is 9 s versus 4 s in local[4]; on terabytes, hours. The RDD is still the right tool when the data has no schema or the logic does not fit into column expressions, and it is what lies underneath every DataFrame; but for analytics over events with a schema, DataFrames is the API.

Common Mistakes and Tips

  • collect() on a large DataFrame. It brings every row to the driver, which has a few GB of memory: OutOfMemoryError on the driver. To take a look, show() or take(n); to save, write.
  • Forgetting that transformations are lazy. A badly written filter does not fail when you write it, but at the first action, with a stack trace that points to the write. And a print inside a lambda does not show up on the driver: it runs on the executors (check their logs).
  • groupByKey + sum on RDDs. It moves all the values over the network. reduceByKey or aggregateByKey combine locally. With DataFrames, groupBy().agg() already does so.
  • Row-by-row Python UDFs. A udf in PySpark serialises every row out to a Python process and back; it defeats Catalyst and Tungsten. Almost everything can be expressed with F.*; if not, use pandas_udf (vectorised in batches with Arrow).
  • Caching without using it, or never releasing. cache() on something used once is cost with no benefit; caching lots of things without unpersist() overflows the executors' memory and causes silent evictions and recomputations.
  • 200 shuffle partitions for 10 MB. The default value of spark.sql.shuffle.partitions is meant for large clusters. Adjust it or trust AQE; and coalesce before writing so as not to produce hundreds of one-kilobyte files that will choke the NameNode (04-02).
  • Trusting the automatic broadcast with a CSV. Without statistics, Spark may fail to estimate the size and do a sort-merge join. Use an explicit F.broadcast() and check it in explain().
  • Inferring the JSON schema in production. It is an extra pass and a schema that changes with the data (one day without version and the column disappears). Use an explicit schema, versioned along with the event contract (02-05).
  • Writing with overwrite without partitionOverwriteMode=dynamic. It deletes the whole output directory, including the days that were not being recomputed. It is the most expensive mistake made with a backfill pipeline.

Exercises

Exercise 1: Ranking per market in a single program

Extend sales_dataframe so that, in addition to the sales, it produces for each (day, market) the top-selling producer and its share of the market total (as a percentage), using window functions (pyspark.sql.Window). How many shuffles does your solution add? Compare it with the two jobs of exercise 1 in 05-02.

Exercise 2: Reading a plan

This is the plan of a modified version of the program, written by a colleague. Identify four performance problems from the plan and say how to fix each one.

== Physical Plan ==
+- SortMergeJoin [producer], [producer], LeftOuter
   :- Sort [producer ASC]
   :  +- Exchange hashpartitioning(producer, 200)
   :     +- HashAggregate(keys=[day, market, producer], functions=[sum(amount)])
   :        +- Exchange hashpartitioning(day, market, producer, 200)
   :           +- HashAggregate(keys=[day, market, producer], functions=[partial_sum(amount)])
   :              +- BatchEvalPython [compute_amount(quantity, price)]
   :                 +- Generate explode(data.lines)
   :                    +- Filter (type = order.created)
   :                       +- FileScan json [event_id, type, version, timestamp_ms, source, data]
   +- Sort [producer ASC]
      +- Exchange hashpartitioning(producer, 200)
         +- FileScan csv [producer, producer_name, province]

Exercise 3: Reprocessing Grape Harvest Week

The events from 8 to 14 September 2026 (Grape Harvest Week) had an error in the price of crianza-wine that orders has corrected by regenerating the orders.jsonl files for those seven days in HDFS. Write the invocation (or invocations) of daily_sales.py that recomputes exactly those seven days without touching the rest, and explain which combination of the program's options guarantees that (a) no old data for those days is left behind, (b) the other days are not deleted, and (c) running it twice gives the same result. What would you change if the input were a glob orders-*.jsonl with several files per day?

Solutions

Exercise 1.

from pyspark.sql import Window

by_market = Window.partitionBy("day", "market")
ranking = (sales
    .withColumn("market_total", F.sum("amount").over(by_market))
    .withColumn("share", F.round(F.col("amount") / F.col("market_total") * 100, 1))
    .withColumn("rank", F.row_number().over(by_market.orderBy(F.desc("amount"))))
    .filter(F.col("rank") == 1)
    .select("day", "market", "producer", "amount", "share"))

The two windows share the partitioning (day, market), so Spark adds one shuffle (Exchange hashpartitioning(day, market)) and a Sort within each partition for row_number; with AQE and the same keys, it sometimes reuses the exchange. Total: two shuffles in the program (the groupBy and the window), in a single job with three stages, versus MapReduce's two jobs with their four trips through HDFS. And the join with the catalogue still costs no shuffle because sales already came with it resolved.

Exercise 2.

  1. SortMergeJoin with two Exchange nodes by producer instead of BroadcastHashJoin: the three-row catalogue is causing a shuffle of the sales and a sort. Fix: F.broadcast(catalog).
  2. BatchEvalPython [compute_amount]: a row-by-row Python UDF, which also prevents partial_sum from being computed in the JVM without going out to Python. Fix: F.col("ln.quantity") * F.col("ln.price").
  3. Exchange ... 200: 200 shuffle partitions for an aggregate of a few dozen rows; 200 tiny tasks per stage. Fix: spark.sql.shuffle.partitions set to 8 (or AQE with coalescing enabled).
  4. FileScan json [event_id, type, version, timestamp_ms, source, data] with no PushedFilters and no pruning: every column is read, and probably without an explicit schema (inference, an extra pass). Fix: a declared schema and a select of the columns needed right after the read; the type filter should appear under PushedFilters (it does when the column is compared with a literal and the schema is known).

A fifth detail: there is no InMemoryRelation, so if the program runs more than one action, it will re-read the JSON each time.

Exercise 3.

A single invocation with a glob for the seven days, or seven invocations (one per day, which is what Airflow will do in 05-05 with the backfill):

spark-submit --master spark://spark-master:7077 /app/analytics/daily_sales.py \
  'hdfs://namenode:8020/km0/events/2026-09-{08,09,10,11,12,13,14}/orders.jsonl' \
  /app/analytics/catalog.csv hdfs://namenode:8020/km0/aggregates/daily_sales

(a) mode("overwrite") replaces the contents of each day=2026-09-NN/ partition the job writes, atomically per partition (a write to a temporary location and a commit). (b) partitionOverwriteMode=dynamic limits the deletion to the partitions present in the job's output; without it, overwrite would empty the whole of daily_sales/, including August and the rest of September. (c) The computation is deterministic (same input, same aggregate) and the write replaces the complete partition: two runs leave the same bytes (apart from internal file names), without duplicating or accumulating. It is important that day is derived from timestamp_ms (event time) and not from the directory name: if an event from the 14th arrived late and orders had left it in the file for the 15th, the job for the 15th would write it to day=2026-09-14/, overwriting the partition for the 14th with only that event. To avoid this there are two options: filter in the job by the range of days being processed (F.col("day").between(...)) and discard or log those out of range, or make the input and output partitions match by construction. With a glob orders-*.jsonl per day nothing changes in the program (spark.read.json accepts globs and directories); it is only worth checking that none of the files is still being written, which is exactly what the sensor of 05-05 will watch for with _SUCCESS or with a closing file.

Conclusion

Spark takes the MapReduce model (partitions, re-runnable tasks, a shuffle by key) and strips out what made it slow: the entire computation is a DAG of operators that the driver knows in full, cuts into stages only where there is a wide dependency, chains everything else in the same task, and keeps the intermediate data in the memory of executors that live for the whole application. Fault tolerance comes from lineage, which recomputes the lost partition instead of having written it to disk. On top of RDDs, immutable and lazy, DataFrames add a schema and an optimiser, Catalyst, which pushes down filters, prunes columns, inserts partial aggregations and chooses broadcast or sort-merge for each join; Tungsten compiles the plan, and Parquet makes reading two columns from a year of events cost what those two columns take up. We have learnt to read an explain() to verify that the plan does what we think, to use cache when there are several actions, broadcast for the catalogue, coalesce before writing, salt for Montblanc Dairy, and partitionBy with dynamic overwrite so that the daily sales job is idempotent per day. services/analytics/daily_sales.py now computes in six seconds, and in a single program, what in 05-02 was three jobs and a minute; and ALS in MLlib has turned the clicks in the lake into recommendations without the hundred iterations meaning a hundred reads.

Everything we have done starts from a bounded dataset: the file for 14 September, already closed, or seven days of clicks. But orders.events never closes: stock.updated events arrive at a rate of hundreds per second, and the couriers' positions at 2.4 million a day, and neither the delivery dashboard nor the low-stock alert can wait for the nightly batch. The next lesson deals with stream processing: what changes when the dataset never ends, how "the last five minutes" is defined when events arrive out of order (watermarks), and how Flink and Spark Structured Streaming run the same DAG as this lesson over a stream that never stops.

Distributed Architectures Course

Module 1: Introduction to Distributed Systems

Module 2: Communication in Distributed Systems

Module 3: Consistency and Replication

Module 4: Distributed Storage

Module 5: Distributed Computing

Module 6: Security in Distributed Systems

Module 7: Monitoring and Maintenance

Module 8: Case Studies and Applications

© Copyright 2026. All rights reserved