This is an automated email from the ASF dual-hosted git repository.
andygrove 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 f13cc977ca fix: codegen dispatcher null short-circuit swallowed ANSI
errors, plus two latent TIME-type gaps (#5219)
f13cc977ca is described below
commit f13cc977cac30df422f99fa9cf04132ff3229390
Author: Andy Grove <[email protected]>
AuthorDate: Mon Aug 3 13:58:24 2026 -0600
fix: codegen dispatcher null short-circuit swallowed ANSI errors, plus two
latent TIME-type gaps (#5219)
* fix: codegen dispatcher null short-circuit swallowed errors Spark raises
The kernel applied an "any input null -> null output" short-circuit on the
union of input ordinals whenever every node in the bound tree was
NullIntolerant. Spark's null handling is per-node and left-to-right
(BinaryExpression.nullSafeCodeGen emits the left child's code
unconditionally, then tests the left child's null, then the right child's),
so a union short-circuit skips a subtree Spark would have evaluated and
loses any error it raises.
Under ANSI, `add_months(cast(s as date), i)` on ('notadate', NULL) raises
CAST_INVALID_INPUT in Spark; Comet returned NULL. ANSI is on by default in
Spark 4, and ~70 built-in expressions route through CometCodegenDispatch.
Restrict the short-circuit to trees that read exactly one input ordinal.
With one input, Spark also evaluates nothing when that input is null, so
the optimization stays exact and the common unary shapes (upper, length,
date_format) keep the fast path.
Also close two TIME-type gaps in the same dispatcher, where canHandle
accepted a type the runtime rejected:
- CometScalaUDFCodegen.specFor omitted TimeNanoVector and threw
UnsupportedOperationException at execute time, after the plan had
committed to the kernel, with no fallback available.
- CometSpecializedGettersDispatch.get had no time-type branch, so
get(ordinal, TimeType) threw. That is the path SafeProjection and every
CodegenFallback.eval(row) use.
Closes #5218
* ci: register CometSpecializedGettersDispatchSuite in PR workflows
* fix: keep the null short-circuit for leaf-only multi-input trees
Addresses review feedback on #5219. The previous fix dropped the
union-of-ordinals short-circuit for every multi-argument NullIntolerant
tree, not only the ones that could swallow an error.
The bug needs a subtree between the root and a leaf that can raise
independently of nullness, like the Cast in add_months(CAST(s AS DATE), i).
A BoundReference or Literal read cannot raise on its own, so when every
direct child of the root is a leaf the tree is one level deep and the union
short-circuit is still exact: any error comes from the root's own logic,
which runs only once every input is known non-null, in Spark's shape and in
ours alike.
canShortCircuitNulls now accepts either a single input ordinal or a
leaf-only-children root, restoring the fast path for the common no-cast
multi-argument case (pmod, conv, make_timestamp) across the ~70 expressions
that route through this dispatcher. defaultBody emits the disjunction over
every ordinal the tree reads rather than only the first.
Verified against Spark's generated code that the leaf-only rule holds even
where Spark reorders children: DivModLike.doGenCode and Pmod.doGenCode
evaluate the divisor first but still test the dividend's null before their
divide/remainder-by-zero throw, so pmod(NULL, 0) returns NULL rather than
raising.
* test: make the pmod-by-zero error class version-aware
Spark 4.1 introduced REMAINDER_BY_ZERO; 3.4, 3.5 and 4.0 raise
DIVIDE_BY_ZERO for pmod. The new #5218 test hard-coded the 4.1 class,
so the [expressions] job failed on those three profiles.
---
.github/workflows/pr_build_linux.yml | 1 +
.github/workflows/pr_build_macos.yml | 1 +
.../comet/codegen/CometBatchKernelCodegen.scala | 191 +++++++++++++--------
.../codegen/CometSpecializedGettersDispatch.scala | 12 +-
.../comet/udf/codegen/CometScalaUDFCodegen.scala | 10 +-
.../org/apache/comet/CometCodegenSourceSuite.scala | 131 +++++++++++++-
.../scala/org/apache/comet/CometCodegenSuite.scala | 142 ++++++++++++++-
.../CometSpecializedGettersDispatchSuite.scala | 74 ++++++++
8 files changed, 483 insertions(+), 79 deletions(-)
diff --git a/.github/workflows/pr_build_linux.yml
b/.github/workflows/pr_build_linux.yml
index 2d01ecc104..004f10b037 100644
--- a/.github/workflows/pr_build_linux.yml
+++ b/.github/workflows/pr_build_linux.yml
@@ -400,6 +400,7 @@ jobs:
org.apache.comet.CometCodegenHOFSuite
org.apache.comet.CometFuzzMathSuite
org.apache.comet.CometCodegenFuzzSuite
+ org.apache.comet.codegen.CometSpecializedGettersDispatchSuite
org.apache.comet.CometStringDecodeSuite
org.apache.comet.CometWidthBucketSuite
org.apache.comet.CometUuidExpressionSuite
diff --git a/.github/workflows/pr_build_macos.yml
b/.github/workflows/pr_build_macos.yml
index 5f60c07451..d290714411 100644
--- a/.github/workflows/pr_build_macos.yml
+++ b/.github/workflows/pr_build_macos.yml
@@ -216,6 +216,7 @@ jobs:
org.apache.comet.CometCodegenHOFSuite
org.apache.comet.CometFuzzMathSuite
org.apache.comet.CometCodegenFuzzSuite
+ org.apache.comet.codegen.CometSpecializedGettersDispatchSuite
org.apache.comet.CometStringDecodeSuite
org.apache.comet.CometWidthBucketSuite
org.apache.comet.CometUuidExpressionSuite
diff --git
a/spark/src/main/scala/org/apache/comet/codegen/CometBatchKernelCodegen.scala
b/spark/src/main/scala/org/apache/comet/codegen/CometBatchKernelCodegen.scala
index cce8d79476..c29300f9ed 100644
---
a/spark/src/main/scala/org/apache/comet/codegen/CometBatchKernelCodegen.scala
+++
b/spark/src/main/scala/org/apache/comet/codegen/CometBatchKernelCodegen.scala
@@ -337,10 +337,10 @@ object CometBatchKernelCodegen extends Logging with
CometExprTraitShim with Come
}
/**
- * Per-row body. For `NullIntolerant` expressions where the entire tree
propagates nulls,
- * prepends a short-circuit on the union of input ordinals so the whole
`ev.code` cost is
- * skipped on null rows. Otherwise the standard shape: run `ev.code`, then
`setNull` or write
- * based on `ev.isNull`.
+ * Per-row body. For `NullIntolerant` expressions whose input nulls fully
determine a null
+ * output (see [[canShortCircuitNulls]]), prepends a short-circuit on those
ordinals so the
+ * whole `ev.code` cost is skipped on null rows. Otherwise the standard
shape: run `ev.code`,
+ * then `setNull` or write based on `ev.isNull`.
*
* `subExprsCode` is the CSE helper-invocation block. It must run before
`ev.code`. Inside the
* short-circuit it lives in the else branch so null rows skip CSE too.
@@ -351,82 +351,131 @@ object CometBatchKernelCodegen extends Logging with
CometExprTraitShim with Come
ev: ExprCode,
writeSnippet: String,
subExprsCode: String): String = {
- boundExpr match {
- case _ if isNullIntolerant(boundExpr) && allNullIntolerant(boundExpr) =>
- // Every node from root to leaf is `NullIntolerant` or a leaf, so "any
BoundReference null
- // -> whole expression null". A non-null-propagating node like
`coalesce` or `if` would
- // make this incorrect (`coalesce(null, x)` is `x`);
`allNullIntolerant` rejects those.
- val inputOrdinals =
- boundExpr.collect { case b: BoundReference => b.ordinal }.distinct
- // Primitive Arrow vectors are wrapped in `CometPlainVector` at
input-cast time, which
- // exposes `isNullAt(int)` rather than the raw Arrow `isNull(int)`.
Pick the right method
- // per ordinal so the short-circuit compiles for timestamp / int /
float columns too,
- // not just VarChar / Decimal vectors that stay as raw Arrow types.
- def nullCheckCall(ord: Int): String = {
- val method =
CometBatchKernelCodegenInput.nullCheckMethod(inputSchema(ord))
- s"this.col$ord.$method(i)"
- }
- val nullCheck =
- if (inputOrdinals.isEmpty) "false"
- else inputOrdinals.map(nullCheckCall).mkString(" || ")
- // `NullIntolerant` only constrains "any input null -> output null";
it does NOT promise
- // that non-null inputs always produce non-null output.
`MakeTimestamp(failOnError=false)`
- // is `NullIntolerant=true` but its `doGenCode` catches
`DateTimeException` for invalid
- // year/month/day/hour/min/sec components and sets `ev.isNull = true`.
Honor `ev.isNull`
- // post-eval whenever the expression is nullable; skip the guard only
when the root is
- // statically non-nullable (`ev.isNull` is then a literal `false`).
- if (boundExpr.nullable) {
- s"""
- |if ($nullCheck) {
- | output.setNull(i);
- |} else {
- | $subExprsCode
- | ${ev.code}
- | if (${ev.isNull}) {
- | output.setNull(i);
- | } else {
- | $writeSnippet
- | }
- |}
+ val inputOrdinals = boundExpr.collect { case b: BoundReference =>
b.ordinal }.distinct
+ if (canShortCircuitNulls(boundExpr, inputOrdinals)) {
+ // Primitive Arrow vectors are wrapped in `CometPlainVector` at
input-cast time, which
+ // exposes `isNullAt(int)` rather than the raw Arrow `isNull(int)`. Pick
the right method
+ // for the ordinal so the short-circuit compiles for timestamp / int /
float columns too,
+ // not just VarChar / Decimal vectors that stay as raw Arrow types.
+ //
+ // Multi-ordinal trees test the disjunction of their ordinals. That is
only reachable for
+ // leaf-only-children roots, where it is exact; see
[[canShortCircuitNulls]].
+ val nullCheck = inputOrdinals
+ .map(ord =>
+
s"this.col$ord.${CometBatchKernelCodegenInput.nullCheckMethod(inputSchema(ord))}(i)")
+ .mkString(" || ")
+ // `NullIntolerant` only constrains "any input null -> output null"; it
does NOT promise
+ // that non-null inputs always produce non-null output.
`MakeTimestamp(failOnError=false)`
+ // is `NullIntolerant=true` but its `doGenCode` catches
`DateTimeException` for invalid
+ // year/month/day/hour/min/sec components and sets `ev.isNull = true`.
Honor `ev.isNull`
+ // post-eval whenever the expression is nullable; skip the guard only
when the root is
+ // statically non-nullable (`ev.isNull` is then a literal `false`).
+ if (boundExpr.nullable) {
+ s"""
+ |if ($nullCheck) {
+ | output.setNull(i);
+ |} else {
+ | $subExprsCode
+ | ${ev.code}
+ | if (${ev.isNull}) {
+ | output.setNull(i);
+ | } else {
+ | $writeSnippet
+ | }
+ |}
""".stripMargin
- } else {
- s"""
- |if ($nullCheck) {
- | output.setNull(i);
- |} else {
- | $subExprsCode
- | ${ev.code}
- | $writeSnippet
- |}
+ } else {
+ s"""
+ |if ($nullCheck) {
+ | output.setNull(i);
+ |} else {
+ | $subExprsCode
+ | ${ev.code}
+ | $writeSnippet
+ |}
""".stripMargin
- }
- case _ =>
- // NonNullableOutputShortCircuit: when `nullable = false`, drop the
`if (ev.isNull)`
- // guard at source level rather than relying on JIT folding.
- if (!boundExpr.nullable) {
- s"""
- |$subExprsCode
- |${ev.code}
- |$writeSnippet
+ }
+ } else {
+ // NonNullableOutputShortCircuit: when `nullable = false`, drop the `if
(ev.isNull)`
+ // guard at source level rather than relying on JIT folding.
+ if (!boundExpr.nullable) {
+ s"""
+ |$subExprsCode
+ |${ev.code}
+ |$writeSnippet
""".stripMargin
- } else {
- s"""
- |$subExprsCode
- |${ev.code}
- |if (${ev.isNull}) {
- | output.setNull(i);
- |} else {
- | $writeSnippet
- |}
+ } else {
+ s"""
+ |$subExprsCode
+ |${ev.code}
+ |if (${ev.isNull}) {
+ | output.setNull(i);
+ |} else {
+ | $writeSnippet
+ |}
""".stripMargin
- }
+ }
}
}
+ /**
+ * Gates the [[defaultBody]] null short-circuit ("any input null implies
null output", tested as
+ * the disjunction of the tree's input ordinals before `ev.code` runs).
+ *
+ * The short-circuit is only equivalent to Spark when no subtree that Spark
would have evaluated
+ * ahead of a null check gets skipped. Spark's null handling is per-node and
left-to-right:
+ * `BinaryExpression.nullSafeCodeGen` emits the left child's code
unconditionally, then tests
+ * the left child's null, then the right child's. So for `add_months(cast(s
as date), i)` on
+ * `('notadate', NULL)` Spark evaluates the cast and, under ANSI, raises
`CAST_INVALID_INPUT`; a
+ * short-circuit on the union of ordinals would skip the cast and return
null, swallowing the
+ * error (#5218).
+ *
+ * Conditions, all necessary:
+ *
+ * - The tree reads at least one input ordinal. A literal-only tree has
nothing to test.
+ * (Catalyst's `ConstantFolding` evaluates those at analysis time, so
this is defensive.)
+ * - The root is `NullIntolerant`, so a null input really does mean a null
result.
+ * - Every node in the tree is null-propagating ([[allNullIntolerant]]); a
`Coalesce` / `If` /
+ * `CaseWhen` anywhere would break the chain.
+ * - Either the tree reads exactly one ordinal, or every direct child of
the root is a leaf
+ * ([[rootChildrenAreLeaves]]). Both shapes make the short-circuit exact:
+ * - One ordinal: there is nothing left for Spark to evaluate ahead of
that ordinal's own
+ * null check.
+ * - Leaf-only children: the tree is one level deep, so the only code
Spark runs ahead of
+ * its null checks is `BoundReference` / `Literal` reads, which cannot
raise. Any error
+ * comes from the root's own logic, which runs only once every input
is known non-null --
+ * in both Spark's shape and ours. This keeps the fast path for the
common no-cast
+ * multi-argument case (`pmod(a, b)`, `conv(a, b, c)`,
`make_timestamp(y, m, d, h, mi,
+ * s)`) across the ~70 expressions that route through this dispatcher.
+ *
+ * The leaf-only rule relies on a `NullIntolerant` root not raising before
it has checked every
+ * input for null. Spark's own generated code honors that even where it
reorders children: the
+ * divide/remainder family (`DivModLike.doGenCode`, and `Pmod.doGenCode`,
which carries its own
+ * copy of the same shape) evaluates the divisor first, but still tests the
dividend's null
+ * *before* its divide/remainder-by-zero throw, so `pmod(NULL, 0)` returns
null rather than
+ * raising. A root that raised ahead of its null checks would contradict
`NullIntolerant`.
+ */
+ private def canShortCircuitNulls(expr: Expression, inputOrdinals: Seq[Int]):
Boolean =
+ inputOrdinals.nonEmpty && isNullIntolerant(expr) &&
allNullIntolerant(expr) &&
+ (inputOrdinals.size == 1 || rootChildrenAreLeaves(expr))
+
+ /**
+ * True iff every direct child of the root is a leaf (`BoundReference` or
`Literal`), i.e. the
+ * tree is one level deep with no subtree between the root and its inputs.
One of the conditions
+ * on [[canShortCircuitNulls]]: it is what makes a multi-ordinal
short-circuit exact, because a
+ * leaf read cannot raise an error that Spark's left-to-right evaluation
would have surfaced.
+ */
+ private def rootChildrenAreLeaves(expr: Expression): Boolean =
+ expr.children.forall {
+ case _: BoundReference | _: Literal => true
+ case _ => false
+ }
+
/**
* True iff every node in the tree propagates nulls (`NullIntolerant`,
`BoundReference`, or
- * `Literal`). Gates the [[defaultBody]] short-circuit, which is only
correct when no node
- * (`Coalesce`, `If`, `CaseWhen`, `Concat`, ...) breaks the propagation
chain.
+ * `Literal`). One of the conditions on [[canShortCircuitNulls]]: the
short-circuit is only
+ * correct when no node (`Coalesce`, `If`, `CaseWhen`, `Concat`, ...) breaks
the propagation
+ * chain.
*/
private def allNullIntolerant(expr: Expression): Boolean =
!expr.exists {
diff --git
a/spark/src/main/scala/org/apache/comet/codegen/CometSpecializedGettersDispatch.scala
b/spark/src/main/scala/org/apache/comet/codegen/CometSpecializedGettersDispatch.scala
index d258e9e6ef..957790d180 100644
---
a/spark/src/main/scala/org/apache/comet/codegen/CometSpecializedGettersDispatch.scala
+++
b/spark/src/main/scala/org/apache/comet/codegen/CometSpecializedGettersDispatch.scala
@@ -22,17 +22,23 @@ package org.apache.comet.codegen
import org.apache.spark.sql.catalyst.expressions.SpecializedGetters
import org.apache.spark.sql.types._
+import org.apache.comet.shims.CometTypeShim
+
/**
* Shared `SpecializedGetters.get(ordinal, dataType)` dispatch used by
[[CometInternalRow]] and
* [[CometArrayData]]. Spark codegen paths (notably `SafeProjection` for
ScalaUDF struct args) and
* interpreted-eval fallbacks (`ArrayDistinct.nullSafeEval` etc.) call the
generic `get` instead
* of the typed getter, so both kernel-side bases need a non-throwing
implementation.
*
+ * The type surface here must stay in step with
`CometBatchKernelCodegen.isSupportedDataType`,
+ * which is what gates the expression at plan time. A type accepted there but
missing here throws
+ * at execute time, after the plan has already committed to the kernel.
+ *
* For complex types, the typed getter allocates a fresh `InputStruct_*` /
`InputArray_*` /
* `InputMap_*` per call (`ColumnarRow`-style), so retain-by-reference
consumers like
* `OpenHashSet` get distinct identities.
*/
-private[codegen] object CometSpecializedGettersDispatch {
+private[codegen] object CometSpecializedGettersDispatch extends CometTypeShim {
def get(g: SpecializedGetters, ordinal: Int, dataType: DataType): AnyRef = {
if (g.isNullAt(ordinal)) return null
@@ -45,6 +51,10 @@ private[codegen] object CometSpecializedGettersDispatch {
case LongType | TimestampType | TimestampNTZType | _:
DayTimeIntervalType =>
java.lang.Long.valueOf(g.getLong(ordinal))
case CalendarIntervalType => g.getInterval(ordinal)
+ // Spark 4.1's `TimeType` is nanos-since-midnight in a long, read
through `getLong` just
+ // like the other long-backed temporal types. Matches
`elementGetterCall` and
+ // `emitSpecializedGetterExpr`, which both route time types to `getLong`.
+ case dt if isTimeType(dt) => java.lang.Long.valueOf(g.getLong(ordinal))
case FloatType => java.lang.Float.valueOf(g.getFloat(ordinal))
case DoubleType => java.lang.Double.valueOf(g.getDouble(ordinal))
case _: StringType => g.getUTF8String(ordinal)
diff --git
a/spark/src/main/scala/org/apache/comet/udf/codegen/CometScalaUDFCodegen.scala
b/spark/src/main/scala/org/apache/comet/udf/codegen/CometScalaUDFCodegen.scala
index 56746f5156..ef541fb3e2 100644
---
a/spark/src/main/scala/org/apache/comet/udf/codegen/CometScalaUDFCodegen.scala
+++
b/spark/src/main/scala/org/apache/comet/udf/codegen/CometScalaUDFCodegen.scala
@@ -185,6 +185,11 @@ class CometScalaUDFCodegen extends CometUDF with Logging {
/**
* Build the compile-time spec for one input Arrow vector. Recurses on
complex types.
*
+ * The vector classes matched here must cover every Spark type
+ * `CometBatchKernelCodegen.isSupportedDataType` admits, because that
predicate is what
+ * `canHandle` gates the plan on. A type accepted at plan time but unmatched
here throws at
+ * execute time, when the operator can no longer fall back to Spark.
+ *
* Top-level `nullable=true` is hardcoded: the cache key does not specialize
on per-batch null
* density. Schema-declared nullability still reaches the kernel via
`BoundReference.nullable`
* embedded in `bytesKey`, so `BoundReference.doGenCode` elides its own
`isNullAt` probe on
@@ -219,8 +224,9 @@ class CometScalaUDFCodegen extends CometUDF with Logging {
StructColumnSpec(nullable = true, fieldSpecs)
case _: BitVector | _: TinyIntVector | _: SmallIntVector | _: IntVector |
_: BigIntVector |
_: Float4Vector | _: Float8Vector | _: DecimalVector | _:
VarCharVector |
- _: VarBinaryVector | _: DateDayVector | _: DurationVector | _:
TimeStampMicroVector |
- _: TimeStampMicroTZVector | _: IntervalYearVector | _:
IntervalMonthDayNanoVector =>
+ _: VarBinaryVector | _: DateDayVector | _: DurationVector | _:
TimeNanoVector |
+ _: TimeStampMicroVector | _: TimeStampMicroTZVector | _:
IntervalYearVector |
+ _: IntervalMonthDayNanoVector =>
ScalarColumnSpec(v.getClass.asInstanceOf[Class[_ <: ValueVector]],
nullable = true)
case other =>
throw new UnsupportedOperationException(
diff --git
a/spark/src/test/scala/org/apache/comet/CometCodegenSourceSuite.scala
b/spark/src/test/scala/org/apache/comet/CometCodegenSourceSuite.scala
index 87c52f153c..adc4440915 100644
--- a/spark/src/test/scala/org/apache/comet/CometCodegenSourceSuite.scala
+++ b/spark/src/test/scala/org/apache/comet/CometCodegenSourceSuite.scala
@@ -24,7 +24,7 @@ import org.scalatest.funsuite.AnyFunSuite
import org.apache.spark.SparkConf
import org.apache.spark.serializer.JavaSerializer
import org.apache.spark.sql.catalyst.InternalRow
-import org.apache.spark.sql.catalyst.expressions.{Add, AddMonths,
BoundReference, Coalesce, Concat, CreateArray, CreateMap, DateFormatClass,
ElementAt, Expression, GetStructField, LeafExpression, Length, Literal,
MakeTimestamp, MicrosToTimestamp, MillisToTimestamp, MonthsBetween,
Nondeterministic, Rand, Size, ToUnixTimestamp, Unevaluable, UnixMicros,
UnixMillis, UnixSeconds, Upper}
+import org.apache.spark.sql.catalyst.expressions.{Add, AddMonths,
BoundReference, Cast, Coalesce, Concat, CreateArray, CreateMap,
DateFormatClass, ElementAt, Expression, GetStructField, LeafExpression, Length,
Literal, MakeTimestamp, MicrosToTimestamp, MillisToTimestamp, MonthsBetween,
Nondeterministic, Rand, Size, ToUnixTimestamp, Unevaluable, UnixMicros,
UnixMillis, UnixSeconds, Upper}
import org.apache.spark.sql.catalyst.expressions.codegen.{CodeFormatter,
CodegenContext, CodegenFallback, ExprCode}
import org.apache.spark.sql.types._
import org.apache.spark.unsafe.types.UTF8String
@@ -41,8 +41,10 @@ import org.apache.comet.udf.codegen.CometScalaUDFCodegen
* assert on the emitted Java directly, without invoking Janino. The goal is
to catch regressions
* in the optimizations we claim the dispatcher applies:
*
- * - `NullIntolerant` short-circuit wraps `ev.code` in `if (any-input-null)
{ setNull } else {
- * ev.code; write }`.
+ * - `NullIntolerant` short-circuit wraps `ev.code` in `if (input-null) {
setNull } else {
+ * ev.code; write }`. It is emitted for a tree reading a single ordinal,
and for a
+ * multi-ordinal tree whose root has only leaf children; it is *not*
emitted once a subtree
+ * sits between the root and a leaf (see the multi-input tests for why).
* - Non-nullable column declaration emits `return false;` from
`isNullAt(ord)`, and a
* `BoundReference.nullable=false` (Catalyst sets this from
schema-declared nullability) makes
* Spark's `doGenCode` skip emitting its own `row.isNullAt(ord)` probe
entirely.
@@ -166,6 +168,53 @@ class CometCodegenSourceSuite extends AnyFunSuite {
s"got:\n$src")
}
+ test("NullIntolerant short-circuit skipped when a subtree sits between root
and leaf (#5218)") {
+ // Even when every node is NullIntolerant, a short-circuit on the *union*
of input ordinals is
+ // not equivalent to Spark once a real subtree sits between the root and a
leaf. Spark's null
+ // handling is per-node and left-to-right:
`BinaryExpression.nullSafeCodeGen` emits the left
+ // child's code unconditionally, then tests the left child's null, then
the right child's. So
+ // for `Add(cast(c0 AS INT), c1)` on a row where c0 = 'abc' and c1 IS
NULL, Spark evaluates the
+ // cast first and (under ANSI) raises CAST_INVALID_INPUT. A union
short-circuit would skip the
+ // cast and return null, swallowing the error. See CometCodegenSuite's
add_months test for the
+ // end-to-end witness.
+ //
+ // The `Cast` is what disqualifies the short-circuit here: it is a
non-leaf child of the root
+ // that can raise on its own. The sibling test below covers the
leaf-only-children shape, which
+ // keeps the short-circuit.
+ val strCol = ArrowColumnSpec(varCharVectorClass, nullable = true)
+ val intCol = ArrowColumnSpec(
+ CometBatchKernelCodegen.vectorClassBySimpleName("IntVector"),
+ nullable = true)
+ val expr = Add(
+ Cast(BoundReference(0, StringType, nullable = true), IntegerType,
ansiEnabled = true),
+ BoundReference(1, IntegerType, nullable = true))
+ val src = gen(expr, strCol, intCol)
+ assert(
+ !src.contains("this.col0.isNull(i) || this.col1.isNullAt(i)"),
+ s"expected no union-of-inputs short-circuit when a Cast sits under the
root; got:\n$src")
+ assert(
+ !src.contains("if (this.col0.isNull(i))") && !src.contains("if
(this.col1.isNullAt(i))"),
+ s"expected no pre-eval input-null short-circuit at all for this shape;
got:\n$src")
+ }
+
+ test("NullIntolerant short-circuit kept for a multi-input leaf-only tree
(#5218)") {
+ // Counterpart to the test above. When every direct child of the root is a
`BoundReference` or
+ // `Literal`, the tree is one level deep: the only code Spark runs ahead
of its own null checks
+ // is leaf reads, which cannot raise. Any error comes from the root's own
logic, which runs only
+ // once every input is known non-null -- in Spark's shape and in ours
alike. So the union
+ // short-circuit is exact and worth keeping for the common no-cast
multi-argument case.
+ val intCol = ArrowColumnSpec(
+ CometBatchKernelCodegen.vectorClassBySimpleName("IntVector"),
+ nullable = true)
+ val expr = Add(
+ BoundReference(0, IntegerType, nullable = true),
+ BoundReference(1, IntegerType, nullable = true))
+ val src = gen(expr, intCol, intCol)
+ assert(
+ src.contains("if (this.col0.isNullAt(i) || this.col1.isNullAt(i))"),
+ s"expected union-of-inputs short-circuit for a leaf-only two-input tree;
got:\n$src")
+ }
+
test("canHandle accepts CodegenFallback expressions (delegates to
eval(row))") {
// CodegenFallback.doGenCode emits ((Expression) references[N]).eval(row)
which is the same
// mechanism that backs HigherOrderFunction support: the eval reads
through the kernel's typed
@@ -404,6 +453,9 @@ class CometCodegenSourceSuite extends AnyFunSuite {
// non-null output and dropped the post-eval guard; that wrote stale
`ev.value` bytes for
// every invalid row. The short-circuit on input nulls must coexist with a
post-eval
// `if (ev.isNull) setNull` check whenever the expression itself is
nullable.
+ //
+ // All six children are `BoundReference`s, so this is a leaf-only tree and
keeps the
+ // union-of-ordinals short-circuit (#5218).
val expr = MakeTimestamp(
BoundReference(0, IntegerType, nullable = true),
BoundReference(1, IntegerType, nullable = true),
@@ -428,12 +480,83 @@ class CometCodegenSourceSuite extends AnyFunSuite {
val formatted = CodeFormatter.format(result.code)
// Two distinct setNull sites must exist: the input-null short-circuit
before `ev.code` runs,
// and the post-eval guard that propagates `ev.isNull = true` set by
MakeTimestamp's catch
- // block on invalid components. Pre-fix there was only one (the input
short-circuit).
+ // block on invalid components. Pre-#4554 there was only one (the input
short-circuit).
val setNullOccurrences = "output\\.setNull\\(i\\);".r.findAllIn(src).length
assert(
setNullOccurrences >= 2,
"expected at least two setNull sites (input short-circuit + post-eval
ev.isNull guard); " +
s"found $setNullOccurrences. Source:\n$formatted")
+ // The short-circuit must test every ordinal the tree reads, not just the
first.
+ assert(
+ src.contains(
+ "if (this.col0.isNullAt(i) || this.col1.isNullAt(i) || " +
+ "this.col2.isNullAt(i) || this.col3.isNullAt(i) ||
this.col4.isNullAt(i) || " +
+ "this.col5.isNull(i))"),
+ s"expected the short-circuit to test all six ordinals;
source:\n$formatted")
+ }
+
+ test("multi-input tree with a non-leaf child keeps only the post-eval guard
(#5218)") {
+ // The counterpart to the test above: the same
nullable-NullIntolerant-root shape, but with a
+ // `Cast` between the root and one leaf. That disqualifies the
short-circuit, so exactly one
+ // setNull site remains -- the post-eval `ev.isNull` guard, which is the
actual #4554
+ // regression and must survive independently of the short-circuit.
+ val expr = MakeTimestamp(
+ Cast(BoundReference(0, StringType, nullable = true), IntegerType,
ansiEnabled = true),
+ BoundReference(1, IntegerType, nullable = true),
+ BoundReference(2, IntegerType, nullable = true),
+ Literal(0),
+ Literal(0),
+ Literal(Decimal(0, 8, 6), DecimalType(8, 6)),
+ timezone = None,
+ timeZoneId = Some("UTC"),
+ failOnError = false)
+ assert(expr.nullable, "MakeTimestamp(failOnError=false) must be nullable
for this test")
+ val strCol = ArrowColumnSpec(varCharVectorClass, nullable = true)
+ val intCol = ArrowColumnSpec(
+ CometBatchKernelCodegen.vectorClassBySimpleName("IntVector"),
+ nullable = true)
+ val result =
+ CometBatchKernelCodegen.generateSource(expr, IndexedSeq(strCol, intCol,
intCol))
+ val src = result.body
+ val formatted = CodeFormatter.format(result.code)
+ val setNullOccurrences = "output\\.setNull\\(i\\);".r.findAllIn(src).length
+ assert(
+ setNullOccurrences == 1,
+ "expected exactly one setNull site (the post-eval ev.isNull guard, with
no short-circuit); " +
+ s"found $setNullOccurrences. Source:\n$formatted")
+ assert(
+ !src.contains("if (this.col0.isNull(i))"),
+ s"expected no input-null short-circuit when a Cast sits under the root;
source:\n$formatted")
+ }
+
+ test("single-input nullable NullIntolerant root emits both short-circuit and
post-eval guard") {
+ // Counterpart to the test above, for the shape that does keep the
short-circuit: one input
+ // ordinal. Two distinct setNull sites must exist -- the input-null
short-circuit before
+ // `ev.code` runs, and the post-eval `ev.isNull` guard.
+ val expr = MakeTimestamp(
+ BoundReference(0, IntegerType, nullable = true),
+ Literal(1),
+ Literal(1),
+ Literal(0),
+ Literal(0),
+ Literal(Decimal(0, 8, 6), DecimalType(8, 6)),
+ timezone = None,
+ timeZoneId = Some("UTC"),
+ failOnError = false)
+ assert(expr.nullable, "MakeTimestamp(failOnError=false) must be nullable
for this test")
+ val intCol = ArrowColumnSpec(
+ CometBatchKernelCodegen.vectorClassBySimpleName("IntVector"),
+ nullable = true)
+ val result = CometBatchKernelCodegen.generateSource(expr,
IndexedSeq(intCol))
+ val src = result.body
+ assert(
+ src.contains("if (this.col0.isNullAt(i))"),
+ s"expected input-null short-circuit for the single-input tree;
got:\n$src")
+ val setNullOccurrences = "output\\.setNull\\(i\\);".r.findAllIn(src).length
+ assert(
+ setNullOccurrences >= 2,
+ "expected two setNull sites (input short-circuit + post-eval ev.isNull
guard); " +
+ s"found $setNullOccurrences.
Source:\n${CodeFormatter.format(result.code)}")
}
test("ArrayType(StringType) output emits ListVector startNewValue/endValue
recursion") {
diff --git a/spark/src/test/scala/org/apache/comet/CometCodegenSuite.scala
b/spark/src/test/scala/org/apache/comet/CometCodegenSuite.scala
index afb61d09cc..5806cb3501 100644
--- a/spark/src/test/scala/org/apache/comet/CometCodegenSuite.scala
+++ b/spark/src/test/scala/org/apache/comet/CometCodegenSuite.scala
@@ -22,7 +22,7 @@ package org.apache.comet
import scala.util.Random
import org.apache.arrow.vector._
-import org.apache.spark.{SparkConf, TaskContext}
+import org.apache.spark.{SparkConf, SparkEnv, TaskContext}
import org.apache.spark.sql.CometTestBase
import org.apache.spark.sql.api.java.UDF1
import org.apache.spark.sql.catalyst.expressions.{BoundReference, CreateArray,
CreateMap, CreateNamedStruct, Expression, Literal, MapConcat}
@@ -31,6 +31,7 @@ import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.types._
import org.apache.spark.unsafe.types.UTF8String
+import org.apache.comet.CometSparkSessionExtensions.isSpark41Plus
import org.apache.comet.codegen.CometBatchKernelCodegen
import org.apache.comet.codegen.CometBatchKernelCodegen.ArrowColumnSpec
import org.apache.comet.udf.codegen.CometScalaUDFCodegen
@@ -1443,6 +1444,145 @@ class CometCodegenSuite
}
}
+ test("multi-input NullIntolerant tree does not swallow an ANSI error
(#5218)") {
+ // `add_months` is NullIntolerant and a plain BinaryExpression, so Spark's
`nullSafeCodeGen`
+ // emits the LEFT child's code unconditionally before testing the right
child's null. On the
+ // ('notadate', NULL) row Spark therefore evaluates the cast and raises
CAST_INVALID_INPUT.
+ // The dispatcher used to short-circuit on the union of input ordinals,
see the null on `i`,
+ // and return NULL -- silently losing the error. Note `pmod` / `div` are
not witnesses here:
+ // `DivModLike` deliberately evaluates its right child first, so Spark
also returns NULL.
+ withTable("t") {
+ sql("CREATE TABLE t (s STRING, i INT) USING parquet")
+ sql("INSERT INTO t VALUES ('notadate', NULL), ('2024-01-31', 1)")
+ withSQLConf("spark.sql.ansi.enabled" -> "true") {
+ CometScalaUDFCodegen.resetStats()
+ val (sparkErr, cometErr) =
+ checkSparkAnswerMaybeThrows(sql("SELECT add_months(CAST(s AS DATE),
i) FROM t"))
+ val stats = CometScalaUDFCodegen.stats()
+ assert(
+ stats.compileCount + stats.cacheHitCount >= 1,
+ s"expected the codegen dispatcher to run for this query, got $stats")
+ assert(
+ sparkErr.isDefined,
+ "expected Spark to raise on the invalid ANSI cast; the test row is
no longer a witness")
+ assert(
+ cometErr.isDefined,
+ "Comet returned a value where Spark raised: the null short-circuit
swallowed the error")
+ assert(
+ cometErr.get.getMessage.contains("CAST_INVALID_INPUT"),
+ s"expected the same CAST_INVALID_INPUT error Spark raises, got:
${cometErr.get}")
+ }
+ }
+ }
+
+ test("single-input NullIntolerant tree still short-circuits nulls") {
+ // Guards against over-correcting #5218: the single-ordinal short-circuit
is exact (Spark also
+ // evaluates nothing when that one input is null) and must be preserved.
+ withSubjects("abc", null, "xyz") {
+ assertCodegenRan {
+ checkSparkAnswerAndOperator(sql("SELECT upper(substring(s, 1, 2)) FROM
t"))
+ }
+ }
+ }
+
+ test("multi-input leaf-only NullIntolerant tree short-circuits nulls
correctly (#5218)") {
+ // The leaf-only-children shape keeps the union-of-ordinals short-circuit,
because the only
+ // code Spark runs ahead of its own null checks is `BoundReference` reads.
Exercises every
+ // null combination across two ordinals to confirm the disjunction matches
Spark's per-node
+ // left-to-right null handling row for row. Wrapped in a UDF so the
argument expression is
+ // guaranteed to route through the dispatcher rather than Comet's native
path.
+ spark.udf.register("idInt", (i: Integer) => i)
+ withTable("t") {
+ sql("CREATE TABLE t (a INT, b INT) USING parquet")
+ sql(
+ "INSERT INTO t VALUES (7, 3), (NULL, 3), (7, NULL), (NULL, NULL), " +
+ "(-7, 3), (7, -3), (0, 3)")
+ assertCodegenRan {
+ checkSparkAnswerAndOperator(sql("SELECT idInt(pmod(a, b)) FROM t"))
+ }
+ assertCodegenRan {
+ checkSparkAnswerAndOperator(sql("SELECT idInt(a + b) FROM t"))
+ }
+ }
+ }
+
+ test("leaf-only short-circuit preserves ANSI remainder-by-zero behaviour
(#5218)") {
+ // The one shape where a `NullIntolerant` root's error check is not simply
gated behind
+ // "all inputs non-null": `Pmod.doGenCode` evaluates the divisor first and
throws on a zero
+ // divisor under ANSI. It still tests the dividend's null *before* that
throw, so
+ // `pmod(NULL, 0)` returns NULL in Spark and the union short-circuit stays
exact. The
+ // (NULL, 0) row pins the non-raising case, the (7, 0) row the raising one.
+ //
+ // Spark 4.1 introduced REMAINDER_BY_ZERO; older versions raise
DIVIDE_BY_ZERO for `pmod`.
+ // The error comes from Spark's own generated code running inside the
kernel, so the class
+ // tracks the Spark version under test.
+ val expectedError = if (isSpark41Plus) "REMAINDER_BY_ZERO" else
"DIVIDE_BY_ZERO"
+ spark.udf.register("idInt", (i: Integer) => i)
+ withTable("t") {
+ sql("CREATE TABLE t (a INT, b INT) USING parquet")
+ sql("INSERT INTO t VALUES (NULL, 0)")
+ withSQLConf("spark.sql.ansi.enabled" -> "true") {
+ assertCodegenRan {
+ checkSparkAnswerAndOperator(sql("SELECT idInt(pmod(a, b)) FROM t"))
+ }
+ }
+ sql("INSERT INTO t VALUES (7, 0)")
+ withSQLConf("spark.sql.ansi.enabled" -> "true") {
+ val (sparkErr, cometErr) =
+ checkSparkAnswerMaybeThrows(sql("SELECT idInt(pmod(a, b)) FROM t"))
+ assert(
+ sparkErr.isDefined,
+ "expected Spark to raise on pmod by zero under ANSI; the test row is
no longer a witness")
+ assert(
+ cometErr.isDefined,
+ "Comet returned a value where Spark raised: the null short-circuit
swallowed the error")
+ assert(
+ cometErr.get.getMessage.contains(expectedError),
+ s"expected the same $expectedError error Spark raises, got:
${cometErr.get}")
+ assert(
+ sparkErr.get.getMessage.contains(expectedError),
+ s"expected Spark to raise $expectedError, got: ${sparkErr.get}")
+ }
+ }
+ }
+
+ test("TIME input column routes through the dispatcher (#5218)") {
+ assume(isSpark41Plus, "TimeType requires Spark 4.1+")
+ // `canHandle` accepts TIME (`isSupportedDataType`) and `emitTypedGetters`
emits a getLong
+ // case for `TimeNanoVector`, but `CometScalaUDFCodegen.specFor` used to
omit the vector class
+ // and throw `UnsupportedOperationException` at execute time -- after the
plan had already
+ // committed to the kernel, so there was no fallback. Driven through the
dispatcher directly
+ // because Spark 4.1 still rejects TIME columns in file-based data
sources, so no SQL query
+ // can produce a TIME input today.
+ val timeVec = new TimeNanoVector("tm", CometArrowAllocator)
+ val exprVec = new VarBinaryVector("expr", CometArrowAllocator)
+ var out: ValueVector = null
+ try {
+ timeVec.allocateNew()
+ timeVec.setSafe(0, 45296000000000L) // 12:34:56
+ timeVec.setNull(1)
+ timeVec.setValueCount(2)
+
+ val timeType =
org.apache.spark.sql.comet.util.Utils.fromArrowField(timeVec.getField)
+ val expr = BoundReference(0, timeType, nullable = true)
+ val serialized =
SparkEnv.get.closureSerializer.newInstance().serialize(expr)
+ val bytes = new Array[Byte](serialized.remaining())
+ serialized.get(bytes)
+ exprVec.allocateNew()
+ exprVec.setSafe(0, bytes)
+ exprVec.setValueCount(1)
+
+ out = new CometScalaUDFCodegen().evaluate(Array(exprVec, timeVec), 2)
+ val comet = CometVector.getVector(out.asInstanceOf[FieldVector], null)
+ assert(comet.getLong(0) === 45296000000000L)
+ assert(comet.isNullAt(1))
+ } finally {
+ if (out != null) out.close()
+ exprVec.close()
+ timeVec.close()
+ }
+ }
+
// Runtime coverage for nullable nested `getStruct` / `getArray` / `getMap`
element reads is
// exercised through HOFs in `CometCodegenHOFSuite`. Static emitter
assertions live in
// `CometCodegenSourceSuite`.
diff --git
a/spark/src/test/scala/org/apache/comet/codegen/CometSpecializedGettersDispatchSuite.scala
b/spark/src/test/scala/org/apache/comet/codegen/CometSpecializedGettersDispatchSuite.scala
new file mode 100644
index 0000000000..7ad93fd9fc
--- /dev/null
+++
b/spark/src/test/scala/org/apache/comet/codegen/CometSpecializedGettersDispatchSuite.scala
@@ -0,0 +1,74 @@
+/*
+ * 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.
+ */
+
+package org.apache.comet.codegen
+
+import org.scalatest.funsuite.AnyFunSuite
+
+import org.apache.arrow.vector.types.TimeUnit
+import org.apache.arrow.vector.types.pojo.{ArrowType, Field, FieldType}
+import org.apache.spark.sql.comet.util.Utils
+import org.apache.spark.sql.types.{DataType, DateType, LongType}
+
+import org.apache.comet.CometSparkSessionExtensions.isSpark41Plus
+
+/**
+ * Unit coverage for the generic `SpecializedGetters.get(ordinal, dataType)`
dispatch the kernel
+ * bases delegate to. Spark reaches it from `SafeProjection` (for ScalaUDF
struct arguments) and
+ * from every `CodegenFallback.eval(row)`, including higher-order functions,
so its type surface
+ * has to match what `CometBatchKernelCodegen.isSupportedDataType` admits at
plan time. A type
+ * accepted there but missing here throws at execute time, when there is no
longer a fallback.
+ */
+class CometSpecializedGettersDispatchSuite extends AnyFunSuite {
+
+ /** Minimal `CometInternalRow` over one fixed long, to drive the dispatch
through `get`. */
+ private class StubRow(value: Long, isNull: Boolean = false) extends
CometInternalRow {
+ override def numFields: Int = 1
+ override def isNullAt(ordinal: Int): Boolean = isNull
+ override def getLong(ordinal: Int): Long = value
+ override def getInt(ordinal: Int): Int = value.toInt
+ }
+
+ /** Spark `DataType` for Arrow `Time(NANOSECOND, 64)`, resolved without a
4.1 compile dep. */
+ private def timeType: DataType =
+ Utils.fromArrowField(
+ new Field(
+ "t",
+ FieldType.nullable(new ArrowType.Time(TimeUnit.NANOSECOND, 64)),
+ java.util.Collections.emptyList[Field]()))
+
+ test("get dispatches TimeType through getLong (#5218)") {
+ assume(isSpark41Plus, "TimeType requires Spark 4.1+")
+ val row = new StubRow(45296000000000L) // 12:34:56
+ assert(row.get(0, timeType) === java.lang.Long.valueOf(45296000000000L))
+ }
+
+ test("get returns null for a null TimeType slot") {
+ assume(isSpark41Plus, "TimeType requires Spark 4.1+")
+ val row = new StubRow(45296000000000L, isNull = true)
+ assert(row.get(0, timeType) == null)
+ }
+
+ test("get still dispatches the long- and int-backed temporal types") {
+ // Guards against the TimeType case being inserted ahead of a case it
would shadow.
+ val row = new StubRow(19723L) // 2024-01-31 as an epoch day
+ assert(row.get(0, LongType) === java.lang.Long.valueOf(19723L))
+ assert(row.get(0, DateType) === java.lang.Integer.valueOf(19723))
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]