dongjunhwang created SPARK-59561:
------------------------------------

             Summary: ExecutorAllocationManager permanently stalls if a 
SparkListenerStageSubmitted event is dropped from the executorManagement queue
                 Key: SPARK-59561
                 URL: https://issues.apache.org/jira/browse/SPARK-59561
             Project: Spark
          Issue Type: Bug
          Components: Spark Core
    Affects Versions: 5.0.0
            Reporter: dongjunhwang


h3. Problem

SPARK-58935 documented that {{LiveListenerBus}} delivers the same logical event 
to several independent, separately-capacity-limited queues, and that 
{{ExecutorAllocationManager}}'s listener (registered on the 
{{executorManagement}} queue via {{addToManagementQueue}}, 
[LiveListenerBus.scala#L70|https://github.com/apache/spark/blob/9b4e4f7547c07c5eba3d2b0822a6d3dbb023a150/core/src/main/scala/org/apache/spark/scheduler/LiveListenerBus.scala#L70])
 can permanently miss a {{SparkListenerStageSubmitted}} event if that queue is 
full when the event is posted ({{AsyncEventQueue.post}}, 
[AsyncEventQueue.scala#L168|https://github.com/apache/spark/blob/9b4e4f7547c07c5eba3d2b0822a6d3dbb023a150/core/src/main/scala/org/apache/spark/scheduler/AsyncEventQueue.scala#L168]).

{{ExecutorAllocationManager}}'s "executors needed" calculation 
({{maxNumExecutorsNeededPerResourceProfile}}, 
[ExecutorAllocationManager.scala#L416|https://github.com/apache/spark/blob/9b4e4f7547c07c5eba3d2b0822a6d3dbb023a150/core/src/main/scala/org/apache/spark/ExecutorAllocationManager.scala#L416])
 is driven entirely by bookkeeping populated in {{onStageSubmitted}} 
([ExecutorAllocationManager.scala#L820|https://github.com/apache/spark/blob/9b4e4f7547c07c5eba3d2b0822a6d3dbb023a150/core/src/main/scala/org/apache/spark/ExecutorAllocationManager.scala#L820]).
 If that event is dropped, this manager never learns the stage exists and 
permanently omits it from the calculation for the stage's entire lifetime -- 
there is no periodic resync. Meanwhile the Spark UI/REST API, backed by 
{{AppStatusStore}} via the separate {{appStatus}} queue ({{addToStatusQueue}}, 
[LiveListenerBus.scala#L75|https://github.com/apache/spark/blob/9b4e4f7547c07c5eba3d2b0822a6d3dbb023a150/core/src/main/scala/org/apache/spark/scheduler/LiveListenerBus.scala#L75]),
 can continue to correctly show the stage as {{RUNNING}}, since a drop on one 
queue does not imply a drop on the other.

SPARK-58935 fixed this only partially, by documenting the existing 
{{queue.executorManagement.numDroppedEvents}} metric so operators can at least 
detect and alert on the condition 
([PR|https://github.com/apache/spark/pull/58216]). The underlying 
permanent-stall behavior itself -- called out explicitly as unaddressed in that 
PR's description -- is still open. This ticket is that follow-up.

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 multi-task stage once the 
queue is full. The event is dropped, and 
{{ExecutorAllocationManager.maxNumExecutorsNeededPerResourceProfile}} returns 
{{0}} for that stage even though its tasks are pending, while 
{{AppStatusStore.activeStages()}} (fed by the separate, unconstrained 
{{appStatus}} queue) correctly shows the stage as active.

h3. Proposed fix

A lightweight, best-effort reconciliation on top of 
{{ExecutorAllocationManager}}'s existing ~100ms polling loop ({{schedule()}}, 
[ExecutorAllocationManager.scala#L460|https://github.com/apache/spark/blob/9b4e4f7547c07c5eba3d2b0822a6d3dbb023a150/core/src/main/scala/org/apache/spark/ExecutorAllocationManager.scala#L460]):
* {{schedule()}} cheaply checks {{LiveListenerBus}}'s existing 
{{executorManagement}} drop counter (a single {{Long}} comparison) and, only 
when it has increased, reconciles.
* Reconciliation diffs {{AppStatusStore.activeStages()}} 
([AppStatusStore.scala#L171|https://github.com/apache/spark/blob/9b4e4f7547c07c5eba3d2b0822a6d3dbb023a150/core/src/main/scala/org/apache/spark/status/AppStatusStore.scala#L171])
 against the manager's own bookkeeping and registers any stage 
{{AppStatusStore}} knows about but the manager doesn't, through the same code 
path as a normal {{onStageSubmitted}}. A recovered stage's ongoing 
pending/running counts are then read fresh from {{AppStatusStore}} (via 
{{AppStatusStore.stageAttempt}}, 
[AppStatusStore.scala#L226|https://github.com/apache/spark/blob/9b4e4f7547c07c5eba3d2b0822a6d3dbb023a150/core/src/main/scala/org/apache/spark/status/AppStatusStore.scala#L226])
 rather than reconstructing the manager's normal per-task-index bookkeeping, 
for as long as it remains "recovered," until it completes.
* The same congestion that drops a stage's {{StageSubmitted}} could later also 
drop its {{StageCompleted}}; reconciliation also sweeps recovered attempts 
whose ground-truth status has since become terminal and cleans them up the same 
way a real {{StageCompleted}} would, so this can't leak for the remaining 
lifetime of the application.

This intentionally does not introduce a new pub/sub framework analogous to 
K8s's {{ExecutorPodsSnapshotsStore}} (there is only one consumer here), and 
does not attempt a locality-optimized placement for recovered stages (their 
locality preferences aren't available from {{AppStatusStore}}'s lightweight 
{{StageData}}; the goal is a correct executor count, not optimal placement).

Known limitation: a task that was speculatively submitted but not yet started 
during the drop window can't be recovered, since {{AppStatusStore}}'s 
lightweight path has no ground truth for "requested but not yet running" 
speculative tasks. This self-heals once the task actually starts and is picked 
up by normal running-task accounting.

I have a working patch and tests and will open a PR shortly.

h3. Related work

* SPARK-32597 (still open, unassigned) previously identified this general class 
of problem and proposed a more invasive {{VariableLinkedBlockingQueue}} 
approach ([PR #29413|https://github.com/apache/spark/pull/29413], closed 
unmerged in 2020). {{mridulm}} noted a simpler fix (manually setting queue 
capacity) already existed; {{cloud-fan}} noted no queue-capacity approach can 
fully eliminate drops; {{tgravescs}} separately suggested the more complete fix 
is integrating dynamic allocation directly into the core scheduler rather than 
depending on the listener bus at all -- that hasn't been implemented in the 5 
years since. This proposal does not attempt that larger redesign; it takes a 
complementary angle, detecting drops cheaply after the fact and repairing their 
effect on dynamic allocation specifically, using ground truth Spark already 
maintains elsewhere.
* SPARK-58935 (Resolved, Fixed) documented the specific manifestation and added 
the diagnostic metric mentioned above; this ticket implements the "full fix" 
its PR description called out as future work.



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