Kafka through Spark readStream window aggregation to writeStream sink

Spark Structured Streaming for system design interviews

Micro-batch streaming on Spark: architecture, readStream/writeStream, triggers, watermarks, output modes, foreachBatch, Kafka exactly-once, ops commands — and when to pick Flink instead.

Why Structured Streaming

Structured Streaming replaced legacy DStreams (RDD-based, low-level). The mental model: a stream is an unbounded table; each trigger adds new rows; your query is incremental. Same Catalyst optimizer and DataFrame API as batch Spark — one stack for nightly ETL and near-real-time pipelines.

01Model

Micro-batch table.

02API

readStream · writeStream.

03Recovery

Checkpoint dir.

04Alt

Flink for ms latency.

Hands-on lab

Run a local rate source → window → console sink, then open the Streaming tab in the Spark UI.

# Local Spark with UI
pip install pyspark==3.5.1
# or: docker run -it --rm -p 4040:4040 bitnami/spark:3.5 ...
# save as rate_lab.py → spark-submit --master local[2] rate_lab.py
from pyspark.sql import SparkSession
from pyspark.sql.functions import window, count, col

spark = SparkSession.builder.appName("ss-lab").getOrCreate()

# Built-in rate source — no Kafka needed for the lab
rates = spark.readStream \
    .format("rate") \
    .option("rowsPerSecond", 5) \
    .load()

agg = rates \
    .withWatermark("timestamp", "10 seconds") \
    .groupBy(window(col("timestamp"), "10 seconds")) \
    .agg(count("*").alias("n"))

query = agg.writeStream \
    .outputMode("update") \
    .format("console") \
    .option("truncate", False) \
    .trigger(processingTime="5 seconds") \
    .option("checkpointLocation", "/tmp/ss-lab-chk") \
    .start()

print(query.status)
query.awaitTermination(60)  # run ~1 minute then Ctrl+C
# Open http://localhost:4040 → Streaming tab

A motivating example

Click events stream into Kafka. Product wants page-view counts refreshed every minute — not daily batch, but sub-second latency isn't required either. A plain Kafka consumer could count in memory, but crash recovery and scale-out state are painful.

Structured Streaming reads Kafka every minute, aggregates by page, writes to Redis or Postgres. A checkpoint directory stores Kafka offsets and query metadata — restart picks up where it left off.

Continuous stream divided into sequential micro-batches at trigger intervals.
Micro-batch model — streaming API, batch execution engine.

Architecture

A streaming query has three parts: source (readStream), transformations (same DataFrame ops as batch), and sink (writeStream). The engine maintains a checkpoint location for fault tolerance and offset tracking. Under the hood it still uses a Driver + Executors like batch Spark — each trigger is a mini Spark job.

Kafka readStream through transformations to writeStream sink with checkpoint directory.
Structured Streaming architecture — checkpoint ties source offsets to sink writes.
  • Streaming query — long-running job started by .start(); runs until .awaitTermination() or killed.
  • Trigger — how often to process new data (default: as-soon-as-possible; set explicitly in interviews).
  • Checkpoint location — durable dir (S3/HDFS) storing offsets, WAL, and aggregator state. Required for recovery and most aggregations.
  • State store — RocksDB-backed keyed state for aggregations and stream-stream joins across batches.
  • Incremental execution — Spark plans which parts of the query can reuse prior batch results.
  • Progress reporterquery.lastProgress / Spark UI Streaming tab: input rate, batch duration, lag.

Micro-batch lifecycle

  1. Trigger fires (timer or available data).
  2. Source reads a bounded set of new offsets (capped by maxOffsetsPerTrigger).
  3. Transformations run as a normal Spark job (stages, shuffle, tasks).
  4. Sink commits results.
  5. Checkpoint updates offsets + state — then next trigger.

Core API

Every streaming job follows: create SparkSession → readStream → transform → writeStream with options → start → awaitTermination.

from pyspark.sql import SparkSession
from pyspark.sql.functions import from_json, col, window, count
from pyspark.sql.types import StructType, StringType, TimestampType

spark = SparkSession.builder \
    .appName("PageViews") \
    .config("spark.sql.shuffle.partitions", "100") \
    .getOrCreate()

schema = StructType() \
    .add("page", StringType()) \
    .add("event_ts", TimestampType())

# --- Source: Kafka ---
raw = spark.readStream \
    .format("kafka") \
    .option("kafka.bootstrap.servers", "broker:9092") \
    .option("subscribe", "clicks") \
    .option("startingOffsets", "latest") \
    .option("failOnDataLoss", "false") \
    .load()

events = raw.select(
    from_json(col("value").cast("string"), schema).alias("data")
).select("data.*")

