AdvancedSpark · 15 min · 11 challenges

Lazy Evaluation: Advanced

A LinkedIn data engineer runs nightly label propagation over the member graph to find communities: each round, every member adopts the smallest label among their connections, and a sum checks whether any label moved. Round 2 finishes in 4 minutes. Round 30 takes an hour on identical data, because Spark replays every earlier round before it can answer.

Lineage That Grows in Loops

Daily Life
Interviews

Reassigning a DataFrame nests the old plan

A DataFrame is a plan, and every transformation returns a new DataFrame whose plan holds the old one as its child. Inside a loop, labels = new_labels rebinds a Python name to the newer plan. The older plan stays reachable as a subtree, so after 30 rounds the name labels points at a single DataFrame whose plan stacks 30 joins and 30 groupBys on top of the original nodes scan.

Here is the job from the intro. The edges table holds each connection in both directions plus a self-loop for every member, so a member's own label is always on offer. Each round, every member takes the smallest label it is offered. Labels can only fall, so once their sum stops falling, no label has moved and the loop stops.

labels = nodes.select("node_id", F.col("node_id").alias("label"))
previous_total = None
for round_no in range(1, 31):
new_labels = (
edges.join(labels, edges.src == labels.node_id)
.groupBy(F.col("dst").alias("node_id"))
.agg(F.min("label").alias("label"))
)
total = new_labels.agg(F.sum("label")).first()[0]
if total == previous_total:
break
previous_total = total
labels = new_labels

Read the loop the way Spark does. The single line that touches data is first(), the action at the end of the stopping check. Every other line adds operators to a plan held in driver memory. The table follows what labels refers to as each round begins.

Round startingJoins in labels' plangroupBys in labels' planScans of edges in it
1000
2111
10999
30292929
Building that plan computed none of it. The description grows by 1 join and 1 groupBy every round whether an action runs, and each action decides how much of it gets executed. The next section prices that decision.
Multiple Choice

This loop runs 12 rounds with no action inside it. When the loop finishes, what has Spark done?

for i in range(12):
df = df.join(dims[i], "user_id").filter(F.col("score") > 0)

A long plan is driver work

The driver pays for that plan in 2 places. Column references are resolved as each DataFrame is built, which is why a misspelled column fails on the line that names it. Every reassignment in the loop therefore checks its new join and groupBy against a plan one round deeper, so a deep loop slows down before any action runs. When an action does run, the driver rewrites the whole plan, chooses how each operator will run, and cuts the result into stages for the DAGScheduler, all on a single machine. At round 20, it does that for 20 joins to produce a single number, and the round after, for 21.
On the Jobs tab, planning shows up as a gap. A job's clock starts when the DAGScheduler receives it, after planning is done, so a check that spends 40 seconds in the driver leaves 40 idle seconds on the event timeline between the previous job's end and its own start. Gaps that widen round after round, on input that stays the same size, mean the plan is growing.
The same growth in an RDD loop
An RDD loop such as ranks = ranks.join(links).mapValues(update) grows a chain of dependencies where a DataFrame loop grows a plan. Serializing a task walks that chain recursively, so after enough rounds the walk overflows the driver thread's stack and the job dies with a StackOverflowError before a single task runs. Iterative RDD jobs with no checkpoint are the classic source of this error.

The animation runs the same growth on a loop small enough to watch. It starts from Monday's events, adds another day per round with union, and ends in a single collect().

week = mon
for day in [tue, wed, thu]:
week = week.union(day)
week.collect()
mon3 rows
event_id
user_id
event_type
1
101
view
2
102
click
3
101
buy
tue3 rows
event_id
user_id
event_type
4
103
view
5
101
click
6
104
view
wed3 rows
event_id
user_id
event_type
7
102
buy
8
105
view
9
103
click
thu3 rows
event_id
user_id
event_type
10
104
click
11
101
view
12
106
buy
Preparing the animation

