Descotte created SPARK-59731:
--------------------------------

             Summary: foreachBatch in PySpark Structured Streaming leaks a full 
SparkSession per micro-batch, causing driver OOM on long-running continuous 
streams
                 Key: SPARK-59731
                 URL: https://issues.apache.org/jira/browse/SPARK-59731
             Project: Spark
          Issue Type: Bug
          Components: PySpark, Structured Streaming
    Affects Versions: 4.1.0
            Reporter: Descotte


h3. Problem

On a long-running - about 10 hours - PySpark Structured Streaming query using 
{{foreachBatch}} with
{{trigger(processingTime=...)}} (a standard periodic trigger, not
{{{}availableNow{}}}), the driver's live heap (post-GC retained set) grows
monotonically until it hits *java.lang.OutOfMemoryError: GC overhead limit*
{*}exceeded{*}. This happens even though the {{foreachBatch}} callback itself is
completely stateless (e.g. a single {{df.write.saveAsTable(..., mode="append")}}
call) and holds no external references.

Heap dump analysis on a crashed driver shows thousands of live
{{org.apache.spark.sql.classic.SparkSession}} instances (~3,700+ at crash time),
each fully retained along with its {{{}SessionState{}}}, session catalog, SQL
metrics/accumulators (~200 per session) and associated
{{ConcurrentHashMap$Node}} entries (~20,000+ per session). The number of
retained sessions tracks the number of micro-batches processed, not wall-clock
time: heavier-traffic streams (more micro-batches/hour) hit the OOM sooner than
lighter ones running on an identical driver size, which points to a per-batch
leak rather than a time-based or load-based effect.

We traced this to the {{ForeachBatchFunction}} boundary
({{{}pyspark/sql/utils.py{}}}, in the code path invoked by
{{{}ForeachBatchFunction.call{}}}): on every JVM→Python callback, two JVM 
objects
are passed to Python — the batch {{Dataset}} and its
{{{}Dataset.sparkSession(){}}}. Under Py4J, objects passed across the JVM/Python
boundary are registered in {{{}Gateway.bindings{}}}, a {{ConcurrentHashMap}} of
*strong* references, released only when the corresponding Python-side proxy is
garbage collected — which does not reliably happen because {{JavaObject}} and
{{JavaMember}} form a reference cycle on the Python side (documented Py4J
behavior). As a result, the retained {{Dataset}} keeps its
{{{}QueryExecution{}}}/physical plan (hence its {{SQLMetric}} accumulators) 
alive,
and the retained session drags in its full session state.

We were unable to determine, from the current Spark source alone, what
specifically causes a *distinct* {{SparkSession}} to be retained per
micro-batch rather than a single shared session being reused — we could not
find anywhere in {{MicroBatchExecution}} that calls {{cloneSession()}} per
batch (unlike the per-query clone in {{{}StreamExecution{}}}, which is fine).
{{SPARK-34087}} (a previously known session-clone leak) appears fixed since
3.2.0 and does not seem to match this pattern, but we cannot rule out
Databricks Runtime-specific glue for {{ForeachBatchSink}} being involved, since
we cannot inspect that code.
h3. Impact

On streams with a periodic trigger and enough throughput, the driver's live
heap grows without bound and the streaming query eventually crashes with an
OOM, restarts, and repeats the cycle indefinitely. Lower-traffic streams show
the same retention pattern but take proportionally longer (days instead of
hours) to hit the same ceiling — increasing driver memory only delays the
crash, it does not fix it.
h3. Repro sketch
{code:python}
def write_batch(batch_df, batch_id):
    batch_df.write.saveAsTable("some_table", mode="append")

(spark.readStream
    .format("...")
    .load()
    .writeStream
    .foreachBatch(write_batch)
    .trigger(processingTime="a few seconds")
    .start())
{code}
Let this run for several hours while taking periodic driver heap
histograms/dumps. The count of live
{{org.apache.spark.sql.classic.SparkSession}} instances (and of
{{AccumulatorMetadata}} entries) grows roughly linearly with the number of
micro-batches processed, and never goes back down.
h3. Workaround we applied

For streams whose {{foreachBatch}} body is a plain append (no per-batch
business logic requiring Python), replacing:
{code:python}
.foreachBatch(write_batch).start()
{code}
with the native sink:
{code:python}
.toTable("some_table")
{code}
removes the leak entirely, because the native Delta/table sink runs fully
inside the JVM and never crosses the Py4J boundary per micro-batch. This is
only a workaround for the subset of pipelines that do not actually need
Python-side per-batch logic; queries that genuinely need {{foreachBatch}}
still have the underlying issue.

As a more generic (unverified/unmeasured at scale) mitigation for cases that
do need {{{}foreachBatch{}}}, explicitly detaching the Py4J binding at the end 
of
the callback (e.g. {{{}df._sc._gateway.detach(df._jdf){}}}, or calling
{{{}gc.collect(){}}}) appears to release the retained objects, but this relies 
on
private PySpark/Py4J internals and its per-batch overhead has not been
benchmarked.
h3. Environment
 * Spark version: 4.1.0
 * Structured Streaming, PySpark, {{foreachBatch}} sink, periodic
({{{}processingTime{}}}) trigger, continuous/long-running query
 * Reproduced with a driver heap of several GB; leak rate scales with
micro-batch count, not elapsed time



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to