Animated guide · Apache Spark 4.2

Spark from the inside

How a Spark program really runs — driver, executors, lineage, stages, shuffle, memory, the SQL optimizer — and then how to consume Kafka topics with DStreams without losing or duplicating a record. Scroll — the diagrams start by themselves.

jobs → stages → tasks 🧠
scroll ↓

How to read this guide

mechanisms first, API second

You can write Spark code for months by copying snippets. Then one day a job that took 4 minutes takes 4 hours, or your Kafka stream is 3 million records behind, and the snippets stop helping. What helps is knowing the machine: who runs your lambda, when it runs, and what moves across the network.

So every chapter explains one mechanism and gives you a diagram you can poke. Each diagram has a 💥 button that breaks something on purpose — because you learn a system fastest by watching it fail.

The guide has levels. Chapters and blocks carry a chip — L1 essentials, L2 practitioner, L3 internals. Choose your depth with the switch in the bottom-left corner; deeper material hides until you want it. The map below jumps anywhere and ticks off what you've read.

Facts checked against the Apache Spark 4.2.0 documentation and the Spark source code (tag v4.0.0) in September 2026. Code examples are Java. DStreams (Spark Streaming) is the legacy streaming engine: the docs say it gets no more updates and recommend Structured Streaming for new pipelines. You still meet DStream jobs in production — this guide is for running and understanding them.

🧠 Brain power

A DStream job reads a Kafka topic every 10 seconds. Each batch takes 12 seconds to process. Nothing crashes, no error appears in the log. What happens over the next hour — and which two numbers in the UI tell you? Keep your guess; the finale answers it.

Part I

The core engine

Driver and executors, RDDs and lineage, how an action becomes jobs, stages and tasks, the shuffle, memory, and the SQL optimizer.

Anatomy of a Spark application

one brain, many hands

A Spark application is two kinds of processes. The driver runs your main() and owns the SparkContext. It plans the work and hands it out. The executors are JVM processes on worker nodes. They run tasks in threads and keep data in memory or on disk for your application.

Between them sits a cluster manager — Spark's own standalone manager, YARN or Kubernetes. The driver asks it for executors; after that, the driver talks to the executors directly. Each application gets its own executors, which live as long as the application does. Two applications therefore cannot share cached data without writing it to external storage.

▶ Submitting an application then lose an executor
Press ▶ to submit.
Simplified: real executors have many task slots (cores), the driver also needs heartbeats and a block manager, and the cluster manager starts executors through its own agents. Task counts are illustrative.

The deploy mode decides where the driver runs: in cluster mode the framework launches it inside the cluster; in client mode it runs where you ran spark-submit. Either way, executors must be able to connect back to the driver for its whole life, so keep the driver close to the workers. Each driver serves a web UI, typically on port 4040.

❓ There are no dumb questions
Q:
Does my map lambda run on the driver?
A:
No. The driver serializes the function and ships it inside tasks. It runs on executors, against copies of any variables it captured. Changing a captured variable on an executor never changes the driver's copy.
Q:
Then what does run on the driver?
A:
Everything outside the lambdas: building the plan, scheduling, and receiving results of actions like collect() — which is why collect() on a huge dataset can kill the driver.
📌 Bullet points
  • Driver = plans and schedules. Executors = run tasks and store data.
  • The cluster manager only allocates executors; it does not schedule your tasks.
  • A task is the unit of work sent to one executor.

RDDs: lazy recipes with a memory

nothing happens until you ask

An RDD (Resilient Distributed Dataset) is an immutable collection split into partitions. One task processes one partition. You never change an RDD; each transformation (map, filter, reduceByKey…) returns a new RDD that remembers its parent and the function to apply.

Transformations are lazy: Spark only records them. Work starts when an action — count(), collect(), saveAsTextFile() — needs a result. The chain of recorded steps is the lineage, and it is the "resilient" in RDD: if an executor dies and a partition is lost, Spark recomputes just that partition from its lineage.

