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

milenkovicm pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-ballista.git


The following commit(s) were added to refs/heads/main by this push:
     new 083f7a2cb feat(aqe): CoalescePartitionsRule — shuffle-partition 
coalescing on resolved stats (#1684)
083f7a2cb is described below

commit 083f7a2cb528805524ee97cc9d8f2059390fba71
Author: mete <[email protected]>
AuthorDate: Mon May 18 14:18:46 2026 +0100

    feat(aqe): CoalescePartitionsRule — shuffle-partition coalescing on 
resolved stats (#1684)
    
    * feat: AQE rule to coalesce shuffle partitions (Spark-port)
    
    After an upstream shuffle stage finalizes with per-partition byte stats,
    this rule rewrites the downstream stage to read K < M logical partitions
    when several upstream partitions are near-empty. Mirrors Spark's
    CoalesceShufflePartitions for distributed query execution.
    
    The rule is opt-in (`ballista.coalesce.enabled` defaults to false). The
    only tunable is `ballista.coalesce.target_partition_bytes` (advisory,
    64 MB default) — Spark's `advisoryPartitionSizeInBytes`.
    
    ## How it plugs into the AQE planner
    
    The rule is invoked **per stage** in `actionable_stages()`, right
    before each newly-runnable stage is handed to `BallistaAdapter`. It is
    deliberately NOT registered in `default_optimizers()` because that
    chain runs on the entire residual plan tree on every `replan_stages()`
    call, which causes two problems:
    
      1. Cross-stage alignment groups. The walker collects "topmost
         ExchangeExecs", which spans multiple future stages' inputs into
         one alignment group. A K decision driven by stage N's byte
         distribution gets stamped onto stage N+1's input exchanges too.
      2. Stale state across fires. `set_coalesce` uses interior mutability
         on a shared `Arc<ExchangeExec>`. A fire that bails on K=1
         degenerate doesn't invalidate the K=5 a previous fire wrote on
         the same exchange — the next stage then sees mismatched leg Ks.
    
    Per-stage invocation fixes both: each call receives one stage's plan
    as its root, the walker descends to that stage's input exchanges only,
    and the K decision is local to that stage. The "unresolved leaf" bail
    path becomes unreachable by construction (a stage becomes runnable
    precisely when all its inputs are resolved).
    
    ## Example: TPC-H Q8 stage 11, both legs aligned
    
    After stage 10 finalizes (writes `Hash(o_custkey, 16)`), the rule
    fires on stage 11's plan:
    
        AdaptiveDatafusionExec
          ProjectionExec
            SortMergeJoinExec on (o_custkey, c_custkey)
              SortExec
                ExchangeExec Hash([o_custkey], 16) plan_id=11  <- leaf #1, 
resolved
                  ...stage 10 writer output...
              SortExec
                ExchangeExec Hash([c_custkey], 16) plan_id=4   <- leaf #2, 
resolved
                  ...customer table scan...
    
    Walker collects exactly 2 leaves: {plan_id=11, plan_id=4}. Per-leaf
    bytes are ~19 MB each across 16 partitions, summed [38M×16]. Bin-pack
    at 64 MB target → K=5. Both leaves get the SAME `CoalescePlan` (K=5,
    M=16, identical group mapping) → the SMJ runs with 5 partitions on
    each side, hash buckets stay aligned.
    
    ## Example: Q8 stage 12, three resolved siblings, K=1 degenerate
    
    When stage 11 finalizes and stage 12 is surfaced, the walker sees:
    
        leaves = [plan_id=12 (c_nationkey from stage 11),
                  plan_id=5  (n_nationkey from stage 5),
                  plan_id=6  (n_nationkey from stage 6),
                  plan_id=7  (r_regionkey from stage 7)]
    
    All four resolved, but summed bytes total only ~76 MB (the multi-join
    filtered hard). Bin-pack returns K=1 → degenerate → the rule no-ops
    on this stage. The SMJ runs at native M=16 across all four legs.
    No partition-count mismatch.
    
    ## Components
    
    - Proto: `CoalescePlan` / `PartitionGroup` on `ShuffleReaderExecNode`
      and `UnresolvedShuffleExecNode` (field numbers 7/8 — non-clashing
      with PR #1647's `broadcast` / `upstream_partition_count` fields 5/6).
    - Reader: `ShuffleReaderExec::try_new_coalesced` builds the K-shape
      pre-concatenated reader; legacy `try_new` path byte-for-byte
      unchanged when no `CoalescePlan` is attached. Coexists with
      `try_new_broadcast` from PR #1647.
    - Algorithm: `split_size_list_by_target_size` is a verbatim Rust port
      of Spark's legacy `splitSizeListByTargetSize` — merged-factor early
      flush and small-tail post-loop merge preserved.
    - Rule: `CoalescePartitionsRule` (unit struct), invoked per-stage in
      `actionable_stages()`. ExchangeExec's display conditionally appends
      `coalesce=K of M` only when attached, so existing AQE snapshots that
      don't involve coalesce are unchanged.
    
    ## Behavior preservation
    
    When `coalesce.enabled=false`, the rule short-circuits as the first
    statement of `optimize()` and returns the input Arc verbatim. The
    reader path with no `CoalescePlan` attached is byte-for-byte identical
    to today's reader. Orthogonal to PR #1647 (broadcast hash join,
    merged) and PR #1649 (lazy AQE planner, merged). All three use
    DataFusion's standard `PhysicalOptimizerRule` trait.
    
    ## Validation
    
    - `cargo test --workspace`: ~600 tests pass, 0 failures.
    - `cargo clippy --workspace --all-targets --tests`: 0 warnings.
    - TPC-H SF100 sanity, 22 queries × 2 join variants (hash-pref and
      sort-pref), `coalesce.enabled=true`: 44/44 rc=0, row counts
      identical across variants, 9 queries get meaningful K reductions
      (K=2..K=13 across nation/region/supplier/customer joins).
    
    * refactor(coalesce): address PR review — namespace keys, Float64 storage
    
    - Rename ballista.coalesce.* → ballista.planner.coalesce.* to match the
      existing ballista.planner.* convention used for adaptive/broadcast knobs
    - Add DataType::Float64 to BallistaConfig::parse_value
    - Switch small/merged partition factors from Utf8 to Float64 storage
    - Extract get_float_setting helper mirroring get_usize_setting; drop the
      manual f64::from_str + unwrap_or fallback at the call sites
    - Document the neighboring-partitions-only grouping rule in the module doc
    
    * fix(coalesce): bail on heterogeneous M to avoid Q22 panic
    
    Guard against the byte-sum loop indexing past `summed.len()` when an
    alignment group contains leaf Exchanges with different partition_counts
    (e.g. Q22's scalar avg subquery at M=14 alongside hash-join legs at M=48).
    
    * style: cargo fmt the Q22 guard
    
    * refactor(coalesce): address PR review nits
    
    - Remove dead ShuffleReaderExec branch in BallistaAdapter — the coalesce
      rule never produces one (it only calls set_coalesce on the Exchange),
      so transform_children only ever sees ExchangeExec.
    - Restore PartitionStats.num_bytes to pub(crate); rule reads via the
      existing num_bytes() getter instead of direct field access.
---
 .gitignore                                         |   2 +-
 ballista/core/proto/ballista.proto                 |  19 +
 ballista/core/src/config.rs                        |  82 +++-
 ballista/core/src/execution_plans/mod.rs           |   2 +-
 .../core/src/execution_plans/shuffle_reader.rs     | 273 ++++++++++++-
 .../core/src/execution_plans/unresolved_shuffle.rs | 102 ++++-
 ballista/core/src/extension.rs                     | 103 ++++-
 ballista/core/src/serde/generated/ballista.rs      |  24 ++
 ballista/core/src/serde/mod.rs                     | 338 +++++++++++++++-
 ballista/core/src/serde/scheduler/mod.rs           |   8 +
 ballista/scheduler/src/state/aqe/adapter.rs        |  43 +-
 .../scheduler/src/state/aqe/coalesce/algorithm.rs  | 241 ++++++++++++
 .../state/aqe/{optimizer_rule => coalesce}/mod.rs  |  17 +-
 ballista/scheduler/src/state/aqe/execution_plan.rs |  58 ++-
 ballista/scheduler/src/state/aqe/mod.rs            |   3 +-
 .../aqe/optimizer_rule/coalesce_partitions.rs      | 311 +++++++++++++++
 .../scheduler/src/state/aqe/optimizer_rule/mod.rs  |   2 +
 ballista/scheduler/src/state/aqe/planner.rs        |  17 +-
 .../scheduler/src/state/aqe/test/coalesce_rule.rs  | 434 +++++++++++++++++++++
 ballista/scheduler/src/state/aqe/test/mod.rs       |   2 +
 20 files changed, 2049 insertions(+), 32 deletions(-)

diff --git a/.gitignore b/.gitignore
index 20c28224d..42f5e3ebe 100644
--- a/.gitignore
+++ b/.gitignore
@@ -115,4 +115,4 @@ CLAUDE.md
 # git worktrees (local only)
 .worktrees/
 # ignore insta captures
-*-snap
\ No newline at end of file
+*-snap
diff --git a/ballista/core/proto/ballista.proto 
b/ballista/core/proto/ballista.proto
index bdd68ba88..cb209b55e 100644
--- a/ballista/core/proto/ballista.proto
+++ b/ballista/core/proto/ballista.proto
@@ -78,6 +78,8 @@ message UnresolvedShuffleExecNode {
   datafusion.Partitioning partitioning = 5;
   bool broadcast = 6;
   uint32 upstream_partition_count = 7;
+  // Optional coalesce metadata. Absent means "no coalesce" (legacy one-to-one 
read behavior).
+  optional CoalescePlan coalesce = 8;
 }
 
 message ShuffleReaderExecNode {
@@ -88,6 +90,8 @@ message ShuffleReaderExecNode {
   datafusion.Partitioning partitioning = 4;
   bool broadcast = 5;
   uint32 upstream_partition_count = 6;
+  // Optional coalesce metadata. Absent means "no coalesce" (legacy one-to-one 
read behavior).
+  optional CoalescePlan coalesce = 7;
 }
 
 message ShuffleReaderPartition {
@@ -95,6 +99,21 @@ message ShuffleReaderPartition {
   repeated PartitionLocation location = 1;
 }
 
+// CoalescePartitionsRule output: groups upstream partitions into coalesced 
output partitions.
+// Empty when no coalesce is applied (the optional field on the parent message 
is absent).
+message CoalescePlan {
+  // Original number of upstream partitions (M) before coalescing. Required 
for EXPLAIN's "K of M" rendering.
+  uint32 upstream_partition_count = 1;
+  // Coalesced output groups. Length is K (the post-coalesce partition count).
+  repeated PartitionGroup groups = 2;
+}
+
+// One coalesced output partition's source list: a set of upstream partition 
indices in [0, upstream_partition_count).
+// Default algorithm produces only contiguous indices, but proto allows 
arbitrary index sets for future strategies.
+message PartitionGroup {
+  repeated uint32 upstream_indices = 1;
+}
+
 
///////////////////////////////////////////////////////////////////////////////////////////////////
 // Ballista Scheduling
 
///////////////////////////////////////////////////////////////////////////////////////////////////
diff --git a/ballista/core/src/config.rs b/ballista/core/src/config.rs
index 334d5b991..fbd0de708 100644
--- a/ballista/core/src/config.rs
+++ b/ballista/core/src/config.rs
@@ -86,6 +86,21 @@ pub const 
BALLISTA_SHUFFLE_SORT_BASED_MEMORY_LIMIT_PER_TASK_BYTES: &str =
 pub const BALLISTA_BROADCAST_JOIN_THRESHOLD_BYTES: &str =
     "ballista.optimizer.broadcast_join_threshold_bytes";
 
+/// Configuration key to enable AQE coalesce-shuffle-partitions rule.
+/// Disabled by default — opt in when the workload benefits from larger
+/// downstream tasks more than from preserved parallelism.
+pub const BALLISTA_COALESCE_ENABLED: &str = 
"ballista.planner.coalesce.enabled";
+/// Configuration key for the target post-coalesce partition byte size (bytes).
+/// Mirrors Spark's `spark.sql.adaptive.advisoryPartitionSizeInBytes`.
+pub const BALLISTA_COALESCE_TARGET_PARTITION_BYTES: &str =
+    "ballista.planner.coalesce.target_partition_bytes";
+/// Configuration key for the small-partition merge factor (Spark legacy 
semantics).
+pub const BALLISTA_COALESCE_SMALL_PARTITION_FACTOR: &str =
+    "ballista.planner.coalesce.small_partition_factor";
+/// Configuration key for the merged-partition early-flush factor (Spark 
legacy semantics).
+pub const BALLISTA_COALESCE_MERGED_PARTITION_FACTOR: &str =
+    "ballista.planner.coalesce.merged_partition_factor";
+
 /// Result type for configuration parsing operations.
 pub type ParseResult<T> = result::Result<T, String>;
 use std::sync::LazyLock;
@@ -178,7 +193,33 @@ static CONFIG_ENTRIES: LazyLock<HashMap<String, 
ConfigEntry>> = LazyLock::new(||
         ConfigEntry::new(BALLISTA_CLIENT_IO_RETRY_WAIT_TIME_MS.to_string(),
                          "Wait time in milliseconds between IO retries in the 
Ballista client.".to_string(),
                          DataType::UInt64,
-                         Some(3000.to_string()))
+                         Some(3000.to_string())),
+        ConfigEntry::new(BALLISTA_COALESCE_ENABLED.to_string(),
+                         "Enables the AQE coalesce-shuffle-partitions rule. \
+                          Disabled by default — opt in when fewer/larger \
+                          downstream tasks matter more than 
parallelism.".to_string(),
+                         DataType::Boolean,
+                         Some(false.to_string())),
+        ConfigEntry::new(
+            BALLISTA_COALESCE_TARGET_PARTITION_BYTES.to_string(),
+            "Target post-coalesce partition byte size in bytes. Mirrors 
Spark's \
+             advisoryPartitionSizeInBytes."
+                .to_string(),
+            DataType::UInt64,
+            Some((64 * 1024 * 1024_usize).to_string()),
+        ),
+        ConfigEntry::new(
+            BALLISTA_COALESCE_SMALL_PARTITION_FACTOR.to_string(),
+            "Small-partition merge factor (Spark legacy).".to_string(),
+            DataType::Float64,
+            Some("0.2".to_string()),
+        ),
+        ConfigEntry::new(
+            BALLISTA_COALESCE_MERGED_PARTITION_FACTOR.to_string(),
+            "Merged-partition early-flush factor (Spark legacy).".to_string(),
+            DataType::Float64,
+            Some("1.2".to_string()),
+        ),
     ];
     entries
         .into_iter()