The loop's 3 rounds launch nothing. When collect() runs, 1 job with 1 stage starts: each day's table brings its 2 partitions along, so the stage runs 8 tasks, and the driver gathers all 12 rows. Every round left its operators, and here its partitions too, in the job that finally ran.

Try it on the seed

This cell rewrites profit 5 times in a loop over orders, then totals it by status. Run it, change range(1, 6) to range(1, 41), and run it again. The totals change; the loop still finishes before any task starts, and all 40 rewrites wait for the collect() at the bottom.

orders = spark.table("orders")
df = orders.filter("status IS NOT NULL").select("status", "profit")

for round_no in range(1, 6):
    df = df.selectExpr("status", "profit * 1.1 AS profit")

result = (
    df.groupBy("status")
    .sum("profit")
    .withColumnRenamed("sum(profit)", "profit_after_rounds")
)
result.collect()

Two lines decide what the cell computes. The reassignment df = df.selectExpr(...) hands each round the DataFrame the previous round built, so the plan stacks every rewrite; without it, each round would discard its rewrite and start over from the unchanged df. And collect() sits after the loop, so all the rounds run inside the single job it launches.

Multiple Choice

This loop leaves out the reassignment. What does collect() return?

df = orders.select("status", "profit")
for round_no in range(1, 6):
df.selectExpr("status", "profit * 1.1 AS profit")
df.collect()

The Recompute Cost

Daily Life
Interviews

Each check replays every earlier round

The stopping check ends in first(), an action, and an action runs a plan from its sources. At round k, the check's plan begins at the nodes scan and holds k joins and k groupBys, so Spark recomputes rounds 1 through k before adding up a single label. The job for round k-1 sent its sum to the driver and kept nothing a later job could start from.

Put a price on it. Suppose a round of join and groupBy over the member graph takes 2 minutes of cluster time. The check at round k then costs about 2k minutes, and the run pays for every check along the way:
RoundRounds its check replaysCheck timeRounds of work so far
112 min1
224 min3
101020 min55
202040 min210
303060 min465
After n checks the total is n(n+1)/2 rounds, which grows with the square of n. 30 rounds of progress cost 465 rounds of work, or 930 minutes at 2 minutes a round, where computing each round once would take 60 minutes. Doubling the run to 60 rounds roughly quadruples the bill, to 1,830 rounds.
15.5x
work per round of real progress
29
computed rounds that round 30 redoes

Narrow loops pay less of this bill on executors. A loop of withColumn rewrites like the one in the previous section's cell replays cheaply, because pipelining fuses its steps into a single pass over each row. Its cost lands on the driver, which still plans every step. Loops whose rounds hold joins and groupBys pay on both sides.

Fewer actions, same plan

Running fewer actions cuts the cost without any new API. Here are 3 loop shapes over 30 rounds, priced in rounds of work.
Loop shapeRounds of workJoins in the last plan
Check after every round46530
Check after every 5th round10530
30 fixed rounds, one action at the end3030
Checking less often cuts executor work, but the final action still plans and runs a 30-join chain, and a fixed round count either stops too early or wastes rounds after the labels settle. Only a checkpoint bounds the depth of the plan itself.

The bill shows up in 3 places

Where repeated rounds cost you
  • ExecutorsGrows with the square of the round countEach check rescans edges and reruns every earlier join and groupBy. Round k's job reads the full edges table k times.
  • DriverGrows every roundPlanning round k's check means working through k joins. Gaps between jobs widen while executors sit idle.
  • RecoveryGrows with depthIf an executor dies mid-job, the DAGScheduler resubmits the parent stages whose output it held. In a deep plan, those parents reach back through earlier rounds.

Reading it in the Spark UI

