dongjunhwang created SPARK-58935:
------------------------------------

             Summary: ExecutorAllocationManager silently stalls at zero needed 
executors when a stage-submitted event is dropped from the executorManagement 
queue
                 Key: SPARK-58935
                 URL: https://issues.apache.org/jira/browse/SPARK-58935
             Project: Spark
          Issue Type: Bug
          Components: Scheduler, Spark Core
    Affects Versions: 4.0.1
         Environment: Kubernetes deployment, Spark Connect (long-running 
driver), Spark 4.0.1
            Reporter: dongjunhwang


h3. Problem

{{LiveListenerBus}} delivers the same logical event to several independent, 
separately-capacity-limited queues. {{ExecutorAllocationManager}}'s listener is 
registered on the {{executorManagement}} queue 
({{LiveListenerBus.addToManagementQueue}}, 
[LiveListenerBus.scala#L70|https://github.com/apache/spark/blob/5abbd34c837571b059de659898bbf699f6c6d3fb/core/src/main/scala/org/apache/spark/scheduler/LiveListenerBus.scala#L70]),
 while the listener that drives the Spark UI is registered on a completely 
separate {{appStatus}} queue ({{addToStatusQueue}}, 
[LiveListenerBus.scala#L75|https://github.com/apache/spark/blob/5abbd34c837571b059de659898bbf699f6c6d3fb/core/src/main/scala/org/apache/spark/scheduler/LiveListenerBus.scala#L75]).

Each queue independently drops events once its bounded capacity is exceeded 
({{AsyncEventQueue.post}}, 
[AsyncEventQueue.scala#L168|https://github.com/apache/spark/blob/5abbd34c837571b059de659898bbf699f6c6d3fb/core/src/main/scala/org/apache/spark/scheduler/AsyncEventQueue.scala#L168]).
 A dropped event is never redelivered or resynced.

{{ExecutorAllocationManager}}'s "executors needed" calculation is driven 
entirely by {{stageAttemptToNumTasks}}, which is populated *only* in 
{{onStageSubmitted}} 
([ExecutorAllocationManager.scala#L687|https://github.com/apache/spark/blob/5abbd34c837571b059de659898bbf699f6c6d3fb/core/src/main/scala/org/apache/spark/ExecutorAllocationManager.scala#L687])
 and consumed by {{maxNumExecutorsNeededPerResourceProfile}} 
([ExecutorAllocationManager.scala#L297|https://github.com/apache/spark/blob/5abbd34c837571b059de659898bbf699f6c6d3fb/core/src/main/scala/org/apache/spark/ExecutorAllocationManager.scala#L297]).
 If the {{SparkListenerStageSubmitted}} event for a given stage is dropped 
specifically from the {{executorManagement}} queue:

* The {{appStatus}} queue is unaffected, so the Spark UI/REST API continues to 
show the stage as {{RUNNING}} normally.
* {{ExecutorAllocationManager}} never learns about that stage's tasks. 
{{maxNumExecutorsNeededPerResourceProfile}} permanently omits them from its 
calculation for the lifetime of the stage -- there is no periodic resync.
* When the stage eventually completes, {{onStageCompleted}} removes a 
{{StageAttempt}} key that was never inserted; this is a silent no-op on the 
underlying {{mutable.HashMap}}, so there is no error, warning, or other signal 
that anything went wrong.

The net effect: dynamic allocation can stop requesting new executors for an 
application that, from the UI/REST API and logs, looks completely healthy and 
busy.

h3. How this was found

Observed on a long-running (2+ day uptime) Spark Connect driver (Spark 4.0.1) 
in a Kubernetes deployment. {{ExecutorAllocationManager}}'s JMX metrics showed:

{code}
numberMaxNeededExecutors = 0   (with 2 pending tasks on an active stage)
numberTargetExecutors    = 0
{code}

while the Spark UI's REST API ({{/api/v1/applications/<id>/jobs}}) 
simultaneously reported the job as {{RUNNING}}. A completely unrelated, 
concurrently-running session on the same driver process was independently 
affected in the same way, ruling out any per-job cause. The only workaround was 
restarting the driver process. Thread dumps showed the dynamic-allocation 
scheduling thread alive and running normally (not deadlocked) -- only the 
*calculation result* was wrong.

h3. Reproduction

Deterministically reproduced by constructing a {{LiveListenerBus}} with 
{{spark.scheduler.listenerbus.eventqueue.executorManagement.capacity=1}}, 
occupying the queue's single dispatch thread with a blocking listener, and 
posting a {{SparkListenerStageSubmitted}} event for a 2-task stage once the 
queue is full. The event is dropped (confirmed via 
{{queue.executorManagement.numDroppedEvents}}), and 
{{ExecutorAllocationManager.maxNumExecutorsNeededPerResourceProfile}} returns 
{{0}} for that stage, even though 2 tasks are pending.

In a real cluster, this queue can fill up under a burst of many other events 
sharing the same queue ({{HeartbeatReceiver}} is also registered on 
{{executorManagement}}, 
[LiveListenerBus.scala#L70|https://github.com/apache/spark/blob/5abbd34c837571b059de659898bbf699f6c6d3fb/core/src/main/scala/org/apache/spark/scheduler/LiveListenerBus.scala#L70]
 area), or if a listener on that queue is briefly slow.

h3. Expected behavior and proposed fix (this PR)

A full self-healing fix (e.g., periodically reconciling 
{{ExecutorAllocationManager}}'s bookkeeping against the ground-truth stage/task 
state already tracked by {{AppStatusStore}}) is a larger, more invasive change 
that needs broader design discussion.

As a first, narrowly-scoped step, this PR makes the problem *observable* 
instead of silent: it exposes the executor-management queue's dropped-event 
count as a new {{ExecutorAllocationManager}} metric 
({{numDroppedExecutorManagementEvents}}), so operators can alert on it and 
correlate "dynamic allocation stopped requesting executors" with "the 
executorManagement queue actually dropped an event" -- which is not currently 
possible (the only existing signal is a generic, easy-to-miss {{WARN}} log line 
shared by every queue, logged at most once per 60 seconds, with no indication 
of which downstream component is affected).

This PR does not change any existing behavior; it only adds a new gauge metric, 
matching the existing pattern used by {{ExecutorAllocationManagerSource}}'s 
other gauges (e.g. {{numberMaxNeededExecutors}}).

h3. Related work

* SPARK-32597 (still open, unassigned) previously identified that event drops 
in the async listener bus can cause "inconsistent state for the Spark 
Application (sometimes application is hung state)" in general, and proposed a 
more invasive {{VariableLinkedBlockingQueue}} approach ([PR 
#29413|https://github.com/apache/spark/pull/29413], closed unmerged in 2020). 
This ticket documents a specific, reproducible manifestation of that general 
class of problem, with a much narrower first fix.
* SPARK-26927 (Fixed) previously fixed a different specific mechanism -- event 
*ordering* (not dropping) across queues -- that similarly corrupted 
{{ExecutorAllocationManager}}'s bookkeeping and caused a production Spark 
Thrift Server hang.
* SPARK-58446 (open, unassigned, [PR 
#57651|https://github.com/apache/spark/pull/57651]) reports a 
similarly-surfacing symptom -- dynamic allocation stuck at zero needed/target 
executors -- but from a distinct mechanism confirmed at the code level: a late 
{{TaskStart}}/{{SpeculativeTaskSubmitted}} event arriving *after* 
{{onStageCompleted}} has already removed the stage's bookkeeping re-creates 
stale state and drives the pending-task count negative. That fix guards 
{{onTaskStart}}/{{onSpeculativeTaskSubmitted}} with 
{{stageAttemptToNumTasks.contains(stageAttempt)}}; it does not touch 
{{onStageSubmitted}} and would not prevent or fix the issue described here, 
where {{stageAttemptToNumTasks}} is never populated for the affected stage 
attempt in the first place because the {{SparkListenerStageSubmitted}} event 
never reaches the listener.



--
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