▶ Lineage in action define, run, lose a partition
Press ① — then ②.
Dashed = only a recipe, solid = computed, green = cached. Simplified: real recovery is driven by the scheduler when a task fails or an executor is lost; with cache() only the lost cached block is rebuilt.
SparkConf conf = new SparkConf().setAppName("word-count");
JavaSparkContext sc = new JavaSparkContext(conf);

JavaRDD<String> lines = sc.textFile("hdfs:///logs/*.txt");       // nothing is read yet
JavaPairRDD<String, Integer> counts = lines
    .flatMap(l -> Arrays.asList(l.split(" ")).iterator())      // narrow
    .mapToPair(w -> new Tuple2<>(w, 1))                           // narrow
    .reduceByKey(Integer::sum);                                   // wide: needs a shuffle

counts.cache();                        // a marker, still nothing computed
long distinct = counts.count();        // action → job 0, fills the cache
List<Tuple2<String, Integer>> some = counts.take(10);  // job 1, reads cached partitions
System.out.println(counts.toDebugString());          // prints the lineage
⚠️ Watch it!

Without cache()/persist(), every action recomputes the whole lineage — re-reading the files and re-running every step. Two actions on an expensive RDD = twice the work. Cache what you reuse, and unpersist() it when done; otherwise Spark evicts old cached partitions in least-recently-used (LRU) order.

🤓 Storage levels
LevelWhat it doesUse it when
MEMORY_ONLYDeserialized objects in the JVM; partitions that do not fit are recomputed when needed. The default for cache() on RDDs.Data fits comfortably; fastest access.
MEMORY_AND_DISKPartitions that do not fit go to disk and are read from there.Recomputing is more expensive than a disk read.
MEMORY_ONLY_SER / MEMORY_AND_DISK_SERStored as serialized bytes: smaller, more CPU to read.Memory is tight or GC is a problem.
DISK_ONLYOnly on disk.Very large, rarely re-read data.
…_2 (e.g. MEMORY_ONLY_2)Each partition replicated on two nodes.You cannot wait for recomputation after a node loss.
📌 Bullet points
  • Transformations build a recipe (lineage); actions run it.
  • Lost partitions are recomputed from lineage — only the lost ones.
  • Each action recomputes from the source unless you persist.

Jobs, stages and tasks

where the DAG gets cut

Each action submits a job. The job is split into stages, and each stage into tasks — one task per partition. Where does one stage end? At a shuffle.

Dependencies come in two kinds. Narrow: each output partition needs one parent partition (map, filter, flatMap). Spark pipelines narrow steps inside one task — a record flows through flatMap → mapToPair without any intermediate collection. Wide (shuffle): an output partition needs data from many parent partitions (reduceByKey, groupByKey, join, repartition). That needs an all-to-all exchange, so the stage must finish writing before the next can read: a barrier.

▶ DAG builder add operations, watch the stage cuts
Add operations, then run the action.
Each stage runs 4 tasks here (4 partitions, illustrative). Simplified: a real re-run after lost shuffle files recomputes only the missing map outputs.
❓ There are no dumb questions
Q:
How many tasks will a stage have?
A:
As many as the partitions of the RDD that stage computes. A file read gets partitions from the input splits. After a shuffle, it is the number you pass (reduceByKey(f, 64)) or spark.default.parallelism; if that is unset, the largest partition count among the parent RDDs.
Q:
Can two stages run at the same time?
A:
Yes, if neither depends on the other — for example the two input sides of a join. A stage only waits for its own parent stages.
🔬 Go deeper: DAGScheduler vs TaskScheduler

The DAGScheduler (on the driver) implements stage-oriented scheduling. For each job it builds the DAG of stages by cutting the RDD graph at shuffle boundaries. Stages come in two types: ShuffleMapStages write map output files for a shuffle; the final ResultStage runs the action. It tracks which RDDs are cached and which shuffle outputs already exist, so a later job reuses them and skips stages (the UI shows them as "skipped").

It computes a preferred location for each task (from cached blocks, shuffle outputs or input splits) and submits each stage as a TaskSet to the TaskScheduler. The TaskScheduler launches tasks on executors, honoring locality: it waits up to spark.locality.wait (default 3s) for a data-local slot before running the task somewhere less local.