Where to lookLoop with its plan depth cappedLoop replaying every round
Jobs tab, check durationsRise for a few rounds, then drop back to one round's timeA straight climb: 2, 4, 6 ... 60 minutes
Stages per check jobStays under a fixed capMore each round, a join and groupBy's worth
Stages tab, edges inputRead a capped number of times per checkRead k times by round k's check
Event timelineShort, steady gapsWidening gaps with idle executors
A climbing line on the Jobs tab while input size holds still points to lineage before it points to data. To confirm, open 2 check jobs a few rounds apart and compare their DAG visualizations. If the later one holds more joins, the plan is carrying history from round to round.
Multiple Choice

dims holds 8 DataFrames, and each join takes about 1 minute when run once. Roughly how many join-minutes does this loop spend on executors?

events = spark.table("user_events")
for i in range(8):
events = events.join(dims[i], "user_id")
print(i, events.count())

Checkpointing to Cut Lineage

Daily Life
Interviews

Saving the rows, dropping the recipe

df.checkpoint() runs the plan of df, writes the resulting rows as files under the checkpoint directory, and returns a new DataFrame whose plan is a single scan of those files. The returned DataFrame keeps no link to the joins that produced it. Any plan built on it starts from the saved rows, so a later check can reach back no further than the most recent save.

spark.sparkContext.setCheckpointDir("/shared/checkpoints/label_propagation")
CHECKPOINT_EVERY = 5
labels = nodes.select("node_id", F.col("node_id").alias("label"))
previous_total = None
for round_no in range(1, 31):
new_labels = (
edges.join(labels, edges.src == labels.node_id)
.groupBy(F.col("dst").alias("node_id"))
.agg(F.min("label").alias("label"))
)
if round_no % CHECKPOINT_EVERY == 0:
new_labels = new_labels.checkpoint()
total = new_labels.agg(F.sum("label")).first()[0]
if total == previous_total:
break
previous_total = total
labels = new_labels

Two lines carry the change. setCheckpointDir names a directory on storage that the driver and every executor can reach and that outlives any executor; call checkpoint() without it and Spark raises an error saying the checkpoint directory has not been set. The reassignment new_labels = new_labels.checkpoint() is what cuts the lineage. The call leaves the DataFrame it was invoked on unchanged, so dropping its return value pays for the save and carries on with the uncut plan.

//

Eager and lazy saves

checkpoint() is eager by default: the call itself launches the save, one of those jobs that run before your own action. With checkpoint(eager=False), the call returns at once and the files are written when the first action runs on the returned DataFrame. Loops usually keep the default, so the save lands on a known line and the check right after it reads files.

A reliable save makes 2 passes

A checkpoint runs the plan twice. The first pass is a job that counts the rows; the second is a job that writes them, and since the first pass kept nothing, the second recomputes every row. Spark's documentation for the underlying checkpoint call recommends keeping the data in executor storage before saving so the write reads stored rows instead; the caching lessons later in the track cover how. In the loop above, the save at round 5 replays 5 rounds once to count and once to write.

Choosing the interval

With a save every n rounds, no job ever replays more than n rounds. Within each block of n, the checks before the save replay 1, 2, up to n-1 rounds, and the save replays n rounds twice. Over 30 rounds:
Save everyRounds of workSaves writtenDeepest job
1 round60301 round
5 rounds12065 rounds
10 rounds195310 rounds
Never465030 rounds
4 hours
30 rounds, saved every 5
345
rounds of replay the saves remove

Rounds of work fall as the interval shrinks, but each save writes the whole labels table to shared storage and adds 2 jobs for the driver to schedule. Saving every round cuts 465 rounds of work to 60, at the price of 30 full writes of the table and 60 extra jobs. The right interval is where another save costs about as much as the replay it removes, and you find it by timing a save job and a round on the Jobs tab. Spark's own ALS recommender exposes the same choice as checkpointInterval, which defaults to 10 and takes effect only once a checkpoint directory is set.

TIP
On a checkpointed DataFrame, the query graph in the Spark UI begins at a node named Scan ExistingRDD where the joins used to be. A check whose graph still shows the full chain of joins after a save means the return value of checkpoint() was dropped.
Multiple Choice

