This is an automated email from the ASF dual-hosted git repository.
avantgardnerio 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 06f8f1d9c feat(core): RangeShuffleReaderExec — ordering-preserving
shuffle reader (purely additive) (#2255)
06f8f1d9c is described below
commit 06f8f1d9cfd07d04549404d9ef870d7d42a39b02
Author: Brent Gardner <[email protected]>
AuthorDate: Sat Aug 8 09:46:24 2026 -0600
feat(core): RangeShuffleReaderExec — ordering-preserving shuffle reader
(purely additive) (#2255)
* feat(core): RangeShuffleReaderExec — ordering-preserving shuffle reader
(purely additive)
**Purely additive.** No in-tree callers, no behavior change. The type is
defined, serialized, and round-trip tested; wiring lands in follow-ups.
Extracted from #2223 (a working end-to-end parallel-window branch) as
the first slice of the epic to land parallel windows incrementally,
one reviewable piece at a time.
Sibling to `ShuffleReaderExec`: keeps each upstream source alive as its
own stream and feeds all N into a `StreamingMerge` keyed on the child's
declared output ordering. The regular reader concatenates in arrival
order, which breaks the monotonicity RANGE-frame window operators (and
sort-merge-join build sides) require.
- `range_shuffle_reader.rs` — operator impl + tests
- proto message + oneof slot 12
- serde encode/decode + roundtrip test
- three shuffle_reader helpers promoted to `pub(crate)` so RSR can
reuse them: `local_remote_read_split`, `fetch_partition_local`,
`fetch_partition_remote`
* docs(executor): TODO for RangeShuffleReaderExec late-bind
Marks the follow-up: create_query_stage_exec only downcasts
ShuffleReaderExec, so a task carrying a RangeShuffleReaderExec would
miss work_dir + client_pool the moment a planner rule plants one.
No functional change today — no code path emits the operator yet.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
---
ballista/core/proto/ballista.proto | 13 +
ballista/core/src/execution_plans/mod.rs | 2 +
.../src/execution_plans/range_shuffle_reader.rs | 569 +++++++++++++++++++++
.../core/src/execution_plans/shuffle_reader.rs | 6 +-
ballista/core/src/serde/generated/ballista.rs | 22 +-
ballista/core/src/serde/mod.rs | 145 +++++-
ballista/executor/src/execution_engine.rs | 4 +
7 files changed, 755 insertions(+), 6 deletions(-)
diff --git a/ballista/core/proto/ballista.proto
b/ballista/core/proto/ballista.proto
index 285accd52..e8becaba7 100644
--- a/ballista/core/proto/ballista.proto
+++ b/ballista/core/proto/ballista.proto
@@ -57,6 +57,7 @@ message BallistaPhysicalPlanNode {
OrderedRangeRepartitionExecNode ordered_range_repartition = 9;
PerPartitionFilterExecNode per_partition_filter = 10;
PartitionedBoundedWindowAggExecNode partitioned_bounded_window_agg = 11;
+ RangeShuffleReaderExecNode range_shuffle_reader = 12;
}
}
@@ -201,6 +202,18 @@ message ShuffleReaderPartition {
repeated PartitionLocation location = 1;
}
+// Ordering-preserving shuffle reader. Reuses `ShuffleReaderPartition` for the
+// M-shape source layout. Partitioning is derived from `partition.len()`
+// (always `UnknownPartitioning`, range-partitioned by `merge_ordering`).
+message RangeShuffleReaderExecNode {
+ repeated ShuffleReaderPartition partition = 1;
+ datafusion_common.Schema schema = 2;
+ uint32 stage_id = 3;
+ // Sort key the reader's k-way merge preserves. Advertised on the reader's
+ // `PlanProperties.eq_properties` for downstream consumers.
+ repeated datafusion.PhysicalSortExprNode merge_ordering = 4;
+}
+
// 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 {
diff --git a/ballista/core/src/execution_plans/mod.rs
b/ballista/core/src/execution_plans/mod.rs
index 230b68913..22ce701c0 100644
--- a/ballista/core/src/execution_plans/mod.rs
+++ b/ballista/core/src/execution_plans/mod.rs
@@ -27,6 +27,7 @@ mod partitioned_bounded_window_agg;
mod per_partition_filter;
pub mod plan_algebra;
mod range_repartition_common;
+mod range_shuffle_reader;
mod runtime_stats;
mod shuffle_reader;
mod shuffle_writer;
@@ -46,6 +47,7 @@ pub use
ordered_range_repartition::OrderedRangeRepartitionExec;
pub use partitioned_bounded_window_agg::PartitionedBoundedWindowAggExec;
pub use per_partition_filter::{PerPartitionFilterExec,
range_partition_predicates};
pub use plan_algebra::{preserves_distribution, preserves_partitioning};
+pub use range_shuffle_reader::RangeShuffleReaderExec;
pub use runtime_stats::{
MergedRuntimeStats, RuntimeStatsExec, TaskRuntimeStats,
collect_reports as collect_runtime_stats_reports, cut_partitions,
diff --git a/ballista/core/src/execution_plans/range_shuffle_reader.rs
b/ballista/core/src/execution_plans/range_shuffle_reader.rs
new file mode 100644
index 000000000..abeb33938
--- /dev/null
+++ b/ballista/core/src/execution_plans/range_shuffle_reader.rs
@@ -0,0 +1,569 @@
+// 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.
+
+//! Ordering-preserving shuffle reader.
+//!
+//! The regular [`ShuffleReaderExec`](super::ShuffleReaderExec) fans every
+//! upstream source for an output partition into one mpsc channel,
`try_flatten`s
+//! them in arrival order, then coalesces batches. Each source file is
+//! internally sorted, but the concatenation is not — the resulting stream
+//! violates the monotonicity that RANGE-frame window operators (and
+//! sort-merge-join build sides) require.
+//!
+//! `RangeShuffleReaderExec` keeps each upstream source alive as its own
+//! stream and feeds all N into a `StreamingMerge` keyed on the child's
+//! declared output ordering. Batches within an output partition are
+//! globally sorted on the merge key.
+//!
+//! Trade-offs vs. the regular reader:
+//!
+//! - No permit-based fetch governor. Backpressure flows from the merge
+//! consumer down through each per-source stream — the merge only polls
+//! the source it needs next, so h2 (for remote) and disk (for local)
+//! throttle naturally.
+//! - No mid-body fetch retry. `fetch_partition_remote` streams directly
+//! without buffering the whole source, so a transport error mid-body
+//! fails the merged stream; the task-level retry re-executes.
+//! - No coalesce or broadcast variants. Ordered outputs come from
+//! `OrderedRangeRepartitionExec`, which is one-to-one and never fanned
+//! into a broadcast.
+//!
+//! # Future work
+//!
+//! Today the reader pulls whole upstream files and lets `StreamingMerge`
+//! do the work. Once value-indexed shuffle files land end-to-end (writer
+//! side in [PR #2204]), the reader will consult per-file ValueIndex
+//! offsets and fetch only the byte ranges covering its target output
+//! partition — dropping the "read the whole file to throw most of it
+//! away" cost that dominates when K is large.
+//!
+//! [PR #2204]: https://github.com/apache/datafusion-ballista/pull/2204
+
+use crate::client_pool::BallistaClientPool;
+use crate::execution_plans::shuffle_reader::{
+ fetch_partition_local, fetch_partition_remote, local_remote_read_split,
+ stats_for_partition,
+};
+use crate::extension::SessionConfigExt;
+use crate::serde::scheduler::PartitionLocation;
+use crate::utils::GrpcClientConfig;
+use datafusion::arrow::datatypes::SchemaRef;
+use datafusion::common::{Result, Statistics};
+use datafusion::error::DataFusionError;
+use datafusion::execution::TaskContext;
+use datafusion::execution::memory_pool::MemoryConsumer;
+use datafusion::physical_expr::{EquivalenceProperties, LexOrdering,
Partitioning};
+use datafusion::physical_plan::metrics::{BaselineMetrics,
ExecutionPlanMetricsSet};
+use datafusion::physical_plan::sorts::streaming_merge::StreamingMergeBuilder;
+use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
+use datafusion::physical_plan::{
+ DisplayAs, DisplayFormatType, ExecutionPlan, PlanProperties,
+ SendableRecordBatchStream,
+};
+use futures::TryStreamExt;
+use log::debug;
+use std::sync::Arc;
+
+/// Ordering-preserving shuffle reader. See module docs.
+#[derive(Debug)]
+pub struct RangeShuffleReaderExec {
+ /// Upstream stage that produced these files.
+ pub stage_id: usize,
+ pub(crate) schema: SchemaRef,
+ /// M-shape: `partition[k]` lists the upstream partition locations that
+ /// output partition `k` needs to merge.
+ pub partition: Vec<Vec<PartitionLocation>>,
+ /// Sort key the merge preserves. Advertised in
`PlanProperties.eq_properties`
+ /// so downstream operators (BWAG, SMJ build side) see the output ordering.
+ merge_ordering: LexOrdering,
+ metrics: ExecutionPlanMetricsSet,
+ properties: Arc<PlanProperties>,
+ work_dir: Option<String>,
+ client_pool: Option<Arc<dyn BallistaClientPool>>,
+}
+
+impl RangeShuffleReaderExec {
+ /// The output partition count is `partition.len()`. Partitioning is
+ /// range on the merge key — DF has no `Partitioning::Range`, so it's
+ /// reported as `UnknownPartitioning`. Downstream co-partitioning is
+ /// carried by the advertised ordering, not by partitioning kind.
+ pub fn try_new(
+ stage_id: usize,
+ partition: Vec<Vec<PartitionLocation>>,
+ schema: SchemaRef,
+ merge_ordering: LexOrdering,
+ ) -> Result<Self> {
+ let output_partition_count = partition.len();
+ let eq_properties = EquivalenceProperties::new_with_orderings(
+ schema.clone(),
+ vec![merge_ordering.clone()],
+ );
+ let properties = Arc::new(PlanProperties::new(
+ eq_properties,
+ Partitioning::UnknownPartitioning(output_partition_count),
+
datafusion::physical_plan::execution_plan::EmissionType::Incremental,
+ datafusion::physical_plan::execution_plan::Boundedness::Bounded,
+ ));
+ Ok(Self {
+ stage_id,
+ schema,
+ partition,
+ merge_ordering,
+ metrics: ExecutionPlanMetricsSet::new(),
+ properties,
+ work_dir: None,
+ client_pool: None,
+ })
+ }
+
+ /// Late-bound by the executor.
+ pub fn with_work_dir(&self, work_dir: String) -> Self {
+ Self {
+ stage_id: self.stage_id,
+ schema: self.schema.clone(),
+ partition: self.partition.clone(),
+ merge_ordering: self.merge_ordering.clone(),
+ metrics: self.metrics.clone(),
+ properties: self.properties.clone(),
+ work_dir: Some(work_dir),
+ client_pool: self.client_pool.clone(),
+ }
+ }
+
+ /// Late-bound by the executor.
+ pub fn with_client_pool(&self, client_pool: Arc<dyn BallistaClientPool>)
-> Self {
+ Self {
+ stage_id: self.stage_id,
+ schema: self.schema.clone(),
+ partition: self.partition.clone(),
+ merge_ordering: self.merge_ordering.clone(),
+ metrics: self.metrics.clone(),
+ properties: self.properties.clone(),
+ work_dir: self.work_dir.clone(),
+ client_pool: Some(client_pool),
+ }
+ }
+
+ /// Sort key the merge preserves.
+ pub fn merge_ordering(&self) -> &LexOrdering {
+ &self.merge_ordering
+ }
+}
+
+impl DisplayAs for RangeShuffleReaderExec {
+ fn fmt_as(
+ &self,
+ t: DisplayFormatType,
+ f: &mut std::fmt::Formatter,
+ ) -> std::fmt::Result {
+ match t {
+ DisplayFormatType::Default | DisplayFormatType::Verbose => {
+ write!(
+ f,
+ "RangeShuffleReaderExec: upstream_stage: {}, partitions:
{}, ordering: {}",
+ self.stage_id,
+ self.partition.len(),
+ self.merge_ordering,
+ )
+ }
+ DisplayFormatType::TreeRender => {
+ writeln!(f, "upstream_stage={}", self.stage_id)?;
+ writeln!(f, "output_partitions={}", self.partition.len())?;
+ writeln!(f, "ordering={}", self.merge_ordering)
+ }
+ }
+ }
+}
+
+impl ExecutionPlan for RangeShuffleReaderExec {
+ fn name(&self) -> &str {
+ "RangeShuffleReaderExec"
+ }
+
+ fn schema(&self) -> SchemaRef {
+ self.schema.clone()
+ }
+
+ fn properties(&self) -> &Arc<PlanProperties> {
+ &self.properties
+ }
+
+ fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
+ vec![]
+ }
+
+ fn with_new_children(
+ self: Arc<Self>,
+ children: Vec<Arc<dyn ExecutionPlan>>,
+ ) -> Result<Arc<dyn ExecutionPlan>> {
+ if !children.is_empty() {
+ return Err(DataFusionError::Plan(
+ "RangeShuffleReaderExec does not support children
plans".to_owned(),
+ ));
+ }
+ Ok(Arc::new(Self {
+ stage_id: self.stage_id,
+ schema: self.schema.clone(),
+ partition: self.partition.clone(),
+ merge_ordering: self.merge_ordering.clone(),
+ metrics: ExecutionPlanMetricsSet::new(),
+ properties: self.properties.clone(),
+ work_dir: self.work_dir.clone(),
+ client_pool: self.client_pool.clone(),
+ }))
+ }
+
+ fn execute(
+ &self,
+ output_partition: usize,
+ ctx: Arc<TaskContext>,
+ ) -> Result<SendableRecordBatchStream> {
+ let task_id = ctx
+ .task_id()
+ .unwrap_or_else(|| output_partition.to_string());
+ debug!("RangeShuffleReaderExec::execute({task_id})");
+
+ let config = ctx.session_config();
+ let work_dir = self.work_dir.as_ref().ok_or_else(|| {
+ DataFusionError::Configuration(
+ "RangeShuffleReaderExec work dir should have been set by
executor"
+ .to_owned(),
+ )
+ })?;
+
+ let locations = self.partition[output_partition].clone();
+ let (local_locations, remote_locations) = local_remote_read_split(
+ work_dir,
+ locations,
+ config.ballista_shuffle_reader_force_remote_read(),
+ );
+
+ let mut sub_streams: Vec<SendableRecordBatchStream> =
+ Vec::with_capacity(local_locations.len() + remote_locations.len());
+
+ for loc in local_locations {
+ let stream = fetch_partition_local(work_dir, &loc)
+ .map_err(|e| DataFusionError::External(Box::new(e)))?;
+ sub_streams.push(stream);
+ }
+
+ if !remote_locations.is_empty() {
+ let grpc_config: Arc<GrpcClientConfig> =
+ Arc::new((&config.ballista_config()).into());
+ let customize_endpoint =
+ config.ballista_override_create_grpc_client_endpoint();
+ let prefer_flight =
config.ballista_shuffle_reader_remote_prefer_flight();
+ let client_pool = self.client_pool.clone();
+
+ for loc in remote_locations {
+ let schema = self.schema.clone();
+ let grpc_config = grpc_config.clone();
+ let customize_endpoint = customize_endpoint.clone();
+ let client_pool = client_pool.clone();
+
+ // Lazy connect: the first poll from the merge triggers the
+ // remote fetch; subsequent polls stream batches directly. No
+ // buffering, no retry — task-level retry covers transport
+ // failures.
+ let lazy = futures::stream::once(async move {
+ fetch_partition_remote(
+ &loc,
+ grpc_config,
+ prefer_flight,
+ customize_endpoint,
+ client_pool,
+ )
+ .await
+ .map_err(|e| DataFusionError::External(Box::new(e)))
+ })
+ .try_flatten();
+
+
sub_streams.push(Box::pin(RecordBatchStreamAdapter::new(schema, lazy)));
+ }
+ }
+
+ if sub_streams.is_empty() {
+ return Ok(Box::pin(RecordBatchStreamAdapter::new(
+ self.schema.clone(),
+ futures::stream::empty(),
+ )));
+ }
+
+ let baseline = BaselineMetrics::new(&self.metrics, output_partition);
+ let reservation = MemoryConsumer::new(format!(
+ "RangeShuffleReaderExec[stage={},out={}]",
+ self.stage_id, output_partition,
+ ))
+ .register(ctx.memory_pool());
+
+ let merged = StreamingMergeBuilder::new()
+ .with_streams(sub_streams)
+ .with_schema(self.schema.clone())
+ .with_expressions(&self.merge_ordering)
+ .with_batch_size(config.batch_size())
+ .with_metrics(baseline)
+ .with_reservation(reservation)
+ .build()?;
+
+ Ok(merged)
+ }
+
+ fn metrics(&self) ->
Option<datafusion::physical_plan::metrics::MetricsSet> {
+ Some(self.metrics.clone_inner())
+ }
+
+ fn partition_statistics(&self, partition: Option<usize>) ->
Result<Arc<Statistics>> {
+ if let Some(idx) = partition {
+ let partition_count = self.partition.len();
+ if idx >= partition_count {
+ return datafusion::common::internal_err!(
+ "Invalid partition index: {}, the partition count is {}",
+ idx,
+ partition_count
+ );
+ }
+ let stats =
+ stats_for_partition(idx, self.schema.fields().len(),
&self.partition)?;
+ return Ok(Arc::new(stats));
+ }
+ Ok(Arc::new(Statistics::new_unknown(&self.schema)))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::serde::scheduler::{
+ ExecutorMetadata, ExecutorOperatingSystemSpecification,
ExecutorSpecification,
+ PartitionId, PartitionStats,
+ };
+ use datafusion::arrow::array::{ArrayRef, Float64Array, RecordBatch};
+ use datafusion::arrow::datatypes::{DataType, Field, Schema};
+ use datafusion::arrow::ipc::writer::StreamWriter;
+ use datafusion::physical_expr::PhysicalSortExpr;
+ use datafusion::physical_expr::expressions::Column;
+ use datafusion::prelude::SessionContext;
+ use std::fs::{File, create_dir_all};
+ use tempfile::tempdir;
+
+ /// Write an Arrow IPC stream containing `batches` at `path`.
+ fn write_ipc_stream(path: &std::path::Path, batches: &[RecordBatch]) {
+ create_dir_all(path.parent().unwrap()).unwrap();
+ let file = File::create(path).unwrap();
+ let mut writer = StreamWriter::try_new(file,
&batches[0].schema()).unwrap();
+ for b in batches {
+ writer.write(b).unwrap();
+ }
+ writer.finish().unwrap();
+ }
+
+ fn sorted_batch(schema: SchemaRef, values: &[f64]) -> RecordBatch {
+ let col: ArrayRef = Arc::new(Float64Array::from(values.to_vec()));
+ RecordBatch::try_new(schema, vec![col]).unwrap()
+ }
+
+ fn make_location(job: &str, stage: usize, partition: usize) ->
PartitionLocation {
+ PartitionLocation {
+ map_partition_id: partition,
+ partition_id: PartitionId {
+ job_id: job.into(),
+ stage_id: stage,
+ partition_id: partition,
+ },
+ executor_meta: ExecutorMetadata {
+ id: "test-executor".to_string(),
+ host: "127.0.0.1".to_string(),
+ port: 0,
+ grpc_port: 0,
+ specification: ExecutorSpecification::default(),
+ os_info: ExecutorOperatingSystemSpecification::default(),
+ },
+ partition_stats: PartitionStats::default(),
+ file_id: None,
+ is_sort_shuffle: false,
+ }
+ }
+
+ /// Two upstream sources, each internally sorted; the range reader must
+ /// interleave them into a single globally-sorted stream. This is the core
+ /// correctness property the regular reader violates.
+ #[tokio::test]
+ async fn merges_two_sorted_sources() {
+ let dir = tempdir().unwrap();
+ let work_dir = dir.path();
+ let job = "job-merges-two";
+ let stage_id = 1usize;
+
+ let schema =
+ Arc::new(Schema::new(vec![Field::new("v", DataType::Float64,
false)]));
+
+ // Source 0: [1.0, 3.0, 5.0]
+ // Source 1: [2.0, 4.0, 6.0]
+ // Merged: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]
+ let src0 = sorted_batch(schema.clone(), &[1.0, 3.0, 5.0]);
+ let src1 = sorted_batch(schema.clone(), &[2.0, 4.0, 6.0]);
+
+ let loc0 = make_location(job, stage_id, 0);
+ let loc1 = make_location(job, stage_id, 1);
+ let path0 = loc0.path(work_dir.to_str().unwrap()).expect("path0");
+ let path1 = loc1.path(work_dir.to_str().unwrap()).expect("path1");
+ write_ipc_stream(&path0, &[src0]);
+ write_ipc_stream(&path1, &[src1]);
+
+ // Both sources land in output partition 0.
+ let partitions = vec![vec![loc0, loc1]];
+ let merge_ordering =
LexOrdering::new(vec![PhysicalSortExpr::new_default(
+ Arc::new(Column::new("v", 0)),
+ )])
+ .unwrap();
+
+ let reader = RangeShuffleReaderExec::try_new(
+ stage_id,
+ partitions,
+ schema.clone(),
+ merge_ordering,
+ )
+ .unwrap()
+ .with_work_dir(work_dir.to_string_lossy().to_string());
+
+ let ctx = SessionContext::new();
+ let task_ctx = ctx.task_ctx();
+ let stream = reader.execute(0, task_ctx).unwrap();
+ let batches: Vec<RecordBatch> = stream.try_collect().await.unwrap();
+
+ let all: Vec<f64> = batches
+ .iter()
+ .flat_map(|b| {
+ b.column(0)
+ .as_any()
+ .downcast_ref::<Float64Array>()
+ .unwrap()
+ .values()
+ .to_vec()
+ })
+ .collect();
+ assert_eq!(all, vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
+ }
+
+ /// If the caller only has one source per output partition, the reader
+ /// must still work — StreamingMerge over 1 stream is a valid degenerate
+ /// case.
+ #[tokio::test]
+ async fn passes_through_single_source() {
+ let dir = tempdir().unwrap();
+ let work_dir = dir.path();
+ let job = "job-single-source";
+ let stage_id = 2usize;
+
+ let schema =
+ Arc::new(Schema::new(vec![Field::new("v", DataType::Float64,
false)]));
+ let src = sorted_batch(schema.clone(), &[10.0, 20.0, 30.0]);
+
+ let loc = make_location(job, stage_id, 0);
+ let path = loc.path(work_dir.to_str().unwrap()).expect("path");
+ write_ipc_stream(&path, &[src]);
+
+ let partitions = vec![vec![loc]];
+ let merge_ordering =
LexOrdering::new(vec![PhysicalSortExpr::new_default(
+ Arc::new(Column::new("v", 0)),
+ )])
+ .unwrap();
+
+ let reader = RangeShuffleReaderExec::try_new(
+ stage_id,
+ partitions,
+ schema.clone(),
+ merge_ordering,
+ )
+ .unwrap()
+ .with_work_dir(work_dir.to_string_lossy().to_string());
+
+ let ctx = SessionContext::new();
+ let stream = reader.execute(0, ctx.task_ctx()).unwrap();
+ let batches: Vec<RecordBatch> = stream.try_collect().await.unwrap();
+ let all: Vec<f64> = batches
+ .iter()
+ .flat_map(|b| {
+ b.column(0)
+ .as_any()
+ .downcast_ref::<Float64Array>()
+ .unwrap()
+ .values()
+ .to_vec()
+ })
+ .collect();
+ assert_eq!(all, vec![10.0, 20.0, 30.0]);
+ }
+
+ /// Empty partitions.len() > 0 with all-empty inner locations must return
+ /// an empty stream instead of tripping StreamingMerge on zero sub-streams.
+ #[tokio::test]
+ async fn handles_empty_partition() {
+ let schema =
+ Arc::new(Schema::new(vec![Field::new("v", DataType::Float64,
false)]));
+ let merge_ordering =
LexOrdering::new(vec![PhysicalSortExpr::new_default(
+ Arc::new(Column::new("v", 0)),
+ )])
+ .unwrap();
+
+ let reader = RangeShuffleReaderExec::try_new(
+ 7,
+ vec![vec![]],
+ schema.clone(),
+ merge_ordering,
+ )
+ .unwrap()
+ .with_work_dir("/tmp".to_string());
+
+ let ctx = SessionContext::new();
+ let stream = reader.execute(0, ctx.task_ctx()).unwrap();
+ let batches: Vec<RecordBatch> = stream.try_collect().await.unwrap();
+ assert!(batches.is_empty());
+ }
+
+ /// The reader must advertise its merge ordering so downstream operators
+ /// see the sortedness invariant (BWAG's RANGE-frame cursor, SMJ build
side).
+ #[test]
+ fn advertises_merge_ordering() {
+ let schema =
+ Arc::new(Schema::new(vec![Field::new("v", DataType::Float64,
false)]));
+ let merge_ordering =
LexOrdering::new(vec![PhysicalSortExpr::new_default(
+ Arc::new(Column::new("v", 0)),
+ )])
+ .unwrap();
+
+ let reader = RangeShuffleReaderExec::try_new(
+ 3,
+ vec![vec![]; 4],
+ schema,
+ merge_ordering.clone(),
+ )
+ .unwrap();
+
+ let out = reader
+ .properties()
+ .eq_properties
+ .oeq_class()
+ .iter()
+ .next()
+ .cloned();
+ let expected = out.expect("expected an advertised ordering");
+ assert_eq!(expected, merge_ordering);
+
+ assert_eq!(reader.properties().partitioning.partition_count(), 4);
+ }
+}
diff --git a/ballista/core/src/execution_plans/shuffle_reader.rs
b/ballista/core/src/execution_plans/shuffle_reader.rs
index d85f1978f..6f4ec1a6a 100644
--- a/ballista/core/src/execution_plans/shuffle_reader.rs
+++ b/ballista/core/src/execution_plans/shuffle_reader.rs
@@ -692,7 +692,7 @@ impl RecordBatchStream for GovernedStream {
/// Local partitions are read directly from local Arrow IPC files,
/// while remote partitions are fetched using the Arrow Flight client.
/// If `force_remote_read` is true, all partitions are treated as remote.
-fn local_remote_read_split(
+pub(crate) fn local_remote_read_split(
work_dir: &str,
partition_locations: Vec<PartitionLocation>,
force_remote_read: bool,
@@ -1037,7 +1037,7 @@ async fn new_ballista_client(
.await
}
-async fn fetch_partition_remote(
+pub(crate) async fn fetch_partition_remote(
location: &PartitionLocation,
config: Arc<GrpcClientConfig>,
prefer_flight: bool,
@@ -1108,7 +1108,7 @@ async fn fetch_partition_remote(
}
}
-fn fetch_partition_local(
+pub(crate) fn fetch_partition_local(
work_dir: &str,
location: &PartitionLocation,
) -> result::Result<SendableRecordBatchStream, BallistaError> {
diff --git a/ballista/core/src/serde/generated/ballista.rs
b/ballista/core/src/serde/generated/ballista.rs
index c5d3899dd..e01a99aa7 100644
--- a/ballista/core/src/serde/generated/ballista.rs
+++ b/ballista/core/src/serde/generated/ballista.rs
@@ -31,7 +31,7 @@ pub struct LogicalPlanCacheNode {
pub struct BallistaPhysicalPlanNode {
#[prost(
oneof = "ballista_physical_plan_node::PhysicalPlanType",
- tags = "1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11"
+ tags = "1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12"
)]
pub physical_plan_type: ::core::option::Option<
ballista_physical_plan_node::PhysicalPlanType,
@@ -63,6 +63,8 @@ pub mod ballista_physical_plan_node {
PerPartitionFilter(super::PerPartitionFilterExecNode),
#[prost(message, tag = "11")]
PartitionedBoundedWindowAgg(super::PartitionedBoundedWindowAggExecNode),
+ #[prost(message, tag = "12")]
+ RangeShuffleReader(super::RangeShuffleReaderExecNode),
}
}
/// Value-range router over N locally-sorted overlapping input partitions.
@@ -249,6 +251,24 @@ pub struct ShuffleReaderPartition {
#[prost(message, repeated, tag = "1")]
pub location: ::prost::alloc::vec::Vec<PartitionLocation>,
}
+/// Ordering-preserving shuffle reader. Reuses `ShuffleReaderPartition` for the
+/// M-shape source layout. Partitioning is derived from `partition.len()`
+/// (always `UnknownPartitioning`, range-partitioned by `merge_ordering`).
+#[derive(Clone, PartialEq, ::prost::Message)]
+pub struct RangeShuffleReaderExecNode {
+ #[prost(message, repeated, tag = "1")]
+ pub partition: ::prost::alloc::vec::Vec<ShuffleReaderPartition>,
+ #[prost(message, optional, tag = "2")]
+ pub schema: ::core::option::Option<::datafusion_proto_common::Schema>,
+ #[prost(uint32, tag = "3")]
+ pub stage_id: u32,
+ /// Sort key the reader's k-way merge preserves. Advertised on the reader's
+ /// `PlanProperties.eq_properties` for downstream consumers.
+ #[prost(message, repeated, tag = "4")]
+ pub merge_ordering: ::prost::alloc::vec::Vec<
+ ::datafusion_proto::protobuf::PhysicalSortExprNode,
+ >,
+}
/// 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)]
diff --git a/ballista/core/src/serde/mod.rs b/ballista/core/src/serde/mod.rs
index eb2f0181c..1a4885fef 100644
--- a/ballista/core/src/serde/mod.rs
+++ b/ballista/core/src/serde/mod.rs
@@ -58,8 +58,8 @@ use crate::execution_plans::sort_shuffle::SortShuffleConfig;
use crate::execution_plans::{
BufferExec, BufferMode, ChaosExec, CoalescePlan,
OrderedRangeRepartitionExec,
PartitionGroup, PartitionedBoundedWindowAggExec, PerPartitionFilterExec,
- RuntimeStatsExec, ShuffleReaderExec, ShuffleWriterExec,
SortShuffleWriterExec,
- UnorderedRangeRepartitionExec, UnresolvedShuffleExec,
+ RangeShuffleReaderExec, RuntimeStatsExec, ShuffleReaderExec,
ShuffleWriterExec,
+ SortShuffleWriterExec, UnorderedRangeRepartitionExec,
UnresolvedShuffleExec,
};
use crate::serde::protobuf::{
ballista_logical_plan_node::LogicalPlanType,
@@ -507,6 +507,46 @@ impl PhysicalExtensionCodec for
BallistaPhysicalExtensionCodec {
};
Ok(Arc::new(exec))
}
+ PhysicalPlanType::RangeShuffleReader(range_reader) => {
+ let stage_id = range_reader.stage_id as usize;
+ let schema: SchemaRef =
Arc::new(convert_required!(range_reader.schema)?);
+ let partition_location: Vec<Vec<PartitionLocation>> =
range_reader
+ .partition
+ .iter()
+ .map(|p| {
+ p.location
+ .iter()
+ .map(|l| {
+ l.clone().try_into().map_err(|e| {
+ DataFusionError::Internal(format!(
+ "Fail to get partition location due to
{e:?}"
+ ))
+ })
+ })
+ .collect::<Result<Vec<_>, _>>()
+ })
+ .collect::<Result<Vec<_>, DataFusionError>>()?;
+ let merge_ordering_exprs = parse_physical_sort_exprs(
+ &range_reader.merge_ordering,
+ &decode_ctx,
+ schema.as_ref(),
+ &converter,
+ )?;
+ let merge_ordering =
datafusion::physical_expr::LexOrdering::new(
+ merge_ordering_exprs,
+ )
+ .ok_or_else(|| {
+ proto_error(
+ "RangeShuffleReaderExec: merge_ordering must be
non-empty",
+ )
+ })?;
+ Ok(Arc::new(RangeShuffleReaderExec::try_new(
+ stage_id,
+ partition_location,
+ schema,
+ merge_ordering,
+ )?))
+ }
PhysicalPlanType::UnresolvedShuffle(unresolved_shuffle) => {
let schema: SchemaRef =
Arc::new(convert_required!(unresolved_shuffle.schema)?);
@@ -804,6 +844,46 @@ impl PhysicalExtensionCodec for
BallistaPhysicalExtensionCodec {
))
})?;
+ Ok(())
+ } else if let Some(exec) =
node.downcast_ref::<RangeShuffleReaderExec>() {
+ let stage_id = exec.stage_id as u32;
+ let mut partition = vec![];
+ for location in &exec.partition {
+ partition.push(protobuf::ShuffleReaderPartition {
+ location: location
+ .iter()
+ .map(|l| {
+ l.clone().try_into().map_err(|e| {
+ DataFusionError::Internal(format!(
+ "Fail to get partition location due to
{e:?}"
+ ))
+ })
+ })
+ .collect::<Result<Vec<_>, _>>()?,
+ });
+ }
+ let converter = DefaultPhysicalProtoConverter {};
+ let merge_ordering = serialize_physical_sort_exprs(
+ exec.merge_ordering().iter().cloned(),
+ self.default_codec.as_ref(),
+ &converter,
+ )?;
+ let proto = protobuf::BallistaPhysicalPlanNode {
+ physical_plan_type: Some(PhysicalPlanType::RangeShuffleReader(
+ protobuf::RangeShuffleReaderExecNode {
+ stage_id,
+ partition,
+ schema: Some(exec.schema().as_ref().try_into()?),
+ merge_ordering,
+ },
+ )),
+ };
+ proto.encode(buf).map_err(|e| {
+ DataFusionError::Internal(format!(
+ "failed to encode range shuffle reader execution plan:
{e:?}"
+ ))
+ })?;
+
Ok(())
} else if let Some(exec) =
node.downcast_ref::<UnresolvedShuffleExec>() {
let converter = DefaultPhysicalProtoConverter {};
@@ -1218,6 +1298,67 @@ mod test {
);
}
+ /// `RangeShuffleReaderExec` carries its merge ordering across the wire —
+ /// the reader's k-way merge machinery is inert without it, and the
+ /// scheduler side plants the reader with the child's declared ordering.
+ #[tokio::test]
+ async fn test_range_shuffle_reader_exec_roundtrip() {
+ use datafusion::arrow::compute::SortOptions;
+ use datafusion::physical_expr::{LexOrdering, PhysicalSortExpr};
+
+ let schema = create_test_schema();
+ let sort_expr = PhysicalSortExpr {
+ expr: col("id", schema.as_ref()).unwrap(),
+ options: SortOptions {
+ descending: false,
+ nulls_first: true,
+ },
+ };
+ let merge_ordering =
LexOrdering::new(vec![sort_expr.clone()]).unwrap();
+
+ let original = RangeShuffleReaderExec::try_new(
+ 7,
+ vec![vec![]; 4],
+ schema.clone(),
+ merge_ordering.clone(),
+ )
+ .unwrap();
+
+ let codec = BallistaPhysicalExtensionCodec::default();
+ let mut buf: Vec<u8> = vec![];
+ codec.try_encode(Arc::new(original), &mut buf).unwrap();
+
+ let ctx = SessionContext::new().task_ctx();
+ let decoded_plan = codec.try_decode(&buf, &[], &ctx).unwrap();
+ let decoded = decoded_plan
+ .downcast_ref::<RangeShuffleReaderExec>()
+ .expect("Expected RangeShuffleReaderExec");
+
+ assert_eq!(decoded.stage_id, 7);
+ assert_eq!(decoded.schema().as_ref(), schema.as_ref());
+ assert_eq!(decoded.partition.len(), 4, "partition shape must survive");
+ assert_eq!(
+ decoded.merge_ordering().len(),
+ 1,
+ "merge_ordering must round-trip"
+ );
+ assert_eq!(
+ decoded.merge_ordering().first().expr.to_string(),
+ sort_expr.expr.to_string(),
+ );
+ // The ordering must land on `PlanProperties.eq_properties` —
downstream
+ // consumers (BWAG, SMJ build side) read it there.
+ let advertised = decoded
+ .properties()
+ .eq_properties
+ .oeq_class()
+ .iter()
+ .next()
+ .cloned()
+ .expect("advertised ordering");
+ assert_eq!(advertised, merge_ordering);
+ }
+
/// The sort shuffle writer's per-task memory budget must survive the
/// protobuf round trip so a session override reaches the executor where
the
/// writer actually runs (issue #2089). A non-default value and the special
diff --git a/ballista/executor/src/execution_engine.rs
b/ballista/executor/src/execution_engine.rs
index 4f8e6663d..fcfd52514 100644
--- a/ballista/executor/src/execution_engine.rs
+++ b/ballista/executor/src/execution_engine.rs
@@ -135,6 +135,10 @@ impl ExecutionEngine for DefaultExecutionEngine {
) -> Result<Arc<dyn QueryStageExecutor>> {
let plan = plan
.transform(|p| {
+ // TODO: RangeShuffleReaderExec needs the same late-bind
+ // (with_work_dir + with_client_pool) once a planner rule
+ // plants it; without it, the first task carrying one will
+ // fail with "work dir should have been set by executor".
if let Some(reader) = p.downcast_ref::<ShuffleReaderExec>() {
match &self.client_pool {
Some(client_pool) => Ok(Transformed::yes(Arc::new(
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]