@@ -272,6 +313,11 @@ impl BallistaConfig {
             DataType::Utf8 => {
                 val.to_string();
             }
+            DataType::Float64 => {
+                val.to_string()
+                    .parse::<f64>()
+                    .map_err(|e| format!("{e:?}"))?;
+            }
             _ => {
                 return Err(format!("not support data type: {data_type}"));
             }
@@ -383,6 +429,27 @@ impl BallistaConfig {
         self.get_usize_setting(BALLISTA_BROADCAST_JOIN_THRESHOLD_BYTES)
     }
 
+    /// Returns whether the AQE coalesce-shuffle-partitions rule is enabled.
+    pub fn coalesce_enabled(&self) -> bool {
+        self.get_bool_setting(BALLISTA_COALESCE_ENABLED)
+    }
+
+    /// Returns the target post-coalesce partition byte size in bytes
+    /// (Spark's `advisoryPartitionSizeInBytes`).
+    pub fn coalesce_target_partition_bytes(&self) -> u64 {
+        self.get_usize_setting(BALLISTA_COALESCE_TARGET_PARTITION_BYTES) as u64
+    }
+
+    /// Returns the small-partition merge factor (Spark legacy).
+    pub fn coalesce_small_partition_factor(&self) -> f64 {
+        self.get_float_setting(BALLISTA_COALESCE_SMALL_PARTITION_FACTOR)
+    }
+
+    /// Returns the merged-partition early-flush factor (Spark legacy).
+    pub fn coalesce_merged_partition_factor(&self) -> f64 {
+        self.get_float_setting(BALLISTA_COALESCE_MERGED_PARTITION_FACTOR)
+    }
+
     /// Should client employ pull or push job tracking strategy
     pub fn client_pull(&self) -> bool {
         self.get_bool_setting(BALLISTA_CLIENT_PULL)
@@ -439,6 +506,19 @@ impl BallistaConfig {
             v.to_string()
         }
     }
+
+    #[allow(dead_code)]
+    fn get_float_setting(&self, key: &str) -> f64 {
+        if let Some(v) = self.settings.get(key) {
+            // infallible because we validate all configs in the constructor
+            v.parse::<f64>().unwrap()
+        } else {
+            let entries = Self::valid_entries();
+            // infallible because we validate all configs in the constructor
+            let v = entries.get(key).unwrap().default_value.as_ref().unwrap();
+            v.parse::<f64>().unwrap()
+        }
+    }
 }
 
 impl datafusion::config::ExtensionOptions for BallistaConfig {
diff --git a/ballista/core/src/execution_plans/mod.rs 
b/ballista/core/src/execution_plans/mod.rs
index d94fa4476..ae46fad68 100644
--- a/ballista/core/src/execution_plans/mod.rs
+++ b/ballista/core/src/execution_plans/mod.rs
@@ -31,7 +31,7 @@ use std::path::{Path, PathBuf};
 use datafusion::common::exec_err;
 pub use distributed_explain_analyze::DistributedExplainAnalyzeExec;
 pub use distributed_query::DistributedQueryExec;
-pub use shuffle_reader::ShuffleReaderExec;
+pub use shuffle_reader::{CoalescePlan, PartitionGroup, ShuffleReaderExec};
 pub use shuffle_reader::{stats_for_partition, stats_for_partitions};
 pub use shuffle_writer::DEFAULT_SHUFFLE_CHANNEL_CAPACITY;
 pub use shuffle_writer::ShuffleWriterExec;
diff --git a/ballista/core/src/execution_plans/shuffle_reader.rs 
b/ballista/core/src/execution_plans/shuffle_reader.rs
index ee75917c5..fff578350 100644
--- a/ballista/core/src/execution_plans/shuffle_reader.rs
+++ b/ballista/core/src/execution_plans/shuffle_reader.rs
@@ -60,6 +60,39 @@ use std::task::{Context, Poll};
 use tokio::sync::{Semaphore, mpsc};
 use tokio_stream::wrappers::ReceiverStream;
 
+/// Coalesce plan attached to a `ShuffleReaderExec` or `UnresolvedShuffleExec`.
+///
+/// Produced by the AQE `CoalescePartitionsRule` and round-tripped through
+/// proto so it survives stage retries. Absent (`None` on the parent operator)
+/// means "no coalesce" — the existing one-to-one read behavior.
+///
+/// `K = self.groups.len()` is the post-coalesce partition count.
+/// `M = self.upstream_partition_count` is the original upstream partition 
count.
+/// EXPLAIN renders this as `coalesce: K of M` (see `DisplayAs::fmt_as`).
+///
+/// Note: `Default` is intentionally NOT derived. Callers must construct 
explicitly
+/// to keep "absent coalesce" (`Option::None`) semantically distinct from 
"empty plan".
+#[derive(Debug, Clone, PartialEq)]
+pub struct CoalescePlan {
+    /// Original upstream partition count (M) before coalescing.
+    pub upstream_partition_count: u32,
+    /// Output partition groups. Length is K (the post-coalesce partition 
count).
+    pub groups: Vec<PartitionGroup>,
+}
+
+/// One output partition's upstream-index list.
+///
+/// Each value is an index into the M-shape `Vec<Vec<PartitionLocation>>` 
produced by
+/// the upstream `ShuffleWriterExec` (or `SortShuffleWriterExec`). The default
+/// `split_size_list_by_target_size` algorithm produces only contiguous ranges,
+/// but proto permits arbitrary index sets for future strategies.
+#[derive(Debug, Clone, PartialEq)]
+pub struct PartitionGroup {
+    /// Indices into the upstream `Vec<Vec<PartitionLocation>>` that this 
output
+    /// partition concatenates.
+    pub upstream_indices: Vec<u32>,
+}
+
 /// ShuffleReaderExec reads partitions that have already been materialized by 
a ShuffleWriterExec
 /// being executed by an executor
 #[derive(Debug, Clone)]
@@ -76,8 +109,14 @@ pub struct ShuffleReaderExec {
     pub broadcast: bool,
     /// Number of shuffle output partitions on the upstream stage. Useful for
     /// metrics and EXPLAIN output. For non-broadcast readers this equals
-    /// `partition.len()`.
+    /// `partition.len()` (or, when coalesced, 
`coalesce.upstream_partition_count`).
     pub upstream_partition_count: usize,
+    /// Optional coalesce metadata. `None` means the reader behaves 
identically to
+    /// the legacy one-to-one read (no coalescing). When `Some`, 
`partition.len()` equals
+    /// `coalesce.groups.len()` (= K, the post-coalesce partition count); the 
rule
+    /// is responsible for pre-concatenating the M-shape upstream
+    /// `Vec<Vec<PartitionLocation>>` into K-shape before invoking 
`try_new_coalesced`.
+    pub coalesce: Option<CoalescePlan>,
     /// Execution metrics
     metrics: ExecutionPlanMetricsSet,
     properties: Arc<PlanProperties>,