Here step holds a round's join and groupBy, and the loop is meant to save every 5 rounds. How many rounds does the check at round 30 replay, and why?

for round_no in range(1, 31):
new_labels = step(labels)
if round_no % 5 == 0:
new_labels.checkpoint()
total = new_labels.agg(F.sum("label")).first()[0]
labels = new_labels

Local vs Reliable Checkpoints

Daily Life
Interviews

Executor storage or shared storage

df.localCheckpoint() cuts lineage the same way checkpoint() does: the DataFrame it returns has a plan that starts from saved rows. What differs is where those rows live. A local save keeps each partition in the storage of the executor that computed it, in memory with spill to that executor's local disk. A reliable save writes files to the checkpoint directory on shared storage.

PropertylocalCheckpoint()checkpoint()
Where rows liveExecutor memory and local diskFiles in the checkpoint directory
Needs `setCheckpointDir`NoYes
Passes over the plan per save12, unless the rows are already in executor storage
Write targetExecutor memory or local diskShared storage, usually slower
After an executor is lostIts partitions are goneAny executor reads the files
CleanupFreed after the driver drops its last reference to itLeft on storage unless `spark.cleaner.referenceTracking.cleanCheckpoints` is `true`

Reliable saves outlive the run. With spark.cleaner.referenceTracking.cleanCheckpoints at its default of false, every save's files stay in the checkpoint directory after the job ends, so a nightly job that saves 6 times a night leaves 180 sets of files behind after a month. Give each run its own subdirectory and delete it when the run succeeds.

Losing an executor after a local save

Normally a lost executor costs only time: the DAGScheduler notices the missing output and reruns the stages that produced it, using their lineage. A local save removed that lineage on purpose. If the executor holding partition 2 of the round-25 labels dies, no plan exists that can rebuild partition 2, so the task that needs it fails with an error saying the checkpoint block was not found because the executor that saved it is no longer alive.

Retries cannot fix this, because every attempt looks for the same missing block. After spark.task.maxFailures attempts, 4 by default, the stage is aborted and the job fails in round 26 with 25 rounds of progress lost. Recovery means rerunning from round 1.

localCheckpoint(), executor 3 lost
PartitionHeld byNext read
0executor 1Succeeds
1executor 2Succeeds
2executor 3Fails: no copy, no lineage
3executor 4Succeeds
checkpoint(), executor 3 lost
PartitionStored asNext read
0file part-00000Succeeds
1file part-00001Succeeds
2file part-00002Succeeds on another executor
3file part-00003Succeeds
Multiple Choice

A 20-round loop runs labels = new_labels.localCheckpoint() every 5 rounds. During round 14, the executor holding partition 6 of the round-10 save dies. What happens?

Choosing between local and reliable

Back to the 30-round job with a save every 5 rounds. A local save stores rows as the counting pass computes them, so it replays its 5 rounds once. Each block then costs 10 rounds of checks plus 5 for the save: 90 rounds over the run against 120 with reliable saves, and no writes to shared storage. The price is exposure. With local saves, a lost executor fails the job whenever it held partitions of the latest save, which most executors do; with reliable saves, a lost executor costs at most the rounds since the last save.
3 hours
30 rounds, saved locally every 5
25%
less compute than reliable saves
Picking a save
  • ▸Local suits runs short enough that rerunning from scratch costs less than writing every save to shared storage.
  • ▸Reliable suits long runs, where losing an executor in hour 3 would otherwise throw away hours 1 and 2.
  • ▸Reliable suits clusters that remove executors mid-run, through failures or by shrinking to save money.
  • ▸Local saves occupy executor memory and disk, and saves from earlier rounds stay there until the driver drops them.
Multiple Choice

A risk team's job runs 40 rounds of fraud-ring detection for 5 hours on 200 executors, and 3 of last month's runs lost an executor partway through. Which save strategy fits?

Side Effects Inside Transformations

Daily Life
Interviews

A function runs each time its task runs