Failure handling is split too. A task that fails for an ordinary reason is retried by the TaskScheduler; after spark.task.maxFailures (default 4) failures of the same task, the job is aborted. But if a task fails with FetchFailed — a shuffle file from an earlier stage is gone — the DAGScheduler resubmits the parent stage to rebuild the missing map outputs. Re-runs of a stage are called attempts.

📌 Bullet points
  • Action → job → stages (cut at shuffles) → tasks (one per partition).
  • Narrow steps are pipelined inside one task; wide steps need a shuffle and a barrier.
  • Count the shuffles in your code and you know the stage count.

The shuffle, up close

the most expensive line of your job

A shuffle moves every record to the partition that owns its key. The Spark docs call it expensive because it costs disk I/O, serialization and network I/O at once. It runs as two sets of tasks, named after MapReduce: map tasks organize their output by target partition, reduce tasks fetch and aggregate it.

On the map side, results are held in memory until they no longer fit, then sorted by target partition and written to a single file per map task (plus an index telling where each target partition's block starts). Each reduce task reads its block from every map task's file. These shuffle files stay on disk until the RDDs that need them are garbage collected — so a lost executor does not always mean a full recompute, and a long-running job can fill spark.local.dir.

▶ reduceByKey vs groupByKey count what crosses the network
Map tasks (3 input partitions)
Reduce tasks (3 shuffle partitions)
Pick an operation.
Colored chips are records; the key decides the target partition (a = 0, b = 1, c = 2 here — real Spark uses hash(key) mod partitions). Counts are illustrative.
⚠️ Watch it!

Skew: one hot key sends most records to one reduce task. The stage is only done when its slowest task is done, so 199 tasks finish in seconds and one runs for an hour — and may spill or run out of memory. Look for one long bar in the stage's task timeline. Fixes: combine on the map side, salt the hot key, or (DataFrames) let AQE split skewed join partitions (chapter 6).

🔬 Go deeper: memory, spills and the bypass path

reduceByKey and aggregateByKey build in-memory tables on the map side; the …ByKey operations build them on the reduce side. When a table does not fit, Spark spills it to disk, paying extra I/O and GC. A spill is not an error, but many spills in the stage metrics mean the tasks are too big: raise the partition count.

The sort-based shuffle manager has a shortcut: with no map-side aggregation and at most spark.shuffle.sort.bypassMergeThreshold (default 200) reduce partitions, it writes one temporary file per reduce partition and concatenates them instead of merge-sorting.

Element order after a shuffle is not deterministic. Need order? Use sortBy, or repartitionAndSortWithinPartitions to sort while repartitioning.

📌 Bullet points
  • Shuffle = map tasks write sorted files, reduce tasks fetch their block from every file.
  • reduceByKey combines before shipping; groupByKey ships every value.
  • A stage is as slow as its slowest task — skew kills.

Unified memory: execution vs storage

who may evict whom

Executor memory has two main customers. Execution memory is for computation — shuffles, joins, sorts, aggregations. Storage memory is for cached data and data propagated across the cluster, like broadcasts. Since the unified memory manager, both share one region M, and each can borrow the other's free space.

The rule that matters: execution may evict storage, but only until storage falls back to a protected region R inside M. Storage never evicts execution. If execution still needs more, it spills to disk.

▶ Executor heap cache, then run a big sort
reserved 300 MiBuser (1 − fraction)storageexecutionfree in M
Press + cache RDD.
Region sizes are computed from the real formulas with defaults spark.memory.fraction = 0.6 and spark.memory.storageFraction = 0.5. The amounts cached and sorted are illustrative; off-heap and overhead memory are not shown.
SettingDefault · whatChange it when
spark.executor.memory1g · heap per executorAlways size it for your workload.
spark.memory.fraction0.6 · M as a fraction of (heap − 300 MiB); the rest is for user data structures and Spark metadataRarely; lower it if you see OOMs from your own objects.
spark.memory.storageFraction0.5 · R as a fraction of M, cached blocks immune to evictionRarely; raise it if a cache you depend on keeps getting evicted.
😌 Relax

The Spark tuning guide says most users should not need to touch the two fractions. Tune spark.executor.memory and your partition counts first.

📌 Bullet points
  • M = (heap − 300 MiB) × 0.6; R = M × 0.5.
  • Execution can evict cached blocks down to R; storage cannot evict execution.
  • When execution runs out, it spills.

DataFrames: an optimizer between you and the RDDs

you say what, Catalyst decides how

With RDDs, Spark runs your lambdas as written — it cannot look inside them. A DataFrame query is different: it is a tree of relational operators Spark understands. So the Catalyst optimizer can rewrite it before anything runs. df.explain(true) prints the four phases: Parsed Logical Plan → Analyzed Logical Plan (names and types resolved against the catalog) → Optimized Logical Plan (rule-based rewrites) → Physical Plan (concrete operators such as a sort-merge join).

▶ A query through Catalyst step through the phases

    
Press Next.
Plan text is simplified and shortened, not literal explain() output. The size of countries is illustrative.

Two more layers work below the plan. Whole-stage code generation compiles a subtree of supported operators into a single Java function, so rows are not passed through one virtual call per operator. And Adaptive Query Execution (AQE), on by default (spark.sql.adaptive.enabled = true), re-optimizes the plan during execution using real statistics from finished shuffle stages.

AQE featureWhat it doesHelps when
Coalesce post-shuffle partitionsMerges contiguous small shuffle partitions up to spark.sql.adaptive.advisoryPartitionSizeInBytes.The default spark.sql.shuffle.partitions = 200 is too many for your data.
Sort-merge → broadcast hash joinIf a join side turns out smaller than the adaptive broadcast threshold at runtime, switches the join.Filters made one side tiny, but estimates did not know.
Skew joinSplits (and replicates if needed) skewed partitions of a sort-merge join into evenly sized tasks.One join key is hot.
🔥 Fireside chat
RDDI run exactly what they wrote. Honest. Predictable.
DataFrameYou run a filter after a join because they wrote it that way. I push it below the join, so the join reads less data.
RDDMy lambdas can do anything.
DataFrameWhich is exactly why nobody can optimize them.
📌 Bullet points
  • Catalyst: parsed → analyzed → optimized → physical plan.
  • Code generation fuses operators into one function.
  • AQE fixes the plan at runtime: fewer partitions, broadcast joins, skew splits.
Part II

Kafka with DStreams

The micro-batch model, the direct Kafka stream, offsets and delivery guarantees, state and windows, and keeping up with the topic.

A DStream is a conveyor belt of RDDs

streaming = many tiny batch jobs

Spark Streaming does not process records one by one. It cuts time into batch intervals (say 10 s). Everything that arrived during an interval becomes one RDD. A DStream (discretized stream) is just that series of RDDs, and every operation on a DStream is applied to each RDD in it.

So everything from Part I still holds — every batch is a normal Spark job with stages, tasks and shuffles. And like RDDs, DStreams are lazy: only output operations (foreachRDD, print, saveAs…Files) make Spark do the work. No output operation, no processing. By default, output operations run one at a time, in the order you defined them.

▶ Batches on a timeline processing time vs batch interval
Pick a scenario.
Batch interval 10 s. One batch job runs at a time; a new batch waits while the previous one is still running. Durations are illustrative.

The Streaming tab of the web UI shows the two numbers that matter. Processing time is how long each batch took. Scheduling delay is how long a batch waited in the queue for earlier batches to finish. If processing time is consistently above the batch interval, or scheduling delay keeps growing, the application is falling behind.

⚠️ Watch it! — connections in foreachRDD

Code in foreachRDD runs on the driver; code inside rdd.foreach… runs on executors. Creating a DB connection at the driver level and using it inside rdd.foreach fails (the connection is not serializable). Creating one per record works but is slow. Create one per partition — or take one from a pool:

stream.foreachRDD(rdd -> {                   // driver
  rdd.foreachPartition(records -> {           // executor, once per partition
    Connection c = ConnectionPool.get();      // your own lazily created pool
    while (records.hasNext()) c.send(records.next());
    ConnectionPool.release(c);
  });
});
🔬 Go deeper: the StreamingContext rules

Once a StreamingContext is started, you cannot add new streaming computations to it. Once stopped, it cannot be restarted. Only one can be active per JVM. stop() also stops the SparkContext unless you pass stopSparkContext = false. The batch interval is fixed when you create the context and should come from your latency needs and what the cluster can sustain.

📌 Bullet points
  • DStream = one RDD per batch interval; each batch = ordinary Spark jobs.
  • Output operations trigger the work, one at a time by default.
  • Stable only if processing time < batch interval.

The direct Kafka stream

one Kafka partition = one Spark partition

The Kafka integration for Spark Streaming is spark-streaming-kafka-0-10 (for Kafka brokers 0.10.0+). It is a direct stream: there is no long-running receiver. At every batch the driver looks up the latest offsets for each partition and defines the batch as a set of offset ranges — "partition 0 from 1 200 to 1 450". The resulting KafkaRDD has exactly one Spark partition per Kafka partition, and executors read their range from Kafka when the task runs.

▶ From topic to tasks batch by batch
Topic orders
Driver: offset ranges of this batch
Executors (cached consumers)
Press ▶ Next batch.
Offsets and message rates are illustrative. Partition-to-executor placement mimics PreferConsistent (an even spread), not its exact algorithm.
SparkConf conf = new SparkConf().setAppName("orders-stream");
JavaStreamingContext jssc = new JavaStreamingContext(conf, Durations.seconds(10));

Map<String, Object> kafkaParams = new HashMap<>();
kafkaParams.put("bootstrap.servers", "broker1:9092,broker2:9092");
kafkaParams.put("key.deserializer", StringDeserializer.class);
kafkaParams.put("value.deserializer", StringDeserializer.class);
kafkaParams.put("group.id", "orders-stream");         // one group.id per stream
kafkaParams.put("auto.offset.reset", "latest");
kafkaParams.put("enable.auto.commit", false);          // we commit ourselves (next chapter)

JavaInputDStream<ConsumerRecord<String, String>> stream =
    KafkaUtils.createDirectStream(
        jssc,
        LocationStrategies.PreferConsistent(),
        ConsumerStrategies.<String, String>Subscribe(List.of("orders"), kafkaParams));

stream.mapToPair(r -> new Tuple2<>(r.key(), r.value()))
      .foreachRDD(rdd -> { /* output */ });

jssc.start();
jssc.awaitTermination();

Depend only on org.apache.spark:spark-streaming-kafka-0-10_2.13; the docs warn not to add kafka-clients yourself, because mismatched versions fail in hard-to-diagnose ways. Mark spark-core and spark-streaming as provided.

OptionWhat it doesUse it when
LocationStrategies.PreferConsistentSpreads partitions evenly across available executors.Almost always.
LocationStrategies.PreferBrokersPrefers the executor on the host of the partition's Kafka leader.Executors run on the same hosts as the brokers.
LocationStrategies.PreferFixedYour explicit partition → host map; unlisted partitions use a consistent location.Load is heavily skewed between partitions.
ConsumerStrategies.SubscribeFixed collection of topics; reacts to partitions added while running.The normal case.
ConsumerStrategies.SubscribePatternTopics matching a regex.Topic-per-tenant or similar naming schemes.
ConsumerStrategies.AssignFixed collection of partitions, optionally with starting offsets.You store offsets yourself (next chapter).
🤓 Cached consumers

A Kafka consumer pre-fetches messages into buffers, so creating one per batch would waste them. Executors therefore keep cached consumers, keyed by topic-partition and group.id — which is why each createDirectStream call should have its own group.id. The cache holds 64 consumers per executor by default (spark.streaming.kafka.consumer.cache.maxCapacity); raise it if you have more than 64 × executors partitions. spark.streaming.kafka.consumer.cache.enabled = false turns caching off.

🔬 Go deeper: what the driver does each batch

In DirectKafkaInputDStream.compute(), the driver's own consumer is polled (the source calls it paranoidPoll) and asked for the latest offsets with seekToEnd; newly assigned partitions are picked up here. The latest offsets are clamped by the rate limit (chapter 11), turned into OffsetRange(topic, partition, from, until) objects, and wrapped in a KafkaRDD with preferred hosts from the location strategy. The ranges are reported to the UI's input info, and currentOffsets moves to the until-offsets. Last, queued offset commits are sent (commitAll()). Also in the source: persisting or windowing the raw stream logs an error, because a ConsumerRecord is not serializable — map to your own fields first.

📌 Bullet points
  • Direct stream: the driver defines offset ranges; tasks read them from Kafka.
  • 1 Kafka partition = 1 RDD partition = 1 task per batch.
  • More Kafka partitions = more parallelism per batch.

Offsets and delivery guarantees

when you save the bookmark decides everything

Spark output operations are at-least-once: after a failure, a batch may be written again. What you get end to end depends on where and when you store the offsets. The docs list three options, in order of increasing reliability and code complexity — plus one trap.

▶ Crash simulator pick a strategy, crash at a bad moment
Sink (rows written)
Stored offset
Pick a strategy and run a batch.
One batch = offsets 100–200 of one partition (illustrative). Auto-commit timing is shown as "committed early"; in reality the Kafka consumer commits on its own schedule, which is exactly why the result is undefined.
StrategyHowResult after a crashCatch
Checkpointjssc.checkpoint(dir); offsets are saved in the checkpointRepeated output → output must be idempotentCannot recover from a checkpoint after changing application code.
Kafka commitAsyncCommit offset ranges after the output succeededRepeated output → idempotentKafka is durable across code changes, but not transactional with your sink.
Your own transactional storeWrite results and offsets in one transaction; start with Assign from stored offsetsEquivalent of exactly-onceMost code; you must detect repeated or skipped ranges.
enable.auto.commit = trueConsumer commits periodically by itselfUndefined: polled ≠ writtenDo not use; set it to false.
// Option 2: commit to Kafka after the output is stored
stream.foreachRDD(rdd -> {
  OffsetRange[] ranges = ((HasOffsetRanges) rdd.rdd()).offsetRanges();  // first call on the stream!
  rdd.foreachPartition(records -> {
    OffsetRange o = ranges[TaskContext.get().partitionId()];            // this task's range
    writeIdempotently(records);                                        // e.g. upsert by key
  });
  ((CanCommitOffsets) stream.inputDStream()).commitAsync(ranges);
});
⚠️ Watch it!

The cast to HasOffsetRanges (and to CanCommitOffsets) only works on the stream returned by createDirectStream, as the first method in the chain — not after map. And after anything that shuffles or repartitions (reduceByKey, window), RDD partition i is no longer Kafka partition i. Grab the ranges first, transform after.

🔬 Go deeper: the transactional pattern (Java sketch)
Map<TopicPartition, Long> from = db.loadOffsets("orders-stream");      // your table
JavaInputDStream<ConsumerRecord<String, String>> stream =
    KafkaUtils.createDirectStream(jssc, LocationStrategies.PreferConsistent(),
        ConsumerStrategies.<String, String>Assign(from.keySet(), kafkaParams, from));

stream.foreachRDD(rdd -> {
  OffsetRange[] ranges = ((HasOffsetRanges) rdd.rdd()).offsetRanges();
  List<Result> results = compute(rdd);          // e.g. an aggregate collected to the driver
  db.inTransaction(tx -> {
    tx.save(results);
    // update only where stored offset == range.fromOffset(); otherwise roll back
    tx.advanceOffsets(ranges);
  });
});

This works even for aggregated outputs, which are hard to make idempotent. The offset check inside the transaction is what detects repeated or skipped ranges.

L3 detail: commitAsync only queues the ranges (it is thread-safe). The driver sends queued commits the next time the stream computes a batch. So a crash right after commitAsync can still replay the last batch — one more reason to write idempotently.

📌 Bullet points
  • Always enable.auto.commit = false.
  • Store offsets after output (idempotent sink) or with output (one transaction).
  • Read offset ranges on the direct stream before any transformation.

State, windows and checkpointing

remembering across batches

Window operations compute over the last N batches. Two parameters: window length (how much time) and sliding interval (how often). Both must be multiples of the batch interval — the source code rejects the DStream otherwise. reduceByKeyAndWindow(f, Durations.seconds(30), Durations.seconds(10)) gives per-key totals over 30 s, every 10 s.

▶ Sliding window length 3 batches, slide 2
Press ▶ Slide.
Batch interval 1 unit, window 3, slide 2 — the same shape as the Spark docs' figure. Counts are illustrative.

With an inverse function — reduceByKeyAndWindow(f, invF, 30 s, 10 s) — Spark updates the previous window result: add what entered, subtract what left. Cheaper for long windows, but it needs checkpointing. Windowed DStreams are persisted in memory automatically.

State across the whole stream: updateStateByKey(func) calls your function every batch for every key, with the new values and the previous state. It requires a checkpoint directory.

Two kinds of checkpoint

KindWhat is savedNeeded for
Metadata checkpointConfiguration, DStream operations, incomplete batchesRecovering from driver failure
Data (RDD) checkpointGenerated RDDs of stateful transformationsCutting lineage that would grow forever; mandatory with updateStateByKey or inverse windows
String checkpointDir = "hdfs:///checkpoints/orders-stream";

JavaStreamingContext ctx = JavaStreamingContext.getOrCreate(checkpointDir, () -> {
  JavaStreamingContext jssc = new JavaStreamingContext(conf, Durations.seconds(10));
  // create ALL DStreams and output operations here
  jssc.checkpoint(checkpointDir);
  return jssc;
});
ctx.start();
ctx.awaitTermination();

If the directory exists, the context is rebuilt from it; otherwise the function creates a fresh one. The driver must also be restarted automatically after a failure — that is the job of your deployment (cluster mode supervision, Kubernetes, YARN attempts), not of getOrCreate.

⚠️ Watch it!

A checkpoint holds serialized Java objects of your code. After an upgrade, deserializing them with changed classes may fail. The docs' options: run the new version in parallel and then stop the old one, or stop gracefully and start the new version with a new checkpoint directory. With Kafka offsets stored outside the checkpoint (previous chapter), the new version knows where to start.

🔬 Go deeper: checkpoint interval

Checkpointing RDDs costs a write to reliable storage, so batches that checkpoint take longer. For stateful DStreams the default interval is a multiple of the batch interval of at least 10 s. The docs suggest trying 5–10 sliding intervals, set with dstream.checkpoint(interval). Accumulators and broadcast variables are not recovered from a checkpoint — create them as lazily initialized singletons so they are rebuilt after a restart.

📌 Bullet points
  • Window length and slide are multiples of the batch interval.
  • Stateful ops and inverse windows require jssc.checkpoint(dir).
  • Code changes break checkpoint recovery — plan upgrades.

Keeping up: rate limits, backpressure, operations

never bite off a 1-hour backlog

A direct stream, left alone, takes everything between the last offset and the latest one in a single batch. After a restart with an hour of backlog, that one batch is enormous. Two settings bound it. spark.streaming.kafka.maxRatePerPartition is a hard ceiling in records per second per partition, so the batch size is at most rate × partitions × batch seconds. spark.streaming.backpressure.enabled makes Spark estimate, from recent batch delays and processing times, how fast it can go, and set the rate dynamically — still capped by maxRatePerPartition if set.

▶ Restart after downtime batch sizes over time
Tick options, then run.
Illustrative numbers: 4 partitions, 10 s batches, incoming 2 000 rec/s, capacity 5 000 rec/s. The backpressure estimator is simplified to "last batch's observed throughput"; Spark's default estimator is a PID controller.
SettingDefault · whatUse it when
spark.streaming.kafka.maxRatePerPartitionnot set · max records/s read from each partitionAlways in production — it bounds the worst batch.
spark.streaming.kafka.minRatePerPartition1 · min records/s per partitionRarely changed.
spark.streaming.backpressure.enabledfalse · adapt the rate to processing speedLoad or processing speed varies.
spark.streaming.backpressure.initialRatenot set · max rate for the first batch when backpressure is onThe first batch after a restart must be small.
spark.streaming.stopGracefullyOnShutdownfalse · on JVM shutdown, finish received data before stoppingDeployments that stop the app with a signal.
spark.streaming.ui.retainedBatches1000 · batches kept by the Streaming UIYou need a longer history in the UI.
🧰 Production checklist for a Kafka DStream job
  1. Batch interval from latency needs; confirm processing time stays well below it under peak load.
  2. Kafka partitions ≥ the parallelism you want per batch — each batch has one task per partition.
  3. enable.auto.commit = false; offsets stored after (idempotent) or with (transactional) the output.
  4. maxRatePerPartition set; backpressure on if load varies.
  5. If the batch interval exceeds the consumer's session timeout, raise heartbeat.interval.ms and session.timeout.ms; for batches over 5 minutes the broker's group.max.session.timeout.ms too.
  6. Checkpoint dir on reliable storage if you use state; driver restart handled by the deployment.
  7. Alert on scheduling delay and consumer lag, not only on errors.
🤓 Where next: Structured Streaming

For new pipelines the docs point to Structured Streaming: DataFrame queries over an unbounded table, with the Catalyst optimizer from chapter 6 and its own Kafka source. The ideas from this guide — offsets as the source of truth, idempotent or transactional sinks, bounded batch sizes — carry over directly.

🔬 Go deeper: how the rate limit is split across partitions

In DirectKafkaInputDStream.maxMessagesPerPartition, the backpressure rate is a rate for the whole stream. It is divided among partitions in proportion to their lag — a partition with 80% of the backlog gets 80% of the rate — then each share is capped at maxRatePerPartition. Before the estimator has a value, spark.streaming.backpressure.initialRate is used. The per-partition limit × batch seconds becomes the number of messages, never below minRatePerPartition records (default 1) — the source compares that floor directly with the message count.

📌 Bullet points
  • Without limits, the first batch after downtime takes the whole backlog.
  • maxRatePerPartition = hard ceiling; backpressure = adaptive rate below it.
  • Watch scheduling delay and lag.

Exam + cheat sheet

check what stuck
Score: 0 / 0

Cheat sheet

Parameter / APIDefault / what it doesUse when
rdd.cache() / persist(level)keep computed partitions (cache() = MEMORY_ONLY)an RDD feeds more than one action
rdd.toDebugString()prints the lineagefinding shuffles in RDD code
df.explain(true)parsed, analyzed, optimized, physical planschecking joins and pushdowns
spark.default.parallelismpartitions after RDD shuffles if not givenRDD jobs with too few/many tasks
spark.sql.shuffle.partitions200 · DataFrame shuffle partitionsAQE coalescing is not enough
spark.sql.adaptive.enabledtrue · runtime re-optimizationleave on
spark.sql.autoBroadcastJoinThreshold10MB · broadcast smaller join sidessmall dimension tables
spark.executor.memory1g · executor heapalways size it
spark.memory.fraction / storageFraction0.6 / 0.5rarely
spark.task.maxFailures4 · failures of one task before the job failsflaky environments
spark.locality.wait3s · wait for a data-local slottasks idle waiting for locality
KafkaUtils.createDirectStreamone RDD partition per Kafka partitionevery Kafka DStream
HasOffsetRanges / CanCommitOffsetsoffset ranges of a batch / queue a commitfirst call on the direct stream
enable.auto.commitset to falsealways with DStreams
spark.streaming.kafka.maxRatePerPartitionnot set · rec/s per partitionalways in production
spark.streaming.backpressure.enabledfalsevarying load
spark.streaming.kafka.consumer.cache.maxCapacity64 cached consumers per executormore than 64 × executors partitions
JavaStreamingContext.getOrCreaterebuild from checkpoint or createdriver recovery, stateful ops
🎓 Congratulations

Back to the opening question. A 10-second batch that takes 12 seconds is never done before the next one is due. Batches queue up: every batch waits about 2 s longer than the one before, so after an hour (360 batches) the newest one waits roughly 12 minutes before it even starts, and the Kafka lag grows the whole time. Nothing crashes — the Streaming tab shows it: processing time above the batch interval, and scheduling delay climbing without end. The fix is to make batches faster (more partitions and executors, a cheaper sink, fewer shuffles) and to bound them with maxRatePerPartition and backpressure. Next step: open the Spark UI of a real job and find its stages, shuffles and slowest task.