@@ -107,6 +146,7 @@ impl ShuffleReaderExec {
             partition,
             broadcast: false,
             upstream_partition_count,
+            coalesce: None,
             metrics: ExecutionPlanMetricsSet::new(),
             properties,
             work_dir: None,    // to be updated at the executor side
@@ -136,6 +176,58 @@ impl ShuffleReaderExec {
             partition: vec![all_locations],
             broadcast: true,
             upstream_partition_count,
+            coalesce: None,
+            metrics: ExecutionPlanMetricsSet::new(),
+            properties,
+            work_dir: None,    // to be updated at the executor side
+            client_pool: None, // to be updated at the executor side
+        })
+    }
+
+    /// Create a new coalesced ShuffleReaderExec.
+    ///
+    /// `partition` MUST be the K-shape, pre-concatenated 
`Vec<Vec<PartitionLocation>>`
+    /// produced by the AQE rule: each output index `idx` in `0..K` holds the
+    /// concatenation of the upstream `Vec<PartitionLocation>`s named by
+    /// `coalesce.groups[idx].upstream_indices`. `partitioning` MUST
+    /// be `Partitioning::Hash(keys, K)` (or another `Partitioning` of width 
K) so
+    /// `partition_count() == K` and `Partitioning::Hash` co-partitioning is 
preserved
+    /// across joins.
+    ///
+    /// In debug builds this constructor asserts `partition.len() == 
coalesce.groups.len()`
+    /// and `partitioning.partition_count() == coalesce.groups.len()` to catch
+    /// rule-side mistakes early; release builds skip the check.
+    pub fn try_new_coalesced(
+        stage_id: usize,
+        partition: Vec<Vec<PartitionLocation>>,
+        coalesce: CoalescePlan,
+        schema: SchemaRef,
+        partitioning: Partitioning,
+    ) -> Result<Self> {
+        debug_assert_eq!(
+            partition.len(),
+            coalesce.groups.len(),
+            "K-shape partition vector length must equal coalesce.groups.len()",
+        );
+        debug_assert_eq!(
+            partitioning.partition_count(),
+            coalesce.groups.len(),
+            "partitioning.partition_count() must equal coalesce.groups.len() 
(= K)",
+        );
+        let upstream_partition_count = coalesce.upstream_partition_count as 
usize;
+        let properties = Arc::new(PlanProperties::new(
+            
datafusion::physical_expr::EquivalenceProperties::new(schema.clone()),
+            partitioning,
+            
datafusion::physical_plan::execution_plan::EmissionType::Incremental,
+            datafusion::physical_plan::execution_plan::Boundedness::Bounded,
+        ));
+        Ok(Self {
+            stage_id,
+            schema,
+            partition,
+            broadcast: false,
+            upstream_partition_count,
+            coalesce: Some(coalesce),
             metrics: ExecutionPlanMetricsSet::new(),
             properties,
             work_dir: None,    // to be updated at the executor side
@@ -151,6 +243,7 @@ impl ShuffleReaderExec {
             partition: self.partition.clone(),
             broadcast: self.broadcast,
             upstream_partition_count: self.upstream_partition_count,
+            coalesce: self.coalesce.clone(),
             metrics: self.metrics.clone(),
             properties: self.properties.clone(),
             work_dir: Some(work_dir),
@@ -165,6 +258,7 @@ impl ShuffleReaderExec {
             partition: self.partition.clone(),
             broadcast: self.broadcast,
             upstream_partition_count: self.upstream_partition_count,
+            coalesce: self.coalesce.clone(),
             metrics: self.metrics.clone(),
             properties: self.properties.clone(),
             work_dir: self.work_dir.clone(),
@@ -192,7 +286,16 @@ impl DisplayAs for ShuffleReaderExec {
                         f,
                         "ShuffleReaderExec: partitioning: {}",
                         self.properties.partitioning,
-                    )
+                    )?;
+                    if let Some(c) = &self.coalesce {
+                        write!(
+                            f,
+                            ", coalesce: {} of {}",
+                            c.groups.len(),
+                            c.upstream_partition_count,
+                        )?;
+                    }
+                    Ok(())
                 }
             }
             DisplayFormatType::TreeRender => {
@@ -233,6 +336,7 @@ impl ExecutionPlan for ShuffleReaderExec {
                 partition: self.partition.clone(),
                 broadcast: self.broadcast,
                 upstream_partition_count: self.upstream_partition_count,
+                coalesce: self.coalesce.clone(),
                 metrics: ExecutionPlanMetricsSet::new(),
                 properties: self.properties.clone(),
                 work_dir: self.work_dir.clone(),
@@ -351,8 +455,19 @@ impl ExecutionPlan for ShuffleReaderExec {
                     partition_count
                 );
             }
-            let stat_for_partition =
-                stats_for_partition(idx, self.schema.fields().len(), 
&self.partition);
+            // K-shape (coalesced): self.partition[idx] is the inner Vec 
holding
+            // the concatenated upstream PartitionLocations for output 
partition
+            // `idx`. Sum across that inner Vec.
+            // M-shape (legacy): outer = replicas, inner = partition index.
+            // Use the existing axis-flipped helper.
+            let stat_for_partition = if self.coalesce.is_some() {
+                Ok(stats_for_partitions(
+                    self.schema.fields().len(),
+                    self.partition[idx].iter().map(|loc| loc.partition_stats),
+                ))
+            } else {
+                stats_for_partition(idx, self.schema.fields().len(), 
&self.partition)
+            };
 
             trace!(
                 "shuffle reader at stage: {} and partition {} returned 
statistics: {:?}",
@@ -881,6 +996,67 @@ mod tests {
     use datafusion::prelude::SessionContext;
     use tempfile::{TempDir, tempdir};
 
+    /// Build an M-shape upstream `Vec<Vec<PartitionLocation>>` with 
per-partition
+    /// `num_bytes` and `num_rows` taken from parallel slices.
+    ///
+    /// `bytes_per_partition.len()` and `rows_per_partition.len()` define M 
and must
+    /// be equal. Used by tests that require distinct per-partition stats so 
the
+    /// test cannot accidentally pass under a wrong-axis aggregation.
+    fn make_upstream_partitions_nonuniform(
+        stage_id: usize,
+        bytes_per_partition: &[u64],
+        rows_per_partition: &[u64],
+    ) -> Vec<Vec<PartitionLocation>> {
+        assert_eq!(bytes_per_partition.len(), rows_per_partition.len());
+        let job_id = "test_job_coalesce_nonuniform";
+        bytes_per_partition
+            .iter()
+            .zip(rows_per_partition.iter())
+            .enumerate()
+            .map(|(i, (&bytes, &rows))| {
+                vec![PartitionLocation {
+                    map_partition_id: 0,
+                    partition_id: PartitionId {
+                        job_id: job_id.to_string(),
+                        stage_id,
+                        partition_id: i,
+                    },
+                    executor_meta: ExecutorMetadata {
+                        id: "executor_1".to_string(),
+                        host: "executor_1".to_string(),
+                        port: 7070,
+                        grpc_port: 8080,
+                        specification: ExecutorSpecification::default()
+                            .with_task_slots(1),
+                        os_info: 
ExecutorOperatingSystemSpecification::default(),
+                    },
+                    partition_stats: PartitionStats {
+                        num_rows: Some(rows),
+                        num_batches: None,
+                        num_bytes: Some(bytes),
+                    },
+                    file_id: None,
+                    is_sort_shuffle: false,
+                }]
+            })
+            .collect()
+    }
+
+    /// Concatenate selected upstream M-shape inner-Vecs into a K-shape 
inner-Vec.
+    ///
+    /// Used by the coalesce tests to mirror what the rule does at
+    /// construction time.
+    fn coalesce_upstream(
+        upstream: &[Vec<PartitionLocation>],
+        indices: &[u32],
+    ) -> Vec<PartitionLocation> {
+        let mut out = Vec::new();
+        for &i in indices {
+            out.extend(upstream[i as usize].iter().cloned());
+        }
+        out
+    }
+
     #[tokio::test]
     async fn test_stats_for_partitions_empty() {
         let result = stats_for_partitions(0, std::iter::empty());
@@ -1582,4 +1758,93 @@ mod tests {
             "unexpected error message: {msg}"
         );
     }
+
+    #[tokio::test]
+    async fn test_shuffle_reader_exec_display_with_coalesce_renders_k_of_m() 
-> Result<()>
+    {
+        let schema = Arc::new(Schema::new(vec![Field::new("a", 
DataType::Int32, false)]));
+        let coalesce = CoalescePlan {
+            upstream_partition_count: 8,
+            groups: vec![
+                PartitionGroup {
+                    upstream_indices: vec![0, 1, 2],
+                },
+                PartitionGroup {
+                    upstream_indices: vec![3, 4],
+                },
+                PartitionGroup {
+                    upstream_indices: vec![5, 6, 7],
+                },
+            ],
+        };
+        let exec = ShuffleReaderExec::try_new_coalesced(
+            1,
+            vec![vec![], vec![], vec![]],
+            coalesce,
+            schema,
+            Partitioning::UnknownPartitioning(3),
+        )?;
+        // Exercise propagation through with_work_dir to verify the field 
survives a
+        // builder chain (Self-literal propagation pitfall).
+        let exec = exec.with_work_dir("/tmp".to_string());
+        let s = format!(
+            "{}",
+            datafusion::physical_plan::displayable(&exec).indent(false)
+        );
+        assert!(
+            s.contains(", coalesce: 3 of 8"),
+            "expected ', coalesce: 3 of 8' annotation; got: {s}"
+        );
+        Ok(())
+    }
+
+    #[tokio::test]
+    async fn 
test_coalesced_reader_partition_statistics_sums_concatenated_bytes()
+    -> Result<()> {
+        // Non-uniform group sizes [3,2] over M=5, with non-uniform 
per-partition
+        // byte counts [10,20,30,40,50]. Distinct expected totals (60 vs 90) 
ensure
+        // the wrong axis cannot accidentally produce the right result.
+        let schema = Arc::new(Schema::new(vec![Field::new("a", 
DataType::Int32, false)]));
+        let stage_id = 16;
+        let bytes = [10u64, 20, 30, 40, 50];
+        let rows_per_partition = [1u64, 2, 3, 4, 5];
+        let m = bytes.len();
+        let upstream =
+            make_upstream_partitions_nonuniform(stage_id, &bytes, 
&rows_per_partition);
+        let groups = vec![
+            PartitionGroup {
+                upstream_indices: vec![0, 1, 2],
+            },
+            PartitionGroup {
+                upstream_indices: vec![3, 4],
+            },
+        ];
+        let k = groups.len();
+        let coalesce = CoalescePlan {
+            upstream_partition_count: m as u32,
+            groups: groups.clone(),
+        };
+        let k_shape: Vec<Vec<PartitionLocation>> = groups
+            .iter()
+            .map(|g| coalesce_upstream(&upstream, &g.upstream_indices))
+            .collect();
+
+        let exec = ShuffleReaderExec::try_new_coalesced(
+            stage_id,
+            k_shape,
+            coalesce,
+            schema,
+            Partitioning::UnknownPartitioning(k),
+        )?;
+
+        // partition[0] = upstream [0,1,2] -> 10+20+30 = 60 bytes, 1+2+3 = 6 
rows
+        let stats0 = exec.partition_statistics(Some(0))?;
+        assert_eq!(60, *stats0.total_byte_size.get_value().unwrap());
+        assert_eq!(6, *stats0.num_rows.get_value().unwrap());
+        // partition[1] = upstream [3,4] -> 40+50 = 90 bytes, 4+5 = 9 rows
+        let stats1 = exec.partition_statistics(Some(1))?;
+        assert_eq!(90, *stats1.total_byte_size.get_value().unwrap());
+        assert_eq!(9, *stats1.num_rows.get_value().unwrap());
+        Ok(())
+    }
 }
diff --git a/ballista/core/src/execution_plans/unresolved_shuffle.rs 
b/ballista/core/src/execution_plans/unresolved_shuffle.rs
index 06157b75a..3f3567b6a 100644
--- a/ballista/core/src/execution_plans/unresolved_shuffle.rs
+++ b/ballista/core/src/execution_plans/unresolved_shuffle.rs
@@ -26,6 +26,8 @@ use datafusion::physical_plan::{
     SendableRecordBatchStream,
 };
 
+use crate::execution_plans::CoalescePlan;
+
 /// UnresolvedShuffleExec represents a dependency on the results of a 
ShuffleWriterExec node which hasn't computed yet.
 ///
 /// An ExecutionPlan that contains an UnresolvedShuffleExec isn't ready for 
execution. The presence of this ExecutionPlan
@@ -42,15 +44,22 @@ pub struct UnresolvedShuffleExec {
     pub output_partition_count: usize,
 
     /// The number of shuffle output partitions on the upstream stage. For
-    /// non-broadcast readers this equals `output_partition_count`. For
-    /// broadcast readers this is M (one logical output partition fans in
-    /// all M upstream partition files).
+    /// non-broadcast, non-coalesced readers this equals 
`output_partition_count`.
+    /// For broadcast readers this is M (one logical output partition fans in
+    /// all M upstream partition files). For coalesced readers this is M
+    /// (= `coalesce.upstream_partition_count`).
     pub upstream_partition_count: usize,
 
     /// When true, the resolved `ShuffleReaderExec` reads *all* upstream
     /// partition files into its single output partition (broadcast pattern).
     pub broadcast: bool,
 
+    /// Optional coalesce metadata. `None` means the unresolved placeholder 
will
+    /// resolve to a non-coalesced ShuffleReaderExec (legacy behavior). When 
`Some`,
+    /// the value is forwarded at resolution time so the resulting reader is
+    /// constructed via `ShuffleReaderExec::try_new_coalesced`.
+    pub coalesce: Option<CoalescePlan>,
+
     properties: Arc<PlanProperties>,
 }
 
@@ -71,6 +80,7 @@ impl UnresolvedShuffleExec {
             output_partition_count: partition_count,
             upstream_partition_count: partition_count,
             broadcast: false,
+            coalesce: None,
             properties,
         }
     }
@@ -95,6 +105,41 @@ impl UnresolvedShuffleExec {
             output_partition_count: 1,
             upstream_partition_count,
             broadcast: true,
+            coalesce: None,
+            properties,
+        }
+    }
+
+    /// Create a new coalesce-aware UnresolvedShuffleExec.
+    ///
+    /// `partitioning` MUST already reflect the post-coalesce K (e.g. 
`Partitioning::Hash(keys, K)`)
+    /// so `output_partition_count == K` matches `coalesce.groups.len()`. The 
forwarding
+    /// to `ShuffleReaderExec::try_new_coalesced` happens at stage resolution 
time.
+    pub fn new_coalesced(
+        stage_id: usize,
+        schema: SchemaRef,
+        partitioning: Partitioning,
+        coalesce: CoalescePlan,
+    ) -> Self {
+        debug_assert_eq!(
+            partitioning.partition_count(),
+            coalesce.groups.len(),
+            "partitioning.partition_count() must equal coalesce.groups.len() 
(= K)",
+        );
+        let upstream_partition_count = coalesce.upstream_partition_count as 
usize;
+        let properties = Arc::new(PlanProperties::new(
+            
datafusion::physical_expr::EquivalenceProperties::new(schema.clone()),
+            partitioning,
+            
datafusion::physical_plan::execution_plan::EmissionType::Incremental,
+            datafusion::physical_plan::execution_plan::Boundedness::Bounded,
+        ));
+        Self {
+            stage_id,
+            schema,
+            output_partition_count: properties.partitioning.partition_count(),
+            upstream_partition_count,
+            broadcast: false,
+            coalesce: Some(coalesce),
             properties,
         }
     }
@@ -119,7 +164,16 @@ impl DisplayAs for UnresolvedShuffleExec {
                         f,
                         "UnresolvedShuffleExec: partitioning: {}",
                         self.properties().output_partitioning()
-                    )
+                    )?;
+                    if let Some(c) = &self.coalesce {
+                        write!(
+                            f,
+                            ", coalesce: {} of {}",
+                            c.groups.len(),
+                            c.upstream_partition_count,
+                        )?;
+                    }
+                    Ok(())
                 }
             }
             DisplayFormatType::TreeRender => {
@@ -178,3 +232,43 @@ impl ExecutionPlan for UnresolvedShuffleExec {
         ))
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::execution_plans::PartitionGroup;
+    use datafusion::arrow::datatypes::{DataType, Field, Schema};
+
+    #[tokio::test]
+    async fn 
test_unresolved_shuffle_exec_display_with_coalesce_renders_k_of_m() {
+        let schema = Arc::new(Schema::new(vec![Field::new("a", 
DataType::Int32, false)]));
+        let coalesce = CoalescePlan {
+            upstream_partition_count: 8,
+            groups: vec![
+                PartitionGroup {
+                    upstream_indices: vec![0, 1, 2],
+                },
+                PartitionGroup {
+                    upstream_indices: vec![3, 4],
+                },
+                PartitionGroup {
+                    upstream_indices: vec![5, 6, 7],
+                },
+            ],
+        };
+        let exec = UnresolvedShuffleExec::new_coalesced(
+            1,
+            schema,
+            Partitioning::UnknownPartitioning(3),
+            coalesce,
+        );
+        let s = format!(
+            "{}",
+            datafusion::physical_plan::displayable(&exec).indent(false)
+        );
+        assert!(
+            s.contains(", coalesce: 3 of 8"),
+            "expected ', coalesce: 3 of 8' annotation; got: {s}"
+        );
+    }
+}
diff --git a/ballista/core/src/extension.rs b/ballista/core/src/extension.rs
index 08040b4e7..a9da57c78 100644
--- a/ballista/core/src/extension.rs
+++ b/ballista/core/src/extension.rs
@@ -17,7 +17,9 @@
 
 use crate::config::{
     BALLISTA_BROADCAST_JOIN_THRESHOLD_BYTES, 
BALLISTA_CLIENT_GRPC_MAX_MESSAGE_SIZE,
-    BALLISTA_CLIENT_USE_TLS, BALLISTA_JOB_NAME,
+    BALLISTA_CLIENT_USE_TLS, BALLISTA_COALESCE_ENABLED,
+    BALLISTA_COALESCE_MERGED_PARTITION_FACTOR, 
BALLISTA_COALESCE_SMALL_PARTITION_FACTOR,
+    BALLISTA_COALESCE_TARGET_PARTITION_BYTES, BALLISTA_JOB_NAME,
     BALLISTA_SHUFFLE_READER_FORCE_REMOTE_READ, 
BALLISTA_SHUFFLE_READER_MAX_REQUESTS,
     BALLISTA_SHUFFLE_READER_REMOTE_PREFER_FLIGHT, 
BALLISTA_STANDALONE_PARALLELISM,
     BallistaConfig,
@@ -245,6 +247,26 @@ pub trait SessionConfigExt {
 
     /// 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.
+    fn with_ballista_coalesce_enabled(self, enabled: bool) -> Self;
+
+    /// Returns the target post-coalesce partition byte size in bytes.
+    fn ballista_coalesce_target_partition_bytes(&self) -> u64;
+    /// Sets the target post-coalesce partition byte size in bytes.
+    fn with_ballista_coalesce_target_partition_bytes(self, bytes: u64) -> Self;
+
+    /// Returns the small-partition merge factor (Spark legacy).
+    fn ballista_coalesce_small_partition_factor(&self) -> f64;
+    /// Sets the small-partition merge factor (Spark legacy).
+    fn with_ballista_coalesce_small_partition_factor(self, factor: f64) -> 
Self;
+
+    /// Returns the merged-partition early-flush factor (Spark legacy).
+    fn ballista_coalesce_merged_partition_factor(&self) -> f64;
+    /// Sets the merged-partition early-flush factor (Spark legacy).
+    fn with_ballista_coalesce_merged_partition_factor(self, factor: f64) -> 
Self;
 }
 
 /// [SessionConfigHelperExt] is set of [SessionConfig] extension methods
@@ -581,6 +603,85 @@ impl SessionConfigExt for SessionConfig {
             .map(|c| c.client_use_tls())
             .unwrap_or_else(|| BallistaConfig::default().client_use_tls())
     }
+
+    fn ballista_coalesce_enabled(&self) -> bool {
+        self.options()
+            .extensions
+            .get::<BallistaConfig>()
+            .map(|c| c.coalesce_enabled())
+            .unwrap_or_else(|| BallistaConfig::default().coalesce_enabled())
+    }
+
+    fn with_ballista_coalesce_enabled(self, enabled: bool) -> Self {
+        if self.options().extensions.get::<BallistaConfig>().is_some() {
+            self.set_bool(BALLISTA_COALESCE_ENABLED, enabled)
+        } else {
+            self.with_option_extension(BallistaConfig::default())
+                .set_bool(BALLISTA_COALESCE_ENABLED, enabled)
+        }
+    }
+
+    fn ballista_coalesce_target_partition_bytes(&self) -> u64 {
+        self.options()
+            .extensions
+            .get::<BallistaConfig>()
+            .map(|c| c.coalesce_target_partition_bytes())
+            .unwrap_or_else(|| {
+                BallistaConfig::default().coalesce_target_partition_bytes()
+            })
+    }
+
+    fn with_ballista_coalesce_target_partition_bytes(self, bytes: u64) -> Self 
{
+        if self.options().extensions.get::<BallistaConfig>().is_some() {
+            self.set_usize(BALLISTA_COALESCE_TARGET_PARTITION_BYTES, bytes as 
usize)
+        } else {
+            self.with_option_extension(BallistaConfig::default())
+                .set_usize(BALLISTA_COALESCE_TARGET_PARTITION_BYTES, bytes as 
usize)
+        }
+    }
+
+    fn ballista_coalesce_small_partition_factor(&self) -> f64 {
+        self.options()
+            .extensions
+            .get::<BallistaConfig>()
+            .map(|c| c.coalesce_small_partition_factor())
+            .unwrap_or_else(|| {
+                BallistaConfig::default().coalesce_small_partition_factor()
+            })
+    }
+
+    // f64 setter — uses set_str because SessionConfig has no set_f64 in this
+    // workspace; the stored string is round-tripped via f64::to_string() /
+    // f64::from_str(), mirroring the `with_ballista_job_name` set_str pattern.
+    fn with_ballista_coalesce_small_partition_factor(self, factor: f64) -> 
Self {
+        let s = factor.to_string();
+        if self.options().extensions.get::<BallistaConfig>().is_some() {
+            self.set_str(BALLISTA_COALESCE_SMALL_PARTITION_FACTOR, &s)
+        } else {
+            self.with_option_extension(BallistaConfig::default())
+                .set_str(BALLISTA_COALESCE_SMALL_PARTITION_FACTOR, &s)
+        }
+    }
+
+    fn ballista_coalesce_merged_partition_factor(&self) -> f64 {
+        self.options()
+            .extensions
+            .get::<BallistaConfig>()
+            .map(|c| c.coalesce_merged_partition_factor())
+            .unwrap_or_else(|| {
+                BallistaConfig::default().coalesce_merged_partition_factor()
+            })
+    }
+
+    fn with_ballista_coalesce_merged_partition_factor(self, factor: f64) -> 
Self {
+        let s = factor.to_string();
+        if self.options().extensions.get::<BallistaConfig>().is_some() {
+            self.set_str(BALLISTA_COALESCE_MERGED_PARTITION_FACTOR, &s)
+        } else {
+            self.with_option_extension(BallistaConfig::default())
+                .set_str(BALLISTA_COALESCE_MERGED_PARTITION_FACTOR, &s)
+        }
+    }
 }
 
 impl SessionConfigHelperExt for SessionConfig {
diff --git a/ballista/core/src/serde/generated/ballista.rs 
b/ballista/core/src/serde/generated/ballista.rs
index 897326fdd..774c53ad3 100644
--- a/ballista/core/src/serde/generated/ballista.rs
+++ b/ballista/core/src/serde/generated/ballista.rs
@@ -95,6 +95,9 @@ pub struct UnresolvedShuffleExecNode {
     pub broadcast: bool,
     #[prost(uint32, tag = "7")]
     pub upstream_partition_count: u32,
+    /// Optional coalesce metadata. Absent means "no coalesce" (legacy 
one-to-one read behavior).
+    #[prost(message, optional, tag = "8")]
+    pub coalesce: ::core::option::Option<CoalescePlan>,
 }
 #[derive(Clone, PartialEq, ::prost::Message)]
 pub struct ShuffleReaderExecNode {
@@ -111,6 +114,9 @@ pub struct ShuffleReaderExecNode {
     pub broadcast: bool,
     #[prost(uint32, tag = "6")]
     pub upstream_partition_count: u32,
+    /// Optional coalesce metadata. Absent means "no coalesce" (legacy 
one-to-one read behavior).
+    #[prost(message, optional, tag = "7")]
+    pub coalesce: ::core::option::Option<CoalescePlan>,
 }
 #[derive(Clone, PartialEq, ::prost::Message)]
 pub struct ShuffleReaderPartition {
@@ -118,6 +124,24 @@ pub struct ShuffleReaderPartition {
     #[prost(message, repeated, tag = "1")]
     pub location: ::prost::alloc::vec::Vec<PartitionLocation>,
 }
+/// CoalescePartitionsRule output: groups upstream partitions into coalesced 
output partitions.
+/// Empty when no coalesce is applied (the optional field on the parent 
message is absent).
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct CoalescePlan {
+    /// Original number of upstream partitions (M) before coalescing. Required 
for EXPLAIN's "K of M" rendering.
+    #[prost(uint32, tag = "1")]
+    pub upstream_partition_count: u32,
+    /// Coalesced output groups. Length is K (the post-coalesce partition 
count).
+    #[prost(message, repeated, tag = "2")]
+    pub groups: ::prost::alloc::vec::Vec<PartitionGroup>,
+}
+/// One coalesced output partition's source list: a set of upstream partition 
indices in \[0, upstream_partition_count).
+/// Default algorithm produces only contiguous indices, but proto allows 
arbitrary index sets for future strategies.
+#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
+pub struct PartitionGroup {
+    #[prost(uint32, repeated, tag = "1")]
+    pub upstream_indices: ::prost::alloc::vec::Vec<u32>,
+}
 /// 
/////////////////////////////////////////////////////////////////////////////////////////////////
 /// Ballista Scheduling
 /// 
/////////////////////////////////////////////////////////////////////////////////////////////////
diff --git a/ballista/core/src/serde/mod.rs b/ballista/core/src/serde/mod.rs
index d24398201..b90867433 100644
--- a/ballista/core/src/serde/mod.rs
+++ b/ballista/core/src/serde/mod.rs
@@ -53,7 +53,8 @@ use std::{convert::TryInto, io::Cursor};
 
 use crate::execution_plans::sort_shuffle::SortShuffleConfig;
 use crate::execution_plans::{
-    ShuffleReaderExec, ShuffleWriterExec, SortShuffleWriterExec, 
UnresolvedShuffleExec,
+    CoalescePlan, PartitionGroup, ShuffleReaderExec, ShuffleWriterExec,
+    SortShuffleWriterExec, UnresolvedShuffleExec,
 };
 use crate::serde::protobuf::{
     ballista_logical_plan_node::LogicalPlanType,
@@ -67,6 +68,47 @@ pub mod generated;
 /// Scheduler-specific serialization types and conversions.
 pub mod scheduler;
 
+// ============================ CoalescePlan codec ============================
+//
+// Native ↔ proto conversions for `CoalescePlan` and `PartitionGroup`. Borrow-
+// based on the encode side because the call site only has a borrow
+// (`exec.coalesce.as_ref()`); the `Vec<u32>` clone is intentional and cheap
+// for typical K (small post-coalesce partition counts).
+
+impl From<&protobuf::PartitionGroup> for PartitionGroup {
+    fn from(p: &protobuf::PartitionGroup) -> Self {
+        Self {
+            upstream_indices: p.upstream_indices.clone(),
+        }
+    }
+}
+
+impl From<&PartitionGroup> for protobuf::PartitionGroup {
+    fn from(p: &PartitionGroup) -> Self {
+        Self {
+            upstream_indices: p.upstream_indices.clone(),
+        }
+    }
+}
+
+impl From<&protobuf::CoalescePlan> for CoalescePlan {
+    fn from(p: &protobuf::CoalescePlan) -> Self {
+        Self {
+            upstream_partition_count: p.upstream_partition_count,
+            groups: p.groups.iter().map(PartitionGroup::from).collect(),
+        }
+    }
+}
+
+impl From<&CoalescePlan> for protobuf::CoalescePlan {
+    fn from(p: &CoalescePlan) -> Self {
+        Self {
+            upstream_partition_count: p.upstream_partition_count,
+            groups: p.groups.iter().map(Into::into).collect(),
+        }
+    }
+}
+
 impl ProstMessageExt for protobuf::Action {
     fn type_url() -> &'static str {
         "type.googleapis.com/arrow.flight.protocol.sql.Action"
@@ -425,7 +467,15 @@ impl PhysicalExtensionCodec for 
BallistaPhysicalExtensionCodec {
                 )?;
                 let partitioning = partitioning
                     .ok_or_else(|| proto_error("missing required partitioning 
field"))?;
-                let exec = if shuffle_reader.broadcast {
+                let exec = if let Some(c) = shuffle_reader.coalesce.as_ref() {
+                    ShuffleReaderExec::try_new_coalesced(
+                        stage_id,
+                        partition_location,
+                        CoalescePlan::from(c),
+                        schema,
+                        partitioning,
+                    )?
+                } else if shuffle_reader.broadcast {
                     let all_locations = partition_location
                         .into_iter()
                         .next()
@@ -460,7 +510,14 @@ impl PhysicalExtensionCodec for 
BallistaPhysicalExtensionCodec {
                 )?;
                 let partitioning = partitioning
                     .ok_or_else(|| proto_error("missing required partitioning 
field"))?;
-                let exec = if unresolved_shuffle.broadcast {
+                let exec = if let Some(c) = 
unresolved_shuffle.coalesce.as_ref() {
+                    UnresolvedShuffleExec::new_coalesced(
+                        unresolved_shuffle.stage_id as usize,
+                        schema,
+                        partitioning,
+                        CoalescePlan::from(c),
+                    )
+                } else if unresolved_shuffle.broadcast {
                     UnresolvedShuffleExec::new_broadcast(
                         unresolved_shuffle.stage_id as usize,
                         schema,
@@ -597,6 +654,7 @@ impl PhysicalExtensionCodec for 
BallistaPhysicalExtensionCodec {
                         partitioning: Some(partitioning),
                         broadcast: exec.broadcast,
                         upstream_partition_count: 
exec.upstream_partition_count as u32,
+                        coalesce: exec.coalesce.as_ref().map(|c| c.into()),
                     },
                 )),
             };
@@ -622,6 +680,7 @@ impl PhysicalExtensionCodec for 
BallistaPhysicalExtensionCodec {
                         partitioning: Some(partitioning),
                         broadcast: exec.broadcast,
                         upstream_partition_count: 
exec.upstream_partition_count as u32,
+                        coalesce: exec.coalesce.as_ref().map(|c| c.into()),
                     },
                 )),
             };
@@ -662,6 +721,7 @@ struct FileFormatProto {
 #[cfg(test)]
 mod test {
     use super::*;
+    use crate::execution_plans::PartitionGroup;
     use datafusion::arrow::datatypes::{DataType, Field, Schema};
     use datafusion::physical_plan::Partitioning;
     use datafusion::physical_plan::expressions::col;
@@ -747,6 +807,10 @@ mod test {
         assert_eq!(decoded_exec.stage_id, 1);
         assert_eq!(decoded_exec.schema().as_ref(), schema.as_ref());
         assert_eq!(&decoded_exec.properties().partitioning, &partitioning);
+        assert!(
+            decoded_exec.coalesce.is_none(),
+            "absent coalesce field must decode to None (codec inertness)"
+        );
     }
 
     #[tokio::test]
@@ -780,6 +844,274 @@ mod test {
         assert_eq!(decoded_exec.stage_id, 1);
         assert_eq!(decoded_exec.schema().as_ref(), schema.as_ref());
         assert_eq!(&decoded_exec.properties().partitioning, &partitioning);
+        assert!(
+            decoded_exec.coalesce.is_none(),
+            "absent coalesce field must decode to None (codec inertness)"
+        );
+    }
+
+    #[tokio::test]
+    async fn test_shuffle_reader_exec_coalesced_roundtrip_single_group() {
+        let schema = create_test_schema();
+        let partitioning =
+            Partitioning::Hash(vec![col("id", schema.as_ref()).unwrap()], 1);
+        let coalesce = CoalescePlan {
+            upstream_partition_count: 4,
+            groups: vec![PartitionGroup {
+                upstream_indices: vec![0, 1, 2, 3],
+            }],
+        };
+
+        let original_exec = ShuffleReaderExec::try_new_coalesced(
+            7,
+            vec![vec![]; 1], // K-shape: 1 output partition
+            coalesce.clone(),
+            schema.clone(),
+            partitioning.clone(),
+        )
+        .unwrap();
+
+        let codec = BallistaPhysicalExtensionCodec::default();
+        let mut buf: Vec<u8> = vec![];
+        codec
+            .try_encode(Arc::new(original_exec.clone()), &mut buf)
+            .unwrap();
+
+        let ctx = SessionContext::new().task_ctx();
+        let decoded_plan = codec.try_decode(&buf, &[], &ctx).unwrap();
+        let decoded_exec = decoded_plan
+            .as_any()
+            .downcast_ref::<ShuffleReaderExec>()
+            .expect("Expected ShuffleReaderExec");
+
+        assert_eq!(decoded_exec.stage_id, 7);
+        assert_eq!(&decoded_exec.properties().partitioning, &partitioning);
+        let stored = decoded_exec
+            .coalesce
+            .as_ref()
+            .expect("coalesce must round-trip");
+        assert_eq!(stored, &coalesce);
+        assert_eq!(stored.upstream_partition_count, 4);
+        assert_eq!(stored.groups.len(), 1);
+        assert_eq!(stored.groups[0].upstream_indices, vec![0, 1, 2, 3]);
+    }
+
+    #[tokio::test]
+    async fn 
test_shuffle_reader_exec_coalesced_roundtrip_multi_group_mixed_sizes() {
+        let schema = create_test_schema();
+        let partitioning =
+            Partitioning::Hash(vec![col("id", schema.as_ref()).unwrap()], 3);
+        let coalesce = CoalescePlan {
+            upstream_partition_count: 8,
+            groups: vec![
+                PartitionGroup {
+                    upstream_indices: vec![0, 1, 2],
+                },
+                PartitionGroup {
+                    upstream_indices: vec![3, 4],
+                },
+                PartitionGroup {
+                    upstream_indices: vec![5, 6, 7],
+                },
+            ],
+        };
+
+        let original_exec = ShuffleReaderExec::try_new_coalesced(
+            1,
+            vec![vec![]; 3], // K-shape: 3 output partitions
+            coalesce.clone(),
+            schema.clone(),
+            partitioning.clone(),
+        )
+        .unwrap();
+
+        let codec = BallistaPhysicalExtensionCodec::default();
+        let mut buf: Vec<u8> = vec![];
+        codec
+            .try_encode(Arc::new(original_exec.clone()), &mut buf)
+            .unwrap();
+
+        let ctx = SessionContext::new().task_ctx();
+        let decoded_plan = codec.try_decode(&buf, &[], &ctx).unwrap();
+        let decoded_exec = decoded_plan
+            .as_any()
+            .downcast_ref::<ShuffleReaderExec>()
+            .expect("Expected ShuffleReaderExec");
+
+        let stored = decoded_exec
+            .coalesce
+            .as_ref()
+            .expect("coalesce must round-trip");
+        assert_eq!(stored, &coalesce);
+        assert_eq!(stored.upstream_partition_count, 8);
+        assert_eq!(stored.groups.len(), 3);
+    }
+
+    #[tokio::test]
+    async fn test_unresolved_shuffle_exec_coalesced_roundtrip_multi_index() {
+        let schema = create_test_schema();
+        let partitioning =
+            Partitioning::Hash(vec![col("id", schema.as_ref()).unwrap()], 2);
+        let coalesce = CoalescePlan {
+            upstream_partition_count: 5,
+            groups: vec![
+                PartitionGroup {
+                    upstream_indices: vec![0, 1, 2, 3],
+                },
+                PartitionGroup {
+                    upstream_indices: vec![4],
+                },
+            ],
+        };
+
+        let original_exec = UnresolvedShuffleExec::new_coalesced(
+            9,
+            schema.clone(),
+            partitioning.clone(),
+            coalesce.clone(),
+        );
+
+        let codec = BallistaPhysicalExtensionCodec::default();
+        let mut buf: Vec<u8> = vec![];
+        codec
+            .try_encode(Arc::new(original_exec.clone()), &mut buf)
+            .unwrap();
+
+        let ctx = SessionContext::new().task_ctx();
+        let decoded_plan = codec.try_decode(&buf, &[], &ctx).unwrap();
+        let decoded_exec = decoded_plan
+            .as_any()
+            .downcast_ref::<UnresolvedShuffleExec>()
+            .expect("Expected UnresolvedShuffleExec");
+
+        assert_eq!(decoded_exec.stage_id, 9);
+        let stored = decoded_exec
+            .coalesce
+            .as_ref()
+            .expect("coalesce must round-trip");
+        assert_eq!(stored, &coalesce);
+        assert_eq!(stored.upstream_partition_count, 5);
+        assert_eq!(stored.groups[0].upstream_indices.len(), 4);
+    }
+
+    #[tokio::test]
+    async fn 
test_shuffle_reader_exec_coalesced_roundtrip_non_contiguous_indices() {
+        // Proto allows arbitrary upstream_indices sets even though the default
+        // algorithm only emits contiguous ranges.
+        let schema = create_test_schema();
+        let partitioning =
+            Partitioning::Hash(vec![col("id", schema.as_ref()).unwrap()], 2);
+        let coalesce = CoalescePlan {
+            upstream_partition_count: 6,
+            groups: vec![
+                PartitionGroup {
+                    upstream_indices: vec![0, 2, 4],
+                },
+                PartitionGroup {
+                    upstream_indices: vec![1, 3, 5],
+                },
+            ],
+        };
+
+        let original_exec = ShuffleReaderExec::try_new_coalesced(
+            3,
+            vec![vec![]; 2],
+            coalesce.clone(),
+            schema.clone(),
+            partitioning.clone(),
+        )
+        .unwrap();
+
+        let codec = BallistaPhysicalExtensionCodec::default();
+        let mut buf: Vec<u8> = vec![];
+        codec
+            .try_encode(Arc::new(original_exec.clone()), &mut buf)
+            .unwrap();
+
+        let ctx = SessionContext::new().task_ctx();
+        let decoded_plan = codec.try_decode(&buf, &[], &ctx).unwrap();
+        let decoded_exec = decoded_plan
+            .as_any()
+            .downcast_ref::<ShuffleReaderExec>()
+            .expect("Expected ShuffleReaderExec");
+
+        let stored = decoded_exec
+            .coalesce
+            .as_ref()
+            .expect("coalesce must round-trip");
+        assert_eq!(stored, &coalesce);
+        // Non-contiguous indices preserved bit-for-bit:
+        assert_eq!(stored.groups[0].upstream_indices, vec![0, 2, 4]);
+        assert_eq!(stored.groups[1].upstream_indices, vec![1, 3, 5]);
+    }
+
+    // ---- CoalescePlan native ↔ proto direct conversion round-trips ----
+
+    #[test]
+    fn coalesce_plan_native_to_proto_roundtrip_empty() {
+        let native = CoalescePlan {
+            upstream_partition_count: 0,
+            groups: vec![],
+        };
+        let proto: protobuf::CoalescePlan = (&native).into();
+        let back: CoalescePlan = (&proto).into();
+        assert_eq!(native, back);
+    }
+
+    #[test]
+    fn coalesce_plan_native_to_proto_roundtrip_single_group() {
+        let native = CoalescePlan {
+            upstream_partition_count: 4,
+            groups: vec![PartitionGroup {
+                upstream_indices: vec![0, 1, 2, 3],
+            }],
+        };
+        let proto: protobuf::CoalescePlan = (&native).into();
+        let back: CoalescePlan = (&proto).into();
+        assert_eq!(native, back);
+    }
+
+    #[test]
+    fn coalesce_plan_native_to_proto_roundtrip_multi_group_mixed_sizes() {
+        let native = CoalescePlan {
+            upstream_partition_count: 8,
+            groups: vec![
+                PartitionGroup {
+                    upstream_indices: vec![0, 1, 2],
+                },
+                PartitionGroup {
+                    upstream_indices: vec![3, 4],
+                },
+                PartitionGroup {
+                    upstream_indices: vec![5, 6, 7],
+                },
+            ],
+        };
+        let proto: protobuf::CoalescePlan = (&native).into();
+        let back: CoalescePlan = (&proto).into();
+        assert_eq!(native, back);
+        assert_eq!(back.groups.len(), 3);
+        assert_eq!(back.upstream_partition_count, 8);
+    }
+
+    #[test]
+    fn coalesce_plan_native_to_proto_roundtrip_non_contiguous_indices() {
+        // Proto allows arbitrary index sets even though the default algorithm
+        // only produces contiguous ranges.
+        let native = CoalescePlan {
+            upstream_partition_count: 6,
+            groups: vec![
+                PartitionGroup {
+                    upstream_indices: vec![0, 2, 4],
+                },
+                PartitionGroup {
+                    upstream_indices: vec![1, 3, 5],
+                },
+            ],
+        };
+        let proto: protobuf::CoalescePlan = (&native).into();
+        let back: CoalescePlan = (&proto).into();
+        assert_eq!(native, back);
     }
 
     #[tokio::test]
diff --git a/ballista/core/src/serde/scheduler/mod.rs 
b/ballista/core/src/serde/scheduler/mod.rs
index 9e80d91b6..eca6700d9 100644
--- a/ballista/core/src/serde/scheduler/mod.rs
+++ b/ballista/core/src/serde/scheduler/mod.rs
@@ -270,6 +270,9 @@ pub struct ExecutorDataChange {
 pub struct PartitionStats {
     pub(crate) num_rows: Option<u64>,
     pub(crate) num_batches: Option<u64>,
+    /// Per-partition byte size reported by the shuffle writer. Read by the
+    /// AQE coalesce rule (in `ballista-scheduler`) to bin-pack alignment
+    /// groups, so this field is `pub` rather than `pub(crate)`.
     pub(crate) num_bytes: Option<u64>,
 }
 
@@ -297,6 +300,11 @@ impl PartitionStats {
         }
     }
 
+    /// Returns the per-partition byte size, if populated by the writer.
+    pub fn num_bytes(&self) -> Option<u64> {
+        self.num_bytes
+    }
+
     /// Returns the Arrow struct field representation of these statistics.
     pub fn arrow_struct_repr(self) -> Field {
         Field::new(
diff --git a/ballista/scheduler/src/state/aqe/adapter.rs 
b/ballista/scheduler/src/state/aqe/adapter.rs
index 111f036b6..dae2a2944 100644
--- a/ballista/scheduler/src/state/aqe/adapter.rs
+++ b/ballista/scheduler/src/state/aqe/adapter.rs
@@ -22,6 +22,7 @@ use ballista_core::execution_plans::ShuffleReaderExec;
 use datafusion::common::exec_err;
 use datafusion::config::ConfigOptions;
 use datafusion::error::DataFusionError;
+use datafusion::physical_plan::Partitioning;
 use datafusion::{
     common::tree_node::{Transformed, TreeNode},
     physical_plan::ExecutionPlan,
@@ -57,10 +58,46 @@ impl BallistaAdapter {
             })?;
             self.inputs.push(stage_id);
             let partitioning = exchange.properties().partitioning.clone();
-            let shuffle_read =
-                ShuffleReaderExec::try_new(stage_id, partitions, schema, 
partitioning)?;
 
-            Ok(Transformed::yes(Arc::new(shuffle_read)))
+            let reader = match exchange.coalesce() {
+                Some(cp) => {
+                    // Concatenate M-shape locations into K-shape per 
CoalescePlan.groups.
+                    let k_shape: Vec<Vec<_>> = cp
+                        .groups
+                        .iter()
+                        .map(|pg| {
+                            let mut concat = Vec::new();
+                            for &idx in &pg.upstream_indices {
+                                if let Some(inner) = partitions.get(idx as 
usize) {
+                                    concat.extend_from_slice(inner);
+                                }
+                            }
+                            concat
+                        })
+                        .collect();
+                    let new_partitioning = match &partitioning {
+                        Partitioning::Hash(keys, _m) => {
+                            Partitioning::Hash(keys.clone(), cp.groups.len())
+                        }
+                        _ => 
Partitioning::UnknownPartitioning(cp.groups.len()),
+                    };
+                    ShuffleReaderExec::try_new_coalesced(
+                        stage_id,
+                        k_shape,
+                        (*cp).clone(),
+                        schema,
+                        new_partitioning,
+                    )?
+                }
+                None => ShuffleReaderExec::try_new(
+                    stage_id,
+                    partitions,
+                    schema,
+                    partitioning,
+                )?,
+            };
+
+            Ok(Transformed::yes(Arc::new(reader)))
         } else {
             Ok(Transformed::no(plan))
         }
diff --git a/ballista/scheduler/src/state/aqe/coalesce/algorithm.rs 
b/ballista/scheduler/src/state/aqe/coalesce/algorithm.rs
new file mode 100644
index 000000000..1e7a1c739
--- /dev/null
+++ b/ballista/scheduler/src/state/aqe/coalesce/algorithm.rs
@@ -0,0 +1,241 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Bin-packing helpers that turn per-partition byte sizes into coalesce
+//! decisions.
+//!
+//! `split_size_list_by_target_size` walks the size list left-to-right,
+//! accumulating into a bucket and flushing when adding the next size would
+//! overshoot `target`. Two refinements smooth out pathological shapes that
+//! pure overshoot-flushing produces:
+//!
+//! - **merged-factor early flush** — when a small flushed bucket sits next
+//!   to another small one, fold them together rather than leaving two tiny
+//!   downstream tasks.
+//! - **small-tail folding** — the post-loop pass folds a small final bucket
+//!   into its predecessor.
+//!
+//! The implementation derives from Spark's `ShufflePartitionsUtil`; both
+//! refinements are load-bearing on bursty workloads and aren't optional
+//! polish.
+//!
+//! Float arithmetic is intentional: every comparison casts `u64 → f64` so
+//! `target * factor` matches the original semantics exactly. Don't
+//! pre-compute integer thresholds.
+
+use ballista_core::execution_plans::PartitionGroup;
+
+/// Pack `sizes` into bins whose total bytes approach `target` and return
+/// each bin's start index.
+///
+/// Output `starts` defines bins as: bin `k` covers
+/// `sizes[starts[k]..starts.get(k+1).unwrap_or(&sizes.len())]`. Always
+/// returns at least `vec![0]`, including for an empty input.
+///
+/// `small_factor` (default 0.2) and `merged_factor` (default 1.2) tune the
+/// merge-on-flush refinement: a bucket is merged back into its predecessor
+/// when their combined size is below `target * merged_factor`, or when
+/// either is below `target * small_factor`. The rule wires these from
+/// `ConfigOptions`; tests pass them inline.
+pub fn split_size_list_by_target_size(
+    sizes: &[u64],
+    target: u64,
+    small_factor: f64,
+    merged_factor: f64,
+) -> Vec<usize> {
+    let mut starts: Vec<usize> = vec![0];
+    let mut current: u64 = 0;
+    // Last flushed bucket's size, or None before the first flush.
+    let mut last: Option<u64> = None;
+
+    for (i, &size) in sizes.iter().enumerate() {
+        // Strict `>`: if the next size would push current PAST target, flush.
+        if i > 0 && current + size > target {
+            try_merge_partitions(
+                &mut starts,
+                current,
+                &mut last,
+                target,
+                small_factor,
+                merged_factor,
+            );
+            starts.push(i);
+            current = size;
+        } else {
+            current += size;
+        }
+    }
+    // Unconditional post-loop merge so the small tail bucket has a chance to
+    // fold back into its predecessor.
+    try_merge_partitions(
+        &mut starts,
+        current,
+        &mut last,
+        target,
+        small_factor,
+        merged_factor,
+    );
+    starts
+}
+
+// Decide whether to fold `current` back into the previous bucket. Only
+// callable from `split_size_list_by_target_size`. Float casts are
+// intentional — see module docs.
+fn try_merge_partitions(
+    starts: &mut Vec<usize>,
+    current: u64,
+    last: &mut Option<u64>,
+    target: u64,
+    small_factor: f64,
+    merged_factor: f64,
+) {
+    // Skipped on the first flush, when there's nothing to merge into.
+    let should_merge = match *last {
+        None => false,
+        Some(l) => {
+            let combined = (current + l) as f64;
+            let curr_f = current as f64;
+            let last_f = l as f64;
+            let target_f = target as f64;
+            combined < target_f * merged_factor
+                || curr_f < target_f * small_factor
+                || last_f < target_f * small_factor
+        }
+    };
+    if should_merge {
+        // Pop the last start — merging the current bucket back into the 
previous.
+        starts.pop();
+        // Safe because should_merge implies last.is_some().
+        *last = Some(last.expect("should_merge guarantees Some") + current);
+    } else {
+        *last = Some(current);
+    }
+}
+
+/// Expand the start-index output of [`split_size_list_by_target_size`]
+/// into `PartitionGroup`s.
+///
+/// Group `k` covers `[starts[k], starts.get(k+1).copied().unwrap_or(n))`.
+/// Produces only contiguous ranges; non-contiguous groups stay representable
+/// in proto for future strategies but this algorithm never emits them.
+///
+/// `n` is the total number of upstream partitions (M) — the same value the
+/// `CoalescePlan::upstream_partition_count` field will carry.
+pub fn start_indices_to_partition_groups(
+    starts: &[usize],
+    n: usize,
+) -> Vec<PartitionGroup> {
+    starts
+        .iter()
+        .enumerate()
+        .map(|(k, &start)| {
+            let end = starts.get(k + 1).copied().unwrap_or(n);
+            let upstream_indices: Vec<u32> = (start..end).map(|i| i as 
u32).collect();
+            PartitionGroup { upstream_indices }
+        })
+        .collect()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    // Defaults the rule passes in real life. Pulled out so each test below
+    // reads as "this input → this start-indices vector at the production
+    // configuration", and so a reader doesn't have to wonder whether a
+    // tweaked factor is what's driving the assertion.
+    const TARGET: u64 = 1024;
+    const SMALL: f64 = 0.2;
+    const MERGED: f64 = 1.2;
+
+    fn pack(sizes: &[u64]) -> Vec<usize> {
+        split_size_list_by_target_size(sizes, TARGET, SMALL, MERGED)
+    }
+
+    #[test]
+    fn empty_input_returns_single_bucket() {
+        // No partitions → one (empty) bucket. The `[0]` seed survives the
+        // post-loop merge because `last=None` skips it.
+        assert_eq!(pack(&[]), vec![0]);
+    }
+
+    #[test]
+    fn single_partition_returns_single_bucket() {
+        // Single 2048-byte partition (≥ target). The `i > 0` guard never
+        // fires so no flush happens mid-loop; one bucket.
+        assert_eq!(pack(&[2048]), vec![0]);
+    }
+
+    #[test]
+    fn all_zero_sizes_collapse_to_one_bucket() {
+        // 0 + 0 + 0 + 0 never exceeds target; never flushes.
+        assert_eq!(pack(&[0, 0, 0, 0]), vec![0]);
+    }
+
+    #[test]
+    fn sum_exactly_target_does_not_split() {
+        // 512 + 512 = 1024. The comparison is strict `>`, so 1024 > 1024
+        // is false; the bucket fills to target without flushing.
+        assert_eq!(pack(&[512, 512]), vec![0]);
+    }
+
+    #[test]
+    fn one_byte_over_target_flushes_then_post_merges() {
+        // 512 + 513 = 1025 > 1024 → mid-loop flush. The post-loop merge
+        // then folds the second bucket back in (combined 1025 < 1024*1.2),
+        // so the final result is one bucket.
+        assert_eq!(pack(&[512, 513]), vec![0]);
+    }
+
+    #[test]
+    fn small_tail_folds_into_predecessor() {
+        // 1000 fills bucket 0, 50 starts bucket 1, post-loop sees a
+        // 50-byte tail next to a 1000-byte previous. Combined 1050 <
+        // 1024 * 1.2 = 1228.8 → merge. One bucket.
+        assert_eq!(pack(&[1000, 50]), vec![0]);
+    }
+
+    #[test]
+    fn alternating_pattern_keeps_buckets_separate() {
+        // 800 + 100 = 900 in bucket 0. Adding the third 800 would push to
+        // 1700 > 1024, so bucket 0 closes and bucket 1 starts with 800;
+        // the trailing 100 fills bucket 1 to 900. Post-loop merge is
+        // rejected (1800 ≥ 1228.8; both buckets ≥ small threshold of 205).
+        assert_eq!(pack(&[800, 100, 800, 100]), vec![0, 2]);
+    }
+
+    #[test]
+    fn factor_zero_disables_merging() {
+        // With small=0 and merged=1, only the strict `>` overshoot drives
+        // boundaries; the merging refinement is fully suppressed. Each
+        // 600-byte partition gets its own bucket.
+        let starts = split_size_list_by_target_size(&[600, 600, 600], 1024, 
0.0, 1.0);
+        assert_eq!(starts, vec![0, 1, 2]);
+    }
+
+    #[test]
+    fn start_indices_expand_into_contiguous_partition_groups() {
+        // [0, 3, 5] over n=8 means: group 0 covers indices [0,1,2],
+        // group 1 covers [3,4], group 2 covers [5,6,7]. Pure unpacking,
+        // no algorithm logic.
+        let groups = start_indices_to_partition_groups(&[0, 3, 5], 8);
+        assert_eq!(groups.len(), 3);
+        assert_eq!(groups[0].upstream_indices, vec![0, 1, 2]);
+        assert_eq!(groups[1].upstream_indices, vec![3, 4]);
+        assert_eq!(groups[2].upstream_indices, vec![5, 6, 7]);
+    }
+}
diff --git a/ballista/scheduler/src/state/aqe/optimizer_rule/mod.rs 
b/ballista/scheduler/src/state/aqe/coalesce/mod.rs
similarity index 63%
copy from ballista/scheduler/src/state/aqe/optimizer_rule/mod.rs
copy to ballista/scheduler/src/state/aqe/coalesce/mod.rs
index af23d2a7b..50e9aa592 100644
--- a/ballista/scheduler/src/state/aqe/optimizer_rule/mod.rs
+++ b/ballista/scheduler/src/state/aqe/coalesce/mod.rs
@@ -15,10 +15,15 @@
 // specific language governing permissions and limitations
 // under the License.
 
-pub mod datafusion_patch;
-pub mod distributed_exchange;
-pub mod propagate_empty;
+//! AQE coalesce-shuffle-partitions helpers.
+//!
+//! This submodule packages the pure-CPU helpers `CoalescePartitionsRule`
+//! consumes:
+//!
+//! - [`split_size_list_by_target_size`] — bin-packs per-partition byte sizes
+//!   into bucket boundaries near a target size.
+//! - [`start_indices_to_partition_groups`] — expands those boundaries into
+//!   `PartitionGroup`s attached to a `CoalescePlan`.
 
-pub use datafusion_patch::*;
-pub use distributed_exchange::*;
-pub use propagate_empty::*;
+pub(crate) mod algorithm;
+pub(crate) use algorithm::*;
diff --git a/ballista/scheduler/src/state/aqe/execution_plan.rs 
b/ballista/scheduler/src/state/aqe/execution_plan.rs
index 3fc23de49..ac37328f6 100644
--- a/ballista/scheduler/src/state/aqe/execution_plan.rs
+++ b/ballista/scheduler/src/state/aqe/execution_plan.rs
@@ -33,7 +33,9 @@
 //!   adaptive and to carry mutable state such as `is_final` and resolved
 //!   shuffle metadata.
 
-use ballista_core::execution_plans::{stats_for_partition, 
stats_for_partitions};
+use ballista_core::execution_plans::{
+    CoalescePlan, stats_for_partition, stats_for_partitions,
+};
 use ballista_core::serde::scheduler::PartitionLocation;
 use datafusion::execution::{SendableRecordBatchStream, TaskContext};
 use datafusion::physical_plan::Statistics;
@@ -64,7 +66,7 @@ use std::sync::{Arc, atomic::AtomicI64};
 /// Note: this type implements DataFusion's `ExecutionPlan` trait but returns
 /// an error from `execute` because it is not directly runnable.
 #[derive(Debug)]
-pub(crate) struct ExchangeExec {
+pub struct ExchangeExec {
     input: Arc<dyn ExecutionPlan>,
     properties: Arc<PlanProperties>,
     pub(crate) partitioning: Option<Partitioning>,
@@ -81,6 +83,19 @@ pub(crate) struct ExchangeExec {
     /// can not be assumed.
     shuffle_partitions: Arc<Mutex<Option<Vec<Vec<PartitionLocation>>>>>,
 
+    /// Per-stage coalesce decision attached to this Exchange by
+    /// `CoalescePartitionsRule` before adapter conversion.
+    ///
+    /// `None` means: build the SR with `try_new` (M-partition, no coalesce).
+    /// `Some(cp)` means: build the SR with `try_new_coalesced(cp)` so the
+    /// reader exposes K = `cp.groups.len()` partitions, each backed by the
+    /// upstream-index range described by the corresponding `PartitionGroup`.
+    ///
+    /// Wrapped in `Arc<Mutex<…>>` so `with_new_children` can clone the slot
+    /// alongside the Exchange, keeping rule decisions in sync across
+    /// transform-rebuilt parent chains. Same pattern as `shuffle_partitions`.
+    coalesce: Arc<Mutex<Option<Arc<CoalescePlan>>>>,
+
     /// this disables stage from running even it would be suitable to run.
     ///
     /// the main reason for this property this is to allow rules to override
@@ -90,6 +105,9 @@ pub(crate) struct ExchangeExec {
 }
 
 impl ExchangeExec {
+    /// Creates a new `ExchangeExec` with default stage ID (-1) and empty
+    /// partition set. The stage ID and partitions should be resolved
+    /// before the exchange participates in AQE rules.
     pub fn new(
         input: Arc<dyn ExecutionPlan>,
         partitioning: Option<Partitioning>,
@@ -104,6 +122,9 @@ impl ExchangeExec {
         )
     }
 
+    /// Creates a new `ExchangeExec` with explicitly-provided stage ID and
+    /// partition storage. Used by the AQE rule infrastructure to construct
+    /// exchanges that share atomic state with the enclosing `AdaptivePlanner`.
     pub fn new_with_details(
         input: Arc<dyn ExecutionPlan>,
         partitioning: Option<Partitioning>,
@@ -130,6 +151,7 @@ impl ExchangeExec {
             stage_id,
             shuffle_partitions: stage_partitions,
             partitioning,
+            coalesce: Arc::new(Mutex::new(None)),
             inactive_stage: false,
         }
     }
@@ -176,6 +198,8 @@ impl ExchangeExec {
             .store(id as i64, std::sync::atomic::Ordering::Relaxed);
     }
 
+    /// Returns the stage ID assigned to this exchange, or `None` if the
+    /// stage has not yet been resolved (initial value -1).
     pub fn stage_id(&self) -> Option<usize> {
         let stage_id = 
self.stage_id.load(std::sync::atomic::Ordering::Relaxed);
 
@@ -186,9 +210,23 @@ impl ExchangeExec {
         }
     }
 
+    /// Returns a reference to the input (child) execution plan.
     pub fn input(&self) -> &Arc<dyn ExecutionPlan> {
         &self.input
     }
+
+    /// Attaches a `CoalescePlan` to this Exchange. The adapter consumes the
+    /// plan when converting Exchange → ShuffleReader: a Some value triggers
+    /// `try_new_coalesced` (K-partition reader); None uses `try_new`
+    /// (M-partition reader). Idempotent overwrite.
+    pub fn set_coalesce(&self, cp: Arc<CoalescePlan>) {
+        self.coalesce.lock().replace(cp);
+    }
+
+    /// Returns the attached `CoalescePlan`, if `set_coalesce` was called.
+    pub fn coalesce(&self) -> Option<Arc<CoalescePlan>> {
+        self.coalesce.lock().clone()
+    }
 }
 
 impl DisplayAs for ExchangeExec {
@@ -210,8 +248,17 @@ impl DisplayAs for ExchangeExec {
                     self.stage_id()
                         .map(|stage_id| format!("{}", stage_id))
                         .unwrap_or_else(|| "pending".to_string()),
-                    self.shuffle_created()
-                )
+                    self.shuffle_created(),
+                )?;
+                if let Some(cp) = self.coalesce.lock().as_ref() {
+                    write!(
+                        f,
+                        ", coalesce={} of {}",
+                        cp.groups.len(),
+                        cp.upstream_partition_count,
+                    )?;
+                }
+                Ok(())
             }
             DisplayFormatType::TreeRender => {
                 writeln!(
@@ -273,6 +320,9 @@ impl ExecutionPlan for ExchangeExec {
                 self.shuffle_partitions.clone(),
             );
             new_exec.inactive_stage = self.inactive_stage;
+            // Carry the coalesce slot so a transform-rebuilt parent chain
+            // doesn't lose the rule's decision.
+            new_exec.coalesce = self.coalesce.clone();
 
             Ok(Arc::new(new_exec))
         } else {
diff --git a/ballista/scheduler/src/state/aqe/mod.rs 
b/ballista/scheduler/src/state/aqe/mod.rs
index 6b1903dc9..79e6d6595 100644
--- a/ballista/scheduler/src/state/aqe/mod.rs
+++ b/ballista/scheduler/src/state/aqe/mod.rs
@@ -50,7 +50,8 @@ use std::vec;
 // an AQE optimizer rule in a follow-up PR.
 
 mod adapter;
-mod execution_plan;
+pub(crate) mod coalesce;
+pub(crate) mod execution_plan;
 pub mod optimizer_rule;
 pub mod planner;
 #[cfg(test)]
diff --git 
a/ballista/scheduler/src/state/aqe/optimizer_rule/coalesce_partitions.rs 
b/ballista/scheduler/src/state/aqe/optimizer_rule/coalesce_partitions.rs
new file mode 100644
index 000000000..6efd6b332
--- /dev/null
+++ b/ballista/scheduler/src/state/aqe/optimizer_rule/coalesce_partitions.rs
@@ -0,0 +1,311 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! AQE rule that coalesces shuffle partitions after upstream stages finalize.
+//!
+//! [`CoalescePartitionsRule`] runs once per `replan_stages()` pass on a
+//! stage subtree whose root is either an [`ExchangeExec`] (intermediate
+//! stage) or an [`AdaptiveDatafusionExec`] (final stage). The rule walks the
+//! subtree, collects every leaf [`ExchangeExec`] — the resolved upstream
+//! shuffles feeding this stage — and decides whether to coalesce.
+//!
+//! # The alignment group
+//!
+//! Every leaf `ExchangeExec` in a single stage subtree forms one **alignment
+//! group**. Why one group, not one decision per leaf?
+//!
+//! - Hash-partitioned joins (`HashJoinExec(Partitioned)`, `SortMergeJoinExec`)
+//!   require their two inputs to have the *same partition count* and to be
+//!   hash-partitioned on the join key. If we coalesced left to `K=4` and
+//!   right to `K=2`, DataFusion's `EnforceDistribution` would either reject
+//!   the plan or insert remediation repartitions that undo the optimization.
+//! - Both join legs read shuffle output from upstream stages that wrote
+//!   `M` partitions using the *same* hash function on the *same* key
+//!   (that's what made them joinable in the first place). So upstream
+//!   partition `i` of the left and upstream partition `i` of the right
+//!   hold rows that must meet at downstream partition `f(i)`. Coalescing
+//!   them with the *same* mapping `i → group(i)` keeps that meeting point
+//!   consistent; coalescing them with different mappings scatters it.
+//!
+//! Practically: we treat all leaf Exchanges as a single workload, sum their
+//! per-partition byte counts element-wise, bin-pack the summed sizes once,
+//! and attach the *same* `CoalescePlan` to every leaf. Joins with two leaves
+//! and chains of joins with three or more leaves all go through the same
+//! code path — there is no per-leaf decision.
+//!
+//! Concretely for `[25; 8]` bytes per partition on both sides of a join:
+//! summed `[50; 8]`, bin-pack at target `200` produces `K=2` (4 upstream
+//! partitions per group), both leaves get `coalesce=2 of 8`, the downstream
+//! join runs with 2 partitions on each side, hash buckets stay aligned.
+//!
+//! # Default off
+//!
+//! `ballista.planner.coalesce.enabled` is `false` by default. The rule is an 
opt-in
+//! trade — coalescing reduces task overhead and IPC cost, but at the price
+//! of less downstream parallelism. Users who want the trade explicitly turn
+//! the rule on. When off, the rule short-circuits at the first statement
+//! of `optimize()` and the plan flows through untouched.
+//!
+//! Conceptually:
+//!   - `coalesce.enabled=false` (default) ≈ Spark's `parallelismFirst=true`
+//!     outcome — partitions preserved, no packing.
+//!   - `coalesce.enabled=true` (opt-in) ≈ Spark's `parallelismFirst=false`
+//!     outcome — pack toward the advisory target, accept fewer/larger tasks.
+//!
+//! # Algorithm
+//!
+//!   1. Find leaf `ExchangeExec`s — the alignment group. If empty, this
+//!      stage reads from scans and has nothing to coalesce.
+//!   2. All leaves share the upstream partition count `M` (the writer side).
+//!   3. Sum per-partition byte sizes element-wise across the group to get
+//!      combined work per upstream index.
+//!   4. Bin-pack the summed sizes into `K` buckets near
+//!      `target_partition_bytes` (Spark's `advisoryPartitionSizeInBytes`,
+//!      64 MB by default) using `split_size_list_by_target_size`.
+//!   5. If `K >= M` or `K <= 1`, the rewrite is degenerate and is skipped.
+//!   6. Otherwise, attach a shared [`CoalescePlan`] (with `K` partition
+//!      groups) to every leaf `ExchangeExec` via `set_coalesce(..)`. The
+//!      adapter consumes that decision when it builds the downstream
+//!      `ShuffleReaderExec`s.
+//!
+//! # Carrier semantics
+//!
+//! The `CoalescePlan` lives on the upstream `ExchangeExec`; the rule does
+//! not rewrite the plan tree. Idempotency is structural — `set_coalesce`
+//! overwrites the slot with an equivalent plan on re-entry, and the
+//! bin-pack is a pure function of the resolved byte sizes, so the second
+//! pass produces the same decision.
+//!
+//! # Grouping discipline
+//!
+//! The bin-pack groups **neighboring** upstream partitions only — each
+//! output partition `k` covers a contiguous index range `[start_k,
+//! start_k+1)` over the input. Non-adjacent partitions are never folded
+//! together, even when that would yield a tighter byte fit. This matches
+//! Spark's `CoalesceShufflePartitions` and is what keeps hash
+//! co-partitioning intact across the rewrite: a hash bucket that used to
+//! live at index `i` still lives in the single output group that covers
+//! `i`, on every leaf of the alignment group.
+//!
+//! # Behavior preservation
+//!
+//! When `ballista.planner.coalesce.enabled=false`, when the subtree has no
+//! leaf Exchanges, or when the bin-pack returns a degenerate `K`, the rule
+//! is a no-op and returns the input `Arc` verbatim (preserving
+//! `Arc::ptr_eq`).
+
+use std::sync::Arc;
+
+use ballista_core::config::BallistaConfig;
+use ballista_core::execution_plans::CoalescePlan;
+use datafusion::common::tree_node::{TreeNode, TreeNodeRecursion};
+use datafusion::config::ConfigOptions;
+use datafusion::physical_optimizer::PhysicalOptimizerRule;
+use datafusion::physical_plan::ExecutionPlan;
+use log::debug;
+
+use crate::state::aqe::coalesce::{
+    split_size_list_by_target_size, start_indices_to_partition_groups,
+};
+use crate::state::aqe::execution_plan::AdaptiveDatafusionExec;
+use crate::state::aqe::execution_plan::ExchangeExec;
+
+/// AQE rule that attaches a coalesce decision to every leaf `ExchangeExec`
+/// feeding the current stage, so the downstream reader exposes `K < M`
+/// partitions.
+///
+/// See module docs for design intent.
+#[derive(Debug, Default)]
+pub struct CoalescePartitionsRule;
+
+impl PhysicalOptimizerRule for CoalescePartitionsRule {
+    fn optimize(
+        &self,
+        plan: Arc<dyn ExecutionPlan>,
+        config: &ConfigOptions,
+    ) -> datafusion::common::Result<Arc<dyn ExecutionPlan>> {
+        let bc = config
+            .extensions
+            .get::<BallistaConfig>()
+            .cloned()
+            .unwrap_or_default();
+        if !bc.coalesce_enabled() {
+            return Ok(plan);
+        }
+        let target = bc.coalesce_target_partition_bytes();
+        let small = bc.coalesce_small_partition_factor();
+        let merged = bc.coalesce_merged_partition_factor();
+
+        debug!(
+            "[coalesce-rule] fire: target_partition_bytes={target} 
small_factor={small} merged_factor={merged}",
+        );
+
+        // Get the subtree below the root. Two root kinds, same outcome.
+        let input = if let Some(ex) = 
plan.as_any().downcast_ref::<ExchangeExec>() {
+            debug!(
+                "[coalesce-rule] root=ExchangeExec plan_id={} stage_id={:?} 
stage_resolved={}",
+                ex.plan_id,
+                ex.stage_id(),
+                ex.shuffle_partitions().is_some(),
+            );
+            ex.input().clone()
+        } else if let Some(adp) = 
plan.as_any().downcast_ref::<AdaptiveDatafusionExec>() {
+            debug!(
+                "[coalesce-rule] root=AdaptiveDatafusionExec stage_id={:?}",
+                adp.stage_id(),
+            );
+            adp.input().clone()
+        } else {
+            debug!(
+                "[coalesce-rule] root is neither ExchangeExec nor 
AdaptiveDatafusionExec; bail"
+            );
+            return Ok(plan); // unexpected root — adapter will fail anyway, 
just bail
+        };
+
+        // Collect the alignment group: every leaf `ExchangeExec` feeding
+        // this stage. `Jump` after each hit stops the walk from descending
+        // into the upstream stage's compute — those nodes aren't part of
+        // *this* stage's group, they belong to whatever stage wrote them.
+        let mut leaves: Vec<Arc<dyn ExecutionPlan>> = Vec::new();
+        input.apply(|node| {
+            if node.as_any().is::<ExchangeExec>() {
+                leaves.push(node.clone());
+                Ok(TreeNodeRecursion::Jump)
+            } else {
+                Ok(TreeNodeRecursion::Continue)
+            }
+        })?;
+
+        // Helper: downcast each Arc back to &ExchangeExec.
+        fn as_exchange(arc: &Arc<dyn ExecutionPlan>) -> &ExchangeExec {
+            arc.as_any()
+                .downcast_ref::<ExchangeExec>()
+                .expect("filtered to ExchangeExec above")
+        }
+
+        debug!(
+            "[coalesce-rule] collected {} leaf ExchangeExec(s)",
+            leaves.len()
+        );
+        for arc in &leaves {
+            let ex = as_exchange(arc);
+            debug!(
+                "[coalesce-rule]   leaf: plan_id={} stage_id={:?} 
partitioning={} M={} resolved={} existing_coalesce={:?}",
+                ex.plan_id,
+                ex.stage_id(),
+                ex.properties().partitioning,
+                ex.properties().partitioning.partition_count(),
+                ex.shuffle_partitions().is_some(),
+                ex.coalesce()
+                    .as_ref()
+                    .map(|cp| (cp.groups.len(), cp.upstream_partition_count)),
+            );
+        }
+
+        // Leaf-scan stage with no upstream Exchanges → nothing to coalesce.
+        if leaves.is_empty() {
+            debug!("[coalesce-rule] no leaves; bail");
+            return Ok(plan);
+        }
+
+        // The alignment-group invariant assumes a shared `M`. In every plan
+        // shape we currently produce, all leaves of one stage subtree are
+        // hash-partitioned by the same target_partitions setting upstream,
+        // so reading `M` from leaf 0 is sufficient.
+        let m = as_exchange(&leaves[0])
+            .properties()
+            .partitioning
+            .partition_count();
+
+        // TODO: per-M subgrouping; for now bail on heterogeneous M (Q22 panic 
guard).
+        if leaves
+            .iter()
+            .any(|arc| 
as_exchange(arc).properties().partitioning.partition_count() != m)
+        {
+            return Ok(plan);
+        }
+
+        // Sum byte sizes element-wise across the alignment group. Upstream
+        // partition `i` is the same logical hash bucket on every leaf, so
+        // `summed[i]` is the total downstream work for that bucket. If any
+        // leaf is still unresolved we bail — early `replan_stages()` passes
+        // run before all upstream stages finalize, and the rule reruns on
+        // every later pass anyway, so the no-op is free.
+        let mut summed = vec![0u64; m];
+        for arc in &leaves {
+            let ex = as_exchange(arc);
+            let Some(parts) = ex.shuffle_partitions() else {
+                debug!(
+                    "[coalesce-rule] leaf plan_id={} unresolved; bail entire 
group",
+                    ex.plan_id
+                );
+                return Ok(plan);
+            };
+            for (i, locs) in parts.iter().enumerate() {
+                summed[i] += locs
+                    .iter()
+                    .filter_map(|l| l.partition_stats.num_bytes())
+                    .sum::<u64>();
+            }
+        }
+        debug!("[coalesce-rule] summed bytes per upstream partition: 
{summed:?}");
+
+        // One bin-pack decision for the whole alignment group, packing toward
+        // `target_partition_bytes` (Spark's `advisoryPartitionSizeInBytes`).
+        // The rule is opt-in (`coalesce.enabled=false` by default), so users
+        // get parallelism preservation unless they explicitly trade it for
+        // larger tasks. This corresponds to Spark's
+        // `parallelismFirst=false` mode — direct advisory-driven packing.
+        let starts = split_size_list_by_target_size(&summed, target, small, 
merged);
+        let k = starts.len();
+        debug!("[coalesce-rule] bin-pack result: K={k} M={m}");
+        if k >= m || k <= 1 {
+            debug!(
+                "[coalesce-rule] K degenerate (K>=M or K<=1); bail without 
setting coalesce"
+            );
+            return Ok(plan);
+        }
+
+        // Attach the same `CoalescePlan` to every member of the alignment
+        // group. Sharing the plan (not just the K value) keeps the upstream
+        // index → group mapping identical across leaves — hash buckets that
+        // were aligned at M stay aligned at K, and the join's
+        // partition-count requirement still holds after the rewrite.
+        let cp = Arc::new(CoalescePlan {
+            upstream_partition_count: m as u32,
+            groups: start_indices_to_partition_groups(&starts, m),
+        });
+        for arc in &leaves {
+            let ex = as_exchange(arc);
+            debug!(
+                "[coalesce-rule] set_coalesce(K={k}) on plan_id={} (was {:?})",
+                ex.plan_id,
+                ex.coalesce().as_ref().map(|cp| cp.groups.len()),
+            );
+            ex.set_coalesce(cp.clone());
+        }
+        Ok(plan)
+    }
+
+    fn name(&self) -> &str {
+        "CoalescePartitionsRule"
+    }
+
+    fn schema_check(&self) -> bool {
+        false
+    }
+}
diff --git a/ballista/scheduler/src/state/aqe/optimizer_rule/mod.rs 
b/ballista/scheduler/src/state/aqe/optimizer_rule/mod.rs
index af23d2a7b..348158bb4 100644
--- a/ballista/scheduler/src/state/aqe/optimizer_rule/mod.rs
+++ b/ballista/scheduler/src/state/aqe/optimizer_rule/mod.rs
@@ -15,10 +15,12 @@
 // specific language governing permissions and limitations
 // under the License.
 
+pub mod coalesce_partitions;
 pub mod datafusion_patch;
 pub mod distributed_exchange;
 pub mod propagate_empty;
 
+pub use coalesce_partitions::*;
 pub use datafusion_patch::*;
 pub use distributed_exchange::*;
 pub use propagate_empty::*;
diff --git a/ballista/scheduler/src/state/aqe/planner.rs 
b/ballista/scheduler/src/state/aqe/planner.rs
index 2346bb380..03361dc3c 100644
--- a/ballista/scheduler/src/state/aqe/planner.rs
+++ b/ballista/scheduler/src/state/aqe/planner.rs
@@ -17,7 +17,8 @@
 use crate::state::aqe::adapter::BallistaAdapter;
 use crate::state::aqe::execution_plan::{AdaptiveDatafusionExec, ExchangeExec};
 use crate::state::aqe::optimizer_rule::{
-    DistributedExchangeRule, PropagateEmptyExecRule, WarnOnDuplicateExecRule,
+    CoalescePartitionsRule, DistributedExchangeRule, PropagateEmptyExecRule,
+    WarnOnDuplicateExecRule,
 };
 
 use crate::state::execution_stage::StageOutput;
@@ -26,6 +27,7 @@ use ballista_core::serde::scheduler::PartitionLocation;
 use datafusion::common;
 use datafusion::common::{HashMap, exec_err};
 use datafusion::execution::{SessionState, SessionStateBuilder};
+use datafusion::physical_optimizer::PhysicalOptimizerRule;
 use datafusion::physical_optimizer::optimizer::PhysicalOptimizer;
 use datafusion::physical_plan::{ExecutionPlan, ExecutionPlanProperties, 
displayable};
 use datafusion::physical_planner::DefaultPhysicalPlanner;
@@ -296,8 +298,13 @@ impl AdaptivePlanner {
                 let (stage_ids, shuffle_writers) = stages
                     .into_iter()
                     .map(|plan| {
-                        // TODO: we need to find input stages for given stage
-                        //       thus result should change
+                        // Run the coalesce rule per-stage: the root of `plan` 
is
+                        // the stage's wrapper exchange, so the rule's walker 
sees
+                        // only this stage's input exchanges as the alignment
+                        // group. This avoids cross-stage gluing and stale 
state
+                        // that would arise if the rule walked the entire 
residual
+                        // plan in `default_optimizers()`.
+                        let plan = CoalescePartitionsRule.optimize(plan, 
config)?;
                         BallistaAdapter::adapt_to_ballista(
                             plan,
                             self.job_name.as_str(),
@@ -431,6 +438,10 @@ impl AdaptivePlanner {
         // rule does not mutate plan hance it can go after 
`DistributedExchangeRule`
         physical_optimizers.push(Arc::new(WarnOnDuplicateExecRule::default()));
 
+        // `CoalescePartitionsRule` is invoked per-stage in 
`actionable_stages()`
+        // rather than registered here, so each invocation sees only one 
stage's
+        // plan and forms an alignment group scoped to that stage's inputs.
+
         physical_optimizers
     }
     /// Creates a session state with the given configuration and optimizer 
rules.
diff --git a/ballista/scheduler/src/state/aqe/test/coalesce_rule.rs 
b/ballista/scheduler/src/state/aqe/test/coalesce_rule.rs
new file mode 100644
index 000000000..d055f1ea9
--- /dev/null
+++ b/ballista/scheduler/src/state/aqe/test/coalesce_rule.rs
@@ -0,0 +1,434 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Functional tests for [`CoalescePartitionsRule`]: drive a query through
+//! `AdaptivePlanner`, finalize the upstream stage with synthetic per-partition
+//! byte stats, and snapshot the displayed plan tree so the rule's effect on
+//! the leaf `ExchangeExec` is visible at the `coalesce=K of M` field.
+//!
+//! Each test uses small synthetic byte sizes paired with a small
+//! `coalesce_target_partition_bytes` so the bin-pack outcome is hand-traceable
+//! against `split_size_list_by_target_size`.
+
+use crate::assert_plan;
+use crate::state::aqe::planner::AdaptivePlanner;
+use crate::state::aqe::test::{mock_batch, mock_schema};
+use ballista_core::extension::SessionConfigExt;
+use ballista_core::serde::scheduler::{
+    ExecutorMetadata, ExecutorOperatingSystemSpecification, 
ExecutorSpecification,
+    PartitionId, PartitionLocation, PartitionStats,
+};
+use datafusion::datasource::MemTable;
+use datafusion::execution::SessionStateBuilder;
+use datafusion::prelude::{SessionConfig, SessionContext};
+use std::sync::Arc;
+
+/// Build a session context with the Ballista config extension installed and
+/// the coalesce-relevant knobs forced to specific values. The rule packs
+/// directly toward `target_partition_bytes` (here 200 for test scale), so
+/// every scenario below traces from inputs alone.
+fn coalesce_context(target_partitions: usize, enabled: bool) -> SessionContext 
{
+    let config = SessionConfig::new_with_ballista()
+        .with_target_partitions(target_partitions)
+        .with_round_robin_repartition(false)
+        .with_ballista_coalesce_enabled(enabled)
+        .with_ballista_coalesce_target_partition_bytes(200);
+
+    let state = SessionStateBuilder::new()
+        .with_config(config)
+        .with_default_features()
+        .build();
+
+    SessionContext::new_with_state(state)
+}
+
+/// Register a MemTable with `n_partitions` partitions, each holding one copy
+/// of `mock_batch`. Multi-partition sources force DataFusion's
+/// `EnforceDistribution` to insert a hash repartition before partitioned
+/// joins — without that, two 1-partition inputs satisfy `Partitioned` on a
+/// single partition and no `ExchangeExec` shows up in the final plan.
+fn register_partitioned_table(
+    ctx: &SessionContext,
+    name: &str,
+    n_partitions: usize,
+) -> datafusion::error::Result<()> {
+    let data = (0..n_partitions)
+        .map(|_| Ok(vec![mock_batch()?]))
+        .collect::<datafusion::error::Result<Vec<_>>>()?;
+    let table = MemTable::try_new(mock_schema(), data)?;
+    ctx.register_table(name, Arc::new(table))?;
+    Ok(())
+}
+
+/// Build a `Vec<Vec<PartitionLocation>>` of length `per_partition_bytes.len()`
+/// where each upstream partition reports the given byte size. The rule sums
+/// `partition_stats.num_bytes` across leaves before bin-packing — that's the
+/// only field these tests need to vary.
+fn partitions_with_byte_sizes(
+    per_partition_bytes: &[u64],
+) -> Vec<Vec<PartitionLocation>> {
+    per_partition_bytes
+        .iter()
+        .enumerate()
+        .map(|(idx, &bytes)| {
+            vec![PartitionLocation {
+                map_partition_id: 0,
+                partition_id: PartitionId {
+                    job_id: "".to_string(),
+                    stage_id: 0,
+                    partition_id: idx,
+                },
+                executor_meta: ExecutorMetadata {
+                    id: "".to_string(),
+                    host: "".to_string(),
+                    port: 0,
+                    grpc_port: 0,
+                    specification: 
ExecutorSpecification::default().with_task_slots(0),
+                    os_info: ExecutorOperatingSystemSpecification::default(),
+                },
+                partition_stats: PartitionStats::new(Some(1), None, 
Some(bytes)),
+                file_id: None,
+                is_sort_shuffle: false,
+            }]
+        })
+        .collect()
+}
+
+/// Happy path: M=8 upstream partitions @ 50 bytes each, target=200.
+/// Bin-pack trace (small_factor=0.2 → 40, merged_factor=1.2 → 240):
+///   i=0..3 accumulate into bucket=200; i=4 overshoots, flush, start new;
+///   i=5..7 accumulate into bucket=200; post-loop merge is rejected
+///   (200 + 200 = 400, not below 240). Result: K=2.
+/// Plan tree therefore shows `coalesce=2 of 8` on the leaf Exchange after
+/// stage 0 finalizes.
+#[tokio::test]
+async fn should_attach_coalesce_when_partitions_pack_below_m()
+-> datafusion::error::Result<()> {
+    let ctx = coalesce_context(8, true);
+    ctx.register_batch("t", mock_batch()?)?;
+
+    let plan = ctx
+        .sql("select min(a) as c0, c as c2 from t group by c")
+        .await?
+        .create_physical_plan()
+        .await?;
+    let mut planner =
+        AdaptivePlanner::try_new(ctx.state().config(), plan, 
"test_job".to_string())?;
+
+    // Before any stage finalizes the leaves are unresolved, so the rule
+    // no-ops: `coalesce=none`.
+    assert_plan!(planner.current_plan(),  @ "
+    AdaptiveDatafusionExec: is_final=false, plan_id=1, stage_id=pending, 
stage_resolved=false
+      ProjectionExec: expr=[min(t.a)@1 as c0, c@0 as c2]
+        AggregateExec: mode=FinalPartitioned, gby=[c@0 as c], aggr=[min(t.a)]
+          ExchangeExec: partitioning=Hash([c@0], 8), plan_id=0, 
stage_id=pending, stage_resolved=false
+            AggregateExec: mode=Partial, gby=[c@1 as c], aggr=[min(t.a)]
+              DataSourceExec: partitions=1, partition_sizes=[1]
+    ");
+
+    // Surface the runnable stage so its id is registered before we finalize.
+    let _ = planner.runnable_stages()?.unwrap();
+
+    // Finalize stage 0 with 8 partitions of 50 bytes each (total = 400, 
target = 200).
+    planner.finalise_stage_internal(0, partitions_with_byte_sizes(&[50; 8]))?;
+
+    // Surface the next runnable stage — this is where `CoalescePartitionsRule`
+    // fires per-stage on the downstream consumer and attaches the
+    // `CoalescePlan` to plan_id=0.
+    let _ = planner.runnable_stages()?;
+
+    assert_plan!(planner.current_plan(),  @ "
+    AdaptiveDatafusionExec: is_final=true, plan_id=1, stage_id=1, 
stage_resolved=false
+      ProjectionExec: expr=[min(t.a)@1 as c0, c@0 as c2]
+        AggregateExec: mode=FinalPartitioned, gby=[c@0 as c], aggr=[min(t.a)]
+          ExchangeExec: partitioning=Hash([c@0], 8), plan_id=0, stage_id=0, 
stage_resolved=true, coalesce=2 of 8
+            AggregateExec: mode=Partial, gby=[c@1 as c], aggr=[min(t.a)]
+              DataSourceExec: partitions=1, partition_sizes=[1]
+    ");
+
+    Ok(())
+}
+
+/// Disabled path: same inputs as above but 
`ballista.planner.coalesce.enabled=false`.
+/// The rule short-circuits at the first statement of `optimize()` and returns
+/// the plan untouched, so the leaf Exchange's coalesce slot stays None.
+#[tokio::test]
+async fn should_skip_coalesce_when_rule_disabled() -> 
datafusion::error::Result<()> {
+    let ctx = coalesce_context(8, false);
+    ctx.register_batch("t", mock_batch()?)?;
+
+    let plan = ctx
+        .sql("select min(a) as c0, c as c2 from t group by c")
+        .await?
+        .create_physical_plan()
+        .await?;
+    let mut planner =
+        AdaptivePlanner::try_new(ctx.state().config(), plan, 
"test_job".to_string())?;
+
+    let _ = planner.runnable_stages()?.unwrap();
+    planner.finalise_stage_internal(0, partitions_with_byte_sizes(&[50; 8]))?;
+
+    assert_plan!(planner.current_plan(),  @ "
+    AdaptiveDatafusionExec: is_final=false, plan_id=1, stage_id=pending, 
stage_resolved=false
+      ProjectionExec: expr=[min(t.a)@1 as c0, c@0 as c2]
+        AggregateExec: mode=FinalPartitioned, gby=[c@0 as c], aggr=[min(t.a)]
+          ExchangeExec: partitioning=Hash([c@0], 8), plan_id=0, stage_id=0, 
stage_resolved=true
+            AggregateExec: mode=Partial, gby=[c@1 as c], aggr=[min(t.a)]
+              DataSourceExec: partitions=1, partition_sizes=[1]
+    ");
+
+    Ok(())
+}
+
+/// Degenerate K=M path: every partition is already at target. Bin-pack
+/// flushes after each one (adding the next would exceed target=200), and
+/// the post-flush merge is rejected (each bucket = 300, neither small nor
+/// combinable). K = M = 8 → the rule treats it as no work and returns the
+/// plan as-is.
+#[tokio::test]
+async fn should_skip_coalesce_when_partitions_are_full() -> 
datafusion::error::Result<()>
+{
+    let ctx = coalesce_context(8, true);
+    ctx.register_batch("t", mock_batch()?)?;
+
+    let plan = ctx
+        .sql("select min(a) as c0, c as c2 from t group by c")
+        .await?
+        .create_physical_plan()
+        .await?;
+    let mut planner =
+        AdaptivePlanner::try_new(ctx.state().config(), plan, 
"test_job".to_string())?;
+
+    let _ = planner.runnable_stages()?.unwrap();
+    planner.finalise_stage_internal(0, partitions_with_byte_sizes(&[300; 8]))?;
+
+    assert_plan!(planner.current_plan(),  @ "
+    AdaptiveDatafusionExec: is_final=false, plan_id=1, stage_id=pending, 
stage_resolved=false
+      ProjectionExec: expr=[min(t.a)@1 as c0, c@0 as c2]
+        AggregateExec: mode=FinalPartitioned, gby=[c@0 as c], aggr=[min(t.a)]
+          ExchangeExec: partitioning=Hash([c@0], 8), plan_id=0, stage_id=0, 
stage_resolved=true
+            AggregateExec: mode=Partial, gby=[c@1 as c], aggr=[min(t.a)]
+              DataSourceExec: partitions=1, partition_sizes=[1]
+    ");
+
+    Ok(())
+}
+
+/// Partitioned hash join: both sides shuffled by the join key, both leaf
+/// Exchanges live in the same final-stage subtree. Per-leaf bytes
+/// `[25; 8]` × 2 leaves → summed `[50; 8]` → bin-pack at target=200
+/// collapses to K=2 (same trace as the happy-path test). Both leaves get
+/// the SAME `CoalescePlan` so the join's partition-count requirement holds
+/// across the rewrite.
+#[tokio::test]
+async fn should_attach_coalesce_to_both_sides_of_hash_join()
+-> datafusion::error::Result<()> {
+    let ctx = coalesce_context(8, true);
+    register_partitioned_table(&ctx, "t1", 8)?;
+    register_partitioned_table(&ctx, "t2", 8)?;
+
+    let plan = ctx
+        .sql("select t1.a, t2.b from t1 join t2 on t1.c = t2.c")
+        .await?
+        .create_physical_plan()
+        .await?;
+    let mut planner =
+        AdaptivePlanner::try_new(ctx.state().config(), plan, 
"test_job".to_string())?;
+
+    let stages = planner.runnable_stages()?.unwrap();
+    assert_eq!(2, stages.len());
+
+    planner.finalise_stage_internal(0, partitions_with_byte_sizes(&[25; 8]))?;
+    planner.finalise_stage_internal(1, partitions_with_byte_sizes(&[25; 8]))?;
+
+    // Surface the join stage so `CoalescePartitionsRule` fires per-stage and
+    // attaches the shared `CoalescePlan` to both leaf Exchanges.
+    let _ = planner.runnable_stages()?;
+
+    assert_plan!(planner.current_plan(),  @ "
+    AdaptiveDatafusionExec: is_final=true, plan_id=2, stage_id=2, 
stage_resolved=false
+      ProjectionExec: expr=[a@0 as a, b@2 as b]
+        SortMergeJoinExec: join_type=Inner, on=[(c@1, c@1)]
+          SortExec: expr=[c@1 ASC], preserve_partitioning=[true]
+            ExchangeExec: partitioning=Hash([c@1], 8), plan_id=0, stage_id=0, 
stage_resolved=true, coalesce=2 of 8
+              DataSourceExec: partitions=8, partition_sizes=[1, 1, 1, 1, 1, 1, 
1, 1]
+          SortExec: expr=[c@1 ASC], preserve_partitioning=[true]
+            ExchangeExec: partitioning=Hash([c@1], 8), plan_id=1, stage_id=1, 
stage_resolved=true, coalesce=2 of 8
+              DataSourceExec: partitions=8, partition_sizes=[1, 1, 1, 1, 1, 1, 
1, 1]
+    ");
+
+    Ok(())
+}
+
+/// Two hash joins in one final stage: 3 upstream Exchanges feed the join
+/// chain (t1 ⋈ t2 ⋈ t3 on a shared key). Per-leaf bytes `[16; 8]` × 3
+/// leaves → summed `[48; 8]`. Bin-pack at target=200: 4 partitions fill
+/// bucket to 192, 5th overshoots (240 > 200) and flushes; next bucket fills
+/// remaining 4 to 192; post-loop merge rejected. K=2.
+/// All three leaves get the same `CoalescePlan`.
+#[tokio::test]
+async fn should_attach_coalesce_to_all_three_legs_of_two_hash_joins()
+-> datafusion::error::Result<()> {
+    let ctx = coalesce_context(8, true);
+    register_partitioned_table(&ctx, "t1", 8)?;
+    register_partitioned_table(&ctx, "t2", 8)?;
+    register_partitioned_table(&ctx, "t3", 8)?;
+
+    let plan = ctx
+        .sql(
+            "select t1.a, t2.b, t3.c from t1 join t2 on t1.c = t2.c \
+             join t3 on t1.c = t3.c",
+        )
+        .await?
+        .create_physical_plan()
+        .await?;
+    let mut planner =
+        AdaptivePlanner::try_new(ctx.state().config(), plan, 
"test_job".to_string())?;
+
+    let stages = planner.runnable_stages()?.unwrap();
+    assert_eq!(3, stages.len());
+
+    planner.finalise_stage_internal(0, partitions_with_byte_sizes(&[16; 8]))?;
+    planner.finalise_stage_internal(1, partitions_with_byte_sizes(&[16; 8]))?;
+    planner.finalise_stage_internal(2, partitions_with_byte_sizes(&[16; 8]))?;
+
+    // Surface the join stage so `CoalescePartitionsRule` fires per-stage and
+    // attaches the same `CoalescePlan` to all three leaf Exchanges.
+    let _ = planner.runnable_stages()?;
+
+    assert_plan!(planner.current_plan(),  @ "
+    AdaptiveDatafusionExec: is_final=true, plan_id=3, stage_id=3, 
stage_resolved=false
+      ProjectionExec: expr=[a@0 as a, b@2 as b, c@3 as c]
+        SortMergeJoinExec: join_type=Inner, on=[(c@1, c@0)]
+          ProjectionExec: expr=[a@0 as a, c@1 as c, b@2 as b]
+            SortMergeJoinExec: join_type=Inner, on=[(c@1, c@1)]
+              SortExec: expr=[c@1 ASC], preserve_partitioning=[true]
+                ExchangeExec: partitioning=Hash([c@1], 8), plan_id=0, 
stage_id=0, stage_resolved=true, coalesce=2 of 8
+                  DataSourceExec: partitions=8, partition_sizes=[1, 1, 1, 1, 
1, 1, 1, 1]
+              SortExec: expr=[c@1 ASC], preserve_partitioning=[true]
+                ExchangeExec: partitioning=Hash([c@1], 8), plan_id=1, 
stage_id=1, stage_resolved=true, coalesce=2 of 8
+                  DataSourceExec: partitions=8, partition_sizes=[1, 1, 1, 1, 
1, 1, 1, 1]
+          SortExec: expr=[c@0 ASC], preserve_partitioning=[true]
+            ExchangeExec: partitioning=Hash([c@0], 8), plan_id=2, stage_id=2, 
stage_resolved=true, coalesce=2 of 8
+              DataSourceExec: partitions=8, partition_sizes=[1, 1, 1, 1, 1, 1, 
1, 1]
+    ");
+
+    Ok(())
+}
+
+/// Sort-merge join: same shuffle structure as the hash-join case, but
+/// DataFusion picks `SortMergeJoinExec` when `prefer_hash_join=false`. The
+/// rule is structural — it walks down to the leaf Exchanges and attaches
+/// `CoalescePlan` regardless of the parent join kind. Per-leaf bytes
+/// `[25; 8]` × 2 leaves trace identically to the hash-join case → K=2.
+#[tokio::test]
+async fn should_attach_coalesce_to_both_sides_of_sort_merge_join()
+-> datafusion::error::Result<()> {
+    let config = SessionConfig::new_with_ballista()
+        .with_target_partitions(8)
+        .with_round_robin_repartition(false)
+        .with_ballista_coalesce_enabled(true)
+        .with_ballista_coalesce_target_partition_bytes(200)
+        .set_bool("datafusion.optimizer.prefer_hash_join", false);
+
+    let state = SessionStateBuilder::new()
+        .with_config(config)
+        .with_default_features()
+        .build();
+    let ctx = SessionContext::new_with_state(state);
+    register_partitioned_table(&ctx, "t1", 8)?;
+    register_partitioned_table(&ctx, "t2", 8)?;
+
+    let plan = ctx
+        .sql("select t1.a, t2.b from t1 join t2 on t1.c = t2.c")
+        .await?
+        .create_physical_plan()
+        .await?;
+    let mut planner =
+        AdaptivePlanner::try_new(ctx.state().config(), plan, 
"test_job".to_string())?;
+
+    let stages = planner.runnable_stages()?.unwrap();
+    assert_eq!(2, stages.len());
+
+    planner.finalise_stage_internal(0, partitions_with_byte_sizes(&[25; 8]))?;
+    planner.finalise_stage_internal(1, partitions_with_byte_sizes(&[25; 8]))?;
+
+    // Surface the join stage so `CoalescePartitionsRule` fires per-stage.
+    let _ = planner.runnable_stages()?;
+
+    assert_plan!(planner.current_plan(),  @ "
+    AdaptiveDatafusionExec: is_final=true, plan_id=2, stage_id=2, 
stage_resolved=false
+      ProjectionExec: expr=[a@0 as a, b@2 as b]
+        SortMergeJoinExec: join_type=Inner, on=[(c@1, c@1)]
+          SortExec: expr=[c@1 ASC], preserve_partitioning=[true]
+            ExchangeExec: partitioning=Hash([c@1], 8), plan_id=0, stage_id=0, 
stage_resolved=true, coalesce=2 of 8
+              DataSourceExec: partitions=8, partition_sizes=[1, 1, 1, 1, 1, 1, 
1, 1]
+          SortExec: expr=[c@1 ASC], preserve_partitioning=[true]
+            ExchangeExec: partitioning=Hash([c@1], 8), plan_id=1, stage_id=1, 
stage_resolved=true, coalesce=2 of 8
+              DataSourceExec: partitions=8, partition_sizes=[1, 1, 1, 1, 1, 1, 
1, 1]
+    ");
+
+    Ok(())
+}
+
+/// End-to-end: after the rule attaches `coalesce=K of M` to a leaf Exchange,
+/// the adapter must build the downstream `ShuffleReaderExec` with `K`
+/// partitions instead of `M`. The next runnable stage's plan tree is the
+/// proof — its `ShuffleReaderExec: partitioning: Hash([c@0], 2)` shows the
+/// rule's decision has flowed through the adapter into the runnable plan,
+/// not just sitting on the Exchange as metadata.
+#[tokio::test]
+async fn shuffle_reader_uses_coalesced_k_when_rule_fires() -> 
datafusion::error::Result<()>
+{
+    let ctx = coalesce_context(8, true);
+    ctx.register_batch("t", mock_batch()?)?;
+
+    let plan = ctx
+        .sql("select min(a) as c0, max(b) as c1, c as c2 from t group by c")
+        .await?
+        .create_physical_plan()
+        .await?;
+    let mut planner =
+        AdaptivePlanner::try_new(ctx.state().config(), plan, 
"test_job".to_string())?;
+
+    // Stage 0 is the upstream shuffle writer, partitioning by `c` into M=8.
+    let stages = planner.runnable_stages()?.unwrap();
+    assert_eq!(1, stages.len());
+    assert_plan!(stages[0].plan.as_ref(),  @ r"
+    SortShuffleWriterExec: partitioning=Hash([c@0], 8)
+      AggregateExec: mode=Partial, gby=[c@2 as c], aggr=[min(t.a), max(t.b)]
+        DataSourceExec: partitions=1, partition_sizes=[1]
+    ");
+
+    // Finalize stage 0 with 8 partitions × 50 bytes. Bin-pack at target=200
+    // yields K=2 (same trace as the happy-path test).
+    planner.finalise_stage_internal(0, partitions_with_byte_sizes(&[50; 8]))?;
+
+    // Stage 1 is the final stage. Its `ShuffleReaderExec` exposes K=2
+    // partitions — the rule's coalesce decision is now baked into the
+    // adapter's reader-construction path.
+    let stages = planner.runnable_stages()?.unwrap();
+    assert_eq!(1, stages.len());
+    assert_plan!(stages[0].plan.as_ref(),  @ r"
+    ShuffleWriterExec: partitioning: None
+      ProjectionExec: expr=[min(t.a)@1 as c0, max(t.b)@2 as c1, c@0 as c2]
+        AggregateExec: mode=FinalPartitioned, gby=[c@0 as c], aggr=[min(t.a), 
max(t.b)]
+          ShuffleReaderExec: partitioning: Hash([c@0], 2), coalesce: 2 of 8
+    ");
+
+    Ok(())
+}
diff --git a/ballista/scheduler/src/state/aqe/test/mod.rs 
b/ballista/scheduler/src/state/aqe/test/mod.rs
index a60cd038a..831f25c1c 100644
--- a/ballista/scheduler/src/state/aqe/test/mod.rs
+++ b/ballista/scheduler/src/state/aqe/test/mod.rs
@@ -17,6 +17,8 @@
 
 /// Test if stages can be added or removed
 mod alter_stages;
+/// Functional tests for the CoalescePartitionsRule end-to-end through the 
planner
+mod coalesce_rule;
 /// Tests if plan is going to be split to stages correctly
 mod plan_to_stages;
 


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

Reply via email to