A function passed to a transformation runs on an executor, inside a task, every time that task executes. Everything earlier in this lesson multiplies task executions: each action replays the plan, a reliable save makes 2 passes, a lost executor's stages get resubmitted, a failed task is retried, and a slow one may get a speculative copy. A function that only computes a value from its row is safe through all of it. A function that also changes something outside the row, such as a counter, a log, an API call or another database, makes that change once per execution.
//

The counter that reads 2,000

Accumulators are Spark's shared counters: tasks add to them, and the driver reads the total. Here a validation function counts payments with no amount as the rows pass through map, and 2 actions follow.

missing_amount = spark.sparkContext.accumulator(0)
payments = spark.table("payments")
def validate(row):
if row.amount is None:
missing_amount.add(1)
return row
checked = payments.rdd.map(validate)
print(checked.count())
spark.createDataFrame(checked, payments.schema).write.parquet("/warehouse/payments_checked/")
print(missing_amount.value)

Say 1,000 payments have no amount. count() runs validate over every row and the counter reaches 1,000. The write is a second action, so it reads the table and runs validate again, and the driver prints 2,000. Spark's documentation states the rule: an accumulator update made inside an action is applied once per task, and an update made inside a transformation may be applied more than once if tasks or stages are re-executed.

Count the ways a row's function can run, and the count of its effect stops being 1:
What runs the function againExampleRuns per row
Another action on the same DataFrame`count()`, then a write1 per action
A reliable save of the DataFrame`checkpoint()` without stored rows2 for the save
A resubmitted stageAn executor dies mid-job1 more for the lost partitions
A speculative copyA slow task gets a twinUp to 2 for that partition
An action that stops early`take(5)`0 for rows it never reads
No actionA loop that never calls one0

Effects go in actions, with a key

Move the effect out of the transformation and into an action. foreachPartition is an action that hands your function an iterator over a partition's rows, so the function opens a connection per partition, sends what it needs to, and closes it. Accumulator updates made inside it count once per task, retries included.

def send_alerts(rows):
client = AlertClient()
for row in rows:
client.upsert(alert_id=row.txn_id, body=row.reason)
client.close()
flagged = (
spark.table("payments")
.filter(F.col("amount").isNull())
.select("txn_id", F.lit("missing amount").alias("reason"))
)
flagged.foreachPartition(send_alerts)

An action still runs again under retries and speculation, and an external effect doesn't roll back. A task that delivered 400 of its 500 alerts and then failed delivers all 500 again on its retry. So the receiver has to tolerate repeats, and that needs a stable key taken from the row, here alert_id=row.txn_id. Designing writes that land once on such a key is the subject of the fault-tolerance lessons later in the track.

✓Do
  • Compute values in transformations; send, write and count in foreachPartition or a write
  • Give every external write an id from the row, so a repeat overwrites the earlier one
  • Read an accumulator after the action that fills it, and fill it only inside actions
  • Open a connection per partition inside foreachPartition, never one per row
✗Don't
  • Call an API, send a message or write a file from a function passed to map
  • Report an accumulator filled inside a transformation as an exact count
  • Debug with print in a transformation: the output goes to executor logs
  • Assume a reliable checkpoint() runs your function once: the save makes 2 passes
Multiple Choice

push_notifs holds 1,000 rows in 4 partitions, and send_push delivers a notification per call. After both actions run, which is true?

sent = spark.sparkContext.accumulator(0)
def notify(row):
send_push(row.user_id)
sent.add(1)
return row
pushed = spark.table("push_notifs").rdd.map(notify)
pushed.count()
pushed.take(5)
PUTTING IT ALL TOGETHER

> A payments company runs a nightly job that groups accounts into fraud rings: accounts sharing a card or device form edges, label propagation runs for up to 40 rounds over 200 million accounts on 200 executors, and every newly found ring triggers an alert to the risk team.