# --- Transform: 1-minute tumbling window ---
page_views = events \
    .withWatermark("event_ts", "2 minutes") \
    .groupBy(window(col("event_ts"), "1 minute"), col("page")) \
    .agg(count("*").alias("views"))

# --- Sink ---
query = page_views.writeStream \
    .outputMode("update") \
    .format("console") \
    .option("truncate", False) \
    .trigger(processingTime="1 minute") \
    .option("checkpointLocation", "s3://checkpoints/page_views/") \
    .queryName("page_views_1m") \
    .start()

print(query.status)
print(query.lastProgress)
query.awaitTermination()

Key methods

  • spark.readStream — streaming DataFrame from Kafka, files, socket, rate, Delta, etc.
  • .writeStream — define sink, mode, trigger, checkpoint; then .start().
  • .trigger(processingTime="10 seconds") — fixed micro-batch interval.
  • .trigger(availableNow=True) — process all available data then stop (Spark 3.3+; replaces once).
  • .trigger(continuous="1 second") — experimental low-latency; limited operators.
  • .option("checkpointLocation", path) — required for fault tolerance and aggregations.
  • query.status / query.lastProgress / query.stop() — ops hooks.
Three trigger types: processingTime, availableNow, and continuous.
Triggers — pick processingTime for dashboards; availableNow for catch-up; continuous only if you understand limits.

Output modes

outputMode controls what rows the sink receives each trigger.

Append Update and Complete output modes for streaming queries.
Output modes — pick based on whether you aggregate and use watermarks.
  • Append — only new rows since last trigger. Non-aggregated queries; aggregations need watermarks so Spark knows a window is closed.
  • Update — only rows that changed (count 5 → 6). Natural for running aggregations.
  • Complete — entire result table every trigger. Use for small outputs (top-N dashboards).

Event time and watermarks

By default Spark uses processing time (when the engine sees the row). For correct analytics on delayed events, use an event time column and withWatermark.

Event timeline with watermark and five-minute tumbling window for late data handling.
Event time & watermarks — bound how long aggregations wait for late events.
# Event-time sliding window with lateness bound
events \
  .withWatermark("event_ts", "10 minutes") \
  .groupBy(
      window(col("event_ts"), "30 minutes", "5 minutes"),  # size, slide
      col("user_id")
  ) \
  .agg(count("*").alias("events"))

Watermark ≈ max event time seen − delay threshold. Rows later than the watermark drop from state (unless you add a separate batch correction job). Same trade-off as Flink watermarks, but updated once per micro-batch — so watermark lag has a floor of one trigger interval.

Sinks and foreachBatch

Built-in sinks: Kafka, console, memory, file (Parquet/JSON), foreach/foreachBatch. File sinks are append-only and great for lakehouse landing zones.

# Parquet landing zone (append)
page_views.writeStream \
    .outputMode("append") \
    .format("parquet") \
    .option("path", "s3://lake/page_views/") \
    .option("checkpointLocation", "s3://chk/page_views_files/") \
    .partitionBy("page") \
    .trigger(processingTime="5 minutes") \
    .start()

# foreachBatch — custom sink (Postgres upsert, Redis, API)
def write_to_pg(batch_df, batch_id):
    # Idempotent upsert keyed by (window, page)
    batch_df.write \
        .format("jdbc") \
        .option("url", "jdbc:postgresql://db/analytics") \
        .option("dbtable", "page_views") \
        .option("user", "spark") \
        .mode("append") \
        .save()  # prefer MERGE/upsert in real code

page_views.writeStream \
    .outputMode("update") \
    .foreachBatch(write_to_pg) \
    .option("checkpointLocation", "s3://chk/page_views_pg/") \
    .trigger(processingTime="1 minute") \
    .start()

Streaming joins

Stream–static joins enrich events with slowly changing dimensions. Stream–stream joins need watermarks on both sides and a time range — state can grow; set watermarks carefully.

# Stream + static (broadcast-friendly dims)
users = spark.read.parquet("s3://dim/users/")  # refreshed periodically
enriched = clicks_stream.join(users, "user_id", "left")

# Stream–stream within 10 minutes (both need watermarks)
clicks_w = clicks.withWatermark("event_ts", "10 minutes")
purchases_w = purchases.withWatermark("event_ts", "10 minutes")

joined = clicks_w.join(
    purchases_w,
    expr("""
      clicks.user_id = purchases.user_id AND
      purchases.event_ts >= clicks.event_ts AND
      purchases.event_ts <= clicks.event_ts + interval 10 minutes
    """)
)

Kafka and exactly-once

Most interview designs start from Kafka. Structured Streaming tracks consumer offsets in the checkpoint and can write to Kafka with transactional / idempotent guarantees when configured.

