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 a9d9a7296 fix: classify wrapped FetchFailed as fetch partition error 
(#2202)
a9d9a7296 is described below

commit a9d9a7296088598ce60ddfcdd1e00fc8542281ea
Author: Ville Brofeldt <[email protected]>
AuthorDate: Fri Aug 7 14:24:37 2026 -0700

    fix: classify wrapped FetchFailed as fetch partition error (#2202)
---
 ballista/core/src/error.rs                         | 221 +++++++++++++++++++--
 .../core/src/execution_plans/shuffle_writer.rs     | 119 +++++++----
 .../src/execution_plans/sort_shuffle/writer.rs     | 112 +++++++++--
 ballista/executor/src/executor.rs                  |   4 +-
 .../src/scheduler_server/query_stage_scheduler.rs  |   5 +
 ballista/scheduler/src/state/aqe/adapter.rs        |  11 +-
 ballista/scheduler/src/state/aqe/mod.rs            |  23 ++-
 ballista/scheduler/src/state/aqe/planner.rs        |  14 +-
 ballista/scheduler/src/state/execution_graph.rs    |  44 ++--
 ballista/scheduler/src/state/task_manager.rs       |  12 ++
 chaos-testing/README.md                            | 167 +++++++---------
 chaos-testing/src/bin/chaos-executor.rs            |   1 +
 chaos-testing/src/cluster.rs                       | 102 +++++++++-
 chaos-testing/src/fixture.rs                       |  15 ++
 chaos-testing/tests/common/mod.rs                  |  23 ++-
 chaos-testing/tests/ha.rs                          | 136 ++++++-------
 16 files changed, 713 insertions(+), 296 deletions(-)

diff --git a/ballista/core/src/error.rs b/ballista/core/src/error.rs
index b5a7a82b0..33504fded 100644
--- a/ballista/core/src/error.rs
+++ b/ballista/core/src/error.rs
@@ -24,7 +24,9 @@ use std::{
 };
 
 use crate::serde::protobuf::failed_task::FailedReason;
-use crate::serde::protobuf::{ExecutionError, FailedTask, FetchPartitionError, 
IoError};
+use crate::serde::protobuf::{
+    ExecutionError, FailedTask, FetchPartitionError, IoError, TaskKilled,
+};
 use datafusion::error::DataFusionError;
 use datafusion::{arrow::error::ArrowError, sql::sqlparser::parser};
 use futures::future::Aborted;
@@ -202,29 +204,75 @@ impl Display for BallistaError {
     }
 }
 
+struct FetchFailedDetails {
+    executor_id: String,
+    map_stage_id: usize,
+    map_partition_id: usize,
+    desc: String,
+}
+
+/// Recovers a shuffle fetch failure that crossed DataFusion as
+/// `ArrowError::ExternalError(FetchFailed)` and may now be wrapped in
+/// `DataFusionError` layers.
+fn find_fetch_failed(e: &BallistaError) -> Option<FetchFailedDetails> {
+    match e {
+        BallistaError::FetchFailed(executor_id, map_stage_id, 
map_partition_id, desc) => {
+            Some(FetchFailedDetails {
+                executor_id: executor_id.clone(),
+                map_stage_id: *map_stage_id,
+                map_partition_id: *map_partition_id,
+                desc: desc.clone(),
+            })
+        }
+        BallistaError::ArrowError(e) => fetch_failed_in_arrow(e),
+        BallistaError::DataFusionError(e) => fetch_failed_in_datafusion(e),
+        _ => None,
+    }
+}
+
+fn fetch_failed_in_datafusion(e: &DataFusionError) -> 
Option<FetchFailedDetails> {
+    match e.find_root() {
+        DataFusionError::ArrowError(e, _) => fetch_failed_in_arrow(e),
+        _ => None,
+    }
+}
+
+fn fetch_failed_in_arrow(e: &ArrowError) -> Option<FetchFailedDetails> {
+    let ArrowError::ExternalError(inner) = e else {
+        return None;
+    };
+    if let Some(e) = inner.downcast_ref::<BallistaError>() {
+        return find_fetch_failed(e);
+    }
+    if let Some(e) = inner.downcast_ref::<DataFusionError>() {
+        return fetch_failed_in_datafusion(e);
+    }
+    None
+}
+
 impl From<BallistaError> for FailedTask {
     fn from(e: BallistaError) -> Self {
+        if let Some(fetch_failed) = find_fetch_failed(&e) {
+            return FailedTask {
+                error: fetch_failed.desc,
+                retryable: false,
+                count_to_failures: false,
+                failed_reason: Some(FailedReason::FetchPartitionError(
+                    FetchPartitionError {
+                        executor_id: fetch_failed.executor_id,
+                        map_stage_id: fetch_failed.map_stage_id as u32,
+                        map_partition_id: fetch_failed.map_partition_id as u32,
+                    },
+                )),
+            };
+        }
         match e {
-            BallistaError::FetchFailed(
-                executor_id,
-                map_stage_id,
-                map_partition_id,
-                desc,
-            ) => {
-                FailedTask {
-                    error: desc,
-                    // fetch partition error is considered to be non-retryable
-                    retryable: false,
-                    count_to_failures: false,
-                    failed_reason: Some(FailedReason::FetchPartitionError(
-                        FetchPartitionError {
-                            executor_id,
-                            map_stage_id: map_stage_id as u32,
-                            map_partition_id: map_partition_id as u32,
-                        },
-                    )),
-                }
-            }
+            BallistaError::Cancelled => FailedTask {
+                error: "Task cancelled".to_string(),
+                retryable: true,
+                count_to_failures: false,
+                failed_reason: Some(FailedReason::TaskKilled(TaskKilled {})),
+            },
             BallistaError::IoError(io) => {
                 FailedTask {
                     error: format!("Task failed due to Ballista IO error: 
{io:?}"),
@@ -266,6 +314,20 @@ mod tests {
         FailedTask::from(e)
     }
 
+    fn fetch_failed(
+        executor_id: &str,
+        map_stage_id: usize,
+        map_partition_id: usize,
+        desc: &str,
+    ) -> BallistaError {
+        BallistaError::FetchFailed(
+            executor_id.to_owned(),
+            map_stage_id,
+            map_partition_id,
+            desc.to_owned(),
+        )
+    }
+
     #[test]
     fn bare_datafusion_io_error_is_retryable() {
         let e = 
BallistaError::DataFusionError(Box::new(DataFusionError::IoError(
@@ -316,6 +378,17 @@ mod tests {
         ));
     }
 
+    #[test]
+    fn cancelled_task_is_retryable_without_counting_to_failures() {
+        let task = FailedTask::from(BallistaError::Cancelled);
+        assert!(task.retryable);
+        assert!(!task.count_to_failures);
+        assert!(matches!(
+            task.failed_reason,
+            Some(FailedReason::TaskKilled(_))
+        ));
+    }
+
     #[test]
     fn shared_wrapped_non_io_error_stays_non_retryable() {
         let inner = DataFusionError::Plan("bad plan".to_string());
@@ -328,4 +401,110 @@ mod tests {
             Some(FailedReason::ExecutionError(_))
         ));
     }
+
+    /// Builds the wrapped shape that can reach task failure classification.
+    fn wrap_in_arrow_external(inner: BallistaError) -> BallistaError {
+        
BallistaError::DataFusionError(Box::new(datafusion_arrow_external(inner)))
+    }
+
+    fn datafusion_arrow_external(inner: BallistaError) -> DataFusionError {
+        DataFusionError::ArrowError(
+            Box::new(ArrowError::ExternalError(Box::new(inner))),
+            None,
+        )
+    }
+
+    fn wrap_in_shared_arrow_external(inner: BallistaError) -> BallistaError {
+        let df = 
DataFusionError::Shared(Arc::new(datafusion_arrow_external(inner)));
+        BallistaError::DataFusionError(Box::new(df))
+    }
+
+    fn wrap_in_context_arrow_external(inner: BallistaError) -> BallistaError {
+        let df = datafusion_arrow_external(inner).context("reading shuffle 
partition");
+        BallistaError::DataFusionError(Box::new(df))
+    }
+
+    fn assert_fetch_partition_error(
+        task: FailedTask,
+        executor_id: &str,
+        map_stage_id: u32,
+        map_partition_id: u32,
+        error: &str,
+    ) {
+        assert!(!task.retryable);
+        assert!(!task.count_to_failures);
+        assert_eq!(task.error, error);
+        match task.failed_reason {
+            Some(FailedReason::FetchPartitionError(fp)) => {
+                assert_eq!(fp.executor_id, executor_id);
+                assert_eq!(fp.map_stage_id, map_stage_id);
+                assert_eq!(fp.map_partition_id, map_partition_id);
+            }
+            other => panic!("expected FetchPartitionError, got {other:?}"),
+        }
+    }
+
+    #[test]
+    fn bare_fetch_failed_maps_to_fetch_partition_error() {
+        let task = FailedTask::from(fetch_failed("exec-1", 3, 7, "boom"));
+        assert_fetch_partition_error(task, "exec-1", 3, 7, "boom");
+    }
+
+    #[test]
+    fn datafusion_arrow_external_fetch_failed_converts_to_bare_fetch_failed() {
+        let e = BallistaError::from(datafusion_arrow_external(fetch_failed(
+            "exec-1",
+            3,
+            7,
+            "connection reset",
+        )));
+
+        match e {
+            BallistaError::FetchFailed(
+                executor_id,
+                map_stage_id,
+                map_partition_id,
+                desc,
+            ) => {
+                assert_eq!(executor_id, "exec-1");
+                assert_eq!(map_stage_id, 3);
+                assert_eq!(map_partition_id, 7);
+                assert_eq!(desc, "connection reset");
+            }
+            other => panic!("expected bare FetchFailed, got {other:?}"),
+        }
+    }
+
+    #[test]
+    fn wrapped_fetch_failed_is_recovered_as_fetch_partition_error() {
+        let e = wrap_in_arrow_external(fetch_failed("exec-1", 3, 7, 
"connection reset"));
+        let task = FailedTask::from(e);
+        assert_fetch_partition_error(task, "exec-1", 3, 7, "connection reset");
+    }
+
+    #[test]
+    fn shared_wrapped_fetch_failed_is_recovered() {
+        let e =
+            wrap_in_shared_arrow_external(fetch_failed("exec-2", 1, 2, "peer 
closed"));
+        let task = FailedTask::from(e);
+        assert_fetch_partition_error(task, "exec-2", 1, 2, "peer closed");
+    }
+
+    #[test]
+    fn context_wrapped_fetch_failed_is_recovered() {
+        let e = wrap_in_context_arrow_external(fetch_failed("exec-3", 5, 9, 
"timeout"));
+        let task = FailedTask::from(e);
+        assert_fetch_partition_error(task, "exec-3", 5, 9, "timeout");
+    }
+
+    #[test]
+    fn wrapped_non_fetch_error_stays_execution_error() {
+        let e = 
wrap_in_arrow_external(BallistaError::General("boom".to_string()));
+        let task = FailedTask::from(e);
+        assert!(!task.retryable);
+        assert!(matches!(
+            task.failed_reason,
+            Some(FailedReason::ExecutionError(_))
+        ));
+    }
 }
diff --git a/ballista/core/src/execution_plans/shuffle_writer.rs 
b/ballista/core/src/execution_plans/shuffle_writer.rs
index 8854962d1..23257e633 100644
--- a/ballista/core/src/execution_plans/shuffle_writer.rs
+++ b/ballista/core/src/execution_plans/shuffle_writer.rs
@@ -575,7 +575,12 @@ impl ShuffleWriterExec {
                         compression_type,
                     )
                     .await
-                    .map_err(|e| 
DataFusionError::Execution(format!("{e:?}")))?;
+                    .map_err(|e| {
+                        DataFusionError::ArrowError(
+                            Box::new(ArrowError::ExternalError(Box::new(e))),
+                            None,
+                        )
+                    })?;
                     let rows = stats.num_rows.unwrap_or(0) as usize;
                     write_metrics.input_rows.add(rows);
                     write_metrics.output_rows.add(rows);
@@ -738,15 +743,12 @@ impl ExecutionPlan for ShuffleWriterExec {
         Ok(Box::pin(RecordBatchStreamAdapter::new(
             schema,
             futures::stream::once(async move {
-                let summaries = rx
-                    .await
-                    .map_err(|_| {
-                        DataFusionError::Internal(
-                            "ShuffleWriterExec coordinator dropped without 
sending"
-                                .to_owned(),
-                        )
-                    })?
-                    .map_err(|e| 
DataFusionError::Execution(format!("{e:?}")))?;
+                let summaries = rx.await.map_err(|_| {
+                    DataFusionError::Internal(
+                        "ShuffleWriterExec coordinator dropped without sending"
+                            .to_owned(),
+                    )
+                })??;
                 summaries_to_batch(
                     summaries,
                     schema_captured,
@@ -847,15 +849,13 @@ async fn run_coordinator(
             }
         }
         Err(e) => {
-            let msg = format!("{e:?}");
-            for (i, slot) in senders.iter_mut().enumerate() {
+            // Share the original error with every output handoff so 
classification
+            // preserves FetchFailed/IO details regardless of stream 
completion order.
+            let shared = Arc::new(e);
+            for slot in senders.iter_mut() {
                 if let Some(sender) = slot.take() {
-                    let err_msg = if i == 0 {
-                        msg.clone()
-                    } else {
-                        format!("shuffle writer failed: {msg}")
-                    };
-                    let _ = 
sender.send(Err(DataFusionError::Execution(err_msg)));
+                    let err = DataFusionError::from(&shared);
+                    let _ = sender.send(Err(err));
                 }
             }
         }
@@ -936,6 +936,8 @@ pub(crate) fn summaries_to_batch(
 #[allow(dead_code, unused_imports)] // clippy false positive with local imports
 mod tests {
     use super::*;
+    use crate::error::BallistaError;
+    use crate::execution_plans::ChaosExec;
     use datafusion::arrow::array::{StringArray, StructArray, UInt32Array, 
UInt64Array};
     use datafusion::datasource::memory::MemorySourceConfig;
     use datafusion::datasource::source::DataSourceExec;
@@ -975,6 +977,50 @@ mod tests {
         Ok(all)
     }
 
+    async fn drive_partition_results(
+        plan: Arc<ShuffleWriterExec>,
+        task_ctx: Arc<TaskContext>,
+    ) -> Vec<crate::error::Result<Vec<RecordBatch>>> {
+        let k = plan.properties().output_partitioning().partition_count();
+        let mut handles = Vec::with_capacity(k);
+        for n in 0..k {
+            let plan = plan.clone();
+            let ctx = task_ctx.clone();
+            handles.push(tokio::spawn(async move {
+                let mut stream = plan.execute(n, 
ctx).map_err(BallistaError::from)?;
+                utils::collect_stream(&mut stream).await
+            }));
+        }
+        let mut results = Vec::new();
+        for h in handles {
+            results.push(
+                h.await
+                    .expect("drive_partition_results task should not panic"),
+            );
+        }
+        results
+    }
+
+    fn assert_shared_structural_error(err: &BallistaError) {
+        let BallistaError::DataFusionError(e) = err else {
+            panic!("expected DataFusionError, got {err:?}");
+        };
+        assert!(
+            matches!(e.as_ref(), DataFusionError::Shared(_)),
+            "expected shared DataFusionError, got {e:?}"
+        );
+        let DataFusionError::ArrowError(arrow, _) = e.find_root() else {
+            panic!("expected ArrowError root, got {:?}", e.find_root());
+        };
+        let ArrowError::ExternalError(inner) = arrow.as_ref() else {
+            panic!("expected Arrow external error, got {arrow:?}");
+        };
+        assert!(
+            inner.downcast_ref::<BallistaError>().is_some(),
+            "expected Arrow external BallistaError, got {inner:?}"
+        );
+    }
+
     #[tokio::test]
     async fn test() -> Result<()> {
         let session_ctx = SessionContext::new();
@@ -1062,36 +1108,29 @@ mod tests {
     }
 
     #[tokio::test]
-    async fn test_no_repart_write_failure_propagates() -> Result<()> {
+    async fn write_failure_is_shared_to_all_output_partitions() -> Result<()> {
         let session_ctx = SessionContext::new();
         let task_ctx = session_ctx.task_ctx();
 
-        // Place a directory at the data-{file_id}.arrow path so File::create
-        // fails inside write_stream_to_disk, not at create_dir_all.
-        // Path: work_dir / job_id / stage_id / partition / 
data-{file_id}.arrow
-        // task_slot defaults to 0 in try_new, so file_id is 0.
-        let tmp = tempfile::TempDir::new().unwrap();
-        let data_arrow_dir = tmp
-            .path()
-            .join("jobOne")
-            .join("1")
-            .join("0")
-            .join("data-0.arrow");
-        std::fs::create_dir_all(&data_arrow_dir).unwrap();
-        let work_dir = tmp.path().to_str().unwrap().to_owned();
-
-        let input_plan = 
Arc::new(CoalescePartitionsExec::new(create_input_plan()?));
+        let input_plan: Arc<dyn ExecutionPlan> = Arc::new(ChaosExec::new(
+            create_input_plan()?,
+            1.0,
+            "transient",
+            Some(42),
+        )?);
+        let work_dir = TempDir::new()?;
         let query_stage = Arc::new(ShuffleWriterExec::try_new(
             "jobOne".into(),
             1,
             input_plan,
-            work_dir,
+            work_dir.path().to_str().unwrap().to_owned(),
         )?);
-        let result = drive_all_partitions(query_stage, task_ctx).await;
-        assert!(
-            result.is_err(),
-            "expected File::create failure in write_stream_to_disk to 
propagate"
-        );
+        let results = drive_partition_results(query_stage, task_ctx).await;
+        assert_eq!(2, results.len());
+        for result in results {
+            let err = result.expect_err("expected injected write failure");
+            assert_shared_structural_error(&err);
+        }
         Ok(())
     }
 
diff --git a/ballista/core/src/execution_plans/sort_shuffle/writer.rs 
b/ballista/core/src/execution_plans/sort_shuffle/writer.rs
index 7802bcb87..4d8405d19 100644
--- a/ballista/core/src/execution_plans/sort_shuffle/writer.rs
+++ b/ballista/core/src/execution_plans/sort_shuffle/writer.rs
@@ -958,15 +958,12 @@ impl ExecutionPlan for SortShuffleWriterExec {
         Ok(Box::pin(RecordBatchStreamAdapter::new(
             schema,
             futures::stream::once(async move {
-                let summaries = rx
-                    .await
-                    .map_err(|_| {
-                        DataFusionError::Internal(
-                            "SortShuffleWriterExec coordinator dropped without 
sending"
-                                .to_owned(),
-                        )
-                    })?
-                    .map_err(|e| 
DataFusionError::Execution(format!("{e:?}")))?;
+                let summaries = rx.await.map_err(|_| {
+                    DataFusionError::Internal(
+                        "SortShuffleWriterExec coordinator dropped without 
sending"
+                            .to_owned(),
+                    )
+                })??;
                 summaries_to_batch(
                     summaries,
                     schema_captured,
@@ -1049,7 +1046,7 @@ async fn run_coordinator(
 
     let mut grouped: Vec<Vec<ShuffleWritePartition>> =
         (0..k).map(|_| Vec::new()).collect();
-    let mut first_error: Option<String> = None;
+    let mut first_error: Option<DataFusionError> = None;
     for handle in handles {
         match handle.await {
             Ok(Ok(summaries)) => {
@@ -1061,24 +1058,24 @@ async fn run_coordinator(
                 }
             }
             Ok(Err(e)) => {
-                first_error.get_or_insert_with(|| format!("{e:?}"));
+                first_error.get_or_insert(e);
             }
             Err(join_err) => {
-                first_error
-                    .get_or_insert_with(|| format!("write task panicked: 
{join_err}"));
+                first_error.get_or_insert_with(|| {
+                    DataFusionError::Execution(format!("write task panicked: 
{join_err}"))
+                });
             }
         }
     }
 
-    if let Some(msg) = first_error {
-        for (i, slot) in senders.iter_mut().enumerate() {
+    if let Some(e) = first_error {
+        // Share the original error with every output handoff so classification
+        // preserves FetchFailed/IO details regardless of stream completion 
order.
+        let shared = Arc::new(e);
+        for slot in senders.iter_mut() {
             if let Some(sender) = slot.take() {
-                let err_msg = if i == 0 {
-                    msg.clone()
-                } else {
-                    format!("sort shuffle writer failed: {msg}")
-                };
-                let _ = sender.send(Err(DataFusionError::Execution(err_msg)));
+                let err = DataFusionError::from(&shared);
+                let _ = sender.send(Err(err));
             }
         }
         return;
@@ -1141,6 +1138,8 @@ fn compute_partition_indices(
 #[cfg(test)]
 mod tests {
     use super::*;
+    use crate::error::BallistaError;
+    use crate::execution_plans::ChaosExec;
     use datafusion::arrow::array::{StringArray, UInt32Array};
     use datafusion::arrow::datatypes::{DataType, Field, Schema};
     use datafusion::arrow::record_batch::RecordBatch;
@@ -1173,6 +1172,45 @@ mod tests {
         Ok(Arc::new(DataSourceExec::new(memory_data_source)))
     }
 
+    async fn drive_partition_results(
+        plan: Arc<SortShuffleWriterExec>,
+        task_ctx: Arc<TaskContext>,
+    ) -> Vec<crate::error::Result<Vec<RecordBatch>>> {
+        let k = plan.properties().output_partitioning().partition_count();
+        let mut handles = Vec::with_capacity(k);
+        for n in 0..k {
+            let plan = plan.clone();
+            let ctx = task_ctx.clone();
+            handles.push(tokio::spawn(async move {
+                let mut stream = plan.execute(n, 
ctx).map_err(BallistaError::from)?;
+                crate::utils::collect_stream(&mut stream).await
+            }));
+        }
+        let mut results = Vec::new();
+        for h in handles {
+            results.push(
+                h.await
+                    .expect("drive_partition_results task should not panic"),
+            );
+        }
+        results
+    }
+
+    fn assert_shared_io_error(err: &BallistaError) {
+        let BallistaError::DataFusionError(e) = err else {
+            panic!("expected DataFusionError, got {err:?}");
+        };
+        assert!(
+            matches!(e.as_ref(), DataFusionError::Shared(_)),
+            "expected shared DataFusionError, got {e:?}"
+        );
+        assert!(
+            matches!(e.find_root(), DataFusionError::IoError(_)),
+            "expected IO root, got {:?}",
+            e.find_root()
+        );
+    }
+
     #[test]
     fn compute_partition_indices_distributes_rows_by_hash() {
         use datafusion::arrow::array::{Int64Array, StringArray};
@@ -1298,6 +1336,38 @@ mod tests {
         Ok(())
     }
 
+    #[tokio::test]
+    async fn write_failure_is_shared_to_all_output_partitions() -> Result<()> {
+        let session_ctx = SessionContext::new();
+        let task_ctx = session_ctx.task_ctx();
+
+        let input_plan: Arc<dyn ExecutionPlan> = Arc::new(ChaosExec::new(
+            create_test_input()?,
+            1.0,
+            "transient",
+            Some(42),
+        )?);
+        let work_dir = TempDir::new()?;
+
+        let writer = Arc::new(SortShuffleWriterExec::try_new(
+            "job1".into(),
+            1,
+            input_plan,
+            work_dir.path().to_str().unwrap().to_string(),
+            Partitioning::Hash(vec![Arc::new(Column::new("a", 0))], 2),
+            SortShuffleConfig::default(),
+        )?);
+
+        let results = drive_partition_results(writer, task_ctx).await;
+        assert_eq!(2, results.len());
+        for result in results {
+            let err = result.expect_err("expected injected write failure");
+            assert_shared_io_error(&err);
+        }
+
+        Ok(())
+    }
+
     /// Shared helper for round-trip tests. Builds `num_batches` batches of
     /// `rows_per_batch` rows each with schema `(k: Int64, v: Int64)`, writes
     /// them through `SortShuffleWriterExec` into `num_partitions` output
diff --git a/ballista/executor/src/executor.rs 
b/ballista/executor/src/executor.rs
index dce01ae7f..b491cfa8a 100644
--- a/ballista/executor/src/executor.rs
+++ b/ballista/executor/src/executor.rs
@@ -236,9 +236,7 @@ impl Executor {
         self.abort_handles.insert(key.clone(), abort_handle);
 
         let partitions = match 
std::panic::AssertUnwindSafe(task).catch_unwind().await {
-            Ok(Ok(result)) => {
-                result.map_err(|e| BallistaError::DataFusionError(Box::new(e)))
-            }
+            Ok(Ok(result)) => result.map_err(BallistaError::from),
             Ok(Err(_)) => {
                 warn!("Task has been aborted!");
                 Err(BallistaError::Cancelled)
diff --git a/ballista/scheduler/src/scheduler_server/query_stage_scheduler.rs 
b/ballista/scheduler/src/scheduler_server/query_stage_scheduler.rs
index 2ccf5a9da..3086071ec 100644
--- a/ballista/scheduler/src/scheduler_server/query_stage_scheduler.rs
+++ b/ballista/scheduler/src/scheduler_server/query_stage_scheduler.rs
@@ -355,6 +355,11 @@ impl<T: 'static + AsLogicalPlan, U: 'static + 
AsExecutionPlan>
                         {
                             warn!("Fail to cancel running tasks due to {e:?}");
                         }
+                        if self.state.config.is_push_staged_scheduling() {
+                            event_sender
+                                
.post_event(QueryStageSchedulerEvent::ReviveOffers)
+                                .await?;
+                        }
                     }
                     Err(e) => {
                         let msg = format!(
diff --git a/ballista/scheduler/src/state/aqe/adapter.rs 
b/ballista/scheduler/src/state/aqe/adapter.rs
index 539b45c6b..b6ec54866 100644
--- a/ballista/scheduler/src/state/aqe/adapter.rs
+++ b/ballista/scheduler/src/state/aqe/adapter.rs
@@ -18,6 +18,7 @@
 use crate::planner::create_shuffle_writer_with_config;
 use crate::state::aqe::execution_plan::{AdaptiveDatafusionExec, ExchangeExec};
 use crate::state::aqe::planner::AdaptiveStageInfo;
+use crate::state::execution_graph::StageOutput;
 use ballista_core::JobId;
 use ballista_core::execution_plans::{
     PerPartitionFilterExec, ShuffleReaderExec, range_partition_predicates,
@@ -31,11 +32,12 @@ use datafusion::{
     physical_plan::ExecutionPlan,
 };
 use log::debug;
+use std::collections::HashMap;
 use std::sync::Arc;
 
 #[derive(Debug, Clone, Default)]
 pub(crate) struct BallistaAdapter {
-    inputs: Vec<usize>,
+    inputs: HashMap<usize, StageOutput>,
 }
 
 ///
@@ -61,7 +63,12 @@ impl BallistaAdapter {
                     "stage ID has to be generated at this point".to_string(),
                 )
             })?;
-            self.inputs.push(stage_id);
+            let mut stage_output = StageOutput::new();
+            for partition in partitions.iter().flatten().cloned() {
+                stage_output.add_partition(partition);
+            }
+            stage_output.complete = true;
+            self.inputs.insert(stage_id, stage_output);
             let partitioning = exchange.properties().partitioning.clone();
 
             let reader = match (exchange.coalesce(), exchange.broadcast) {
diff --git a/ballista/scheduler/src/state/aqe/mod.rs 
b/ballista/scheduler/src/state/aqe/mod.rs
index 0c133db64..dac848221 100644
--- a/ballista/scheduler/src/state/aqe/mod.rs
+++ b/ballista/scheduler/src/state/aqe/mod.rs
@@ -19,7 +19,7 @@ use crate::display::print_stage_metrics;
 use crate::scheduler_server::event::QueryStageSchedulerEvent;
 use crate::scheduler_server::timestamp_millis;
 use crate::state::aqe::execution_plan::RangeRepartitionRouting;
-use crate::state::aqe::planner::AdaptivePlanner;
+use crate::state::aqe::planner::{AdaptivePlanner, AdaptiveStageInfo};
 use crate::state::execution_graph::{
     ExecutionGraph, ExecutionGraphBox, ExecutionStage, ResolvedStage, 
RunningTaskInfo,
     StageOutput,
@@ -29,7 +29,7 @@ use crate::state::task_manager::UpdatedStages;
 use ballista_core::JobId;
 use ballista_core::error::BallistaError;
 use ballista_core::execution_plans::{
-    ShuffleWriter, cut_partitions, merge_runtime_stats_reports, 
repartition_routing_expr,
+    cut_partitions, merge_runtime_stats_reports, repartition_routing_expr,
 };
 use ballista_core::serde::protobuf::failed_task::FailedReason;
 use ballista_core::serde::protobuf::job_status::Status;
@@ -167,7 +167,7 @@ impl AdaptiveExecutionGraph {
         let stages: ballista_core::error::Result<HashMap<usize, 
ExecutionStage>> =
             runnable
                 .into_iter()
-                .map(|s| Self::create_resolved_stage(session_config.clone(), 
s.plan))
+                .map(|s| Self::create_resolved_stage(session_config.clone(), 
s))
                 .collect();
         let stages = stages?;
 
@@ -202,15 +202,15 @@ impl AdaptiveExecutionGraph {
 impl AdaptiveExecutionGraph {
     fn create_resolved_stage(
         session_config: Arc<SessionConfig>,
-        stage: Arc<dyn ShuffleWriter>,
+        stage: AdaptiveStageInfo,
     ) -> ballista_core::error::Result<(usize, ExecutionStage)> {
-        let stage_id = stage.stage_id();
+        let stage_id = stage.plan.stage_id();
         let stage = ExecutionStage::Resolved(ResolvedStage::new(
             stage_id,
             0,
-            stage,
-            vec![],         // we do not know output links at this moment
-            HashMap::new(), // we do not keep inputs at the moment
+            stage.plan,
+            vec![], // we do not know output links at this moment
+            stage.inputs,
             HashSet::new(),
             session_config,
         ));
@@ -399,7 +399,7 @@ impl AdaptiveExecutionGraph {
                     if !self.stages.contains_key(&stage.plan.stage_id()) {
                         let (stage_id, stage) = Self::create_resolved_stage(
                             self.session_config.clone(),
-                            stage.plan,
+                            stage,
                         )?;
                         self.stages.insert(stage_id, stage);
                     }
@@ -525,6 +525,11 @@ impl AdaptiveExecutionGraph {
                 }
             });
 
+        for stage_id in &reset_running_stage {
+            self.planner
+                .remove_exchange_locations(*stage_id, executor_id);
+        }
+
         // check and reset the successful stages
         if !resubmit_inputs.is_empty() {
             self.stages
diff --git a/ballista/scheduler/src/state/aqe/planner.rs 
b/ballista/scheduler/src/state/aqe/planner.rs
index 690a5f2bc..6dc08dab6 100644
--- a/ballista/scheduler/src/state/aqe/planner.rs
+++ b/ballista/scheduler/src/state/aqe/planner.rs
@@ -286,6 +286,17 @@ impl AdaptivePlanner {
         }
     }
 
+    pub fn remove_exchange_locations(&mut self, stage_id: usize, executor_id: 
&str) {
+        if let Some(stage_output) = 
self.runnable_stage_output.get_mut(&stage_id) {
+            stage_output
+                .partition_locations
+                .iter_mut()
+                .for_each(|(_partition, locs)| {
+                    locs.retain(|loc| loc.executor_meta.id != executor_id);
+                });
+        }
+    }
+
     /// Once all tasks have completed, pop the accumulated stage output as a
     /// K-shaped `Vec<Vec<PartitionLocation>>` (or the broadcast-shape variant)
     /// *without* parking it on the ExchangeExec. Caller can post-process
@@ -650,6 +661,5 @@ impl AdaptivePlanner {
 /// Wraps stage plan with addition of references to previous stages
 pub(crate) struct AdaptiveStageInfo {
     pub(crate) plan: Arc<dyn ShuffleWriter>,
-    #[allow(dead_code)] // TODO: still not sure if this is needed
-    pub(crate) inputs: Vec<usize>,
+    pub(crate) inputs: std::collections::HashMap<usize, StageOutput>,
 }
diff --git a/ballista/scheduler/src/state/execution_graph.rs 
b/ballista/scheduler/src/state/execution_graph.rs
index 49f3cab62..99b7c38d5 100644
--- a/ballista/scheduler/src/state/execution_graph.rs
+++ b/ballista/scheduler/src/state/execution_graph.rs
@@ -1819,11 +1819,12 @@ mod test {
     use std::sync::Arc;
 
     use crate::scheduler_server::event::QueryStageSchedulerEvent;
-    use ballista_core::error::Result;
+    use ballista_core::error::{BallistaError, Result};
     use ballista_core::serde::protobuf::{
         self, ExecutionError, FailedTask, FetchPartitionError, IoError, 
JobStatus,
         TaskKilled, failed_task, job_status, task_status,
     };
+    use datafusion::arrow::error::ArrowError;
     use datafusion::common::{DataFusionError, Result as DataFusionResult};
     use datafusion::execution::TaskContext;
     use datafusion::physical_plan::{
@@ -2572,23 +2573,13 @@ mod test {
         let task1 = agg_graph.pop_next_task(&executor2.id)?.unwrap();
         let task_status1 = mock_completed_task(task1, &executor2.id);
 
-        // 2nd task in the Stage 2, failed due to FetchPartitionError
         let task2 = agg_graph.pop_next_task(&executor2.id)?.unwrap();
-        let task_status2 = mock_failed_task(
-            task2,
-            FailedTask {
-                error: "FetchPartitionError".to_string(),
-                retryable: false,
-                count_to_failures: false,
-                failed_reason: 
Some(failed_task::FailedReason::FetchPartitionError(
-                    FetchPartitionError {
-                        executor_id: executor1.id.clone(),
-                        map_stage_id: 1,
-                        map_partition_id: 0,
-                    },
-                )),
-            },
-        );
+        let failed_task = wrapped_fetch_failed_task(&executor1.id, 1, 0);
+        assert!(matches!(
+            failed_task.failed_reason,
+            Some(failed_task::FailedReason::FetchPartitionError(_))
+        ));
+        let task_status2 = mock_failed_task(task2, failed_task);
 
         let mut running_task_count = 0;
         while let Some(_task) = agg_graph.pop_next_task(&executor2.id)? {
@@ -3227,6 +3218,25 @@ mod test {
     //     todo!()
     // }
 
+    fn wrapped_fetch_failed_task(
+        executor_id: &str,
+        map_stage_id: usize,
+        map_partition_id: usize,
+    ) -> FailedTask {
+        let err = 
BallistaError::DataFusionError(Box::new(DataFusionError::ArrowError(
+            Box::new(ArrowError::ExternalError(Box::new(
+                BallistaError::FetchFailed(
+                    executor_id.to_owned(),
+                    map_stage_id,
+                    map_partition_id,
+                    "FetchPartitionError".to_owned(),
+                ),
+            ))),
+            None,
+        )));
+        FailedTask::from(err)
+    }
+
     fn drain_tasks(graph: &mut dyn ExecutionGraph) -> Result<()> {
         let executor = mock_executor("executor-id1".to_string());
         while let Some(task) = graph.pop_next_task(&executor.id)? {
diff --git a/ballista/scheduler/src/state/task_manager.rs 
b/ballista/scheduler/src/state/task_manager.rs
index dea5af100..604b0a31e 100644
--- a/ballista/scheduler/src/state/task_manager.rs
+++ b/ballista/scheduler/src/state/task_manager.rs
@@ -1338,11 +1338,23 @@ mod tests {
         let manager_for_abort = manager.clone();
         let job_id_for_abort = job_id.clone();
         let abort = tokio::spawn(async move {
+            let manager_for_cancel = manager_for_abort.clone();
+            let job_id_for_cancel = job_id_for_abort.clone();
             manager_for_abort
                 .abort_job(
                     &job_id_for_abort,
                     "test failure".to_string(),
                     move |tasks| async move {
+                        let status = manager_for_cancel
+                            .get_job_status(&job_id_for_cancel)
+                            .await?
+                            .expect(
+                                "aborted job should remain visible during 
cancellation",
+                            );
+                        assert!(
+                            matches!(status.status, Some(Status::Failed(_))),
+                            "job must be terminal before executor tasks are 
cancelled"
+                        );
                         cancelled_tasks_for_abort.store(tasks.len(), 
Ordering::SeqCst);
                         Ok(())
                     },
diff --git a/chaos-testing/README.md b/chaos-testing/README.md
index 88b198b57..b4afbfc63 100644
--- a/chaos-testing/README.md
+++ b/chaos-testing/README.md
@@ -26,10 +26,11 @@ injects faults into real queries, to exercise Ballista's 
high-availability
 **This is a bug-hunting harness, not a regression suite in the usual sense.**
 Its job is to surface real defects in Ballista's HA behavior. Where it finds
 one, the corresponding test reproduces the bug rather than working around it.
-Such a test is marked `#[ignore]` with the issue it reproduces, so that it
-does not hold CI red on a bug it did not introduce, and is un-ignored — not
-rewritten — when that issue is fixed, at which point it becomes the regression
-test for the fix. Run them with `cargo test -p ballista-chaos -- --ignored`.
+Such a test is marked `#[ignore]` with the issue or follow-up path it
+reproduces, so that it does not hold CI red on a bug it did not introduce, and
+is un-ignored — not rewritten — when that issue is fixed, at which point it
+becomes the regression test for the fix. Run them with
+`cargo test -p ballista-chaos -- --ignored`.
 See [Findings](#findings) below for the confirmed bugs this harness has
 found so far, each with the test that reproduces it.
 
@@ -133,8 +134,8 @@ non-null without invoking the (volatile) UDF. Two 
regression tests in
 ## How to run
 
 ```sh
-cargo test -p ballista-chaos              # everything except the known-bug 
scenarios
-cargo test -p ballista-chaos -- --ignored # the known-bug scenarios; these 
fail, on purpose
+cargo test -p ballista-chaos              # active regression scenarios
+cargo test -p ballista-chaos -- --ignored # any currently ignored known-bug 
scenarios
 ```
 
 Every test that spawns a cluster does so through `TestCluster`, which holds
@@ -182,19 +183,17 @@ sanity check that every other scenario's assertions 
depend on.
 
 | Scenario | Test                                                              
  | What it does                                                                
                                                                                
                                                 | Expected result              
                                                                                
                                  |
 | -------- | 
------------------------------------------------------------------- | 
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 | 
----------------------------------------------------------------------------------------------------------------------------------------------
 |
-| A        | 
`retryable_fault_is_retried_and_result_is_correct_{aqe_off,aqe_on}` | Injects 
one retryable IO fault (budget 1); the retry must succeed and match baseline.   
                                                                                
                                     | **Ignored (both), reproduces the error 
flattening tracked by 
[#2027](https://github.com/apache/datafusion-ballista/issues/2027) — Finding 
2.** |
+| A        | 
`retryable_fault_is_retried_and_result_is_correct_{aqe_off,aqe_on}` | Injects 
one retryable IO fault (budget 1); the retry must succeed and match baseline.   
                                                                                
                                     | Pass (both).                             
                                                                                
                      |
 | B        | `exhausted_retries_fail_the_job_and_leave_the_cluster_healthy`    
  | Injects an inexhaustible IO fault (budget 99 ≫ `task_max_failures`); job 
must fail, cluster must stay usable after.                                      
                                                    | Pass (both).              
                                                                                
                                     |
 | C        | `panicking_task_fails_the_job_but_the_executor_survives`          
  | Injects a task panic; job must fail non-retryably, both executor processes 
must survive, cluster must stay usable after.                                   
                                                  | Pass (both).                
                                                                                
                                   |
-| D        | `executor_killed_mid_stage_is_recovered`                          
  | SIGKILLs an executor while its tasks are genuinely running (held open by 
`chaos_delay`); scheduler must reschedule onto the survivor and return the 
correct result.                                          | **Ignored (both), 
reproduces [#2027](https://github.com/apache/datafusion-ballista/issues/2027) — 
Finding 1.**                                 |
-| E        | `executor_killed_after_shuffle_write_is_recovered`                
  | SIGKILLs the map-side executor _after_ it wrote shuffle output, with a long 
executor timeout to bias toward the fetch-failure path rather than heartbeat 
expiry; downstream stage must re-run the map stage. | **Ignored (both), 
reproduces [#2027](https://github.com/apache/datafusion-ballista/issues/2027) — 
Finding 1.**                                 |
+| D        | `executor_killed_mid_stage_is_recovered`                          
  | SIGKILLs an executor while its tasks are genuinely running (held open by 
`chaos_delay`); scheduler must reschedule onto the survivor and return the 
correct result.                                          | Pass (both), after 
stale task-attempt cancellation is classified as retryable cleanup.             
                                            |
+| E        | `executor_killed_after_shuffle_write_is_recovered`                
  | SIGKILLs the map-side executor _after_ it wrote shuffle output, with a long 
executor timeout to bias toward the fetch-failure path rather than heartbeat 
expiry; downstream stage must re-run the map stage. | Pass (both).              
                                                                                
                                     |
 | F        | `restarted_executor_rejoins_and_serves_queries`                   
  | Kills an executor, waits for the scheduler to reap it, restarts it, asserts 
the registered count returns to 2 and the cluster still serves the baseline 
query.                                               | Pass (both), after the 
race fix in this crate (see below).                                             
                                        |
 | G        | `killing_every_executor_terminates_the_job`                       
  | SIGKILLs every executor mid-query; asserts the job fails with an error 
naming the executor loss rather than hanging.                                   
                                                     | Regression test for 
[#2029](https://github.com/apache/datafusion-ballista/issues/2029) — Finding 3. 
                                |
 
-An ignored scenario above is not a defect in this harness, and its assertions
-have not been weakened to make it pass — it reproduces a real Ballista bug and
-is ignored only so that CI is not red on a bug this crate did not introduce.
-Run the ignored scenarios with `-- --ignored --test-threads=1` to see the
-failures; see [Findings](#findings) for what each one proves.
+If a future scenario is ignored, it should stay tied to its tracking issue or
+follow-up path, and its assertions should keep reproducing the underlying bug
+rather than being weakened to pass.
 
 ### A note on Scenario F: the harness race that was fixed here
 
@@ -216,78 +215,55 @@ actually promises.
 
 ## Findings
 
-Four scenarios above fail because they have found real bugs in Ballista, not
-because the harness is broken. Each is `#[ignore]`d against the issue it
-reproduces so CI stays green on a tree whose bugs predate this crate, and each
-keeps its original assertions: nothing is relaxed to manufacture a pass. When
-the issue is fixed, delete the `#[ignore]` — the scenario is then the
-regression test for it. One of the findings (#2028) has already gone through
-that cycle in reverse: it was fixed on main, but a concurrent refactor
-re-broke the same scenario through a different mechanism — see Finding 2.
+The findings below are bugs this harness exposed. Fixed findings remain here
+as context for the active regression scenarios; unfixed findings stay ignored
+against their tracking issue or follow-up path so CI is not red on a known bug.
 
 ### Finding 1 — Shuffle-fetch failures lose their type, so the map-stage 
resubmit never fires
 
 Tracked by [#2027](https://github.com/apache/datafusion-ballista/issues/2027).
 
-**Proven by:** Scenario D (`executor_killed_mid_stage_is_recovered`) and
-Scenario E (`executor_killed_after_shuffle_write_is_recovered`), both AQE
-settings, `#[ignore]`d against that issue.
-
-Scenarios D and E are **races**, not deterministic reproducers, and this is
-the one place in the crate where that is true. Killing an executor can be
-noticed by the scheduler in either of two ways, and they are in a footrace: if
-the heartbeat expires first, the `ExecutorLost` path recovers the job
-correctly and the scenario passes in a few seconds; if a downstream task tries
-to fetch shuffle output from the dead executor first, the bug below bites and
-the job fails (or hangs until the scenario's timeout). Scenario D failed
-locally on two of three runs; Scenario E passed locally but failed in CI
-(`aqe_off`, with the reduce stage reporting a `FetchFailed` flattened inside
-`DataFusionError::Shared` — see the log excerpt in #2027). The CI failure also
-settles which way `executor_timeout_seconds` biases the race: raising it to
-60s (as Scenario E does) _delays_ heartbeat expiry, so the downstream fetch
-hits the dead executor first and the scenario exercises the broken
-fetch-failure path; it only passes when the kill happens to land after the
-reduce tasks already fetched their input. Un-ignoring these scenarios once
-#2027 is fixed therefore also means pinning which of the two paths each
-exercises — `executor_timeout_seconds` is the knob that decides the race, and
-Scenario D currently leaves it at the harness default — otherwise they will be
-flaky regression tests.
-
-The shuffle reader (`ballista/core/src/execution_plans/shuffle_reader.rs`)
-correctly produces a typed `BallistaError::FetchFailed(executor_id,
-map_stage_id, map_partition_id, desc)` when it cannot reach a dead executor,
-and `ballista/core/src/error.rs`'s `impl From<BallistaError> for FailedTask`
-has a dedicated arm for exactly that variant (around line 205) which produces
-`FailedReason::FetchPartitionError` — the signal
-`ballista/scheduler/src/state/execution_graph.rs` (around line 826) uses to
-resubmit the lost map stage rather than simply failing the job.
-
-The type does not survive to that point, however. Two real, non-test code
-paths erase it before the executor reports its `TaskStatus`:
-
-- `ballista/executor/src/executor.rs:238-239`, in
-  `Executor::execute_query_stage`, converts the stage's result with
-  `result.map_err(|e| BallistaError::DataFusionError(Box::new(e)))` instead of
-  `BallistaError::from(e)` / `e.into()`. That bypasses the very unwrapping
-  logic `error.rs`'s `impl From<DataFusionError> for BallistaError` exists to
-  provide (`DataFusionError::ArrowError(e, _) => Self::from(*e)`, which would
-  otherwise recover a `FetchFailed` wrapped inside an 
`ArrowError::ExternalError`).
-- `ballista/core/src/execution_plans/shuffle_writer.rs:245`, in
-  `ShuffleWriterExec`'s unpartitioned write branch, Debug-formats a
-  `BallistaError` into an opaque `DataFusionError::Execution(format!("{e:?}"))`
-  — a conversion that can never be undone by any later `.into()`, because the
-  original variant no longer exists, only its printed form.
-
-Either path leaves the executor reporting something like
-`BallistaError::DataFusionError(Execution("FetchFailed(\"<executor-id>\", ...,
-\"...Connection refused...\")"))` — the `FetchFailed` information is present
-only as inert text inside a string. `error.rs`'s `FetchFailed` arm cannot match
-a `DataFusionError::Execution`, so the task falls to the catch-all arm
-(around line 248) and is marked `retryable: false`, 
`FailedReason::ExecutionError`.
-
-**Net effect:** when an executor dies after producing shuffle output that a
-downstream stage still needs, Ballista fails the whole query instead of
-re-running the map stage that produced it.
+**Regression coverage:** Scenario D
+(`executor_killed_mid_stage_is_recovered`) and Scenario E
+(`executor_killed_after_shuffle_write_is_recovered`), both AQE settings.
+
+The shuffle reader produces a typed `BallistaError::FetchFailed(executor_id,
+map_stage_id, map_partition_id, desc)` when it cannot reach a dead executor.
+That type has to survive until task-failure classification, because the
+scheduler's map-stage resubmit path is keyed on `FetchPartitionError`.
+
+Before the fix, production code could turn that structured error into inert
+text inside `DataFusionError::Execution`. Once that happened, the classifier
+saw only a generic execution error and the scheduler never received the
+`FetchPartitionError` signal.
+
+The fix keeps the error structural across both places where it was being lost:
+the executor now uses the existing `BallistaError` conversion when a stage
+fails, and the shuffle-writer coordinator no longer Debug-formats the first
+child error before handing it back through the output stream. The classifier
+also looks through the DataFusion/Arrow wrapper stack so a wrapped
+`FetchFailed` still becomes `FetchPartitionError`.
+
+Scenario E is the direct fetch-failure regression: it kills the map-side
+executor after shuffle output is written and uses a long executor timeout so a
+downstream fetch is likely to hit the dead executor before heartbeat expiry.
+Scenario D covers the adjacent executor-loss race while a stage is still
+running. In that path, heartbeat expiry can win first, so executor-loss task
+resets need to wake push scheduling with fresh offers.
+
+### Scenario D note — Mid-stage executor loss can cancel stale task attempts
+
+Scenario D also exposed an adjacent recovery edge. When executor-loss recovery
+rolls back or resets stages, the scheduler can ask surviving executors to
+cancel tasks from stale stage attempts. Those aborted task futures are reported
+back as `BallistaError::Cancelled`; this is task attempt cleanup, not a
+client-cancelled job.
+
+The fix maps executor-reported `Cancelled` task attempts to retryable,
+non-counting `TaskKilled` failures, so stale work cleanup does not fail a job
+that executor-loss recovery is already rescheduling. Explicit job cancellation
+is still handled by the scheduler by marking the job terminal before cancelling
+running executor tasks.
 
 ### Finding 2 — Retryable IO errors are misclassified because the shuffle 
writer flattens them
 
@@ -296,9 +272,8 @@ Originally tracked by
 fixed); the surviving flattening mechanism is the one
 [#2027](https://github.com/apache/datafusion-ballista/issues/2027) tracks.
 
-**Proven by:** Scenario A, both cases
-(`retryable_fault_is_retried_and_result_is_correct_{aqe_off,aqe_on}`),
-`#[ignore]`d against #2027.
+**Regression coverage:** Scenario A, both cases
+(`retryable_fault_is_retried_and_result_is_correct_{aqe_off,aqe_on}`).
 
 The history matters here because the failure mode moved underneath the
 harness. As first found, only the `aqe_on` case failed: an `IoError` raised on
@@ -317,13 +292,9 @@ fault now reaches the classifier as
 `Execution("IoError(Custom { .. })")` (`aqe_off`) or
 `Execution("Shared(IoError(Custom { .. }))")` (`aqe_on`) — the variant exists
 only as printed text, so `find_root()` has nothing to unwrap and the task is
-marked non-retryable. This is the same type-erasing conversion Finding 1
-describes for `FetchFailed`, which is why both scenarios are ignored against
-#2027 rather than the fixed #2028.
-
-**Net effect:** any genuine transient IO error inside a stage that writes
-shuffle output — which is every non-final stage — is classified non-retryable,
-turning what should be a retried task into an immediate job failure.
+marked non-retryable. Keeping the original `DataFusionError` through the
+shuffle-writer handoff fixes this by giving the existing `find_root()` based
+classifier the real IO error again.
 
 ### Finding 3 — Killing every executor hung the job instead of failing it 
(fixed)
 
@@ -355,12 +326,10 @@ 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
+### For comparison: the heartbeat-expiry path also recovers
 
-Ballista's HA recovery is not uniformly broken. Whenever the kill in Scenario
-D or E happens to be noticed by heartbeat expiry (`ExecutorLost`) before any
-downstream task fetches from the dead executor, the job recovers and matches
-the baseline — that is why both scenarios pass on some runs. What Finding 1
-breaks is specifically the fetch-failure path, and both scenarios are ignored
-against #2027 because whether a given run exercises that path is a timing
-race, not something the harness currently controls.
+Killing an executor can be noticed in two ways: heartbeat expiry
+(`ExecutorLost`) or a downstream shuffle fetch from the dead executor. Scenario
+E biases toward the fetch-failure path. Scenario D exercises the broader
+mid-stage executor-loss path, including stale task-attempt cancellation during
+executor-loss recovery. Both paths now recover and return the baseline result.
diff --git a/chaos-testing/src/bin/chaos-executor.rs 
b/chaos-testing/src/bin/chaos-executor.rs
index a2efe82a7..830d295ae 100644
--- a/chaos-testing/src/bin/chaos-executor.rs
+++ b/chaos-testing/src/bin/chaos-executor.rs
@@ -43,6 +43,7 @@ async fn main() -> ballista_core::error::Result<()> {
         grpc_port: env_u16("CHAOS_EXECUTOR_GRPC_PORT"),
         scheduler_host: "127.0.0.1".to_string(),
         scheduler_port: env_u16("CHAOS_SCHEDULER_PORT"),
+        scheduler_connect_timeout_seconds: 10,
         vcores: std::env::var("CHAOS_CONCURRENT_TASKS")
             .ok()
             .and_then(|v| v.parse().ok())
diff --git a/chaos-testing/src/cluster.rs b/chaos-testing/src/cluster.rs
index 33fc8d040..d1972cd57 100644
--- a/chaos-testing/src/cluster.rs
+++ b/chaos-testing/src/cluster.rs
@@ -69,19 +69,13 @@ fn binary(name: &str) -> PathBuf {
 
 /// One supervised executor process.
 ///
-/// `child` is used by this task's `kill_executor`/`executor_is_alive`. `port`,
-/// `grpc_port`, and `work_dir` are still not read anywhere yet — no code in
-/// this task needed to target an executor by its network address or inspect
-/// its working directory — so they keep a narrower `#[allow(dead_code)]` than
-/// the struct-wide one Task 5 left; a later scenario that needs to address a
-/// specific executor's port or inspect its shuffle files can drop it then.
+/// `child` is used by this task's `kill_executor`/`executor_is_alive`.
 pub(crate) struct ExecutorHandle {
     pub(crate) child: Child,
     #[allow(dead_code)]
     pub(crate) port: u16,
     #[allow(dead_code)]
     pub(crate) grpc_port: u16,
-    #[allow(dead_code)]
     pub(crate) work_dir: PathBuf,
 }
 
@@ -139,6 +133,11 @@ impl TestClusterBuilder {
         self
     }
 
+    pub fn concurrent_tasks(mut self, n: usize) -> Self {
+        self.concurrent_tasks = n;
+        self
+    }
+
     pub async fn start(self) -> Result<TestCluster, String> {
         // Held for the cluster's whole lifetime, so only one cluster exists in
         // this process at a time. `--test-threads=1` gives the same guarantee,
@@ -414,7 +413,7 @@ impl TestCluster {
     }
 
     /// The last lines of every child process log, for timeout diagnostics.
-    fn log_tails(&self) -> String {
+    pub fn log_tails(&self) -> String {
         let mut out = String::new();
         let mut paths: Vec<PathBuf> = std::fs::read_dir(&self.log_dir)
             .map(|d| d.filter_map(|e| e.ok().map(|e| e.path())).collect())
@@ -436,6 +435,16 @@ impl TestCluster {
         out
     }
 
+    pub async fn diagnostics(&self, job_id: &str) -> String {
+        let stages = self
+            .stages(job_id)
+            .await
+            .ok()
+            .and_then(|stages| serde_json::to_string_pretty(&stages).ok())
+            .unwrap_or_else(|| "stage summary unavailable".to_string());
+        format!("--- stages ---\n{stages}\n{}", self.log_tails())
+    }
+
     /// Block until the scheduler considers exactly `n` executors registered.
     ///
     /// Unlike `await_executors` (which waits for *at least* `n`, the right
@@ -657,6 +666,50 @@ impl TestCluster {
         Ok(())
     }
 
+    pub async fn await_successful_shuffle_output(
+        &self,
+        job_id: &str,
+    ) -> Result<(usize, usize), String> {
+        let deadline = Instant::now() + Duration::from_secs(60);
+        loop {
+            let stages = self.stages(job_id).await?;
+            for stage_id in running_stage_ids_with_successful_tasks(&stages) {
+                if let Some(executor_index) =
+                    self.executor_with_shuffle_output(job_id, stage_id)
+                {
+                    return Ok((executor_index, stage_id));
+                }
+            }
+            if self.job_status(job_id).await.unwrap_or_default() == 
"Completed" {
+                return Err(format!(
+                    "job {job_id} completed before a running shuffle-writing 
stage could be targeted\n{}",
+                    self.log_tails()
+                ));
+            }
+            if Instant::now() > deadline {
+                return Err(format!(
+                    "timed out waiting for successful shuffle output for job 
{job_id}\n{}",
+                    self.log_tails()
+                ));
+            }
+            tokio::time::sleep(Duration::from_millis(5)).await;
+        }
+    }
+
+    fn executor_with_shuffle_output(
+        &self,
+        job_id: &str,
+        stage_id: usize,
+    ) -> Option<usize> {
+        self.executors
+            .iter()
+            .enumerate()
+            .find_map(|(index, executor)| {
+                let stage_dir = 
executor.work_dir.join(job_id).join(stage_id.to_string());
+                dir_has_entries(&stage_dir).then_some(index)
+            })
+    }
+
     /// Start a fresh executor process in the given slot and wait for it to 
register.
     pub async fn restart_executor(&mut self, index: usize) -> Result<(), 
String> {
         let expected = self.executors.len();
@@ -677,6 +730,34 @@ fn find_stage(stages: &serde_json::Value, stage_id: usize) 
-> Option<&serde_json
     })
 }
 
+fn dir_has_entries(path: &Path) -> bool {
+    std::fs::read_dir(path)
+        .map(|mut entries| entries.next().is_some())
+        .unwrap_or(false)
+}
+
+fn running_stage_ids_with_successful_tasks(stages: &serde_json::Value) -> 
Vec<usize> {
+    stages
+        .get("stages")
+        .and_then(|s| s.as_array())
+        .into_iter()
+        .flatten()
+        .filter(|stage| {
+            stage.get("stage_status").and_then(|v| v.as_str()) == 
Some("Running")
+                && stage
+                    .get("tasks")
+                    .and_then(|tasks| tasks.as_array())
+                    .into_iter()
+                    .flatten()
+                    .any(|task| {
+                        task.get("status").and_then(|status| status.as_str())
+                            == Some("Successful")
+                    })
+        })
+        .filter_map(|stage| stage.get("stage_id")?.as_str()?.parse().ok())
+        .collect()
+}
+
 /// If `child` already exited with a non-zero status, log the path of its
 /// output so a human investigating a failed scenario knows where to look.
 /// Then make sure it is actually gone.
@@ -712,6 +793,11 @@ mod tests {
     async fn cluster_starts_with_the_requested_executors_registered() {
         let cluster = TestCluster::builder()
             .executors(2)
+            // This canary only checks that registration happened; it kills
+            // nothing, so the default short reap timeout buys it nothing and
+            // lets a CPU-starved executor be reaped mid-startup on a loaded CI
+            // runner before the snapshot below.
+            .executor_timeout_seconds(30)
             .start()
             .await
             .expect("cluster must start");
diff --git a/chaos-testing/src/fixture.rs b/chaos-testing/src/fixture.rs
index 3871fce4e..f8c6d8784 100644
--- a/chaos-testing/src/fixture.rs
+++ b/chaos-testing/src/fixture.rs
@@ -128,6 +128,12 @@ impl Fixture {
          GROUP BY d.name ORDER BY d.name"
     }
 
+    pub fn shuffle_loss_query() -> &'static str {
+        "SELECT f.value, d.name \
+         FROM facts f JOIN dims d ON f.key = d.key \
+         ORDER BY f.value"
+    }
+
     /// The same query with a chaos UDF spliced into the WHERE clause.
     ///
     /// `injection` is a complete `chaos_*(...)` call returning BOOLEAN. Its 
own
@@ -155,6 +161,15 @@ impl Fixture {
              GROUP BY d.name ORDER BY d.name"
         )
     }
+
+    pub fn shuffle_loss_chaos_query(injection: &str) -> String {
+        format!(
+            "SELECT f.value, d.name \
+             FROM facts f JOIN dims d ON f.key = d.key \
+             WHERE {injection} IS NOT NULL \
+             ORDER BY f.value"
+        )
+    }
 }
 
 #[cfg(test)]
diff --git a/chaos-testing/tests/common/mod.rs 
b/chaos-testing/tests/common/mod.rs
index 1a2755d3d..831be3d16 100644
--- a/chaos-testing/tests/common/mod.rs
+++ b/chaos-testing/tests/common/mod.rs
@@ -43,12 +43,23 @@ impl ChaosRun {
         aqe: bool,
         executors: usize,
         executor_timeout_seconds: u64,
+    ) -> Self {
+        Self::start_with_concurrent_tasks(aqe, executors, 
executor_timeout_seconds, 4)
+            .await
+    }
+
+    pub async fn start_with_concurrent_tasks(
+        aqe: bool,
+        executors: usize,
+        executor_timeout_seconds: u64,
+        concurrent_tasks: usize,
     ) -> Self {
         let _ = env_logger::builder().is_test(true).try_init();
 
         let cluster = TestCluster::builder()
             .executors(executors)
             .executor_timeout_seconds(executor_timeout_seconds)
+            .concurrent_tasks(concurrent_tasks)
             .start()
             .await
             .expect("cluster must start");
@@ -105,17 +116,15 @@ impl ChaosRun {
 
     /// The expected answer, computed by plain local DataFusion.
     pub async fn local_baseline(&self) -> String {
+        self.local_sql(Fixture::baseline_query()).await
+    }
+
+    pub async fn local_sql(&self, sql: &str) -> String {
         let ctx = SessionContext::new();
         for stmt in self.fixture.register_sql() {
             ctx.sql(&stmt).await.unwrap().collect().await.unwrap();
         }
-        let batches = ctx
-            .sql(Fixture::baseline_query())
-            .await
-            .unwrap()
-            .collect()
-            .await
-            .unwrap();
+        let batches = ctx.sql(sql).await.unwrap().collect().await.unwrap();
         pretty_format_batches(&batches).unwrap().to_string()
     }
 
diff --git a/chaos-testing/tests/ha.rs b/chaos-testing/tests/ha.rs
index fe9f93d11..4b5980aae 100644
--- a/chaos-testing/tests/ha.rs
+++ b/chaos-testing/tests/ha.rs
@@ -22,9 +22,9 @@
 //! statistics, so a re-run map stage can come back with a different plan than 
the
 //! one whose output was lost.
 //!
-//! Every test in this file spawns a whole multi-process cluster, so this file
-//! must always be run with `--test-threads=1` or ports and CPU will be
-//! exhausted by concurrent clusters.
+//! Every test in this file spawns a whole multi-process cluster. `TestCluster`
+//! serializes those clusters, so `--test-threads=1` is useful for readable
+//! local output but is not required for correctness.
 
 mod common;
 
@@ -66,37 +66,12 @@ use chaos_testing::fixture::Fixture;
 /// result still equals the baseline: a retried stage is exactly where 
duplicated
 /// or dropped partitions would show up.
 ///
-/// This scenario is split into one test per AQE setting rather than being an
-/// `rstest` over both, because historically only the AQE-on case failed and
-/// `rstest` cannot ignore an individual case.
-///
-/// Both cases originally passed or reproduced #2028 (the AQE-on case:
-/// `Shared(IoError)` misses the classifier's shallow match). #2028 was fixed
-/// by classifying on `find_root()` (#2119), but the sort-shuffle writer
-/// refactor (#2038/#2106) then made both cases fail the same way: the shuffle
-/// write coordinator flattens any task error into
-/// `DataFusionError::Execution(format!("{e:?}"))`
-/// (`ballista/core/src/execution_plans/shuffle_writer.rs`, error arm of the
-/// coordinator fan-out), so the injected `IoError` reaches the classifier as
-/// inert text inside an `Execution` string, `find_root()` has nothing to see
-/// through, and the task is marked non-retryable. That is the same
-/// type-erasing mechanism tracked for fetch failures by #2027.
-///
-/// Ignored, not deleted or weakened. Un-ignore both as the regression tests
-/// when #2027's error-flattening is fixed; see chaos-testing/README.md,
-/// Finding 2.
 #[tokio::test]
-#[ignore = "reproduces #2027's error flattening: the shuffle writer 
Debug-formats the IoError into an opaque Execution string, so it is 
misclassified as non-retryable"]
 async fn retryable_fault_is_retried_and_result_is_correct_aqe_off() {
     retryable_fault_is_retried_and_result_is_correct(false).await;
 }
 
-/// See `retryable_fault_is_retried_and_result_is_correct_aqe_off` above; the
-/// AQE-on case fails identically (the error arrives as
-/// `Execution("Shared(IoError(..))")` — stringified before the classifier,
-/// which is what distinguishes this from the fixed #2028).
 #[tokio::test]
-#[ignore = "reproduces #2027's error flattening: the shuffle writer 
Debug-formats the IoError into an opaque Execution string, so it is 
misclassified as non-retryable"]
 async fn retryable_fault_is_retried_and_result_is_correct_aqe_on() {
     retryable_fault_is_retried_and_result_is_correct(true).await;
 }
@@ -210,23 +185,15 @@ use std::time::Duration;
 
 /// Scenario D: SIGKILL an executor while it is running tasks.
 ///
-/// `chaos_delay` holds stage 1 open so the kill lands while tasks are 
genuinely
-/// in flight. The scheduler must detect the loss, reschedule the dead 
executor's
-/// tasks onto the survivor, and still return the correct result.
+/// `chaos_delay` holds the delayed scan stage open so the kill lands while
+/// tasks are genuinely in flight. The scheduler must detect the loss,
+/// reschedule the dead executor's tasks onto the survivor, and still return 
the
+/// correct result.
 ///
-/// Both cases reproduce #2027: the shuffle reader's typed
-/// `BallistaError::FetchFailed` is flattened into an opaque
-/// `DataFusionError::Execution` before the executor reports its `TaskStatus`,
-/// so the scheduler never sees the `FetchPartitionError` that would make it
-/// resubmit the lost map stage, and fails the query instead of recovering it.
-///
-/// Ignored, not deleted or weakened. Un-ignore it as the regression test when
-/// #2027 is fixed; see chaos-testing/README.md, Finding 1.
 #[rstest]
 #[case::aqe_off(false)]
 #[case::aqe_on(true)]
 #[tokio::test]
-#[ignore = "reproduces #2027: FetchFailed loses its type, so the map stage is 
never resubmitted"]
 async fn executor_killed_mid_stage_is_recovered(#[case] aqe: bool) {
     let mut run = ChaosRun::start(aqe, 2).await;
     let expected = run.local_baseline().await;
@@ -235,18 +202,21 @@ async fn executor_killed_mid_stage_is_recovered(#[case] 
aqe: bool) {
     // enough to kill an executor inside it.
     let sql = Fixture::chaos_query("chaos_delay(f.key >= 0, 300)");
 
-    // Submit the query concurrently, then kill executor 0 once stage 1 is 
running.
+    // Submit the query concurrently, then kill executor 0 once the delayed
+    // stage is running. AQE and non-AQE planning assign different stage ids
+    // to that work.
     let query = tokio::spawn({
         let ctx = run.clone_ctx();
         let sql = sql.clone();
         async move { ctx.sql(&sql).await?.collect().await }
     });
 
+    let delayed_stage_id = if aqe { 0 } else { 1 };
     let job_id = run.cluster.running_job_id().await.expect("job must appear");
     run.cluster
-        .await_stage_running(&job_id, 1)
+        .await_stage_running(&job_id, delayed_stage_id)
         .await
-        .expect("stage 1 must start running");
+        .expect("delayed stage must start running");
     run.cluster.kill_executor(0).expect("kill executor 0");
 
     let batches = tokio::time::timeout(Duration::from_secs(120), query)
@@ -273,27 +243,22 @@ async fn executor_killed_mid_stage_is_recovered(#[case] 
aqe: bool) {
 /// recoveries, so the assertion is on correctness, and the path that actually
 /// fired is only recorded.
 ///
-/// Reproduces #2027: whenever the reduce stage genuinely has to fetch from the
-/// dead executor, the typed `FetchFailed` arrives flattened inside
-/// `DataFusionError::Shared`, the scheduler never resubmits the map stage, and
-/// the job fails. The scenario only passes when the kill happens to land after
-/// the reduce tasks have already fetched their partitions, which makes it
-/// timing-dependent (it failed in CI, aqe_off case).
-///
-/// Ignored, not deleted or weakened. Un-ignore it as the regression test when
-/// #2027 is fixed; see chaos-testing/README.md, Finding 1.
 #[rstest]
 #[case::aqe_off(false)]
 #[case::aqe_on(true)]
 #[tokio::test]
-#[ignore = "reproduces #2027: FetchFailed loses its type, so the map stage is 
never resubmitted"]
 async fn executor_killed_after_shuffle_write_is_recovered(#[case] aqe: bool) {
-    let mut run = ChaosRun::start_with(aqe, 2, 60).await;
-    let expected = run.local_baseline().await;
+    let mut run = ChaosRun::start_with_concurrent_tasks(aqe, 2, 60, 1).await;
+    let expected = run.local_sql(Fixture::shuffle_loss_query()).await;
 
-    // Delay the *aggregate* side so the reduce stage is slow, giving us a 
window
-    // between "stage 1 succeeded" and "stage 2 has finished fetching".
-    let sql = Fixture::chaos_query("chaos_delay(f.key >= 0, 50)");
+    run.sql("SET ballista.shuffle.force_remote_read = true")
+        .await
+        .expect("force remote shuffle reads");
+    run.sql("SET ballista.shuffle.max_concurrent_read_requests = 1")
+        .await
+        .expect("serialize shuffle reads");
+
+    let sql = Fixture::shuffle_loss_chaos_query("chaos_delay(d.name IS NOT 
NULL, 300)");
 
     let query = tokio::spawn({
         let ctx = run.clone_ctx();
@@ -302,17 +267,54 @@ async fn 
executor_killed_after_shuffle_write_is_recovered(#[case] aqe: bool) {
     });
 
     let job_id = run.cluster.running_job_id().await.expect("job must appear");
-    run.cluster
-        .await_stage_successful(&job_id, 1)
+    let (executor_index, shuffle_stage_id) = run
+        .cluster
+        .await_successful_shuffle_output(&job_id)
         .await
-        .expect("stage 1 must complete before we kill its executor");
-    run.cluster.kill_executor(0).expect("kill executor 0");
-
-    let batches = tokio::time::timeout(Duration::from_secs(180), query)
+        .expect("a shuffle-writing stage must complete before we kill its 
executor");
+    if run
+        .cluster
+        .job_status(&job_id)
         .await
-        .expect("query must not hang after shuffle output is lost")
-        .expect("query task must not panic")
-        .expect("query must recover by re-running the map stage");
+        .expect("job status must be readable")
+        == "Completed"
+    {
+        panic!(
+            "job completed before executor {executor_index} could be killed 
after stage {shuffle_stage_id} wrote shuffle output\n{}",
+            run.cluster.diagnostics(&job_id).await
+        );
+    }
+    if query.is_finished() {
+        panic!(
+            "query completed before executor {executor_index} could be killed 
after stage {shuffle_stage_id} wrote shuffle output\n{}",
+            run.cluster.diagnostics(&job_id).await
+        );
+    }
+    run.cluster
+        .kill_executor(executor_index)
+        .unwrap_or_else(|e| {
+            panic!(
+                "kill executor {executor_index} with stage {shuffle_stage_id} 
shuffle output: {e}"
+            )
+        });
+
+    let batches = match tokio::time::timeout(Duration::from_secs(180), 
query).await {
+        Ok(result) => match result.expect("query task must not panic") {
+            Ok(batches) => batches,
+            Err(e) => {
+                panic!(
+                    "query must recover by re-running the map stage: {e}\n{}",
+                    run.cluster.diagnostics(&job_id).await
+                )
+            }
+        },
+        Err(e) => {
+            panic!(
+                "query must not hang after shuffle output is lost: {e}\n{}",
+                run.cluster.diagnostics(&job_id).await
+            )
+        }
+    };
 
     let actual = 
datafusion::arrow::util::pretty::pretty_format_batches(&batches)
         .unwrap()


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

Reply via email to