Written as a plain loop with a sum check each round, the 40-round job replays 820 rounds of work, and the driver plans a deeper chain every round.
A save every 5 rounds caps every job at 5 rounds of depth, and reassigning the DataFrame that checkpoint() returns is what makes the cap real.
Over 5 hours on 200 executors, losing one is expected, so the saves are reliable checkpoints to a run-specific directory that is deleted after a successful run.
The ring alerts go out in foreachPartition after the final round, keyed by ring id, so a retried task re-sends onto the same alert records.
If the alert call sat inside the loop's map, every check, every save pass and every retry would page the risk team again.
KEY TAKEAWAYS
Reassigning a DataFrame inside a loop nests the previous plan in the new one, so after n rounds a single DataFrame carries all n rounds.
An action inside the loop replays every round from the source tables: n checks cost n(n+1)/2 rounds of work, and the driver plans a longer chain each time.
checkpoint() and localCheckpoint() run the plan, keep the rows and return a DataFrame whose plan starts from them; only the returned DataFrame is cut, so reassign it.
A reliable save costs a second pass and a shared-storage write and survives a lost executor; a local save is cheaper and fails the job when an executor holding it dies.
A function inside a transformation runs once per task execution, which can be 0, 1 or several times; put effects in actions such as foreachPartition and give each one an id so a repeat overwrites.

Why a loop makes Spark replay every earlier round, and how checkpoints and careful side effects stop it.

Category
Spark
Difficulty
advanced
Duration
15 minutes
Challenges
11 hands-on challenges

Topics covered: Lineage That Grows in Loops, The Recompute Cost, Checkpointing to Cut Lineage, Local vs Reliable Checkpoints, Side Effects Inside Transformations

Lesson Sections

  1. Lineage That Grows in Loops (concepts: Spark Execution Model)

    Reassigning a DataFrame nests the old plan Building that plan computed none of it. The description grows by 1 join and 1 groupBy every round whether an action runs, and each action decides how much of it gets executed. The next section prices that decision. A long plan is driver work The driver pays for that plan in 2 places. Column references are resolved as each DataFrame is built, which is why a misspelled column fails on the line that names it. Every reassignment in the loop therefore checks

  2. The Recompute Cost (concepts: Spark Execution Model)

    Each check replays every earlier round Put a price on it. Suppose a round of join and groupBy over the member graph takes 2 minutes of cluster time. The check at round k then costs about 2k minutes, and the run pays for every check along the way: After n checks the total is n(n+1)/2 rounds, which grows with the square of n. 30 rounds of progress cost 465 rounds of work, or 930 minutes at 2 minutes a round, where computing each round once would take 60 minutes. Doubling the run to 60 rounds rough

  3. Checkpointing to Cut Lineage

    Saving the rows, dropping the recipe Eager and lazy saves A reliable save makes 2 passes A checkpoint runs the plan twice. The first pass is a job that counts the rows; the second is a job that writes them, and since the first pass kept nothing, the second recomputes every row. Spark's documentation for the underlying checkpoint call recommends keeping the data in executor storage before saving so the write reads stored rows instead; the caching lessons later in the track cover how. In the loop

  4. Local vs Reliable Checkpoints (concepts: Caching & Persistence)

    Executor storage or shared storage Losing an executor after a local save Normally a lost executor costs only time: the DAGScheduler notices the missing output and reruns the stages that produced it, using their lineage. A local save removed that lineage on purpose. If the executor holding partition 2 of the round-25 labels dies, no plan exists that can rebuild partition 2, so the task that needs it fails with an error saying the checkpoint block was not found because the executor that saved it i

  5. Side Effects Inside Transformations

    A function runs each time its task runs A function passed to a transformation runs on an executor, inside a task, every time that task executes. Everything earlier in this lesson multiplies task executions: each action replays the plan, a reliable save makes 2 passes, a lost executor's stages get resubmitted, a failed task is retried, and a slow one may get a speculative copy. A function that only computes a value from its row is safe through all of it. A function that also changes something out