Kafka source through micro-batch to Kafka sink with checkpoint offset tracking.
Exactly-once with Kafka — source + sink + checkpointLocation.
# Kafka → transform → Kafka
parsed.writeStream \
    .format("kafka") \
    .option("kafka.bootstrap.servers", "broker:9092") \
    .option("topic", "alerts") \
    .option("checkpointLocation", "s3://chk/alerts/") \
    .outputMode("update") \
    .start()
  • Checkpoint stores source offsets per partition — restart resumes correctly.
  • Kafka sink can use batch id for idempotent writes when transactional producers are enabled.
  • External JDBC sinks still need upsert/merge — not automatic exactly-once.
  • Kafka retention must cover downtime — same constraint as Flink checkpoint rewind.
  • Throttle catch-up with maxOffsetsPerTrigger so a huge lag doesn't OOM one batch.

Commands and operations

Streaming jobs submit like batch jobs via spark-submit. They run forever — plan monitoring, graceful kill, and checkpoint-backed restart.

# Submit long-running streaming job (cluster mode)
spark-submit \
  --master yarn \
  --deploy-mode cluster \
  --name page-views-ss \
  --num-executors 12 \
  --executor-cores 4 \
  --executor-memory 8g \
  --conf spark.sql.shuffle.partitions=100 \
  --conf spark.sql.streaming.kafka.maxOffsetsPerTrigger=500000 \
  --conf spark.sql.streaming.stateStore.providerClass=\
org.apache.spark.sql.execution.streaming.state.RocksDBStateStoreProvider \
  page_views_stream.py

# Local debug
spark-submit --master local[4] --driver-memory 2g page_views_stream.py

# Kill gracefully (checkpoint allows clean restart)
yarn application -kill application_xxx
# or query.stop() from a side channel / admin endpoint

Useful streaming configs

ConfigRole
checkpointLocationOffsets + state + metadata (required)
spark.sql.streaming.kafka.maxOffsetsPerTriggerCap ingest per micro-batch
spark.sql.streaming.minBatchesToRetainState store cleanup for joins
spark.sql.streaming.stateStore.providerClassRocksDB for large keyed state
spark.sql.streaming.stopTimeoutGraceful shutdown window
failOnDataLoss (Kafka)false if retention may drop offsets

Monitoring in code

query = ... .start()

# Poll progress
while query.isActive:
    p = query.lastProgress
    if p:
        print(p["batchId"], p["inputRowsPerSecond"], p["processingTimeMs"])
    query.awaitTermination(10)

# Spark UI :4040 → Streaming tab
#   Input rate, scheduling delay, processing time, watermark

File source (log landing)

spark.readStream \
    .format("json") \
    .schema(schema) \
    .option("maxFilesPerTrigger", 10) \
    .option("latestFirst", "false") \
    .load("s3://logs/incoming/")

Structured Streaming vs Flink

Structured Streaming micro-batch versus Flink record-at-a-time comparison.
Latency floor and ops stack drive the choice.
DimensionStructured StreamingFlink
ExecutionMicro-batchContinuous / native streaming
Typical latencySeconds–minutesMilliseconds–seconds
APISpark SQL / DataFrameDataStream / Table API
StateCheckpoint + state storeCheckpoint barriers, RocksDB
Best whenAlready on Spark; minute dashboards; unified batch+streamCEP, strict event-time, low-latency fraud

In your interview

When to propose it

  • Near-real-time ETL where batch Spark already exists.
  • Minute-level KPIs from Kafka (DAU counters, error rates).
  • Unified SQL over batch history + live stream (stream–static join).
  • Latency SLA ≥ trigger interval (usually 10s–5m).

When not to

  • Sub-second fraud or trading — use Flink or custom consumers.
  • Simple transform-and-write — a Kafka consumer is enough.
  • Complex CEP (small-then-large patterns) — Flink CEP is richer.

Pitfalls to mention

  • Forgetting checkpointLocation — no recovery after Driver restart.
  • Processing-time windows when events arrive late — wrong counts.
  • Complete mode on large aggregation output — sink overload.
  • Too aggressive trigger on huge Kafka lag — micro-batch OOM.
  • Non-idempotent JDBC insert inside foreachBatch — duplicates after retry.

Cost and performance levers

Micro-batch mental model

Failure modes to mention

Driver loss (restart from checkpoint), Kafka lag spike (throttle offsets), state store blow-up (tighten watermark / TTL), sink downtime (pause or buffer to Kafka first), watermark stall (upstream clock skew).

Interview Q&A by level

Interview takeaway

Match depth to the bar: define → trade off → operate.

Wrapping up

Structured Streaming is Spark's answer to "good enough" streaming: same DataFrame API, micro-batch execution, checkpointed Kafka offsets. Know architecture, triggers, watermarks, output modes, foreachBatch, and when Flink wins on latency.

Compare with Apache Spark (batch), Kafka, Flink, and Key technologies.

← Lattice