Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
207f06a
fix: keep a streamed side sorted outside the native plan in order thr…
andygrove Oct 10, 2026
a6a251e
Merge remote-tracking branch 'apache/main' into andygrove/comet-inmem…
andygrove Oct 11, 2026
f74ae8f
test: cover the right outer shape, an aggregate above the join and th…
andygrove Oct 11, 2026
2f95eef
fix: declare the streamed order only over operators that keep a JVM i…
andygrove Oct 12, 2026
2e0fb63
fix: send no streamed order when a Comet join that may move rows prod…
andygrove Oct 12, 2026
79b4d33
fix: walk an adaptive cache's final plan and keep the streamed order …
andygrove Oct 12, 2026
ce6a82f
docs: describe how native hash joins keep the streamed order
andygrove Oct 12, 2026
a2ad967
fix: keep the streamed order to expressible keys and stop CometSample…
andygrove Oct 15, 2026
ddce25a
test: cover the streamed order for decimal, string and timestamp keys
andygrove Oct 15, 2026
15acec6
Merge remote-tracking branch 'apache/main' into andygrove/comet-inmem…
andygrove Oct 15, 2026
5adbeb4
test: compare the key-type rows with Spark's as a multiset
andygrove Oct 15, 2026
8e75130
test: make the aggregate test fail when the join's order is not declared
andygrove Oct 15, 2026
65f406f
fix: declare the order of a native range for the joins above it
andygrove Oct 15, 2026
9c26cd8
refactor: name the streamed side of a hash join once
andygrove Oct 15, 2026
a4eb244
test: cover a native range and share the cache test helpers
andygrove Oct 15, 2026
3533ca6
test: drop the imports the shared test helpers left unused
andygrove Oct 15, 2026
c52fbb8
test: cover a streamed double order with signed zeros and NaN
andygrove Oct 15, 2026
b50b2cd
fix: send a streamed order that starts with an array or a struct
andygrove Oct 15, 2026
204440a
fix: declare the order of a sorted bucketed table read by the native …
andygrove Oct 15, 2026
939a05a
docs: qualify where native hash joins keep the streamed order
andygrove Oct 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/source/contributor-guide/adding_a_new_operator.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,29 @@ case class CometProjectExec(
}
```

#### Reporting an Output Ordering

An operator that overrides `outputOrdering`, as `CometFilterExec` does above with
`child.outputOrdering`, makes a claim that Spark acts on: it drops a sort above the operator that
the order satisfies. Report what the Spark operator reports and nothing more. `SampleExec`, for
example, reports no order, so `CometSampleExec` must not report its child's.

The claim has to hold at run time as well. A native hash join declares the order Spark reports for
its streamed side to DataFusion, and an aggregate above the join may then run in sorted mode, or a
top-k sort may stop reading early, on the strength of that order. If the order is false, the
result is wrong groups or wrong rows, not only rows out of order. So an operator that reports an
ordering must keep it natively, and the native operator must say so by returning `true` from
`maintains_input_order` for that child, as DataFusion's filter and projection do. The planner
declares the order through an operator only when the operator says it keeps it. An operator that
cannot keep the order natively must not report it, or has to be added to `mayReportFalseOrder` in
`operators.scala`, which makes the hash join serde send no order over it.

A leaf that reports an ordering is where the order starts, so the planner has to know that the
native operator produces its rows in that order. It knows that of an input from the JVM and of a
scan of files, which reads the files of each partition one after the other, and the native range
declares its order itself through `SortedInputExec`. A new leaf that reports an ordering must
declare it natively the same way.

#### Plan Identity and Exchange Reuse

Spark's `ReuseExchangeAndSubquery` identifies equivalent plans through canonicalization and may
Expand Down
21 changes: 21 additions & 0 deletions docs/source/user-guide/latest/compatibility/operators.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,27 @@ rows by the grouping keys only, and Spark and Comet can leave rows with equal ke
orders, so a group with more than one candidate value can return a different value than Spark.
Both results are valid under Spark's semantics for these functions.

## Hash Join Output Order

Spark reports the sort order of a hash join's streamed side as the join's own, and drops a sort
above the join that this order satisfies. Comet's native hash joins keep that order where Comet can
show that it holds. That includes a left or right outer join over a streamed side that was sorted
outside the native plan, such as a sorted relation read from the [in-memory cache](#in-memory-cache).

Comet passes the order to the native join as sort keys, and an outer join needs at least the first
key to be one that Comet can express natively. Declaring any leading part of the order keeps the
rows in place, so the join does not need the rest. An outer join whose first sort key Comet cannot
express falls back to Spark and records a fallback reason (see
[Understanding Comet Plans](../understanding-comet-plans.md)), because Comet cannot tell whether
an operator above the join relies on the order.

Where the order Spark reports might not hold, Comet declares nothing and an outer join can return
its unmatched rows out of order. The output of a `NOT IN` subquery or of a nested loop join
([#6898](https://github.com/apache/datafusion-comet/issues/6898)) is one such case, and so is a
cache or checkpoint that one of them wrote. Comet also declares nothing when it cannot follow the
order back to its source, as with an RDD checkpoint, and over some caches written by an outer hash
join whose order does hold ([#6922](https://github.com/apache/datafusion-comet/issues/6922)).

## Window Functions

Comet runs `WindowExec` natively and it is enabled by default (`spark.comet.exec.window.enabled`). A broad set of
Expand Down
2 changes: 2 additions & 0 deletions native/core/src/execution/operators/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,7 @@ mod csv_scan;
pub mod projection;
mod scan;
mod shuffle_scan;
mod sorted_input;
pub use csv_scan::init_csv_datasource_exec;
pub use shuffle_scan::ShuffleScanExec;
pub use sorted_input::SortedInputExec;
135 changes: 135 additions & 0 deletions native/core/src/execution/operators/sorted_input.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// 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.

//! An input that declares the order its rows arrive in.

use std::fmt::Formatter;
use std::sync::Arc;

use datafusion::common::tree_node::TreeNodeRecursion;
use datafusion::common::{internal_err, Result};
use datafusion::execution::TaskContext;
use datafusion::physical_expr::{LexOrdering, PhysicalExpr};
use datafusion::physical_plan::execution_plan::CardinalityEffect;
use datafusion::physical_plan::metrics::MetricsSet;
use datafusion::physical_plan::{
apply_expression_roots, DisplayAs, DisplayFormatType, ExecutionPlan, ExecutionPlanProperties,
PlanProperties, SendableRecordBatchStream,
};

/// Passes `input` through unchanged and declares to DataFusion the order its rows arrive in.
///
/// Spark's plan knows the order of an input that enters the native plan from the JVM, a relation
/// sorted before it was cached for instance, but the `ScanExec` it arrives through declares none,
/// and DataFusion's hash join keeps the unmatched probe rows of a right outer join in place only
/// when the probe input declares an order. Otherwise it moves them after the matched rows of each
/// batch, which loses an order Spark has already dropped a sort for. The planner puts this only
/// on that join's probe input, with the order Spark reports for it, and only when the rows reach
/// that input from the JVM through operators that keep their order. The join keeps the order in
/// its own output, so the operators above it in the native plan see it too: an aggregate grouped
/// by the key runs in sorted, streaming mode, a sort aggregate takes the order as already
/// satisfied, and a top-k sort whose keys start with it stops reading early. A wrong order would
/// therefore give wrong groups or wrong rows, not only rows out of order.
#[derive(Debug)]
pub struct SortedInputExec {
input: Arc<dyn ExecutionPlan>,
ordering: LexOrdering,
cache: Arc<PlanProperties>,
}

impl SortedInputExec {
pub fn new(input: Arc<dyn ExecutionPlan>, ordering: LexOrdering) -> Self {
let input_properties = input.properties();
// Start from the input's own properties, so the constants and equivalence classes it
// knows of stay visible to the operators above.
let mut equivalence_properties = input.equivalence_properties().clone();
equivalence_properties.add_ordering(ordering.iter().cloned());
let cache = Arc::new(PlanProperties::new(
equivalence_properties,
input_properties.output_partitioning().clone(),
input_properties.emission_type,
input_properties.boundedness,
));
Self {
input,
ordering,
cache,
}
}
}

impl DisplayAs for SortedInputExec {
fn fmt_as(&self, _t: DisplayFormatType, f: &mut Formatter) -> std::fmt::Result {
write!(f, "SortedInputExec: ordering=[{}]", self.ordering)
}
}

impl ExecutionPlan for SortedInputExec {
fn name(&self) -> &str {
"SortedInputExec"
}

fn properties(&self) -> &Arc<PlanProperties> {
&self.cache
}

fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
vec![&self.input]
}

fn apply_expressions(
&self,
f: &mut dyn FnMut(&Arc<dyn PhysicalExpr>) -> Result<TreeNodeRecursion>,
) -> Result<TreeNodeRecursion> {
apply_expression_roots(self.ordering.iter().map(|sort| Arc::clone(&sort.expr)), f)
}

fn maintains_input_order(&self) -> Vec<bool> {
vec![true]
}

/// The rows pass through, so the input's metrics are this operator's, and a range whose root
/// this wraps still reports its output rows.
fn metrics(&self) -> Option<MetricsSet> {
self.input.metrics()
}

fn cardinality_effect(&self) -> CardinalityEffect {
CardinalityEffect::Equal
}

fn with_new_children(
self: Arc<Self>,
mut children: Vec<Arc<dyn ExecutionPlan>>,
) -> Result<Arc<dyn ExecutionPlan>> {
if children.len() != 1 {
return internal_err!("SortedInputExec requires one child");
}
Ok(Arc::new(Self::new(
children.remove(0),
self.ordering.clone(),
)))
}

fn execute(
&self,
partition: usize,
context: Arc<TaskContext>,
) -> Result<SendableRecordBatchStream> {
self.input.execute(partition, context)
}
}
Loading
Loading