This is an automated email from the ASF dual-hosted git repository.

andygrove 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 6540d0851 Remove the hash-partitioning shuffle writer (#2106)
6540d0851 is described below

commit 6540d0851d7604fd759b41594d9652e5a4432489
Author: Andy Grove <[email protected]>
AuthorDate: Tue Jul 21 11:58:49 2026 -0600

    Remove the hash-partitioning shuffle writer (#2106)
    
    * refactor: drop hash-partitioning branch from ShuffleWriterExec
    
    Guard try_new() to reject Some(Partitioning::Hash(..)) and delete the
    now-unreachable Hash match arm from execute_shuffle_write, along with
    WriteTracker and the repart_time metric it used. Hash-repartition
    stages are handled by SortShuffleWriterExec instead; ShuffleWriterExec
    now only ever writes the single-file (None) passthrough path.
    
    Update the module's existing tests that exercised the Hash arm to
    exercise the surviving None path instead (test, test_partitioned
    folded into test, display_renders_child_operator_metrics,
    test_hash_repart_write_failure_propagates renamed to
    test_create_dir_failure_propagates), and add
    try_new_rejects_hash_partitioning to cover the new guard.
    
    * test: update EXPLAIN ANALYZE golden after repart_time removal
    
    * refactor: always use sort-based shuffle for hash-repartition stages
    
    * refactor: drop hash writer arm from shuffle_bench
    
    * test: drop hash-vs-sort shuffle comparison tests
    
    * test: drop sort-shuffle enable toggle from AQE tests
    
    * refactor: gate sort-shuffle reads on on-disk index only
    
    * test: fix local shuffle read test for single-partition writer
    
    * feat: remove ballista.shuffle.sort_based.enabled config key
    
    * docs: remove hash-based shuffle documentation
    
    * style: apply rustfmt to shuffle_bench
    
    * refactor: clean up stale references to removed sort_based.enabled key
    
    * docs: apply prettier to shuffle tuning table
    
    * refactor: drop the unused output partitioning from ShuffleWriterExec
    
    `ShuffleWriterExec` no longer repartitions, so its `Option<Partitioning>`
    was dead in every real path: the planner passes `None` at all call sites,
    the AQE adapter passes `None` or `Hash` (which routes to
    `SortShuffleWriterExec`), and proto decode can only produce `None` or
    `Hash`. Rather than reject `Some(Partitioning::Hash(..))` at runtime,
    remove the parameter and field entirely so the writer is unambiguously
    the passthrough writer.
    
    - `ShuffleWriterExec::try_new` drops its `shuffle_output_partitioning`
      argument; the struct drops the field and derives its properties from
      the child plan.
    - The `ShuffleWriter` trait impl returns `None` unconditionally.
    - Proto decode errors on a `ShuffleWriterExecNode` that still carries an
      `output_partitioning`; encode always writes `None`.
    - `create_shuffle_writer_with_config` errors on a non-hash `Some(..)`
      instead of silently building a writer that could not be encoded.
    - `ShuffleWriterVariant::Hash` is renamed to `Passthrough`, which is what
      the variant has actually meant since the hash write path was removed.
    
    Plan display keeps `partitioning: None` so TPC-H plan-stability golden
    files are unchanged.
---
 ballista/client/tests/context_checks.rs            |   2 +-
 ballista/client/tests/sort_shuffle.rs              |  86 ----
 ballista/core/src/config.rs                        |  15 -
 .../core/src/execution_plans/shuffle_reader.rs     |  23 +-
 .../core/src/execution_plans/shuffle_writer.rs     | 570 +++++----------------
 ballista/core/src/extension.rs                     |  11 -
 ballista/core/src/serde/mod.rs                     |  41 +-
 ballista/executor/src/execution_engine.rs          |  22 +-
 ballista/executor/src/executor.rs                  |   3 +-
 ballista/executor/src/flight_service.rs            |   2 +-
 ballista/scheduler/src/planner.rs                  |  33 +-
 ballista/scheduler/src/state/aqe/test/mod.rs       |  16 -
 .../scheduler/src/state/aqe/test/plan_to_stages.rs |  33 +-
 benchmarks/src/bin/shuffle_bench.rs                |  82 +--
 benchmarks/src/bin/tpch.rs                         |   2 +-
 docs/source/user-guide/configs.md                  |   3 +-
 docs/source/user-guide/tuning-guide.md             |  38 +-
 python/python/tests/test_context.py                |   4 +-
 18 files changed, 220 insertions(+), 766 deletions(-)

diff --git a/ballista/client/tests/context_checks.rs 
b/ballista/client/tests/context_checks.rs
index dd67cc103..5d9cf83ea 100644
--- a/ballista/client/tests/context_checks.rs
+++ b/ballista/client/tests/context_checks.rs
@@ -1192,7 +1192,7 @@ mod supported {
             "|                   |           PlaceholderRowExec, metrics=[...] 
                                                                                
                                                                                
                                                                                
                                                                                
                      |",
             "|                   |                                             
                                                                                
                                                                                
                                                                                
                                                                                
                      |",
             "|                   | =========SuccessfulStage[stage_id=2, 
partitions=16]=========                                                         
                                                                                
                                                                                
                                                                                
                             |",
-            "|                   | ShuffleWriterExec: partitioning: None, 
metrics=[output_rows=..., input_rows=..., repart_time=..., write_time=...]      
                                                                                
                                                                                
                                                                                
                           |",
+            "|                   | ShuffleWriterExec: partitioning: None, 
metrics=[output_rows=..., input_rows=..., write_time=...]                       
                                                                                
                                                                                
                                                                                
                           |",
             "|                   |   ProjectionExec: expr=[count(Int64(1))@1 
as count(*), id@0 as id], metrics=[output_rows=..., elapsed_compute=..., 
output_bytes=..., output_batches=..., expr_0_eval_time=..., 
expr_1_eval_time=...]                                                           
                                                                                
                                                   |",
             "|                   |     AggregateExec: mode=FinalPartitioned, 
gby=[id@0 as id], aggr=[count(Int64(1))], metrics=[output_rows=..., 
elapsed_compute=..., output_bytes=..., output_batches=..., spill_count=..., 
spilled_bytes=..., spilled_rows=..., peak_mem_used=..., 
aggregate_arguments_time=..., aggregation_time=..., emitting_time=..., 
time_calculating_group_ids=...]                                          |",
             "|                   |       ShuffleReaderExec: upstream_stage: 1, 
partitioning: Hash([id@0], 16), metrics=[output_rows=..., elapsed_compute=..., 
output_bytes=..., output_batches=..., decoded_bytes=..., fetch_requests=..., 
fetch_retries=..., local_partitions=..., remote_partitions=..., fetch_time=..., 
local_read_time=..., permit_wait_time=...]                                      
                          |",
diff --git a/ballista/client/tests/sort_shuffle.rs 
b/ballista/client/tests/sort_shuffle.rs
index 0a9cc4bf7..fbeb7f052 100644
--- a/ballista/client/tests/sort_shuffle.rs
+++ b/ballista/client/tests/sort_shuffle.rs
@@ -37,14 +37,12 @@ mod sort_shuffle_tests {
         BALLISTA_SHUFFLE_READER_MAX_BLOCKS_PER_ADDRESS,
         BALLISTA_SHUFFLE_READER_MAX_BYTES_IN_FLIGHT,
         BALLISTA_SHUFFLE_READER_REMOTE_PREFER_FLIGHT,
-        BALLISTA_SHUFFLE_SORT_BASED_ENABLED,
     };
     use datafusion::arrow::util::pretty::pretty_format_batches;
     use datafusion::common::Result;
     use datafusion::execution::SessionStateBuilder;
     use datafusion::prelude::{ParquetReadOptions, SessionConfig, 
SessionContext};
     use rstest::rstest;
-    use std::collections::HashSet;
 
     /// Read mode for shuffle data
     #[derive(Debug, Clone, Copy)]
@@ -69,7 +67,6 @@ mod sort_shuffle_tests {
         aqe_enabled: bool,
     ) -> SessionContext {
         let mut config = SessionConfig::new_with_ballista()
-            .set_str(BALLISTA_SHUFFLE_SORT_BASED_ENABLED, "true")
             .set_bool(BALLISTA_ADAPTIVE_PLANNER_ENABLED, aqe_enabled);
 
         // Configure read mode
@@ -103,7 +100,6 @@ mod sort_shuffle_tests {
     /// complete and return correct results.
     async fn create_tiny_budget_remote_context() -> SessionContext {
         let config = SessionConfig::new_with_ballista()
-            .set_str(BALLISTA_SHUFFLE_SORT_BASED_ENABLED, "true")
             .set_str(BALLISTA_SHUFFLE_READER_FORCE_REMOTE_READ, "true")
             .set_str(BALLISTA_SHUFFLE_READER_REMOTE_PREFER_FLIGHT, "true")
             .set_str(BALLISTA_SHUFFLE_READER_MAX_BYTES_IN_FLIGHT, "65536")
@@ -117,17 +113,6 @@ mod sort_shuffle_tests {
         SessionContext::standalone_with_state(state).await.unwrap()
     }
 
-    /// Creates a standalone session context with hash-based shuffle.
-    async fn create_hash_shuffle_context() -> SessionContext {
-        let config = SessionConfig::new_with_ballista()
-            .set_str(BALLISTA_SHUFFLE_SORT_BASED_ENABLED, "false");
-        let state = SessionStateBuilder::new()
-            .with_config(config)
-            .with_default_features()
-            .build();
-        SessionContext::standalone_with_state(state).await.unwrap()
-    }
-
     /// Registers test data in the context.
     async fn register_test_data(ctx: &SessionContext) {
         ctx.register_parquet(
@@ -154,21 +139,6 @@ mod sort_shuffle_tests {
         );
     }
 
-    /// Extracts values from a result set, ignoring order.
-    fn extract_values_unordered(
-        results: &[datafusion::arrow::record_batch::RecordBatch],
-    ) -> HashSet<String> {
-        pretty_format_batches(results)
-            .unwrap()
-            .to_string()
-            .trim()
-            .lines()
-            .skip(3) // Skip header lines
-            .filter(|line| !line.starts_with('+'))
-            .map(|s| s.to_string())
-            .collect()
-    }
-
     // ==================== Basic Aggregation Tests ====================
 
     #[rstest]
@@ -327,62 +297,6 @@ mod sort_shuffle_tests {
         Ok(())
     }
 
-    // ==================== Comparison with Hash Shuffle ====================
-
-    #[tokio::test]
-    async fn test_sort_vs_hash_shuffle_group_by() -> Result<()> {
-        // Test with sort shuffle (local read is sufficient for comparison)
-        let sort_ctx = create_sort_shuffle_context(ReadMode::Local).await;
-        register_test_data(&sort_ctx).await;
-        let sort_results = sort_ctx
-            .sql("SELECT bool_col, SUM(id) as total FROM test GROUP BY 
bool_col")
-            .await?
-            .collect()
-            .await?;
-
-        // Test with hash shuffle
-        let hash_ctx = create_hash_shuffle_context().await;
-        register_test_data(&hash_ctx).await;
-        let hash_results = hash_ctx
-            .sql("SELECT bool_col, SUM(id) as total FROM test GROUP BY 
bool_col")
-            .await?
-            .collect()
-            .await?;
-
-        // Results should be equivalent (order may differ)
-        let sort_values = extract_values_unordered(&sort_results);
-        let hash_values = extract_values_unordered(&hash_results);
-        assert_eq!(sort_values, hash_values);
-        Ok(())
-    }
-
-    #[tokio::test]
-    async fn test_sort_vs_hash_shuffle_distinct() -> Result<()> {
-        // Test with sort shuffle (local read is sufficient for comparison)
-        let sort_ctx = create_sort_shuffle_context(ReadMode::Local).await;
-        register_test_data(&sort_ctx).await;
-        let sort_results = sort_ctx
-            .sql("SELECT DISTINCT bool_col FROM test")
-            .await?
-            .collect()
-            .await?;
-
-        // Test with hash shuffle
-        let hash_ctx = create_hash_shuffle_context().await;
-        register_test_data(&hash_ctx).await;
-        let hash_results = hash_ctx
-            .sql("SELECT DISTINCT bool_col FROM test")
-            .await?
-            .collect()
-            .await?;
-
-        // Results should be equivalent
-        let sort_values = extract_values_unordered(&sort_results);
-        let hash_values = extract_values_unordered(&hash_results);
-        assert_eq!(sort_values, hash_values);
-        Ok(())
-    }
-
     // ==================== Edge Cases ====================
 
     #[rstest]
diff --git a/ballista/core/src/config.rs b/ballista/core/src/config.rs
index 1ec5cadbd..10f64aaaa 100644
--- a/ballista/core/src/config.rs
+++ b/ballista/core/src/config.rs
@@ -84,9 +84,6 @@ pub const BALLISTA_CLIENT_IO_RETRY_WAIT_TIME_MS: &str =
     "ballista.client.io_retry_wait_time_ms";
 /// Enables adaptive query planning
 pub const BALLISTA_ADAPTIVE_PLANNER_ENABLED: &str = 
"ballista.planner.adaptive.enabled";
-/// Configuration key for enabling sort-based shuffle.
-pub const BALLISTA_SHUFFLE_SORT_BASED_ENABLED: &str =
-    "ballista.shuffle.sort_based.enabled";
 /// Configuration key for sort shuffle target batch size in rows.
 pub const BALLISTA_SHUFFLE_SORT_BASED_BATCH_SIZE: &str =
     "ballista.shuffle.sort_based.batch_size";
@@ -233,10 +230,6 @@ static CONFIG_ENTRIES: LazyLock<HashMap<String, 
ConfigEntry>> = LazyLock::new(||
                          "Enables Adaptive Query Planning 
(EXPERIMENTAL)".to_string(),
                          DataType::Boolean,
                          Some(false.to_string())),
-        ConfigEntry::new(BALLISTA_SHUFFLE_SORT_BASED_ENABLED.to_string(),
-                         "Enable sort-based shuffle which writes consolidated 
files with index".to_string(),
-                         DataType::Boolean,
-                         Some(true.to_string())),
         ConfigEntry::new(BALLISTA_SHUFFLE_SORT_BASED_BATCH_SIZE.to_string(),
                          "Target batch size in rows for coalescing small 
batches in sort shuffle".to_string(),
                          DataType::UInt64,
@@ -589,14 +582,6 @@ impl BallistaConfig {
         self.get_bool_setting(BALLISTA_ADAPTIVE_PLANNER_ENABLED)
     }
 
-    /// Returns whether sort-based shuffle is enabled.
-    ///
-    /// When enabled, shuffle writes produce a single consolidated file per 
input
-    /// partition with an index file, rather than one file per output 
partition.
-    pub fn shuffle_sort_based_enabled(&self) -> bool {
-        self.get_bool_setting(BALLISTA_SHUFFLE_SORT_BASED_ENABLED)
-    }
-
     /// Returns the target batch size for sort-based shuffle.
     pub fn shuffle_sort_based_batch_size(&self) -> usize {
         self.get_usize_setting(BALLISTA_SHUFFLE_SORT_BASED_BATCH_SIZE)
diff --git a/ballista/core/src/execution_plans/shuffle_reader.rs 
b/ballista/core/src/execution_plans/shuffle_reader.rs
index c4d7b511b..d85f1978f 100644
--- a/ballista/core/src/execution_plans/shuffle_reader.rs
+++ b/ballista/core/src/execution_plans/shuffle_reader.rs
@@ -772,7 +772,6 @@ fn send_fetch_partitions(
     let max_blocks_per_addr =
         ballista_config.shuffle_reader_max_blocks_in_flight_per_address();
     let default_block_size = 
ballista_config.shuffle_reader_default_block_size_bytes();
-    let sort_shuffle_enabled = config.ballista_sort_shuffle_enabled();
 
     let (response_sender, response_receiver) = mpsc::channel(max_reqs.max(1));
 
@@ -816,7 +815,7 @@ fn send_fetch_partitions(
             for p in local_locations {
                 let r = {
                     let _timer = local_read_time.timer();
-                    fetch_partition_local(&work_dir, &p, sort_shuffle_enabled)
+                    fetch_partition_local(&work_dir, &p)
                 };
                 if let Err(e) = response_sender_c.blocking_send(r) {
                     error!("Fail to send response event to the channel due to 
{e}");
@@ -1112,7 +1111,6 @@ async fn fetch_partition_remote(
 fn fetch_partition_local(
     work_dir: &str,
     location: &PartitionLocation,
-    sort_shuffle_enabled: bool,
 ) -> result::Result<SendableRecordBatchStream, BallistaError> {
     let path = &location.path(work_dir)?;
     let metadata = &location.executor_meta;
@@ -1123,10 +1121,10 @@ fn fetch_partition_local(
     //       replace this check with open, and check for error
     //
     // Check if this is a sort-based shuffle output (has index file)
-    if sort_shuffle_enabled && is_sort_shuffle_output(data_path) {
-        // note: in some cases sort shuffle is not going to be used
-        //       even its enabled. thus we need to check if there is
-        //       sort shuffle file index
+    if is_sort_shuffle_output(data_path) {
+        // A stage's on-disk layout is authoritative: sort-shuffle outputs 
have a
+        // companion index file. Standard single-partition outputs do not, so a
+        // missing index means this is a plain Arrow IPC file.
         debug!(
             "Reading sort-based shuffle for partition {} from {:?}",
             partition_id.partition_id, data_path
@@ -1147,7 +1145,7 @@ fn fetch_partition_local(
         });
     }
     debug!("fetch local partition file: {data_path:?} ");
-    // Standard hash-based shuffle - read the file directly
+    // Standard single-file shuffle output - read the file directly
     let reader = fetch_partition_local_inner(path).map_err(|e| {
         // return BallistaError::FetchFailed may let scheduler retry this task.
         BallistaError::FetchFailed(
@@ -1290,7 +1288,6 @@ mod tests {
     use datafusion::common::DataFusionError;
     use datafusion::datasource::memory::MemorySourceConfig;
     use datafusion::datasource::source::DataSourceExec;
-    use datafusion::physical_expr::expressions::Column;
     use datafusion::physical_plan::common;
     use datafusion::prelude::SessionContext;
     use tempfile::{TempDir, tempdir};
@@ -1744,7 +1741,6 @@ mod tests {
             1,
             create_test_data_plan().unwrap(),
             work_dir.path().to_str().unwrap().to_owned(),
-            Some(Partitioning::Hash(vec![Arc::new(Column::new("a", 0))], 1)),
         )
         .unwrap();
 
@@ -1772,10 +1768,9 @@ mod tests {
             .map_err(|e| DataFusionError::Execution(format!("{e:?}")))
             .unwrap();
 
-        // Writer's K=1 hash routes every row to partition 0. Its coordinator
-        // reads its full input slice (2 partitions × 2 batches = 4 batches),
-        // all routed to the single output file.
-        assert_eq!(result.len(), 4);
+        // With single-partition (None) output, executing input partition 0
+        // writes just that partition's 2 batches to a single output file.
+        assert_eq!(result.len(), 2);
         for b in result {
             assert_eq!(b, create_test_batch())
         }
diff --git a/ballista/core/src/execution_plans/shuffle_writer.rs 
b/ballista/core/src/execution_plans/shuffle_writer.rs
index 56d378740..0742dbf21 100644
--- a/ballista/core/src/execution_plans/shuffle_writer.rs
+++ b/ballista/core/src/execution_plans/shuffle_writer.rs
@@ -20,14 +20,9 @@
 //! partition is re-partitioned and streamed to disk in Arrow IPC format. 
Future stages of the query
 //! will use the ShuffleReaderExec to read these results.
 
-use datafusion::arrow::ipc::writer::StreamWriter;
 use std::fmt::Debug;
-use std::fs;
-use std::fs::File;
 use std::future::Future;
-use std::io::BufWriter;
 use std::iter::Iterator;
-use std::path::PathBuf;
 use std::sync::Arc;
 use std::time::Instant;
 
@@ -39,7 +34,6 @@ use crate::utils;
 
 use crate::serde::protobuf::ShuffleWritePartition;
 use crate::serde::scheduler::PartitionStats;
-use crate::utils::create_write_options;
 use datafusion::arrow::array::{
     ArrayBuilder, ArrayRef, StringBuilder, StructBuilder, UInt32Builder, 
UInt64Builder,
 };
@@ -57,14 +51,14 @@ use datafusion::physical_plan::{
     DisplayAs, DisplayFormatType, ExecutionPlan, Partitioning, PlanProperties,
     SendableRecordBatchStream, Statistics,
 };
-use futures::{StreamExt, TryStreamExt};
+use futures::TryStreamExt;
 
 use datafusion::arrow::error::ArrowError;
 use datafusion::execution::context::TaskContext;
-use datafusion::physical_plan::repartition::{BatchPartitioner, 
RepartitionExec};
+use datafusion::physical_plan::repartition::RepartitionExec;
 use 
datafusion::physical_plan::sorts::sort_preserving_merge::SortPreservingMergeExec;
 use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
-use log::{debug, error};
+use log::debug;
 use std::sync::Mutex;
 use tokio::sync::oneshot;
 
@@ -148,12 +142,11 @@ fn walk_child_partition_mapping(
 /// the wire. The executor-side writer then uses these ids directly rather
 /// than re-walking the plan.
 ///
-/// Three cases:
+/// Two cases:
 ///
 /// - `SortShuffleWriter(Hash(K))` — HashSpace by construction; the K-space
 ///   `[0..K-1]` is intrinsic. Input ids are irrelevant.
-/// - `ShuffleWriter(Hash(K)|RoundRobin(K))` — same K-space semantics.
-/// - `ShuffleWriter(None)` — passthrough; the child's plan shape decides:
+/// - `ShuffleWriter` — always passthrough; the child's plan shape decides:
 ///   - `SortPreservingMergeExec` in the child chain → `[0]` (collapse).
 ///   - `RepartitionExec(Hash|RoundRobin)` in the child chain → `[0..K-1]`.
 ///   - Otherwise (leaf-preserved partitioning) → the input ids as-is.
@@ -171,36 +164,19 @@ pub fn compute_global_output_partition_ids(
         };
         return (0..*k).collect();
     }
-    if let Some(w) = stage_plan.downcast_ref::<ShuffleWriterExec>() {
-        match w.shuffle_output_partitioning() {
-            Some(Partitioning::Hash(_, k)) | 
Some(Partitioning::RoundRobinBatch(k)) => {
-                return (0..*k).collect();
-            }
-            None => {
-                let children = stage_plan.children();
-                let [child] = children.as_slice() else {
-                    unreachable!("ShuffleWriterExec always has exactly one 
child");
-                };
-                return match walk_child_partition_mapping(
-                    child,
-                    global_input_partition_ids,
-                ) {
-                    GlobalPartitionMap::Collapsed => vec![0],
-                    GlobalPartitionMap::HashSpace => {
-                        let k =
-                            
child.properties().output_partitioning().partition_count();
-                        (0..k).collect()
-                    }
-                    GlobalPartitionMap::PassThrough(ids) => ids,
-                };
-            }
-            Some(Partitioning::UnknownPartitioning(_)) => {
-                panic!(
-                    "ShuffleWriterExec::UnknownPartitioning has no meaningful 
output-id \
-                     space; upstream planner should never emit this shape"
-                );
+    if stage_plan.downcast_ref::<ShuffleWriterExec>().is_some() {
+        let children = stage_plan.children();
+        let [child] = children.as_slice() else {
+            unreachable!("ShuffleWriterExec always has exactly one child");
+        };
+        return match walk_child_partition_mapping(child, 
global_input_partition_ids) {
+            GlobalPartitionMap::Collapsed => vec![0],
+            GlobalPartitionMap::HashSpace => {
+                let k = 
child.properties().output_partitioning().partition_count();
+                (0..k).collect()
             }
-        }
+            GlobalPartitionMap::PassThrough(ids) => ids,
+        };
     }
     global_input_partition_ids.to_vec()
 }
@@ -240,13 +216,13 @@ impl Debug for WriterState {
 /// partition is re-partitioned and streamed to disk in Arrow IPC format. 
Future stages of the query
 /// will use the ShuffleReaderExec to read these results.
 ///
-/// # Threading (passthrough / `None` branch)
+/// # Threading
 ///
 /// One Ballista task holds one `ShuffleWriterExec`. The first `execute(k)`
 /// call from the executor initializes K oneshot handoffs and spawns
-/// `run_coordinator`, which invokes `execute_shuffle_write`. In the
-/// passthrough branch (`shuffle_output_partitioning == None`, i.e. the
-/// child already has the target K partitions), `execute_shuffle_write`
+/// `run_coordinator`, which invokes `execute_shuffle_write`. The writer only
+/// ever passes its child's partitioning through (the child already has the
+/// target K partitions), so `execute_shuffle_write`
 /// spawns K concurrent tokio tasks — one per output partition — each
 /// pulling `child.execute(k)` and streaming directly to
 /// `data-{task_id}.arrow` for partition k. Each drain emits one summary;
@@ -263,8 +239,8 @@ impl Debug for WriterState {
 /// deadlock the scatter side.
 ///
 /// Data flows bottom → top (child produces, executor consumes), matching
-/// DataFusion's convention. Under passthrough M = K (child preserves
-/// partition count). Example: K=3.
+/// DataFusion's convention. M = K (the child preserves partition count).
+/// Example: K=3.
 ///
 /// ```text
 ///                     K=3 output partitions (pulled by executor)
@@ -315,18 +291,16 @@ impl Debug for WriterState {
 /// what differs, and downstream `ShuffleReaderExec` uses the summaries to
 /// open whichever set of files is right.
 ///
-/// The `Hash` branch (`shuffle_output_partitioning == Some(Hash)`) is a
-/// legacy shape kept for correctness while the planner transitions to
-/// `RepartitionExec(Hash) → ShuffleWriterExec(None)`; not diagrammed here.
+/// This writer never repartitions: hash-repartition stages use
+/// [`SortShuffleWriterExec`] instead, so the only scheme left here is
+/// passthrough and the writer carries no output-partitioning of its own.
 ///
 /// The coordinator + oneshot plumbing is a shared idiom with
-/// [`SortShuffleWriterExec`] and
-/// with the `Hash` branch; passthrough alone doesn't structurally need it —
-/// only the K concurrent drains, which are the real deadlock guard. Once
-/// `Hash` is retired in favor of DataFusion's `RepartitionExec(Hash)`, this
-/// branch could collapse to per-`execute(k)` eager K-spawn with
-/// `JoinHandle` handoff, keeping the coordinator idiom only where it does
-/// real work (SortShuffle's M×K summary re-bucketing).
+/// [`SortShuffleWriterExec`]; passthrough alone doesn't structurally need
+/// it — only the K concurrent drains, which are the real deadlock guard.
+/// This could collapse to per-`execute(k)` eager K-spawn with `JoinHandle`
+/// handoff, keeping the coordinator idiom only where it does real work
+/// (SortShuffle's M×K summary re-bucketing).
 #[derive(Debug)]
 pub struct ShuffleWriterExec {
     /// Unique ID for the job (query) that this stage is a part of
@@ -337,9 +311,6 @@ pub struct ShuffleWriterExec {
     plan: Arc<dyn ExecutionPlan>,
     /// Path to write output streams to
     work_dir: String,
-    /// Optional shuffle output partitioning.
-    /// If it's none, it means there's no need to do repartitioning.
-    shuffle_output_partitioning: Option<Partitioning>,
     /// Task id (the task's append-order slot in `RunningStage.task_infos`)
     /// used as `file_id` in shuffle paths so files from different tasks
     /// (including retries) don't collide. Seeded by the executor's
@@ -351,11 +322,10 @@ pub struct ShuffleWriterExec {
     /// `global_output_partition_ids[i]` globally.
     ///
     /// Consumed by the passthrough (`None`) branch when the plan is a straight
-    /// pass-through of the slice. The `Hash` branch ignores it — the hash
-    /// K-space is its own global identity — and even for `None`, if the child
-    /// plan contains a partitioning-resetting operator (SPM → 1 output,
-    /// RepartitionExec::Hash → 0..K) the writer detects that at path-build
-    /// time and uses `local` directly instead of 
`global_output_partition_ids[local]`.
+    /// pass-through of the slice. If the child plan contains a
+    /// partitioning-resetting operator (SPM → 1 output, RepartitionExec::Hash
+    /// → 0..K) the writer detects that at path-build time and uses `local`
+    /// directly instead of `global_output_partition_ids[local]`.
     global_output_partition_ids: Vec<usize>,
     /// Execution metrics
     metrics: ExecutionPlanMetricsSet,
@@ -374,7 +344,6 @@ impl Clone for ShuffleWriterExec {
             stage_id: self.stage_id,
             plan: self.plan.clone(),
             work_dir: self.work_dir.clone(),
-            shuffle_output_partitioning: 
self.shuffle_output_partitioning.clone(),
             task_id: self.task_id,
             global_output_partition_ids: 
self.global_output_partition_ids.clone(),
             metrics: self.metrics.clone(),
@@ -391,28 +360,16 @@ impl std::fmt::Display for ShuffleWriterExec {
             .indent(false);
         write!(
             f,
-            "ShuffleWriterExec: job={} stage={} work_dir={} partitioning={:?} 
plan: \n {}",
-            self.job_id,
-            self.stage_id,
-            self.work_dir,
-            self.shuffle_output_partitioning,
-            printable_plan
+            "ShuffleWriterExec: job={} stage={} work_dir={} plan: \n {}",
+            self.job_id, self.stage_id, self.work_dir, printable_plan
         )
     }
 }
 
-pub struct WriteTracker {
-    pub num_batches: usize,
-    pub num_rows: usize,
-    pub writer: StreamWriter<BufWriter<File>>,
-    pub path: PathBuf,
-}
-
 #[derive(Debug, Clone)]
 struct ShuffleWriteMetrics {
     /// Time spend writing batches to shuffle files
     write_time: metrics::Time,
-    repart_time: metrics::Time,
     input_rows: metrics::Count,
     output_rows: metrics::Count,
 }
@@ -428,8 +385,6 @@ impl ShuffleWriteMetrics {
     fn new(input_partition: usize, metrics: &ExecutionPlanMetricsSet) -> Self {
         let write_time =
             MetricBuilder::new(metrics).subset_time("write_time", 
input_partition);
-        let repart_time =
-            MetricBuilder::new(metrics).subset_time("repart_time", 
input_partition);
 
         let input_rows =
             MetricBuilder::new(metrics).counter("input_rows", input_partition);
@@ -438,7 +393,6 @@ impl ShuffleWriteMetrics {
 
         Self {
             write_time,
-            repart_time,
             input_rows,
             output_rows,
         }
@@ -454,13 +408,10 @@ impl ShuffleWriterExec {
         stage_id: usize,
         plan: Arc<dyn ExecutionPlan>,
         work_dir: String,
-        shuffle_output_partitioning: Option<Partitioning>,
     ) -> Result<Self> {
-        // If [`shuffle_output_partitioning`] is none, then there's no need to 
do repartitioning.
-        // Therefore, the partition is the same as its input plan's.
-        let partitioning = shuffle_output_partitioning
-            .clone()
-            .unwrap_or_else(|| 
plan.properties().output_partitioning().clone());
+        // This writer never repartitions, so its output partitioning is
+        // exactly its input plan's.
+        let partitioning = plan.properties().output_partitioning().clone();
         let output_partition_count = partitioning.partition_count();
         let properties = Arc::new(PlanProperties::new(
             
datafusion::physical_expr::EquivalenceProperties::new(plan.schema()),
@@ -473,15 +424,12 @@ impl ShuffleWriterExec {
         // unit tests that don't set a slice. Once every construction path
         // calls `with_global_output_partition_ids`, drop the default and fold 
it into
         // `try_new`'s signature.
-        let child_partition_count =
-            plan.properties().output_partitioning().partition_count();
-        let default_partition_slice: Vec<usize> = 
(0..child_partition_count).collect();
+        let default_partition_slice: Vec<usize> = 
(0..output_partition_count).collect();
         Ok(Self {
             job_id,
             stage_id,
             plan,
             work_dir,
-            shuffle_output_partitioning,
             task_id: 0,
             global_output_partition_ids: default_partition_slice,
             metrics: ExecutionPlanMetricsSet::new(),
@@ -539,28 +487,20 @@ impl ShuffleWriterExec {
             .partition_count()
     }
 
-    /// Get the true output partitioning
-    pub fn shuffle_output_partitioning(&self) -> Option<&Partitioning> {
-        self.shuffle_output_partitioning.as_ref()
-    }
-
     /// Executes the shuffle write operation for this task.
     ///
     /// Returns `(handoff_idx, summary)` pairs. `handoff_idx` indexes into the
     /// coordinator's per-output-partition oneshot slots (0..K in this
     /// operator's output_partitioning). `summary.partition_id` is the
-    /// **global** output partition id downstream will address:
-    ///
-    /// - Passthrough (`None`): computed via `walk_child_partition_mapping`
-    ///   over the child plan — either `global_output_partition_ids[local]`, 
`local` (hash
-    ///   K-space), or `0` (collapsed / SPM).
-    /// - Hash: hash bucket space is global; global = local.
+    /// **global** output partition id downstream will address, computed via
+    /// `walk_child_partition_mapping` over the child plan — either
+    /// `global_output_partition_ids[local]`, `local` (hash K-space), or `0`
+    /// (collapsed / SPM).
     pub fn execute_shuffle_write(
         self,
         context: Arc<TaskContext>,
     ) -> impl Future<Output = Result<Vec<(usize, ShuffleWritePartition)>>> {
         let task_id = self.task_id;
-        let output_partitioning = self.shuffle_output_partitioning.clone();
         let plan = self.plan.clone();
         let partition_map =
             walk_child_partition_mapping(&plan, 
&self.global_output_partition_ids);
@@ -572,272 +512,83 @@ impl ShuffleWriterExec {
             let compression_type = config.shuffle_compression_codec()?;
             let channel_capacity = config.shuffle_writer_channel_capacity();
 
-            match output_partitioning {
-                None => {
-                    // Passthrough shuffle: drain each of the child's output
-                    // partitions into its own file. All K must drain
-                    // CONCURRENTLY, not sequentially — coordinating operators
-                    // below (DynamicRangeRepartitionExec) push to all K
-                    // senders from shared scatter tasks; draining one to EOF
-                    // before the next starts fills up the undrained channel
-                    // and deadlocks the scatter side.
-                    let num_partitions =
-                        
plan.properties().output_partitioning().partition_count();
-                    let mut handles = Vec::with_capacity(num_partitions);
-                    for local_input_partition in 0..num_partitions {
-                        // Each drain owns its own metric bucket, keyed by the
-                        // operator-local input partition it drains. 
Passthrough
-                        // is 1:1 so local input == local output here.
-                        let write_metrics =
-                            ShuffleWriteMetrics::new(local_input_partition, 
&metrics);
-                        let global_partition =
-                            partition_map.resolve(local_input_partition) as 
usize;
-                        let path = create_shuffle_path(
-                            &self.work_dir,
-                            &self.job_id,
-                            self.stage_id,
-                            global_partition,
-                            Some(task_id as u64),
-                            false,
-                        )?;
-
-                        if let Some(parent) = path.parent() {
-                            std::fs::create_dir_all(parent)?;
-                        }
-
-                        debug!("Writing results to {path:?}");
-
-                        let mut stream =
-                            plan.execute(local_input_partition, 
context.clone())?;
-                        handles.push(tokio::spawn(async move {
-                            let stats = utils::write_stream_to_disk(
-                                &mut stream,
-                                path.as_path(),
-                                &write_metrics.write_time,
-                                channel_capacity,
-                                compression_type,
-                            )
-                            .await
-                            .map_err(|e| 
DataFusionError::Execution(format!("{e:?}")))?;
-                            let rows = stats.num_rows.unwrap_or(0) as usize;
-                            write_metrics.input_rows.add(rows);
-                            write_metrics.output_rows.add(rows);
-                            Ok::<_, DataFusionError>((local_input_partition, 
stats))
-                        }));
-                    }
-
-                    let mut results = Vec::with_capacity(num_partitions);
-                    for handle in handles {
-                        let (local_input_partition, stats) =
-                            handle.await.map_err(|e| {
-                                DataFusionError::Execution(format!(
-                                    "shuffle-write drain task panicked: {e}"
-                                ))
-                            })??;
-                        results.push((
-                            local_input_partition,
-                            ShuffleWritePartition {
-                                partition_id: partition_map
-                                    .resolve(local_input_partition),
-                                num_batches: stats.num_batches.unwrap_or(0),
-                                num_rows: stats.num_rows.unwrap_or(0),
-                                num_bytes: stats.num_bytes.unwrap_or(0),
-                                file_id: Some(task_id as u64),
-                                is_sort_shuffle: false,
-                            },
-                        ));
-                    }
-                    debug!(
-                        "task_id {} drained {} partitions in {}s",
-                        task_id,
-                        num_partitions,
-                        now.elapsed().as_secs()
-                    );
-                    Ok(results)
+            // Passthrough shuffle: drain each of the child's output
+            // partitions into its own file. All K must drain
+            // CONCURRENTLY, not sequentially — coordinating operators
+            // below (DynamicRangeRepartitionExec) push to all K
+            // senders from shared scatter tasks; draining one to EOF
+            // before the next starts fills up the undrained channel
+            // and deadlocks the scatter side.
+            let num_partitions =
+                plan.properties().output_partitioning().partition_count();
+            let mut handles = Vec::with_capacity(num_partitions);
+            for local_input_partition in 0..num_partitions {
+                // Each drain owns its own metric bucket, keyed by the
+                // operator-local input partition it drains. Passthrough
+                // is 1:1 so local input == local output here.
+                let write_metrics =
+                    ShuffleWriteMetrics::new(local_input_partition, &metrics);
+                let global_partition =
+                    partition_map.resolve(local_input_partition) as usize;
+                let path = create_shuffle_path(
+                    &self.work_dir,
+                    &self.job_id,
+                    self.stage_id,
+                    global_partition,
+                    Some(task_id as u64),
+                    false,
+                )?;
+
+                if let Some(parent) = path.parent() {
+                    std::fs::create_dir_all(parent)?;
                 }
-                // TODO: just use Datafusion's hash partition operator and 
remove this branch
-                Some(Partitioning::Hash(exprs, num_output_partitions)) => {
-                    // Task drains ALL of the child's (already 
slice-restricted)
-                    // input partitions and routes rows into K writers by hash.
-                    // file_id = task_id so files from different tasks
-                    // (including retries) don't collide. Hash buckets 0..K are
-                    // a global K-space and aren't further sliced.
-                    //
-                    // One metric bucket per operator-local input partition,
-                    // matching the pre-K-drain model (before: N tasks × 1
-                    // bucket; now: 1 task × N buckets). One `BatchPartitioner`
-                    // per input partition so `repart_time` disaggregates the
-                    // same way — total partitioner count across the stage is
-                    // unchanged.
-                    let num_input_partitions =
-                        
plan.properties().output_partitioning().partition_count();
-                    let write_metrics_per_input: Vec<ShuffleWriteMetrics> = (0
-                        ..num_input_partitions)
-                        .map(|i| ShuffleWriteMetrics::new(i, &metrics))
-                        .collect();
-                    let schema = plan.schema();
-                    let (tx, mut rx) = tokio::sync::mpsc::channel::<(usize, 
RecordBatch)>(
+
+                debug!("Writing results to {path:?}");
+
+                let mut stream = plan.execute(local_input_partition, 
context.clone())?;
+                handles.push(tokio::spawn(async move {
+                    let stats = utils::write_stream_to_disk(
+                        &mut stream,
+                        path.as_path(),
+                        &write_metrics.write_time,
                         channel_capacity,
-                    );
-                    let write_times: Vec<metrics::Time> = 
write_metrics_per_input
-                        .iter()
-                        .map(|m| m.write_time.clone())
-                        .collect();
-                    let output_rows_per_input: Vec<metrics::Count> =
-                        write_metrics_per_input
-                            .iter()
-                            .map(|m| m.output_rows.clone())
-                            .collect();
-                    let partitioners: Vec<BatchPartitioner> = 
write_metrics_per_input
-                        .iter()
-                        .map(|m| {
-                            BatchPartitioner::new_hash_partitioner(
-                                exprs.clone(),
-                                num_output_partitions,
-                                m.repart_time.clone(),
-                            )
-                        })
-                        .collect::<Result<Vec<_>, _>>()?;
-                    let work_dir = self.work_dir.clone();
-                    let job_id = self.job_id.clone();
-                    let stage_id = self.stage_id;
-
-                    let handle = tokio::task::spawn_blocking(move || {
-                        let mut writers: Vec<Option<WriteTracker>> =
-                            (0..num_output_partitions).map(|_| None).collect();
-                        let mut partitioners = partitioners;
-
-                        while let Some((local_input_partition, input_batch)) =
-                            rx.blocking_recv()
-                        {
-                            let write_time = 
&write_times[local_input_partition];
-                            let output_rows =
-                                &output_rows_per_input[local_input_partition];
-                            partitioners[local_input_partition].partition(
-                                input_batch,
-                                |output_partition, output_batch| {
-                                    let timer = write_time.timer();
-                                    match &mut writers[output_partition] {
-                                        Some(w) => {
-                                            w.num_batches += 1;
-                                            w.num_rows += 
output_batch.num_rows();
-                                            w.writer.write(&output_batch)?;
-                                        }
-                                        None => {
-                                            let p = create_shuffle_path(
-                                                &work_dir,
-                                                &job_id,
-                                                stage_id,
-                                                output_partition,
-                                                Some(task_id as u64),
-                                                false,
-                                            )?;
-
-                                            if let Some(parent) = p.parent() {
-                                                
std::fs::create_dir_all(parent)?;
-                                            }
-
-                                            debug!("Writing results to {p:?}");
-
-                                            let options =
-                                                
create_write_options(compression_type)?;
-
-                                            let file =
-                                                
BufWriter::new(File::create(p.clone())?);
-                                            let mut writer =
-                                                
StreamWriter::try_new_with_options(
-                                                    file,
-                                                    schema.as_ref(),
-                                                    options,
-                                                )?;
-                                            writer.write(&output_batch)?;
-                                            writers[output_partition] =
-                                                Some(WriteTracker {
-                                                    num_batches: 1,
-                                                    num_rows: 
output_batch.num_rows(),
-                                                    writer,
-                                                    path: p,
-                                                });
-                                        }
-                                    }
-                                    output_rows.add(output_batch.num_rows());
-                                    timer.done();
-                                    Ok(())
-                                },
-                            )?;
-                        }
-
-                        let mut part_locs = vec![];
-                        for (i, w) in writers.iter_mut().enumerate() {
-                            if let Some(w) = w {
-                                w.writer.finish()?;
-                                let num_bytes = fs::metadata(&w.path)?.len();
-                                debug!(
-                                    "Finished writing shuffle partition {} at 
{:?}. Batches: {}. Rows: {}. Bytes: {}.",
-                                    i, w.path, w.num_batches, w.num_rows, 
num_bytes
-                                );
-                                part_locs.push((
-                                    i,
-                                    ShuffleWritePartition {
-                                        partition_id: i as u64,
-                                        num_batches: w.num_batches as u64,
-                                        num_rows: w.num_rows as u64,
-                                        num_bytes,
-                                        file_id: Some(task_id as u64),
-                                        is_sort_shuffle: false,
-                                    },
-                                ));
-                            }
-                        }
-                        Ok(part_locs)
-                    });
-
-                    let mut stream_err = None;
-                    'outer: for (local_input_partition, per_input_metrics) in
-                        write_metrics_per_input.iter().enumerate()
-                    {
-                        let mut stream =
-                            plan.execute(local_input_partition, 
context.clone())?;
-                        loop {
-                            match stream.next().await {
-                                Some(Ok(batch)) => {
-                                    
per_input_metrics.input_rows.add(batch.num_rows());
-                                    if tx
-                                        .send((local_input_partition, batch))
-                                        .await
-                                        .is_err()
-                                    {
-                                        break 'outer;
-                                    }
-                                }
-                                Some(Err(e)) => {
-                                    stream_err = Some(e);
-                                    break 'outer;
-                                }
-                                None => break,
-                            }
-                        }
-                    }
-                    drop(tx);
-
-                    let write_result = handle.await.map_err(|e| {
-                        DataFusionError::Execution(format!(
-                            "Shuffle writer task failed: {e}"
-                        ))
-                    })?;
-                    if let Some(e) = stream_err {
-                        if let Err(write_err) = &write_result {
-                            error!("Shuffle writer also failed: {write_err}");
-                        }
-                        return Err(e);
-                    }
-                    write_result
-                }
+                        compression_type,
+                    )
+                    .await
+                    .map_err(|e| 
DataFusionError::Execution(format!("{e:?}")))?;
+                    let rows = stats.num_rows.unwrap_or(0) as usize;
+                    write_metrics.input_rows.add(rows);
+                    write_metrics.output_rows.add(rows);
+                    Ok::<_, DataFusionError>((local_input_partition, stats))
+                }));
+            }
 
-                _ => Err(DataFusionError::Execution(
-                    "Invalid shuffle partitioning scheme".to_owned(),
-                )),
+            let mut results = Vec::with_capacity(num_partitions);
+            for handle in handles {
+                let (local_input_partition, stats) = handle.await.map_err(|e| {
+                    DataFusionError::Execution(format!(
+                        "shuffle-write drain task panicked: {e}"
+                    ))
+                })??;
+                results.push((
+                    local_input_partition,
+                    ShuffleWritePartition {
+                        partition_id: 
partition_map.resolve(local_input_partition),
+                        num_batches: stats.num_batches.unwrap_or(0),
+                        num_rows: stats.num_rows.unwrap_or(0),
+                        num_bytes: stats.num_bytes.unwrap_or(0),
+                        file_id: Some(task_id as u64),
+                        is_sort_shuffle: false,
+                    },
+                ));
             }
+            debug!(
+                "task_id {} drained {} partitions in {}s",
+                task_id,
+                num_partitions,
+                now.elapsed().as_secs()
+            );
+            Ok(results)
         }
     }
 }
@@ -849,25 +600,13 @@ impl DisplayAs for ShuffleWriterExec {
         f: &mut std::fmt::Formatter,
     ) -> std::fmt::Result {
         match t {
+            // "None" is retained for plan-shape stability: this writer never
+            // repartitions, so the value can only ever be None.
             DisplayFormatType::Default | DisplayFormatType::Verbose => {
-                write!(
-                    f,
-                    "ShuffleWriterExec: partitioning: {}",
-                    self.shuffle_output_partitioning
-                        .as_ref()
-                        .map(|p| p.to_string())
-                        .unwrap_or("None".to_string())
-                )
+                write!(f, "ShuffleWriterExec: partitioning: None")
             }
             DisplayFormatType::TreeRender => {
-                write!(
-                    f,
-                    "partitioning={}",
-                    self.shuffle_output_partitioning
-                        .as_ref()
-                        .map(|p| p.to_string())
-                        .unwrap_or("None".to_string())
-                )
+                write!(f, "partitioning=None")
             }
         }
     }
@@ -907,7 +646,6 @@ impl ExecutionPlan for ShuffleWriterExec {
                     self.stage_id,
                     input,
                     self.work_dir.clone(),
-                    self.shuffle_output_partitioning.clone(),
                 )?
                 .with_task_id(self.task_id)
                 .with_global_output_partition_ids(
@@ -1020,8 +758,9 @@ impl ShuffleWriter for ShuffleWriterExec {
         self.stage_id
     }
 
+    /// Always `None`: this writer preserves its input partitioning.
     fn shuffle_output_partitioning(&self) -> Option<&Partitioning> {
-        self.shuffle_output_partitioning.as_ref()
+        None
     }
 
     fn input_partition_count(&self) -> usize {
@@ -1217,25 +956,23 @@ mod tests {
     }
 
     #[tokio::test]
-    // number of rows in each partition is a function of the hash output, so 
don't test here
-    #[cfg(not(feature = "force_hash_collisions"))]
     async fn test() -> Result<()> {
         let session_ctx = SessionContext::new();
         let task_ctx = session_ctx.task_ctx();
 
-        let input_plan = 
Arc::new(CoalescePartitionsExec::new(create_input_plan()?));
+        // No output partitioning: passthrough writer, one file per one of
+        // the input plan's 2 partitions.
+        let input_plan = create_input_plan()?;
         let work_dir = TempDir::new()?;
         let query_stage = Arc::new(ShuffleWriterExec::try_new(
             JobId::new("jobOne"),
             1,
             input_plan,
             work_dir.path().to_str().unwrap().to_owned(),
-            Some(Partitioning::Hash(vec![Arc::new(Column::new("a", 0))], 2)),
         )?);
         let batches = drive_all_partitions(query_stage, task_ctx).await?;
         // K=2 output partitions -> one metadata batch per execute(N) call
-        // (drive_all_partitions collects one per K). Empty output slots
-        // now emit 0-row batches (no summary), non-empty ones emit 1-row.
+        // (drive_all_partitions collects one per K).
         assert_eq!(2, batches.len());
         for batch in &batches {
             assert_eq!(4, batch.num_columns());
@@ -1275,7 +1012,6 @@ mod tests {
     }
 
     #[tokio::test]
-    #[cfg(not(feature = "force_hash_collisions"))]
     async fn display_renders_child_operator_metrics() -> Result<()> {
         let session_ctx = SessionContext::new();
         let task_ctx = session_ctx.task_ctx();
@@ -1287,7 +1023,6 @@ mod tests {
             1,
             input_plan,
             work_dir.path().to_str().unwrap().to_owned(),
-            Some(Partitioning::Hash(vec![Arc::new(Column::new("a", 0))], 2)),
         )?;
         let mut stream = query_stage.execute(0, task_ctx)?;
         let _ = utils::collect_stream(&mut stream)
@@ -1306,45 +1041,6 @@ mod tests {
         Ok(())
     }
 
-    #[tokio::test]
-    // number of rows in each partition is a function of the hash output, so 
don't test here
-    #[cfg(not(feature = "force_hash_collisions"))]
-    async fn test_partitioned() -> Result<()> {
-        let session_ctx = SessionContext::new();
-        let task_ctx = session_ctx.task_ctx();
-
-        let input_plan = create_input_plan()?;
-        let work_dir = TempDir::new()?;
-        let query_stage = Arc::new(ShuffleWriterExec::try_new(
-            JobId::new("jobOne"),
-            1,
-            input_plan,
-            work_dir.path().to_str().unwrap().to_owned(),
-            Some(Partitioning::Hash(vec![Arc::new(Column::new("a", 0))], 2)),
-        )?);
-        let batches = drive_all_partitions(query_stage, task_ctx).await?;
-        assert_eq!(2, batches.len());
-        let total: u64 = batches
-            .iter()
-            .flat_map(|b| {
-                let stats = 
b.column(3).as_any().downcast_ref::<StructArray>().unwrap();
-                let num_rows = stats
-                    .column_by_name("num_rows")
-                    .unwrap()
-                    .as_any()
-                    .downcast_ref::<UInt64Array>()
-                    .unwrap()
-                    .clone();
-                (0..b.num_rows()).map(move |i| num_rows.value(i))
-            })
-            .sum();
-        // Row conservation across the K output partitions: writer reads its
-        // full input slice (2 partitions × 2 batches × 2 rows = 8).
-        assert_eq!(8, total);
-
-        Ok(())
-    }
-
     #[tokio::test]
     async fn test_no_repart_write_failure_propagates() -> Result<()> {
         let session_ctx = SessionContext::new();
@@ -1370,7 +1066,6 @@ mod tests {
             1,
             input_plan,
             work_dir,
-            None,
         )?);
         let result = drive_all_partitions(query_stage, task_ctx).await;
         assert!(
@@ -1381,13 +1076,13 @@ mod tests {
     }
 
     #[tokio::test]
-    async fn test_hash_repart_write_failure_propagates() -> Result<()> {
+    async fn test_create_dir_failure_propagates() -> Result<()> {
         let session_ctx = SessionContext::new();
         let task_ctx = session_ctx.task_ctx();
 
         // Place a regular file at the stage_id path component so
-        // create_dir_all fails when the writer tries to create
-        // output partition subdirectories underneath it.
+        // create_dir_all fails when the writer tries to create the
+        // output partition subdirectory underneath it.
         // Path structure: work_dir / job_id / stage_id / ...
         let tmp = tempfile::TempDir::new().unwrap();
         let job_dir = tmp.path().join("jobOne");
@@ -1402,12 +1097,11 @@ mod tests {
             1,
             input_plan,
             work_dir,
-            Some(Partitioning::Hash(vec![Arc::new(Column::new("a", 0))], 2)),
         )?);
         let result = drive_all_partitions(query_stage, task_ctx).await;
         assert!(
             result.is_err(),
-            "expected create_dir_all failure in hash writer to propagate"
+            "expected create_dir_all failure in writer to propagate"
         );
         Ok(())
     }
diff --git a/ballista/core/src/extension.rs b/ballista/core/src/extension.rs
index 28fd77e65..31a9e12af 100644
--- a/ballista/core/src/extension.rs
+++ b/ballista/core/src/extension.rs
@@ -261,9 +261,6 @@ pub trait SessionConfigExt {
     /// Get whether to use TLS for executor connections
     fn ballista_use_tls(&self) -> bool;
 
-    /// Is short shuffle used
-    fn ballista_sort_shuffle_enabled(&self) -> bool;
-
     /// Returns whether the AQE coalesce-shuffle-partitions rule is enabled.
     fn ballista_coalesce_enabled(&self) -> bool;
     /// Sets whether the AQE coalesce-shuffle-partitions rule is enabled.
@@ -537,14 +534,6 @@ impl SessionConfigExt for SessionConfig {
             })
     }
 
-    fn ballista_sort_shuffle_enabled(&self) -> bool {
-        self.options()
-            .extensions
-            .get::<BallistaConfig>()
-            .map(|c| c.shuffle_sort_based_enabled())
-            .unwrap_or_else(|| 
BallistaConfig::default().shuffle_sort_based_enabled())
-    }
-
     fn with_ballista_shuffle_reader_maximum_concurrent_requests(
         self,
         max_requests: usize,
diff --git a/ballista/core/src/serde/mod.rs b/ballista/core/src/serde/mod.rs
index a22bb977d..40df51bd4 100644
--- a/ballista/core/src/serde/mod.rs
+++ b/ballista/core/src/serde/mod.rs
@@ -389,19 +389,22 @@ impl PhysicalExtensionCodec for 
BallistaPhysicalExtensionCodec {
             PhysicalPlanType::ShuffleWriter(shuffle_writer) => {
                 let input = inputs[0].clone();
 
-                let shuffle_output_partitioning = 
parse_protobuf_hash_partitioning(
-                    shuffle_writer.output_partitioning.as_ref(),
-                    &decode_ctx,
-                    input.schema().as_ref(),
-                    &converter,
-                )?;
+                // ShuffleWriterExec never repartitions. A plan that still
+                // carries an output partitioning is a legacy hash-shuffle
+                // plan, which this writer no longer implements.
+                if shuffle_writer.output_partitioning.is_some() {
+                    return Err(DataFusionError::Internal(
+                        "hash-partitioned ShuffleWriterExec is no longer 
supported; \
+                         hash-repartition stages use SortShuffleWriterExec"
+                            .to_string(),
+                    ));
+                }
 
                 Ok(Arc::new(ShuffleWriterExec::try_new(
                     shuffle_writer.job_id.clone().into(),
                     shuffle_writer.stage_id as usize,
                     input,
                     "".to_string(), // this is intentional but hacky - the 
executor will fill this in
-                    shuffle_output_partitioning,
                 )?))
             }
             PhysicalPlanType::SortShuffleWriter(sort_shuffle_writer) => {
@@ -603,33 +606,15 @@ impl PhysicalExtensionCodec for 
BallistaPhysicalExtensionCodec {
         buf: &mut Vec<u8>,
     ) -> Result<(), DataFusionError> {
         if let Some(exec) = node.downcast_ref::<ShuffleWriterExec>() {
-            // note that we use shuffle_output_partitioning() rather than 
output_partitioning()
-            // to get the true output partitioning
-            let output_partitioning = match exec.shuffle_output_partitioning() 
{
-                Some(Partitioning::Hash(exprs, partition_count)) => {
-                    Some(datafusion_proto::protobuf::PhysicalHashRepartition {
-                        hash_expr: exprs
-                            .iter()
-                            
.map(|expr|datafusion_proto::physical_plan::to_proto::serialize_physical_expr(&expr.clone(),
 self.default_codec.as_ref()))
-                            .collect::<Result<Vec<_>, DataFusionError>>()?,
-                        partition_count: *partition_count as u64,
-                    })
-                }
-                None => None,
-                other => {
-                    return Err(DataFusionError::Internal(format!(
-                        "physical_plan::to_proto() invalid partitioning for 
ShuffleWriterExec: {other:?}"
-                    )));
-                }
-            };
-
             let proto = protobuf::BallistaPhysicalPlanNode {
                 physical_plan_type: Some(PhysicalPlanType::ShuffleWriter(
                     protobuf::ShuffleWriterExecNode {
                         job_id: exec.job_id().to_string(),
                         stage_id: exec.stage_id() as u32,
                         input: None,
-                        output_partitioning,
+                        // This writer preserves its input partitioning, so
+                        // there is never a shuffle partitioning to encode.
+                        output_partitioning: None,
                     },
                 )),
             };
diff --git a/ballista/executor/src/execution_engine.rs 
b/ballista/executor/src/execution_engine.rs
index 2a83e762b..7f779d2c8 100644
--- a/ballista/executor/src/execution_engine.rs
+++ b/ballista/executor/src/execution_engine.rs
@@ -149,18 +149,17 @@ impl ExecutionEngine for DefaultExecutionEngine {
 
         // the query plan created by the scheduler always starts with a 
shuffle writer
         // (either ShuffleWriterExec or SortShuffleWriterExec)
-        if let Some(shuffle_writer) = plan.downcast_ref::<ShuffleWriterExec>() 
{
+        if plan.downcast_ref::<ShuffleWriterExec>().is_some() {
             let exec = ShuffleWriterExec::try_new(
                 job_id,
                 stage_id,
                 plan.children()[0].clone(),
                 work_dir.to_string(),
-                shuffle_writer.shuffle_output_partitioning().cloned(),
             )?
             .with_task_id(task_id)
             .with_global_output_partition_ids(global_output_partition_ids);
             Ok(Arc::new(DefaultQueryStageExec::new(
-                ShuffleWriterVariant::Hash(exec),
+                ShuffleWriterVariant::Passthrough(exec),
             )))
         } else if let Some(sort_shuffle_writer) =
             plan.downcast_ref::<SortShuffleWriterExec>()
@@ -190,8 +189,9 @@ impl ExecutionEngine for DefaultExecutionEngine {
 /// Enum representing the different shuffle writer implementations.
 #[derive(Debug, Clone)]
 pub enum ShuffleWriterVariant {
-    /// Hash-based shuffle writer (original implementation).
-    Hash(ShuffleWriterExec),
+    /// Passthrough shuffle writer: preserves its input partitioning,
+    /// one file per output partition.
+    Passthrough(ShuffleWriterExec),
     /// Sort-based shuffle writer.
     Sort(SortShuffleWriterExec),
 }
@@ -216,7 +216,7 @@ impl DefaultQueryStageExec {
 impl Display for DefaultQueryStageExec {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         match &self.shuffle_writer {
-            ShuffleWriterVariant::Hash(writer) => {
+            ShuffleWriterVariant::Passthrough(writer) => {
                 let stage_metrics: Vec<String> = writer
                     .metrics()
                     .unwrap_or_default()
@@ -225,7 +225,7 @@ impl Display for DefaultQueryStageExec {
                     .collect();
                 write!(
                     f,
-                    "DefaultQueryStageExec(Hash): ({})\n{}",
+                    "DefaultQueryStageExec(Passthrough): ({})\n{}",
                     stage_metrics.join(", "),
                     writer
                 )
@@ -257,7 +257,9 @@ impl QueryStageExecutor for DefaultQueryStageExec {
     ) -> Result<Vec<ShuffleWritePartition>> {
         let (plan_arc, is_sort_shuffle): (Arc<dyn ExecutionPlan>, bool) =
             match &self.shuffle_writer {
-                ShuffleWriterVariant::Hash(writer) => 
(Arc::new(writer.clone()), false),
+                ShuffleWriterVariant::Passthrough(writer) => {
+                    (Arc::new(writer.clone()), false)
+                }
                 ShuffleWriterVariant::Sort(writer) => 
(Arc::new(writer.clone()), true),
             };
         debug!(
@@ -282,7 +284,9 @@ impl QueryStageExecutor for DefaultQueryStageExec {
 
     fn collect_plan_metrics(&self) -> Vec<MetricsSet> {
         match &self.shuffle_writer {
-            ShuffleWriterVariant::Hash(writer) => 
utils::collect_plan_metrics(writer),
+            ShuffleWriterVariant::Passthrough(writer) => {
+                utils::collect_plan_metrics(writer)
+            }
             ShuffleWriterVariant::Sort(writer) => 
utils::collect_plan_metrics(writer),
         }
     }
diff --git a/ballista/executor/src/executor.rs 
b/ballista/executor/src/executor.rs
index 3a1c103be..dce01ae7f 100644
--- a/ballista/executor/src/executor.rs
+++ b/ballista/executor/src/executor.rs
@@ -424,12 +424,11 @@ mod test {
             1,
             Arc::new(NeverendingOperator::new()),
             work_dir.clone(),
-            None,
         )
         .expect("creating shuffle writer");
 
         let query_stage_exec =
-            
DefaultQueryStageExec::new(ShuffleWriterVariant::Hash(shuffle_write));
+            
DefaultQueryStageExec::new(ShuffleWriterVariant::Passthrough(shuffle_write));
 
         let executor_registration = ExecutorRegistration {
             id: "executor".to_string(),
diff --git a/ballista/executor/src/flight_service.rs 
b/ballista/executor/src/flight_service.rs
index 32a0db404..3e3c8b079 100644
--- a/ballista/executor/src/flight_service.rs
+++ b/ballista/executor/src/flight_service.rs
@@ -143,7 +143,7 @@ impl FlightService for BallistaFlightService {
                     ));
                 }
 
-                // Standard hash-based shuffle - read the entire file
+                // Standard single-file shuffle output - read the entire file
                 let file = File::open(&path)
                     .map_err(|e| {
                         BallistaError::General(format!(
diff --git a/ballista/scheduler/src/planner.rs 
b/ballista/scheduler/src/planner.rs
index 0128540c5..728355899 100644
--- a/ballista/scheduler/src/planner.rs
+++ b/ballista/scheduler/src/planner.rs
@@ -700,16 +700,17 @@ pub(crate) fn create_shuffle_writer_with_config(
 ) -> Result<Arc<dyn ShuffleWriter>> {
     let plan = make_empty_exec_serde_safe(plan)?;
 
-    // Check if sort-based shuffle is enabled
+    // Sort-based shuffle is the only shuffle writer for hash-repartition
+    // stages. Its tuning values still come from the session config.
     let ballista_config = config
         .extensions
         .get::<BallistaConfig>()
         .cloned()
         .unwrap_or_default();
 
-    if ballista_config.shuffle_sort_based_enabled() {
-        // Sort shuffle requires hash partitioning
-        if let Some(Partitioning::Hash(exprs, partition_count)) = partitioning 
{
+    // Sort shuffle requires hash partitioning.
+    match partitioning {
+        Some(Partitioning::Hash(exprs, partition_count)) => {
             let sort_config = SortShuffleConfig::new(
                 true,
                 ballista_config.shuffle_sort_based_batch_size(),
@@ -718,25 +719,27 @@ pub(crate) fn create_shuffle_writer_with_config(
                 
ballista_config.shuffle_sort_based_memory_limit_per_task_bytes(),
             );
 
-            return Ok(Arc::new(SortShuffleWriterExec::try_new(
+            Ok(Arc::new(SortShuffleWriterExec::try_new(
                 job_id.to_owned(),
                 stage_id,
                 plan,
                 "".to_owned(),
                 Partitioning::Hash(exprs, partition_count),
                 sort_config,
-            )?));
+            )?))
         }
+        // Stages that don't repartition write their input partitioning
+        // through: one file per output partition.
+        None => Ok(Arc::new(ShuffleWriterExec::try_new(
+            job_id.to_owned(),
+            stage_id,
+            plan,
+            "".to_owned(),
+        )?)),
+        Some(other) => Err(BallistaError::General(format!(
+            "unsupported shuffle output partitioning: {other}"
+        ))),
     }
-
-    // Fall back to standard shuffle writer
-    Ok(Arc::new(ShuffleWriterExec::try_new(
-        job_id.to_owned(),
-        stage_id,
-        plan,
-        "".to_owned(),
-        partitioning,
-    )?))
 }
 
 #[cfg(test)]
diff --git a/ballista/scheduler/src/state/aqe/test/mod.rs 
b/ballista/scheduler/src/state/aqe/test/mod.rs
index e4bffbbb7..52f173d19 100644
--- a/ballista/scheduler/src/state/aqe/test/mod.rs
+++ b/ballista/scheduler/src/state/aqe/test/mod.rs
@@ -30,8 +30,6 @@ mod plan_to_stages;
 /// A table whose statistics are declared rather than measured
 mod stats_table;
 
-use ballista_core::config::BALLISTA_SHUFFLE_SORT_BASED_ENABLED;
-use ballista_core::extension::SessionConfigExt;
 use ballista_core::serde::scheduler::{
     ExecutorMetadata, ExecutorOperatingSystemSpecification, 
ExecutorSpecification,
     PartitionId, PartitionLocation, PartitionStats,
@@ -135,17 +133,3 @@ pub(crate) fn mock_context() -> SessionContext {
 
     SessionContext::new_with_state(state)
 }
-
-pub(crate) fn mock_context_sort_shuffle() -> SessionContext {
-    let config = SessionConfig::new_with_ballista()
-        .set_str(BALLISTA_SHUFFLE_SORT_BASED_ENABLED, "true")
-        .with_target_partitions(2)
-        .with_round_robin_repartition(false);
-
-    let state = SessionStateBuilder::new()
-        .with_config(config)
-        .with_default_features()
-        .build();
-
-    SessionContext::new_with_state(state)
-}
diff --git a/ballista/scheduler/src/state/aqe/test/plan_to_stages.rs 
b/ballista/scheduler/src/state/aqe/test/plan_to_stages.rs
index b115f273c..993996af4 100644
--- a/ballista/scheduler/src/state/aqe/test/plan_to_stages.rs
+++ b/ballista/scheduler/src/state/aqe/test/plan_to_stages.rs
@@ -19,8 +19,7 @@ use crate::assert_plan;
 use crate::state::aqe::execution_plan::ExchangeExec;
 use crate::state::aqe::planner::AdaptivePlanner;
 use crate::state::aqe::test::{
-    mock_batch, mock_context, mock_context_sort_shuffle, mock_memory_table,
-    mock_partitions_with_statistics,
+    mock_batch, mock_context, mock_memory_table, 
mock_partitions_with_statistics,
 };
 use ballista_core::execution_plans::SortShuffleWriterExec;
 use datafusion::arrow::datatypes::{DataType, Field, Schema};
@@ -455,36 +454,6 @@ async fn should_ignore_inactive_stages() -> 
datafusion::error::Result<()> {
     Ok(())
 }
 
-#[tokio::test]
-async fn should_use_sort_shuffle_when_enabled() -> 
datafusion::error::Result<()> {
-    let ctx = mock_context_sort_shuffle();
-    ctx.register_batch("t", mock_batch()?)?;
-
-    let q = r#"
-            select min(a) as c0, max(b) as c1, c as c2 from t group by c
-        "#;
-
-    let plan = ctx.sql(q).await?.create_physical_plan().await?;
-    let mut planner = AdaptivePlanner::try_from_plan(
-        ctx.state().config(),
-        plan,
-        "test_job".to_owned(),
-    )?;
-
-    let stages = planner.runnable_stages()?.unwrap();
-    assert_eq!(1, stages.len());
-
-    let plan = stages.first().unwrap().plan.as_ref();
-    assert!(
-        (plan as &dyn ExecutionPlan)
-            .downcast_ref::<SortShuffleWriterExec>()
-            .is_some(),
-        "expected SortShuffleWriterExec when sort shuffle is enabled, got 
plan: {plan:?}"
-    );
-
-    Ok(())
-}
-
 #[tokio::test]
 async fn should_use_sort_shuffle_by_default() -> datafusion::error::Result<()> 
{
     let ctx = mock_context();
diff --git a/benchmarks/src/bin/shuffle_bench.rs 
b/benchmarks/src/bin/shuffle_bench.rs
index 8fd9184d2..e2bdbcbf3 100644
--- a/benchmarks/src/bin/shuffle_bench.rs
+++ b/benchmarks/src/bin/shuffle_bench.rs
@@ -17,14 +17,13 @@
 
 //! Standalone shuffle benchmark for profiling Ballista shuffle write
 //! performance outside of a cluster. Streams input from Parquet files and
-//! drives either the hash-based or sort-based shuffle writer end-to-end.
+//! drives the sort-based shuffle writer end-to-end.
 //!
 //! # Usage
 //!
 //! ```sh
 //! cargo run --release --bin shuffle_bench -- \
 //!   --input /data/tpch-sf100/lineitem/ \
-//!   --writer sort \
 //!   --partitions 200 \
 //!   --hash-columns 0,3
 //! ```
@@ -33,10 +32,9 @@
 //! ```sh
 //! cargo flamegraph --release --bin shuffle_bench -- \
 //!   --input /data/tpch-sf100/lineitem/ \
-//!   --writer sort --partitions 200
+//!   --partitions 200
 //! ```
 
-use ballista_core::execution_plans::ShuffleWriterExec;
 use ballista_core::execution_plans::sort_shuffle::{
     SortShuffleConfig, SortShuffleWriterExec,
 };
@@ -70,12 +68,8 @@ struct Args {
     #[arg(long)]
     input: PathBuf,
 
-    /// Shuffle writer to drive: `hash` (default) or `sort`.
-    #[arg(long, default_value = "hash")]
-    writer: String,
-
     /// Partitioning scheme: `hash`, `single`, or `round-robin`. Currently
-    /// both writers only support `hash`; other values are rejected.
+    /// the writer only supports `hash`; other values are rejected.
     #[arg(long, default_value = "hash")]
     partitioning: String,
 
@@ -117,27 +111,11 @@ struct Args {
     vcores: usize,
 }
 
-#[derive(Clone, Copy, Debug)]
-enum WriterKind {
-    Hash,
-    Sort,
-}
-
 #[derive(Clone, Copy, Debug)]
 enum PartitioningKind {
     Hash,
 }
 
-fn parse_writer(s: &str) -> Result<WriterKind, String> {
-    match s.to_lowercase().as_str() {
-        "hash" => Ok(WriterKind::Hash),
-        "sort" => Ok(WriterKind::Sort),
-        other => Err(format!(
-            "unknown writer: {other} (expected 'hash' or 'sort')"
-        )),
-    }
-}
-
 fn parse_partitioning(s: &str) -> Result<PartitioningKind, String> {
     match s.to_lowercase().as_str() {
         "hash" => Ok(PartitioningKind::Hash),
@@ -227,7 +205,6 @@ fn build_partitioning(
 
 async fn execute_shuffle_write(
     args: &Args,
-    writer_kind: WriterKind,
     partitioning_kind: PartitioningKind,
     hash_col_indices: &[usize],
     work_dir: PathBuf,
@@ -270,43 +247,25 @@ async fn execute_shuffle_write(
     let work_dir_str = work_dir.to_str().unwrap().to_string();
     fs::create_dir_all(&work_dir).expect("create work dir");
 
-    let metrics: MetricsSet = match writer_kind {
-        WriterKind::Hash => {
-            let exec = ShuffleWriterExec::try_new(
-                format!("bench_job_{task_id}").into(),
-                1,
-                input,
-                work_dir_str,
-                Some(partitioning),
-            )?;
-            let task_ctx = ctx.task_ctx();
-            let mut stream = exec.execute(0, task_ctx)?;
-            let _ = utils::collect_stream(&mut stream).await;
-            exec.metrics().unwrap_or_default()
-        }
-        WriterKind::Sort => {
-            let cfg = SortShuffleConfig::new(true, args.batch_size);
-            let exec = SortShuffleWriterExec::try_new(
-                format!("bench_job_{task_id}").into(),
-                1,
-                input,
-                work_dir_str,
-                partitioning,
-                cfg,
-            )?;
-            let task_ctx = ctx.task_ctx();
-            let mut stream = exec.execute(0, task_ctx)?;
-            let _ = utils::collect_stream(&mut stream).await;
-            exec.metrics().unwrap_or_default()
-        }
-    };
+    let cfg = SortShuffleConfig::new(true, args.batch_size);
+    let exec = SortShuffleWriterExec::try_new(
+        format!("bench_job_{task_id}").into(),
+        1,
+        input,
+        work_dir_str,
+        partitioning,
+        cfg,
+    )?;
+    let task_ctx = ctx.task_ctx();
+    let mut stream = exec.execute(0, task_ctx)?;
+    let _ = utils::collect_stream(&mut stream).await;
+    let metrics: MetricsSet = exec.metrics().unwrap_or_default();
 
     Ok(metrics)
 }
 
 fn run_iteration(
     args: &Args,
-    writer_kind: WriterKind,
     partitioning_kind: PartitioningKind,
     hash_col_indices: &[usize],
 ) -> (f64, Option<MetricsSet>) {
@@ -317,7 +276,6 @@ fn run_iteration(
             let work_dir = args.output_dir.join("task_0");
             let metrics = execute_shuffle_write(
                 args,
-                writer_kind,
                 partitioning_kind,
                 hash_col_indices,
                 work_dir.clone(),
@@ -337,7 +295,6 @@ fn run_iteration(
                 handles.push(tokio::spawn(async move {
                     let m = execute_shuffle_write(
                         &args,
-                        writer_kind,
                         partitioning_kind,
                         &hash_col_indices,
                         work_dir.clone(),
@@ -433,10 +390,6 @@ fn main() {
         .init();
 
     let args = Args::parse();
-    let writer_kind = parse_writer(&args.writer).unwrap_or_else(|e| {
-        eprintln!("error: {e}");
-        std::process::exit(2);
-    });
     let partitioning_kind = 
parse_partitioning(&args.partitioning).unwrap_or_else(|e| {
         eprintln!("error: {e}");
         std::process::exit(2);
@@ -448,7 +401,6 @@ fn main() {
     let (schema, total_rows) = read_parquet_metadata(&args.input, args.limit);
 
     println!("=== Ballista Shuffle Benchmark ===");
-    println!("Writer:         {writer_kind:?}");
     println!("Partitioning:   {partitioning_kind:?}");
     println!("Input:          {}", args.input.display());
     println!(
@@ -483,7 +435,7 @@ fn main() {
             format!("iter {}/{}", i - args.warmup + 1, args.iterations)
         };
         let (elapsed, metrics) =
-            run_iteration(&args, writer_kind, partitioning_kind, 
&hash_col_indices);
+            run_iteration(&args, partitioning_kind, &hash_col_indices);
         if !is_warmup {
             times.push(elapsed);
             if metrics.is_some() {
diff --git a/benchmarks/src/bin/tpch.rs b/benchmarks/src/bin/tpch.rs
index 2650f3401..53e1fa0f0 100644
--- a/benchmarks/src/bin/tpch.rs
+++ b/benchmarks/src/bin/tpch.rs
@@ -127,7 +127,7 @@ struct BallistaBenchmarkOpt {
 
     /// Configuration overrides in key=value format.
     /// Can be specified multiple times, e.g.
-    /// -c ballista.shuffle.sort_based.enabled=true
+    /// -c ballista.shuffle.sort_based.batch_size=8192
     /// -c datafusion.execution.target_partitions=16
     #[structopt(short = "c", long = "config", number_of_values = 1)]
     config_overrides: Vec<String>,
diff --git a/docs/source/user-guide/configs.md 
b/docs/source/user-guide/configs.md
index 83f0351db..8bc7276f4 100644
--- a/docs/source/user-guide/configs.md
+++ b/docs/source/user-guide/configs.md
@@ -92,14 +92,13 @@ let expected = [
 
 The following session-level keys control Ballista's shuffle behavior. See
 the [tuning guide](tuning-guide.md#shuffle-implementation) for an
-explanation of the sort-based (default) and hash-based shuffle writers.
+explanation of the sort-based shuffle writer.
 
 | key                                                     | type    | default  
 | description                                                                  
                                                                                
                                                                                
                                                                                
              |
 | ------------------------------------------------------- | ------- | 
--------- | 
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 |
 | ballista.shuffle.max_concurrent_read_requests           | UInt64  | 64       
 | Maximum number of concurrent fetch requests the shuffle reader will issue.   
                                                                                
                                                                                
                                                                                
              |
 | ballista.shuffle.force_remote_read                      | Boolean | false    
 | Forces the shuffle reader to fetch every partition through Arrow Flight, 
even when the data is local. Intended for testing.                              
                                                                                
                                                                                
                  |
 | ballista.shuffle.remote_read_prefer_flight              | Boolean | false    
 | For remote reads, prefer the Arrow Flight reader over the block reader. The 
block reader is generally faster.                                               
                                                                                
                                                                                
               |
-| ballista.shuffle.sort_based.enabled                     | Boolean | true     
 | Enables the sort-based shuffle writer (consolidated data file per input 
partition with an index, instead of one file per (input partition, output 
partition) pair).                                                               
                                                                                
                         |
 | ballista.shuffle.sort_based.batch_size                  | UInt64  | 8192     
 | Target row count when coalescing buffered batches before they are written or 
spilled.                                                                        
                                                                                
                                                                                
              |
 | ballista.shuffle.sort_based.memory_limit_per_task_bytes | UInt64  | 
268435456 | Per-task buffered-bytes budget at which the sort-based writer 
spills to disk (256 MiB default), counted independently of the runtime memory 
pool. Set to `0` to disable the per-task budget and spill only under 
memory-pool pressure — safe only with a bounded memory pool, otherwise the 
writer never spills and may run out of memory. |
 
diff --git a/docs/source/user-guide/tuning-guide.md 
b/docs/source/user-guide/tuning-guide.md
index b9f3d5d7f..5391c399d 100644
--- a/docs/source/user-guide/tuning-guide.md
+++ b/docs/source/user-guide/tuning-guide.md
@@ -127,11 +127,12 @@ scheduler or executors.
 
 Ballista exchanges data between query stages by writing the output of each
 upstream task to local files, which downstream tasks read either from disk
-(when co-located) or over Arrow Flight. Two shuffle implementations are
-available, with different trade-offs around file count, memory use, and
-write latency.
+(when co-located) or over Arrow Flight. Ballista uses a sort-based shuffle
+writer that bounds file count and memory use. Single-partition stages (a
+query's final output, coalesce, and broadcast-build stages) are written
+directly to one file.
 
-### Sort-based shuffle (default)
+### Sort-based shuffle
 
 The sort-based writer accumulates incoming batches in memory, tracking each
 row's output partition. It spills the buffered batches to disk when either of
@@ -160,7 +161,7 @@ output partition.
 
 This produces `2 × N` files instead of `N × M`, coalesces small batches
 to a target size before writing, and bounds shuffle memory use via
-spilling — at the cost of higher write latency than the hash writer.
+spilling.
 
 Worst-case sort-shuffle memory per executor is approximately
 `vcores × memory_limit_per_task_bytes`, since one writer task can run per
@@ -169,31 +170,12 @@ sooner, or raise it to keep more data in memory and 
reduce spill I/O. Setting it
 to `0` removes the budget entirely and is safe only with a bounded memory pool
 (see the warning above).
 
-### Hash-based shuffle (opt-in)
-
-The hash-based writer hashes each incoming `RecordBatch` and immediately
-encodes the per-partition slices to Arrow IPC, streaming them into one
-file per `(input_partition, output_partition)` pair. Nothing is buffered
-in memory across batches.
-
-This is simple and low latency, but for `N` input partitions and `M`
-output partitions it produces `N × M` files. Wide shuffles can therefore
-generate a large number of small files. Consider switching to the
-hash-based writer for narrow shuffles where the additional buffering
-and merging of the sort-based writer is unnecessary overhead:
-
-```rust
-let session_config = SessionConfig::new_with_ballista()
-    .set_bool("ballista.shuffle.sort_based.enabled", false);
-```
-
 The following session-level keys tune its behavior:
 
-| key                                                     | type    | default  
 | description                                                                  
                                                                                
                                                                                
                                              |
-| ------------------------------------------------------- | ------- | 
--------- | 
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 |
-| ballista.shuffle.sort_based.enabled                     | Boolean | true     
 | Enables the sort-based shuffle writer.                                       
                                                                                
                                                                                
                                              |
-| ballista.shuffle.sort_based.batch_size                  | UInt64  | 8192     
 | Target row count when coalescing buffered batches before they are written or 
spilled.                                                                        
                                                                                
                                              |
-| ballista.shuffle.sort_based.memory_limit_per_task_bytes | UInt64  | 
268435456 | Per-task buffered-bytes budget at which the writer spills to disk 
(256 MiB default). Counted independently of the runtime memory pool. Set to `0` 
to spill only under memory pressure — safe only with a bounded memory pool, 
otherwise the writer never spills and may run out of memory. |
+| key                                                     | type   | default   
| description                                                                   
                                                                                
                                                                                
                                             |
+| ------------------------------------------------------- | ------ | --------- 
| 
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 |
+| ballista.shuffle.sort_based.batch_size                  | UInt64 | 8192      
| Target row count when coalescing buffered batches before they are written or 
spilled.                                                                        
                                                                                
                                              |
+| ballista.shuffle.sort_based.memory_limit_per_task_bytes | UInt64 | 268435456 
| Per-task buffered-bytes budget at which the writer spills to disk (256 MiB 
default). Counted independently of the runtime memory pool. Set to `0` to spill 
only under memory pressure — safe only with a bounded memory pool, otherwise 
the writer never spills and may run out of memory. |
 
 ## Adaptive Query Execution (Experimental)
 
diff --git a/python/python/tests/test_context.py 
b/python/python/tests/test_context.py
index 047c8986e..6e5180378 100644
--- a/python/python/tests/test_context.py
+++ b/python/python/tests/test_context.py
@@ -95,7 +95,7 @@ def test_cluster_config_propagates_to_distributed_dataframe():
 
 
 def test_cluster_config_accepts_ballista_namespaced_keys():
-    """Ballista-namespaced keys (e.g. ``ballista.shuffle.sort_based.enabled``)
+    """Ballista-namespaced keys (e.g. 
``ballista.shuffle.sort_based.batch_size``)
     are not understood by the local DataFusion ``SessionConfig`` and used to
     panic when applied to it. They are forwarded to the scheduler only and
     must be ignored locally rather than crashing context construction.
@@ -103,7 +103,7 @@ def test_cluster_config_accepts_ballista_namespaced_keys():
     (address, port) = setup_test_cluster()
     overrides = {
         "datafusion.execution.target_partitions": "8",
-        "ballista.shuffle.sort_based.enabled": "true",
+        "ballista.shuffle.sort_based.batch_size": "8192",
     }
     ctx = BallistaSessionContext(
         address=f"df://{address}:{port}",


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


Reply via email to