This is an automated email from the ASF dual-hosted git repository.
mbutrovich pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-comet.git
The following commit(s) were added to refs/heads/main by this push:
new 5ef4bd7a72 fix: widen local table scan child nullability to match
native kernels (#4843)
5ef4bd7a72 is described below
commit 5ef4bd7a723dda4c12882bd1af50c694dc2c6474
Author: Andy Grove <[email protected]>
AuthorDate: Wed Jul 22 10:19:12 2026 -0600
fix: widen local table scan child nullability to match native kernels
(#4843)
An in-memory Seq/Map column read through CometLocalTableScanExec encodes its
array element and map value as non-null (containsNull=false /
valueContainsNull=false), but Comet expression serdes promise nullable
children. Feeding a non-null child into a native array/map kernel fails the
planned-vs-actual type assertion (e.g. spark_array_slice returning
List(non-null Int32) while List(Int32) was promised) or panics building a
ListArray for map_entries.
Widen nested child-field nullability on both sides so they agree: the
declared
scan schema (serialized from op.output in CometSink) and the Arrow batch
produced by the local scan. Without widening the declared schema too, the
native ScanExec casts the batch back to the non-null type in
build_record_batch.
The widening is scoped to the local table scan via an overridable
scanFieldType
hook, leaving other sinks untouched. Map keys stay non-null, matching Arrow.
This mirrors the Parquet scan path, which already reads children as
nullable.
Closes #4789
---
.../apache/comet/serde/operator/CometSink.scala | 10 ++++++-
.../spark/sql/comet/CometLocalTableScanExec.scala | 17 ++++++++++--
.../apache/comet/CometArrayExpressionSuite.scala | 31 ++++++++++++++++++++++
.../org/apache/comet/CometMapExpressionSuite.scala | 14 ++++++++++
4 files changed, 69 insertions(+), 3 deletions(-)
diff --git
a/spark/src/main/scala/org/apache/comet/serde/operator/CometSink.scala
b/spark/src/main/scala/org/apache/comet/serde/operator/CometSink.scala
index b1834c5083..ed69f82e2a 100644
--- a/spark/src/main/scala/org/apache/comet/serde/operator/CometSink.scala
+++ b/spark/src/main/scala/org/apache/comet/serde/operator/CometSink.scala
@@ -26,6 +26,7 @@ import
org.apache.spark.sql.comet.execution.shuffle.CometShuffleExchangeExec
import org.apache.spark.sql.execution.SparkPlan
import org.apache.spark.sql.execution.adaptive.ShuffleQueryStageExec
import org.apache.spark.sql.execution.exchange.ReusedExchangeExec
+import org.apache.spark.sql.types.DataType
import org.apache.comet.CometConf
import org.apache.comet.CometSparkSessionExtensions.withFallbackReason
@@ -42,6 +43,13 @@ abstract class CometSink[T <: SparkPlan] extends
CometOperatorSerde[T] {
override def enabledConfig: Option[ConfigEntry[Boolean]] = None
+ /**
+ * The data type to declare for a scan output field. Overridden by sinks
whose source carries
+ * non-null nested child fields that must be widened to match the planned
kernel output types
+ * (see [[org.apache.spark.sql.comet.CometLocalTableScanExec]] and issue
#4789).
+ */
+ protected def scanFieldType(dt: DataType): DataType = dt
+
override def convert(
op: T,
builder: Operator.Builder,
@@ -64,7 +72,7 @@ abstract class CometSink[T <: SparkPlan] extends
CometOperatorSerde[T] {
}
val scanTypes = op.output.flatten { attr =>
- serializeDataType(attr.dataType)
+ serializeDataType(scanFieldType(attr.dataType))
}
if (scanTypes.length == op.output.length) {
diff --git
a/spark/src/main/scala/org/apache/spark/sql/comet/CometLocalTableScanExec.scala
b/spark/src/main/scala/org/apache/spark/sql/comet/CometLocalTableScanExec.scala
index 161a4e0553..28ff6c85c2 100644
---
a/spark/src/main/scala/org/apache/spark/sql/comet/CometLocalTableScanExec.scala
+++
b/spark/src/main/scala/org/apache/spark/sql/comet/CometLocalTableScanExec.scala
@@ -31,7 +31,7 @@ import
org.apache.spark.sql.comet.execution.arrow.{CometArrowStream, CometNative
import org.apache.spark.sql.comet.util.Utils
import org.apache.spark.sql.execution.{LeafExecNode, LocalTableScanExec}
import org.apache.spark.sql.execution.metric.{SQLMetric, SQLMetrics}
-import org.apache.spark.sql.types.{DataType, NullType}
+import org.apache.spark.sql.types.{DataType, NullType, StructType}
import com.google.common.base.Objects
@@ -76,7 +76,15 @@ case class CometLocalTableScanExec(
consume: (String, BufferAllocator => ArrowReader) => Iterator[T]):
RDD[T] = {
val numOutputRows = longMetric("numOutputRows")
val maxRecordsPerBatch = CometConf.COMET_BATCH_SIZE.get(conf)
- val sparkSchema = originalPlan.schema
+ // Normalize nested child-field nullability. An in-memory Seq/Map encodes
array elements and
+ // map values as non-null (containsNull=false / valueContainsNull=false),
but Comet expression
+ // serdes promise nullable children, so feeding a non-null child from the
local scan into a
+ // native kernel fails the planned-vs-actual type assertion (issue #4789).
Widening children to
+ // nullable is always safe: the row data contains no nulls, and Arrow map
keys stay non-null via
+ // Utils.toArrowField. This must agree with the widened scan schema
declared in
+ // CometLocalTableScanExec.scanFieldType so the native ScanExec does not
cast the batch back.
+ val sparkSchema =
+ StructType(originalPlan.schema.map(f => f.copy(dataType =
f.dataType.asNullable)))
rdd.mapPartitionsInternal { rowIter =>
val arrowSchema = Utils.toArrowSchema(sparkSchema,
CometArrowStream.NATIVE_TIMEZONE)
consume(
@@ -118,6 +126,11 @@ object CometLocalTableScanExec extends
CometSink[LocalTableScanExec] with DataTy
override def enabledConfig: Option[ConfigEntry[Boolean]] = Some(
CometConf.COMET_EXEC_LOCAL_TABLE_SCAN_ENABLED)
+ // Widen nested child-field nullability in the declared scan schema so it
matches both the widened
+ // Arrow batch produced by CometLocalTableScanExec and the nullable children
promised by
+ // downstream expression serdes (issue #4789).
+ override protected def scanFieldType(dt: DataType): DataType = dt.asNullable
+
// ArrowWriter (used by RowArrowReader) handles NullType via
Utils.toArrowType + NullWriter;
// other types off DataTypeSupport's allow list (TimeType, intervals, ...)
have no ArrowWriter
// coverage and must fall back to Spark.
diff --git
a/spark/src/test/scala/org/apache/comet/CometArrayExpressionSuite.scala
b/spark/src/test/scala/org/apache/comet/CometArrayExpressionSuite.scala
index 87788c0760..7377f4fe4f 100644
--- a/spark/src/test/scala/org/apache/comet/CometArrayExpressionSuite.scala
+++ b/spark/src/test/scala/org/apache/comet/CometArrayExpressionSuite.scala
@@ -1146,6 +1146,37 @@ class CometArrayExpressionSuite extends CometTestBase
with AdaptiveSparkPlanHelp
}
}
+ // Local table scan carries non-null array child fields (an in-memory Seq
encodes
+ // containsNull=false) into native kernels that promise nullable elements.
ConvertToLocalRelation
+ // must be disabled or the optimizer folds the expression at plan time and
nothing runs natively.
+ // https://github.com/apache/datafusion-comet/issues/4789
+ private def withLocalTableScanNoFold(f: => Unit): Unit = {
+ withSQLConf(
+ CometConf.COMET_EXEC_LOCAL_TABLE_SCAN_ENABLED.key -> "true",
+ "spark.sql.optimizer.excludedRules" ->
+ "org.apache.spark.sql.catalyst.optimizer.ConvertToLocalRelation") {
+ f
+ }
+ }
+
+ test("slice on non-null element array from local table scan (#4789)") {
+ withLocalTableScanNoFold {
+ import testImplicits._
+ val df = Seq(Seq(1, 2, 3), Seq(4, 5)).toDF("x")
+ checkSparkAnswerAndOperator(df.selectExpr("slice(x, 2, 2)"))
+ }
+ }
+
+ test("array_insert on non-null element array from local table scan (#4789)")
{
+ assume(isSpark35Plus)
+ withLocalTableScanNoFold {
+ import testImplicits._
+ val df = Seq(Seq(1, 2, 3), Seq(4, 5)).toDF("x")
+ // SPARK-41233 array prepend lowers to array_insert at position 1
+ checkSparkAnswerAndOperator(df.selectExpr("array_insert(x, 1, 0)"))
+ }
+ }
+
// https://issues.apache.org/jira/browse/SPARK-55747
test("(ansi) GetArrayItem on null array from split()") {
withSQLConf(
diff --git
a/spark/src/test/scala/org/apache/comet/CometMapExpressionSuite.scala
b/spark/src/test/scala/org/apache/comet/CometMapExpressionSuite.scala
index 2581ef5b0d..31c36a07c7 100644
--- a/spark/src/test/scala/org/apache/comet/CometMapExpressionSuite.scala
+++ b/spark/src/test/scala/org/apache/comet/CometMapExpressionSuite.scala
@@ -258,4 +258,18 @@ class CometMapExpressionSuite extends CometTestBase {
}
}
+ test("map_entries on non-null value map from local table scan (#4789)") {
+ // An in-memory Map encodes valueContainsNull=false; the local scan must
widen the map value
+ // to nullable so map_entries' native ListArray/Struct build does not fail
on the child type.
+ // ConvertToLocalRelation must be disabled or the expression folds at plan
time.
+ withSQLConf(
+ CometConf.COMET_EXEC_LOCAL_TABLE_SCAN_ENABLED.key -> "true",
+ "spark.sql.optimizer.excludedRules" ->
+ "org.apache.spark.sql.catalyst.optimizer.ConvertToLocalRelation") {
+ import testImplicits._
+ val df = Seq(Map(1 -> 100, 2 -> 200)).toDF("m")
+ checkSparkAnswerAndOperator(df.selectExpr("map_entries(m)"))
+ }
+ }
+
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]