This is an automated email from the ASF dual-hosted git repository.
milenkovicm pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-ballista.git
The following commit(s) were added to refs/heads/main by this push:
new e95c435b3 fix(scheduler): route launch-failure executor removal
through ExecutorLost (#2240)
e95c435b3 is described below
commit e95c435b36ba7f7250e235f1f4aefc8b93a77771
Author: Andy Grove <[email protected]>
AuthorDate: Fri Aug 7 03:42:14 2026 -0600
fix(scheduler): route launch-failure executor removal through ExecutorLost
(#2240)
* fix(scheduler): route launch-failure executor removal through ExecutorLost
The scheduler discovers a dead executor in two ways, and only one of them
armed the empty-cluster grace timer added for #2029. The heartbeat reaper
posts `ExecutorLost`, whose handler rolls back the affected graphs, cancels
their tasks and, when no executors remain, fails the jobs left behind. A
failing task launch instead took `SchedulerState::remove_executor`, which
did
the rollback inline and posted nothing.
That path also deletes the executor's heartbeat, so the reaper can never
rediscover the executor and post the event later. When a whole cluster died
while tasks were being launched onto it, no executor-loss event ever reached
the handler and the job ran forever, which is the intermittent hang behind
`killing_every_executor_terminates_the_job::case_2_aqe_on`.
Post the event from that path too, so both removal paths converge on one
handler.
Closes #2226
* refactor(scheduler): unify the two executor-removal paths
Fold SchedulerServer::remove_executor into SchedulerState::remove_executor
so the reaper, the executor_stopped RPC and a failing task launch all share
one implementation instead of two that merely agree.
Drive the new regression test through a real failing launch, injected via
the test task launcher, rather than by calling remove_executor directly, so
it also covers launch_tasks reaching that path.
---
ballista/scheduler/src/scheduler_server/grpc.rs | 3 +-
ballista/scheduler/src/scheduler_server/mod.rs | 28 ++++--------
.../src/scheduler_server/query_stage_scheduler.rs | 50 ++++++++++++++++++++++
ballista/scheduler/src/state/mod.rs | 42 ++++++++++--------
ballista/scheduler/src/test_utils.rs | 47 ++++++++++++++------
chaos-testing/README.md | 15 +++++++
6 files changed, 135 insertions(+), 50 deletions(-)
diff --git a/ballista/scheduler/src/scheduler_server/grpc.rs
b/ballista/scheduler/src/scheduler_server/grpc.rs
index 3b41e918c..7ccf84b3b 100644
--- a/ballista/scheduler/src/scheduler_server/grpc.rs
+++ b/ballista/scheduler/src/scheduler_server/grpc.rs
@@ -737,7 +737,6 @@ impl<T: 'static + AsLogicalPlan, U: 'static +
AsExecutionPlan> SchedulerGrpc
executor_id, reason
);
- let executor_manager = self.state.executor_manager.clone();
let event_sender =
self.query_stage_event_loop.get_sender().map_err(|e| {
let msg = format!("Get query stage event loop error due to {e:?}");
error!("{msg}");
@@ -745,7 +744,7 @@ impl<T: 'static + AsLogicalPlan, U: 'static +
AsExecutionPlan> SchedulerGrpc
})?;
Self::remove_executor(
- executor_manager,
+ self.state.clone(),
event_sender,
&executor_id,
Some(reason),
diff --git a/ballista/scheduler/src/scheduler_server/mod.rs
b/ballista/scheduler/src/scheduler_server/mod.rs
index ca439dfeb..67c175dee 100644
--- a/ballista/scheduler/src/scheduler_server/mod.rs
+++ b/ballista/scheduler/src/scheduler_server/mod.rs
@@ -35,13 +35,11 @@ use crate::cluster::{BallistaCluster,
ClusterStateEventStream, JobStateEventStre
use crate::config::SchedulerConfig;
use crate::metrics::SchedulerMetricsCollector;
use ballista_core::serde::scheduler::{ExecutorData, ExecutorMetadata};
-use log::{debug, error, warn};
+use log::{debug, warn};
use crate::scheduler_server::event::{QueryStageSchedulerEvent, SubmitPlan};
use crate::scheduler_server::query_stage_scheduler::QueryStageScheduler;
-use crate::state::executor_manager::ExecutorManager;
-
use crate::state::SchedulerState;
use crate::state::task_manager::TaskLauncher;
@@ -364,7 +362,7 @@ impl<T: 'static + AsLogicalPlan, U: 'static +
AsExecutionPlan> SchedulerServer<T
// If executor is expired, remove it immediately
Self::remove_executor(
- state.executor_manager.clone(),
+ state.clone(),
sender_clone,
&executor_id,
Some(stop_reason.clone()),
@@ -389,8 +387,11 @@ impl<T: 'static + AsLogicalPlan, U: 'static +
AsExecutionPlan> SchedulerServer<T
Ok(())
}
+ /// Removes an executor after `wait_secs`, in the background. The removal
+ /// itself is [`SchedulerState::remove_executor`], so this path and the one
+ /// taken when a task launch fails cannot drift apart.
pub(crate) fn remove_executor(
- executor_manager: ExecutorManager,
+ state: Arc<SchedulerState<T, U>>,
event_sender: EventSender<QueryStageSchedulerEvent>,
executor_id: &str,
reason: Option<String>,
@@ -402,20 +403,9 @@ impl<T: 'static + AsLogicalPlan, U: 'static +
AsExecutionPlan> SchedulerServer<T
// Wait for `wait_secs` before removing executor
tokio::time::sleep(Duration::from_secs(wait_secs)).await;
- // Update the executor manager immediately here
- if let Err(e) = executor_manager
- .remove_executor(&executor_id, reason.clone())
- .await
- {
- error!("error removing executor {executor_id}: {e:?}");
- }
-
- if let Err(e) = event_sender
-
.post_event(QueryStageSchedulerEvent::ExecutorLost(executor_id, reason))
- .await
- {
- error!("error sending ExecutorLost event: {e:?}");
- }
+ state
+ .remove_executor(&executor_id, reason, &event_sender)
+ .await;
});
}
diff --git a/ballista/scheduler/src/scheduler_server/query_stage_scheduler.rs
b/ballista/scheduler/src/scheduler_server/query_stage_scheduler.rs
index ef3d118e8..2ccf5a9da 100644
--- a/ballista/scheduler/src/scheduler_server/query_stage_scheduler.rs
+++ b/ballista/scheduler/src/scheduler_server/query_stage_scheduler.rs
@@ -796,6 +796,56 @@ mod tests {
Ok(())
}
+ /// The same outcome as
`test_running_job_fails_when_all_executors_are_lost`,
+ /// but the cluster's death is discovered by a failing task launch instead
of
+ /// by the heartbeat reaper. That path removes the executor — heartbeat
+ /// included — so the reaper can never rediscover it afterwards, making the
+ /// launch failure the only chance the scheduler gets to notice the cluster
+ /// is empty. See
<https://github.com/apache/datafusion-ballista/issues/2226>
+ #[tokio::test]
+ async fn test_running_job_fails_when_launch_failure_loses_last_executor()
-> Result<()>
+ {
+ let plan = test_plan(10);
+
+ let metrics_collector = Arc::new(TestMetricsCollector::default());
+
+ let mut test = SchedulerTest::new(
+ SchedulerConfig::default()
+ .with_scheduler_policy(TaskSchedulingPolicy::PushStaged)
+ .with_no_executors_grace_period_seconds(0),
+ metrics_collector.clone(),
+ 1,
+ 1,
+ None,
+ )
+ .await?;
+
+ // The only executor dies before its first task is launched, so the
+ // scheduler learns of the loss from `launch_multi_task` rather than
+ // from an expired heartbeat.
+ test.make_launches_fail("virtual-executor-0");
+
+ let job_id = test.submit("", &plan).await?;
+
+ let job_id_ref = &job_id;
+ let test_ref = &test;
+ let failed = await_condition(Duration::from_millis(100), 50, || async
move {
+ let status = test_ref.job_status(job_id_ref).await?;
+ Ok(matches!(
+ status.and_then(|s| s.status),
+ Some(job_status::Status::Failed(_))
+ ))
+ })
+ .await?;
+ assert!(
+ failed,
+ "job should be failed after a launch failure lost the last
executor, but status was {:?}",
+ test.job_status(&job_id).await?
+ );
+
+ Ok(())
+ }
+
#[tokio::test]
async fn test_running_job_survives_partial_executor_loss() -> Result<()> {
let plan = test_plan(10);
diff --git a/ballista/scheduler/src/state/mod.rs
b/ballista/scheduler/src/state/mod.rs
index d86488e8f..f4a6ebec4 100644
--- a/ballista/scheduler/src/state/mod.rs
+++ b/ballista/scheduler/src/state/mod.rs
@@ -190,7 +190,7 @@ impl<T: 'static + AsLogicalPlan, U: 'static +
AsExecutionPlan> SchedulerState<T,
let state = self.clone();
tokio::spawn(async move {
let mut if_revive = false;
- match state.launch_tasks(schedulable_tasks).await {
+ match state.launch_tasks(schedulable_tasks, &sender).await {
Ok(unassigned_executor_slots) => {
if !unassigned_executor_slots.is_empty() {
if let Err(e) = state
@@ -222,33 +222,37 @@ impl<T: 'static + AsLogicalPlan, U: 'static +
AsExecutionPlan> SchedulerState<T,
/// Remove an executor.
/// 1. The executor related info will be removed from [`ExecutorManager`]
- /// 2. All of affected running execution graph will be rolled backed
- /// 3. All of the running tasks of the affected running stages will be
cancelled
+ /// 2. A [`QueryStageSchedulerEvent::ExecutorLost`] is posted, which rolls
+ /// back the affected running execution graphs, cancels their running
+ /// tasks, and — when this was the last executor — arms the grace timer
+ /// that fails the jobs left behind on an empty cluster.
+ ///
+ /// Every removal path must go through here, because step 1 also drops the
+ /// executor's heartbeat: once it is gone, nothing else can notice the
+ /// executor is missing and post the event later.
+ /// See <https://github.com/apache/datafusion-ballista/issues/2226>
pub(crate) async fn remove_executor(
&self,
executor_id: &str,
reason: Option<String>,
+ sender: &EventSender<QueryStageSchedulerEvent>,
) {
if let Err(e) = self
.executor_manager
- .remove_executor(executor_id, reason)
+ .remove_executor(executor_id, reason.clone())
.await
{
warn!("Fail to remove executor {executor_id}: {e}");
}
- match self.task_manager.executor_lost(executor_id).await {
- Ok(tasks) => {
- if !tasks.is_empty()
- && let Err(e) =
- self.executor_manager.cancel_running_tasks(tasks).await
- {
- warn!("Fail to cancel running tasks due to {e:?}");
- }
- }
- Err(e) => {
- error!("TaskManager error to handle Executor {executor_id}
lost: {e}");
- }
+ if let Err(e) = sender
+ .post_event(QueryStageSchedulerEvent::ExecutorLost(
+ executor_id.to_owned(),
+ reason,
+ ))
+ .await
+ {
+ error!("Fail to post ExecutorLost for executor {executor_id}:
{e:?}");
}
}
@@ -260,6 +264,7 @@ impl<T: 'static + AsLogicalPlan, U: 'static +
AsExecutionPlan> SchedulerState<T,
async fn launch_tasks(
&self,
bound_tasks: Vec<BoundTask>,
+ sender: &EventSender<QueryStageSchedulerEvent>,
) -> Result<Vec<ExecutorSlot>> {
// Put tasks to the same executor together
// And put tasks belonging to the same stage together for creating
MultiTaskDefinition
@@ -292,6 +297,7 @@ impl<T: 'static + AsLogicalPlan, U: 'static +
AsExecutionPlan> SchedulerState<T,
let n_tasks: usize = tasks.iter().map(|stage_tasks|
stage_tasks.len()).sum();
let state = self.clone();
+ let sender = sender.clone();
let join_handle = tokio::spawn(async move {
let success = match state
.executor_manager
@@ -309,7 +315,9 @@ impl<T: 'static + AsLogicalPlan, U: 'static +
AsExecutionPlan> SchedulerState<T,
// It's OK to remove executor aggressively,
// since if the executor is in healthy state, it
will be registered again.
- state.remove_executor(&executor_id,
Some(err_msg)).await;
+ state
+ .remove_executor(&executor_id, Some(err_msg),
&sender)
+ .await;
false
} else {
diff --git a/ballista/scheduler/src/test_utils.rs
b/ballista/scheduler/src/test_utils.rs
index 496aa8d58..08bc7b9f7 100644
--- a/ballista/scheduler/src/test_utils.rs
+++ b/ballista/scheduler/src/test_utils.rs
@@ -19,7 +19,7 @@ use ballista_core::error::{BallistaError, Result};
use ballista_core::extension::SessionConfigExt;
use ballista_core::{JobId, JobStatusSubscriber};
use datafusion::catalog::Session;
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
use std::future::Future;
use std::sync::Arc;
use std::time::Duration;
@@ -350,6 +350,10 @@ impl TaskLauncher for BlackholeTaskLauncher {
pub struct VirtualTaskLauncher {
sender: Sender<(String, Vec<TaskStatus>)>,
executors: HashMap<String, VirtualExecutor>,
+ /// Executors whose launches must fail, as if the process had died between
+ /// binding and launch. Shared with the owning [`SchedulerTest`], which
adds
+ /// to it through [`SchedulerTest::make_launches_fail`].
+ unreachable: Arc<Mutex<HashSet<String>>>,
}
#[async_trait::async_trait]
@@ -360,6 +364,13 @@ impl TaskLauncher for VirtualTaskLauncher {
tasks: Vec<MultiTaskDefinition>,
_executor_manager: &ExecutorManager,
) -> Result<()> {
+ if self.unreachable.lock().contains(&executor.id) {
+ return Err(BallistaError::Internal(format!(
+ "test: executor {} is unreachable",
+ executor.id
+ )));
+ }
+
let virtual_executor = self.executors.get(&executor.id).ok_or_else(|| {
BallistaError::Internal(format!(
"No virtual executor with ID {} found",
@@ -386,6 +397,7 @@ pub struct SchedulerTest {
scheduler: SchedulerServer<LogicalPlanNode, PhysicalPlanNode>,
session_config: SessionConfig,
status_receiver: Option<Receiver<(String, Vec<TaskStatus>)>>,
+ unreachable_executors: Arc<Mutex<HashSet<String>>>,
}
impl SchedulerTest {
@@ -422,9 +434,12 @@ impl SchedulerTest {
let (status_sender, status_receiver) = channel(1000);
+ let unreachable_executors: Arc<Mutex<HashSet<String>>> =
Arc::default();
+
let launcher = VirtualTaskLauncher {
sender: status_sender,
executors: executors.clone(),
+ unreachable: unreachable_executors.clone(),
};
let mut scheduler: SchedulerServer<LogicalPlanNode, PhysicalPlanNode> =
@@ -466,6 +481,7 @@ impl SchedulerTest {
scheduler,
session_config,
status_receiver: Some(status_receiver),
+ unreachable_executors,
})
}
@@ -548,21 +564,28 @@ impl SchedulerTest {
.await
}
- /// Simulates the loss of an executor: deregisters it from the executor
- /// manager and posts the `ExecutorLost` event. This mirrors the reaper's
+ /// Simulates the loss of an executor. This mirrors the reaper's
/// `remove_executor` path without waiting out the heartbeat timeout.
pub async fn lose_executor(&self, executor_id: &str) -> Result<()> {
- let reason = Some("test: executor lost".to_owned());
self.scheduler
.state
- .executor_manager
- .remove_executor(executor_id, reason.clone())
- .await?;
- self.post_scheduler_event(QueryStageSchedulerEvent::ExecutorLost(
- executor_id.to_owned(),
- reason,
- ))
- .await
+ .remove_executor(
+ executor_id,
+ Some("test: executor lost".to_owned()),
+ &self.scheduler.query_stage_event_loop.get_sender()?,
+ )
+ .await;
+ Ok(())
+ }
+
+ /// Makes every subsequent task launch onto `executor_id` fail, as if the
+ /// process had died after its tasks were bound to it. The scheduler then
+ /// discovers the loss through the failing launch rather than through
+ /// heartbeat expiry.
+ pub fn make_launches_fail(&self, executor_id: &str) {
+ self.unreachable_executors
+ .lock()
+ .insert(executor_id.to_owned());
}
/// Returns the current status of a job, if known.
diff --git a/chaos-testing/README.md b/chaos-testing/README.md
index 35cc551d2..88b198b57 100644
--- a/chaos-testing/README.md
+++ b/chaos-testing/README.md
@@ -340,6 +340,21 @@ fail the job with a clear error. The scenario turns that
grace down via the
cluster builder and asserts the query fails with an error naming the executor
loss.
+**Second path, same hang**
([#2226](https://github.com/apache/datafusion-ballista/issues/2226)).
+Scenario G kept failing intermittently after that fix, because the scheduler
+has _two_ ways of discovering an executor is gone and only one of them armed
+the grace timer. The heartbeat reaper posts `ExecutorLost`, which is where the
+timer lives; a failing task launch instead took
+`SchedulerState::remove_executor`, which rolled the graphs back inline and
+posted nothing. That path also deletes the executor's heartbeat, so the reaper
+could never rediscover it and post the event later — the job was left running
+on an empty cluster with no further executor-loss event to come. Whether a kill
+lands during a task launch or between heartbeats is a timing race, which is why
+the scenario failed only sometimes. The fix routes both removal paths through
+the same `ExecutorLost` event; the unit test
+`test_running_job_fails_when_launch_failure_loses_last_executor` covers the
+launch-failure path deterministically.
+
### For comparison: the heartbeat-expiry path does recover
Ballista's HA recovery is not uniformly broken. Whenever the kill in Scenario
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]