To maintain per-user stateful aggregation in PySpark Structured Streaming with checkpointing, watermarking, and periodic state cleanup, use mapGroupsWithState or flatMapGroupsWithState with event-time timeouts and a checkpoint location. Example skeleton:``python
from pyspark.sql import SparkSession
from pyspark.sql.functions import from_json, col, window
from pyspark.sql.types import StructType, StringType, TimestampType, DoubleType
from pyspark.sql.streaming import GroupStateTimeout
spark = SparkSession.builder.appName("user-stateful-agg").getOrCreate()
# Input schema
schema = StructType() \
.add("user_id", StringType()) \
.add("event_time", TimestampType()) \
.add("value", DoubleType())
# Read stream (e.g., Kafka)
raw = spark.readStream.format("kafka") \
.option("kafka.bootstrap.servers", "broker:9092") \
.option("subscribe", "topic") \
.load()
events = raw.selectExpr("CAST(value AS STRING) as json") \
.select(from_json(col("json"), schema).alias("data")) \
.select("data.*")
# Watermark for late data: drops state updates older than watermark + allowed lateness
events_wm = events.withWatermark("event_time", "10 minutes")
# Define state structure and update function
def update_user_state(user_id, iterator, state):
# state: stores (sum, count, last_event_time)
import datetime
for row in iterator:
s = state.get(("sum", 0.0, 0)) if state.exists else (0.0, 0)
if state.exists:
cur_sum, cur_count = state.get()
else:
cur_sum, cur_count = 0.0, 0
cur_sum += row.value
cur_count += 1
state.update((cur_sum, cur_count))
# Set timeout for event-time cleanup: will be removed when no events for window + timeout
state.setTimeoutTimestamp(int(row.event_time.timestamp() * 1000) + 15 * 60 * 1000) # 15 min
yield (user_id, cur_sum, cur_count)
# Apply stateful aggregation per user
agg = events_wm.groupByKey(lambda r: r.user_id) \
.flatMapGroupsWithState(
outputMode="update",
stateType="tuple",
func=update_user_state,
timeoutConf=GroupStateTimeout.EventTimeTimeout
)
# Write with checkpointing (crucial for recovery)
query = agg.writeStream \
.format("parquet") \
.option("path", "/data/output/user_agg") \
.option("checkpointLocation", "/checkpoints/user_stateful_agg") \
.trigger(processingTime="1 minute") \
.start()
query.awaitTermination()
``Key points:- Set withWatermark("event_time", "10 minutes") to allow late events up to 10 minutes; watermark lets Spark discard old state updates safely.- checkpointLocation persists offsets, state, and progress metadata so jobs can recover after driver/executor failures.- Use map/flatMapGroupsWithState with EventTimeTimeout plus state.setTimeoutTimestamp to expire per-user state (here 15 minutes after last observed event) to control state size.- Trigger(processingTime="1 minute") defines periodic micro-batches; state timeouts fire when watermark passes the timeout timestamp.When checkpointing protects you:- Restores application progress, committed sink offsets, and stored state after driver crashes or job restarts (idempotent writes if sink supports it).- Ensures exactly-once state updates within Structured Streaming semantics for supported sinks.What checkpointing does NOT guarantee:- It does not protect you from external side effects that are not idempotent (e.g., external DB writes without idempotency).- It cannot recover data lost before being checkpointed (e.g., if upstream Kafka retention lapsed and offsets are lost).- Checkpointing alone doesn't prevent logical bugs in state update functions or handle schema incompatibilities — you must manage state migration.- For high-availability across cluster failure, ensure checkpoint storage is durable (e.g., HDFS/S3) and sinks support idempotent or transactional writes.