blob: 6c8d0bb97b3af73c1e368d1990d773cbd2bf128b [file]
// 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.
//! `SchemaAlignExec` reshapes a shuffle writer's input so each column's Arrow type and field-level
//! nullability match what Spark catalyst declared, casting where necessary.
//!
//! This concern is enclosed by shuffle on purpose: everywhere else in the native runtime,
//! return-type drift from DataFusion / `datafusion-spark` is self-healing. When a native plan's
//! output crosses back to the JVM and feeds another native plan, the consuming `ScanExec` casts
//! every imported column to the catalyst-declared type, so a wrong Arrow type never survives the
//! boundary. Shuffle is the lone exception, on two counts:
//!
//! 1. The writer hash-partitions on these columns, and Spark's hash differs by type (e.g. `Int32`
//! vs `Int64`), so a drifted type would route rows to the wrong partition. A read-side cast
//! cannot undo a wrong partition assignment, so the type must be corrected before partitioning.
//! 2. The shuffle read path (`ShuffleScanExec`) does not cast; it stamps the catalyst schema onto
//! the decoded block and errors on any mismatch. The schema is serialized into the block on
//! write and trusted on read.
//!
//! Both force the alignment to happen on the writer input. See
//! <https://github.com/apache/datafusion-comet/issues/4515> for the running list of mismatched
//! functions.
use arrow::array::{ArrayRef, RecordBatch, RecordBatchOptions};
use arrow::compute::{cast_with_options, CastOptions};
use arrow::datatypes::{Field, Schema, SchemaRef};
use datafusion::common::DataFusionError;
use datafusion::physical_expr::EquivalenceProperties;
use datafusion::physical_plan::execution_plan::{Boundedness, EmissionType};
use datafusion::{
execution::TaskContext,
physical_plan::{
DisplayAs, DisplayFormatType, ExecutionPlan, ExecutionPlanProperties, PlanProperties,
RecordBatchStream, SendableRecordBatchStream,
},
};
use futures::{Stream, StreamExt};
use std::{
collections::HashSet,
pin::Pin,
sync::{Arc, Mutex, OnceLock},
task::{Context, Poll},
};
/// Process-wide set of `(column, actual, expected)` signatures we have already warned about.
/// Each schema drift produces the same warning on every partition of every query that runs
/// the offending expression; deduping here keeps logs readable while still surfacing each
/// distinct mismatch once.
fn warn_dedup() -> &'static Mutex<HashSet<String>> {
static SET: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
SET.get_or_init(|| Mutex::new(HashSet::new()))
}
/// Casts each column of `child`'s output to the data_type Spark catalyst declared, widening
/// nullability to `actual.nullable || expected.nullable`. See
/// <https://github.com/apache/datafusion-comet/issues/4515>.
#[derive(Debug)]
pub struct SchemaAlignExec {
child: Arc<dyn ExecutionPlan>,
target_schema: SchemaRef,
column_actions: Arc<Vec<ColumnAction>>,
cache: Arc<PlanProperties>,
}
#[derive(Debug, Clone)]
enum ColumnAction {
/// Pass the input column through unchanged. Any nullability/metadata difference is
/// absorbed when the batch is re-stamped via `RecordBatch::try_new_with_options`.
Passthrough,
/// Cast the input column to the target data_type.
Cast,
}
impl SchemaAlignExec {
/// Build a SchemaAlignExec that aligns `child`'s output to `expected`. Returns
/// `Ok(child)` unchanged when no per-column reshape is needed; otherwise wraps `child`
/// in a SchemaAlignExec whose target schema preserves `expected`'s data_type and metadata
/// but widens nullability to `actual.nullable || expected.nullable`.
pub fn try_new_or_passthrough(
child: Arc<dyn ExecutionPlan>,
expected: &SchemaRef,
) -> Result<Arc<dyn ExecutionPlan>, DataFusionError> {
let actual = child.schema();
if actual.fields().len() != expected.fields().len() {
return Err(DataFusionError::Plan(format!(
"SchemaAlignExec: expected {} fields, child produces {}",
expected.fields().len(),
actual.fields().len()
)));
}
let mut needs_alignment = false;
let mut actions = Vec::with_capacity(actual.fields().len());
let mut target_fields = Vec::with_capacity(actual.fields().len());
for (idx, (actual_field, expected_field)) in actual
.fields()
.iter()
.zip(expected.fields().iter())
.enumerate()
{
let action = if actual_field.data_type() == expected_field.data_type() {
ColumnAction::Passthrough
} else {
let signature = format!(
"{}|{:?}|{:?}",
expected_field.name(),
actual_field.data_type(),
expected_field.data_type()
);
if warn_dedup().lock().unwrap().insert(signature) {
log::warn!(
"ShuffleWriter input schema mismatch on col[{idx}] '{}': child produced \
{:?}, catalyst declared {:?}. Inserting a cast; please file the upstream \
function bug at https://github.com/apache/datafusion-comet/issues/4515.",
expected_field.name(),
actual_field.data_type(),
expected_field.data_type()
);
}
ColumnAction::Cast
};
let target_nullable = actual_field.is_nullable() || expected_field.is_nullable();
let field_changed = !matches!(action, ColumnAction::Passthrough)
|| target_nullable != actual_field.is_nullable()
|| expected_field.metadata() != actual_field.metadata()
|| expected_field.name() != actual_field.name();
if field_changed {
needs_alignment = true;
}
target_fields.push(
Field::new(
expected_field.name(),
expected_field.data_type().clone(),
target_nullable,
)
.with_metadata(expected_field.metadata().clone()),
);
actions.push(action);
}
if !needs_alignment {
return Ok(child);
}
let target_schema: SchemaRef = Arc::new(Schema::new(target_fields));
let cache = Arc::new(PlanProperties::new(
EquivalenceProperties::new(Arc::clone(&target_schema)),
child.output_partitioning().clone(),
EmissionType::Incremental,
Boundedness::Bounded,
));
Ok(Arc::new(Self {
child,
target_schema,
column_actions: Arc::new(actions),
cache,
}))
}
}
impl DisplayAs for SchemaAlignExec {
fn fmt_as(&self, t: DisplayFormatType, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match t {
DisplayFormatType::Default | DisplayFormatType::Verbose => {
write!(f, "CometSchemaAlignExec")
}
DisplayFormatType::TreeRender => unimplemented!(),
}
}
}
impl ExecutionPlan for SchemaAlignExec {
fn schema(&self) -> SchemaRef {
Arc::clone(&self.target_schema)
}
fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
vec![&self.child]
}
fn with_new_children(
self: Arc<Self>,
children: Vec<Arc<dyn ExecutionPlan>>,
) -> datafusion::common::Result<Arc<dyn ExecutionPlan>> {
// Rebuild PlanProperties from the new child since `output_partitioning` may differ.
let new_child = Arc::clone(&children[0]);
let cache = Arc::new(PlanProperties::new(
EquivalenceProperties::new(Arc::clone(&self.target_schema)),
new_child.output_partitioning().clone(),
EmissionType::Incremental,
Boundedness::Bounded,
));
Ok(Arc::new(Self {
child: new_child,
target_schema: Arc::clone(&self.target_schema),
column_actions: Arc::clone(&self.column_actions),
cache,
}))
}
fn execute(
&self,
partition: usize,
context: Arc<TaskContext>,
) -> datafusion::common::Result<SendableRecordBatchStream> {
let child_stream = self.child.execute(partition, context)?;
Ok(Box::pin(SchemaAlignStream {
child_stream,
target_schema: Arc::clone(&self.target_schema),
column_actions: Arc::clone(&self.column_actions),
}))
}
fn properties(&self) -> &Arc<PlanProperties> {
&self.cache
}
fn name(&self) -> &str {
"CometSchemaAlignExec"
}
}
struct SchemaAlignStream {
child_stream: SendableRecordBatchStream,
target_schema: SchemaRef,
column_actions: Arc<Vec<ColumnAction>>,
}
impl SchemaAlignStream {
fn align(&self, batch: RecordBatch) -> Result<RecordBatch, DataFusionError> {
let mut columns: Vec<ArrayRef> = Vec::with_capacity(batch.num_columns());
for (idx, action) in self.column_actions.iter().enumerate() {
let column = batch.column(idx);
let aligned = match action {
ColumnAction::Passthrough => Arc::clone(column),
ColumnAction::Cast => cast_with_options(
column,
self.target_schema.field(idx).data_type(),
&CastOptions::default(),
)?,
};
columns.push(aligned);
}
let options = RecordBatchOptions::new().with_row_count(Some(batch.num_rows()));
RecordBatch::try_new_with_options(Arc::clone(&self.target_schema), columns, &options)
.map_err(DataFusionError::from)
}
}
impl Stream for SchemaAlignStream {
type Item = datafusion::common::Result<RecordBatch>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
match self.child_stream.poll_next_unpin(cx) {
Poll::Ready(Some(Ok(batch))) => Poll::Ready(Some(self.align(batch))),
other => other,
}
}
}
impl RecordBatchStream for SchemaAlignStream {
fn schema(&self) -> SchemaRef {
Arc::clone(&self.target_schema)
}
}
#[cfg(test)]
mod tests {
//! Tests anchored to the concrete drifts tracked in
//! <https://github.com/apache/datafusion-comet/issues/4515>. This operator exists only to
//! reshape those drifts before the shuffle writer; once a function's upstream return type is
//! corrected, its drift pair becomes identical, `try_new_or_passthrough` returns the child
//! unwrapped, and the corresponding test below flips to the passthrough assertion. That is the
//! signal the workaround for that function can be removed.
use super::*;
use arrow::array::{Array, Int32Array, Int64Array, ListArray, TimestampMicrosecondArray};
use arrow::datatypes::{DataType, Int32Type, TimeUnit};
use datafusion::datasource::memory::MemorySourceConfig;
use datafusion::datasource::source::DataSourceExec;
use datafusion::prelude::SessionContext;
fn memory_child(batch: RecordBatch) -> Arc<dyn ExecutionPlan> {
let schema = batch.schema();
let config = MemorySourceConfig::try_new(&[vec![batch]], schema, None).unwrap();
Arc::new(DataSourceExec::new(Arc::new(config)))
}
async fn collect_one_partition(plan: Arc<dyn ExecutionPlan>) -> Vec<RecordBatch> {
let ctx = SessionContext::new().task_ctx();
let mut stream = plan.execute(0, ctx).unwrap();
let mut batches = Vec::new();
while let Some(batch) = stream.next().await {
batches.push(batch.unwrap());
}
batches
}
/// `width_bucket`: since DataFusion 54.1.0 (apache/datafusion#23087) `SparkWidthBucket`
/// returns `Int64`, matching the `Int64` catalyst declares. The former Int32->Int64 drift is
/// gone, so the writer sees identical types and `try_new_or_passthrough` hands the child back
/// unwrapped. This passthrough assertion records that the width_bucket workaround has retired.
#[tokio::test]
async fn width_bucket_no_longer_drifts() {
let child_schema = Arc::new(Schema::new(vec![Field::new("a", DataType::Int64, false)]));
let batch = RecordBatch::try_new(
child_schema,
vec![Arc::new(Int64Array::from(vec![1, 2, 3]))],
)
.unwrap();
let expected = Arc::new(Schema::new(vec![Field::new("a", DataType::Int64, false)]));
let child = memory_child(batch);
let aligned =
SchemaAlignExec::try_new_or_passthrough(Arc::clone(&child), &expected).unwrap();
assert!(
Arc::ptr_eq(&child, &aligned),
"identical schemas must pass through without a SchemaAlignExec wrapper"
);
}
/// `date_trunc`: catalyst declares `Timestamp(us, "UTC")`, DataFusion produces `Timestamp(us)`
/// with no timezone. The same i64 instants are reinterpreted as UTC; no value should change.
#[tokio::test]
async fn aligns_date_trunc_timestamp_timezone() {
let no_tz = DataType::Timestamp(TimeUnit::Microsecond, None);
let utc = DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into()));
let child_schema = Arc::new(Schema::new(vec![Field::new("ts", no_tz, false)]));
let batch = RecordBatch::try_new(
child_schema,
vec![Arc::new(TimestampMicrosecondArray::from(vec![
0, 1_000_000,
]))],
)
.unwrap();
let expected = Arc::new(Schema::new(vec![Field::new("ts", utc.clone(), false)]));
let aligned =
SchemaAlignExec::try_new_or_passthrough(memory_child(batch), &expected).unwrap();
assert_eq!(aligned.schema().field(0).data_type(), &utc);
let out = collect_one_partition(aligned).await;
let col = out[0]
.column(0)
.as_any()
.downcast_ref::<TimestampMicrosecondArray>()
.unwrap();
assert_eq!(col.values(), &[0, 1_000_000]);
}
/// `collect_set`: catalyst declares `List(non-null Int32)`, DataFusion produces
/// `List(nullable Int32)`. This narrows the *inner* element nullability, which arrow's list
/// cast may or may not perform. If it does not, the re-stamp fails on DataType inequality and
/// this test surfaces that the operator does not actually fix the `collect_set` drift.
#[tokio::test]
async fn aligns_collect_set_list_element_nullability() {
let list_array = ListArray::from_iter_primitive::<Int32Type, _, _>([
Some(vec![Some(1), Some(2)]),
Some(vec![Some(3)]),
]);
let actual_list = list_array.data_type().clone();
let child_schema = Arc::new(Schema::new(vec![Field::new("s", actual_list, true)]));
let batch = RecordBatch::try_new(child_schema, vec![Arc::new(list_array)]).unwrap();
let expected_list = DataType::List(Arc::new(Field::new_list_field(DataType::Int32, false)));
let expected = Arc::new(Schema::new(vec![Field::new(
"s",
expected_list.clone(),
true,
)]));
let aligned =
SchemaAlignExec::try_new_or_passthrough(memory_child(batch), &expected).unwrap();
assert_eq!(aligned.schema().field(0).data_type(), &expected_list);
let out = collect_one_partition(aligned).await;
let col = out[0]
.column(0)
.as_any()
.downcast_ref::<ListArray>()
.unwrap();
assert_eq!(col.len(), 2);
let first = col
.value(0)
.as_any()
.downcast_ref::<Int32Array>()
.unwrap()
.values()
.to_vec();
assert_eq!(first, vec![1, 2]);
}
}