Skip to content

Commit 5b8a45e

Browse files
authored
refactor: replace hand-coded rollup of expression fallback reasons onto operators (#5236)
* refactor: replace hand-coded fallback-reason roll-up with a central traversal Comet records fallback reasons in a `TreeNodeTag` side channel. Extended explain output only walks plan nodes, so an expression-level reason is invisible unless something lifts it onto the enclosing operator. That lifting was hand-written at ~200 call sites, and nothing forced it, so forgetting the roll-up argument silently produced a plausible-looking generic message instead of the real reason (fixed twice before, in #2323 and #2716). Steps 1 and 2 of #5230: Strict mode. `CometExecRule.reportUnexplainedFallback` replaces the masking behaviour: when Comet declines an operator whose children are all already native, and neither the operator nor any of its expressions carries a reason, `spark.comet.explain.fallback.strict.enabled` (internal, default off) throws instead of tagging `"<operator> is not supported"`. `CometTestBase` enables it, so the whole test corpus now enforces it. The check is scoped to operators a serde actually attempted; an operator with no registered handler was never attempted and still gets the generic message. Central traversal. `CometExecRule.rollUpFallbackReasons` collects `FALLBACK_REASONS` from `op.expressions` and tags them on the operator at the single point where Comet decides to keep the Spark operator, mirroring the existing `rollUpInfoMessages`. It only runs on the operator that failed conversion, which contains the shared-instance problem for `AttributeReference`s and DPP subquery expressions. `hasFallbackReason` still reads only the node's own tag: it is a planning control signal and must not observe the traversal. With that in place the roll-up parameters are dead, so they are gone from the API: `withFallbackReason(node, info)` and `withFallbackReasons(node, info)` no longer take varargs, and the pure-roll-up overload and `optExprWithFallbackReason` are deleted. The compiler now rejects any attempt to reintroduce a hand-rolled roll-up, and the old signature's lack of type safety (issue point 4) goes with it. Also drops the `var allProjExprs` accumulator in `CometExpandExec`, which existed only to feed the roll-up. Tested: CometExecSuite, CometExpressionSuite, CometAggregateSuite, CometJoinSuite, CometWindowExecSuite, CometGenerateExecSuite, CometExecRuleSuite, CometScanRuleSuite, CometSparkSessionExtensionsSuite, CometFuzzTestSuite, CometFuzzAggregateSuite, CometCastSuite, CometArrayExpressionSuite, CometStringExpressionSuite, CometShuffleSuite, CometNativeShuffleSuite, CometShuffleFallbackStickinessSuite, CometDppFallbackRepro3949Suite, CometCodegenSuite - all pass with strict mode on. Compiles clean on spark-3.4, 3.5, 4.0 and 4.1. * fix: remove unused val left by the roll-up removal in CometIn scalafix RemoveUnused flagged `val allExprs = list ++ Seq(value)`, which only existed to feed the deleted roll-up call. * fix: lift fallback reasons off the rewritten tree in exprToProto Strict mode caught a real pre-existing hole. `exprToProto` runs `DecimalPrecision.promote`, and `transformUp` rebuilds every node on the path to a rewritten one, so for decimal arithmetic the nodes that serde actually converts are copies rather than the nodes in the plan. Any reason recorded during conversion landed on a copy, where neither extended explain nor the operator roll-up could ever see it. The old hand-written roll-up did not find these either - it read the original `projectList`, so the reason was equally lost - which is why this only surfaced now: previously the empty tag still rendered as a bare `[COMET: ]` and nobody noticed. TPC-DS q9's approved plan records exactly that, and is updated here to carry the real reason instead. Copy the reasons from the rewritten tree onto the original node, the same copy-back the `Invoke` / `StaticInvoke` rewrites in `Spark4xCometExprShim` already do. Repro (all Spark versions, [exec] and [expressions] CI shards): INSERT INTO t SELECT CAST(id AS decimal(18,4)) + 0.0001 FROM range(20000) threw "Comet did not convert Project but recorded no fallback reason". Also drops an empty `if (r.isEmpty) {}` block left in CometAlias by the roll-up removal. Verified: CometSqlFileTestSuite, the full [expressions] shard (1127 tests) and [exec] shard (508 tests) on Spark 4.0, and both TPC-DS plan stability suites (129 tests) on Spark 3.4, 3.5, 4.0 and 4.1 - all pass. Plan stability needs spark.test.home pointed at the Comet repo root to run. * test: cover the strict fallback check directly, document its ordering dependency Addresses review feedback on #5236: - add a test that drives `reportUnexplainedFallback` with the exact shape a serde produces when it returns None without recording a reason (a handled operator over native children, no tag anywhere), asserting the strict-mode throw and the generic message when strict mode is off. No serde in the tree reaches that state - which is what the check enforces - so the operator is constructed by hand and the method is now package-visible. - document that `reportUnexplainedFallback` must run after `rollUpFallbackReasons`, since it reads only the operator's own tag.
1 parent 2af9cec commit 5b8a45e

37 files changed

Lines changed: 392 additions & 317 deletions

spark/src/main/scala/org/apache/comet/CometConf.scala

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -668,6 +668,20 @@ object CometConf extends ShimCometConf {
668668
.booleanConf
669669
.createWithDefault(false)
670670

671+
val COMET_STRICT_FALLBACK_REASONS: ConfigEntry[Boolean] =
672+
conf("spark.comet.explain.fallback.strict.enabled")
673+
.category(CATEGORY_TESTING)
674+
.doc(
675+
"Test-only. When enabled, Comet throws if it declines to convert an operator that it " +
676+
"could otherwise have converted (all children are already native) without recording a " +
677+
"fallback reason on the operator or on any of its expressions. Without this check, a " +
678+
"serde that returns `None` and forgets to state a reason silently produces a generic " +
679+
"'<operator> is not supported' message instead of a visible failure. Enabled for all " +
680+
"Comet test suites via `CometTestBase`.")
681+
.internal()
682+
.booleanConf
683+
.createWithDefault(false)
684+
671685
val COMET_ONHEAP_ENABLED: ConfigEntry[Boolean] =
672686
conf("spark.comet.exec.onHeap.enabled")
673687
.category(CATEGORY_TESTING)

spark/src/main/scala/org/apache/comet/CometSparkSessionExtensions.scala

Lines changed: 22 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -274,41 +274,41 @@ object CometSparkSessionExtensions extends Logging {
274274
* Record a fallback reason on a `TreeNode` (a Spark operator or expression) explaining why
275275
* Comet cannot accelerate it. Reasons recorded here are surfaced in extended explain output
276276
* (see `ExtendedExplainInfo`) and, when `COMET_EXPLAIN_FALLBACK_LOG_ENABLED` is enabled, logged
277-
* as warnings. The reasons are also rolled up from child nodes so that the operator that
278-
* remains in the Spark plan carries the reasons from its converted-away subtree.
277+
* as warnings.
279278
*
280279
* Call this in any code path where Comet decides not to convert a given node - serde `convert`
281280
* methods returning `None`, unsupported data types, disabled configs, etc. Do not use this for
282281
* informational messages that are not fallback reasons: anything tagged here is treated by the
283282
* rules as a signal that the node falls back to Spark.
284283
*
284+
* Tag only the node that actually failed, and state a real reason. There is deliberately no way
285+
* to copy reasons from child nodes onto a parent: extended explain only walks plan nodes, so an
286+
* expression-level reason is lifted onto the enclosing operator centrally by
287+
* `CometExecRule.rollUpFallbackReasons` when that operator is left in the Spark plan. See
288+
* https://github.com/apache/datafusion-comet/issues/5230.
289+
*
285290
* @param node
286291
* The Spark operator or expression that is falling back to Spark.
287292
* @param info
288-
* The fallback reason. Optional, may be null or empty - pass empty only when the call is used
289-
* purely to roll up reasons from `exprs`.
290-
* @param exprs
291-
* Child nodes whose own fallback reasons should be rolled up into `node`. Pass the
292-
* sub-expressions or child operators whose failure caused `node` to fall back.
293+
* The fallback reason. Newline-delimited to record more than one reason.
293294
* @tparam T
294295
* The type of the TreeNode. Typically `SparkPlan`, `AggregateExpression`, or `Expression`.
295296
* @return
296-
* `node` with fallback reasons attached (as a side effect on its tag map).
297+
* `node` with the fallback reason attached (as a side effect on its tag map).
297298
*/
298-
def withFallbackReason[T <: TreeNode[_]](node: T, info: String, exprs: T*): T = {
299+
def withFallbackReason[T <: TreeNode[_]](node: T, info: String): T = {
299300
// support existing approach of passing in multiple infos in a newline-delimited string
300301
val infoSet = if (info == null || info.isEmpty) {
301302
Set.empty[String]
302303
} else {
303304
info.split("\n").toSet
304305
}
305-
withFallbackReasons(node, infoSet, exprs: _*)
306+
withFallbackReasons(node, infoSet)
306307
}
307308

308309
/**
309-
* Record one or more fallback reasons on a `TreeNode` and roll up reasons from any child nodes.
310-
* This is the set-valued form of [[withFallbackReason]]; see that overload for the full
311-
* contract.
310+
* Record one or more fallback reasons on a `TreeNode`. This is the set-valued form of
311+
* [[withFallbackReason]]; see that overload for the full contract.
312312
*
313313
* Reasons are accumulated (never overwritten) on the node's `FALLBACK_REASONS` tag and are
314314
* surfaced in extended explain output. When `COMET_EXPLAIN_FALLBACK_LOG_ENABLED` is enabled,
@@ -317,50 +317,32 @@ object CometSparkSessionExtensions extends Logging {
317317
* @param node
318318
* The Spark operator or expression that is falling back to Spark.
319319
* @param info
320-
* The fallback reasons for this node. May be empty when the call is used purely to roll up
321-
* child reasons.
322-
* @param exprs
323-
* Child nodes whose own fallback reasons should be rolled up into `node`.
320+
* The fallback reasons for this node.
324321
* @tparam T
325322
* The type of the TreeNode. Typically `SparkPlan`, `AggregateExpression`, or `Expression`.
326323
* @return
327324
* `node` with fallback reasons attached (as a side effect on its tag map).
328325
*/
329-
def withFallbackReasons[T <: TreeNode[_]](node: T, info: Set[String], exprs: T*): T = {
326+
def withFallbackReasons[T <: TreeNode[_]](node: T, info: Set[String]): T = {
330327
if (CometConf.COMET_EXPLAIN_FALLBACK_LOG_ENABLED.get()) {
331328
for (reason <- info) {
332329
logWarning(s"Comet cannot accelerate ${node.getClass.getSimpleName} because: $reason")
333330
}
334331
}
335-
val existingNodeInfos = node.getTagValue(CometExplainInfo.FALLBACK_REASONS)
336-
val newNodeInfo = (existingNodeInfos ++ exprs
337-
.flatMap(_.getTagValue(CometExplainInfo.FALLBACK_REASONS))).flatten.toSet
338-
node.setTagValue(CometExplainInfo.FALLBACK_REASONS, newNodeInfo ++ info)
332+
val existingNodeInfos =
333+
node.getTagValue(CometExplainInfo.FALLBACK_REASONS).getOrElse(Set.empty[String])
334+
node.setTagValue(CometExplainInfo.FALLBACK_REASONS, existingNodeInfos ++ info)
339335
node
340336
}
341337

342-
/**
343-
* Roll up fallback reasons from `exprs` onto `node` without adding a new reason of its own. Use
344-
* this when a parent operator is itself falling back and wants to preserve the reasons recorded
345-
* on its child expressions/operators so they appear together in explain output.
346-
*
347-
* @param node
348-
* The parent operator or expression falling back to Spark.
349-
* @param exprs
350-
* Child nodes whose fallback reasons should be aggregated onto `node`.
351-
* @tparam T
352-
* The type of the TreeNode. Typically `SparkPlan`, `AggregateExpression`, or `Expression`.
353-
* @return
354-
* `node` with the rolled-up reasons attached (as a side effect on its tag map).
355-
*/
356-
def withFallbackReason[T <: TreeNode[_]](node: T, exprs: T*): T = {
357-
withFallbackReasons(node, Set.empty, exprs: _*)
358-
}
359-
360338
/**
361339
* True if any fallback reason has been recorded on `node` (via [[withFallbackReason]] /
362340
* [[withFallbackReasons]]). Callers that need to short-circuit when a prior rule pass has
363341
* already decided a node falls back can use this as the sticky signal.
342+
*
343+
* This deliberately reads only the node's own tag. It is a planning control signal, not explain
344+
* output, so it must not observe reasons that merely exist somewhere in the node's expression
345+
* trees - see `CometExecRule.rollUpFallbackReasons`.
364346
*/
365347
def hasFallbackReason(node: TreeNode[_]): Boolean = {
366348
node.getTagValue(CometExplainInfo.FALLBACK_REASONS).exists(_.nonEmpty)

spark/src/main/scala/org/apache/comet/expressions/CometCast.scala

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,6 @@ object CometCast
118118
if (childExpr.isDefined) {
119119
castToProto(cast, cast.timeZoneId, cast.dataType, childExpr.get, cometEvalMode)
120120
} else {
121-
withFallbackReason(cast, cast.child)
122121
None
123122
}
124123
}

spark/src/main/scala/org/apache/comet/rules/CometExecRule.scala

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,11 @@ case class CometExecRule(session: SparkSession)
382382
// when COMET_EXPLAIN_FALLBACK_LOG_ENABLED=true) even when the write is fully native.
383383
op
384384
case _ =>
385-
// The operator was not converted to a Comet plan. Possible reasons for this happening:
385+
// The operator was not converted to a Comet plan and no serde handler claimed it, so
386+
// Comet simply has no support for it. (Operators that do have a handler are reported
387+
// by `reportUnexplainedFallback` inside `convertToComet`, which is also where the
388+
// strict check lives - it would be wrong to demand a specific reason here, because
389+
// nothing ever attempted this operator.) Possible reasons for reaching this point:
386390
// 1. Comet does not support this operator.
387391
// 2. The operator could not be supported based on query context and current
388392
// configs. In this case, it should have already been tagged with fallback
@@ -698,6 +702,23 @@ case class CometExecRule(session: SparkSession)
698702

699703
/** Convert a Spark plan to a Comet plan using the specified serde handler */
700704
private def convertToComet(op: SparkPlan, handler: CometOperatorSerde[_]): Option[SparkPlan] = {
705+
val converted = tryConvertToComet(op, handler)
706+
if (converted.isEmpty) {
707+
// Comet looked at this operator and declined it, so it stays in the Spark plan. Lift any
708+
// reasons recorded on its expressions onto the operator itself - see
709+
// `rollUpFallbackReasons` for why this is needed - and then make sure something was
710+
// recorded. The order is required, not incidental: `reportUnexplainedFallback` inspects only
711+
// the operator's own tag, so a reason still sitting on an expression would look like no
712+
// reason at all and trip the strict check.
713+
rollUpFallbackReasons(op)
714+
reportUnexplainedFallback(op)
715+
}
716+
converted
717+
}
718+
719+
private def tryConvertToComet(
720+
op: SparkPlan,
721+
handler: CometOperatorSerde[_]): Option[SparkPlan] = {
701722
val serde = handler.asInstanceOf[CometOperatorSerde[SparkPlan]]
702723
if (isOperatorEnabled(serde, op)) {
703724
// For operators that require native children (like writes), check if all data-producing
@@ -741,6 +762,69 @@ case class CometExecRule(session: SparkSession)
741762
None
742763
}
743764

765+
/**
766+
* Lift fallback reasons recorded on `op`'s expression trees onto `op` itself.
767+
*
768+
* Extended explain output only walks plan nodes (`ExtendedExplainInfo.sortup` follows
769+
* `children` / `innerChildren`, never `expressions`), so a reason tagged on an expression is
770+
* invisible unless something lifts it onto the enclosing operator. This mirrors what
771+
* [[rollUpInfoMessages]] already does for the informational tags, and replaces the roll-up that
772+
* used to be hand-written at every serde call site (see
773+
* https://github.com/apache/datafusion-comet/issues/5230).
774+
*
775+
* Only child *expressions* are collected, not child operators: reasons on a child operator are
776+
* already reachable by the explain traversal via `children`.
777+
*
778+
* Called only when `op` was left in the Spark plan, which scopes the roll-up to the operator
779+
* that actually failed conversion. That matters because some expression instances
780+
* (`AttributeReference`s, DPP subquery expressions) are shared across operators, so an unscoped
781+
* roll-up could surface one expression's reason under several unrelated operators.
782+
*
783+
* [[reportUnexplainedFallback]] relies on this having run first; the two must not be separated.
784+
*/
785+
private def rollUpFallbackReasons(op: SparkPlan): Unit = {
786+
val reasons = op.expressions
787+
.flatMap(_.collect { case e: Expression => e })
788+
.flatMap(_.getTagValue(CometExplainInfo.FALLBACK_REASONS))
789+
.flatten
790+
.toSet
791+
if (reasons.nonEmpty) {
792+
withFallbackReasons(op, reasons)
793+
}
794+
}
795+
796+
/**
797+
* Handle an operator that Comet declined without stating why.
798+
*
799+
* When every child is already native, Comet had a real opportunity to convert `op`, so the
800+
* absence of any reason - on `op` or anywhere in its expression trees - means a serde returned
801+
* `None` and forgot to record one. Under `COMET_STRICT_FALLBACK_REASONS` (enabled for Comet's
802+
* own test suites) that is a hard failure; otherwise fall back to a generic message so users
803+
* still see something. The generic message is what used to mask this whole class of bug, which
804+
* is why the strict check exists.
805+
*
806+
* Must run *after* [[rollUpFallbackReasons]] for the same operator. The check reads only `op`'s
807+
* own tag, because `hasFallbackReason` deliberately does not traverse expressions (it is a
808+
* planning control signal, not explain output), so an expression-level reason that has not been
809+
* lifted yet would be mistaken for no reason at all. [[convertToComet]] is the only production
810+
* caller and keeps the two calls together.
811+
*
812+
* Package-visible so `CometExecRuleSuite` can drive the strict failure directly: no serde in
813+
* the tree reaches this state, which is exactly what the check enforces, so the only way to
814+
* test it is to construct the shape by hand.
815+
*/
816+
private[comet] def reportUnexplainedFallback(op: SparkPlan): Unit = {
817+
if (op.children.forall(_.isInstanceOf[CometNativeExec]) && !hasFallbackReason(op)) {
818+
if (CometConf.COMET_STRICT_FALLBACK_REASONS.get(op.conf)) {
819+
throw new IllegalStateException(
820+
s"Comet did not convert ${op.nodeName} but recorded no fallback reason on the " +
821+
"operator or any of its expressions. Add a withFallbackReason call stating why " +
822+
s"conversion failed. Operator:\n$op")
823+
}
824+
withFallbackReason(op, s"${op.nodeName} is not supported")
825+
}
826+
}
827+
744828
/**
745829
* Lift informational (non-fallback) messages tagged on an operator and its expressions onto the
746830
* converted Comet plan node so they appear in verbose extended explain output. Expression-level

spark/src/main/scala/org/apache/comet/serde/CometBloomFilterMightContain.scala

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ package org.apache.comet.serde
2121

2222
import org.apache.spark.sql.catalyst.expressions.{Attribute, BloomFilterMightContain}
2323

24-
import org.apache.comet.CometSparkSessionExtensions.withFallbackReason
2524
import org.apache.comet.serde.QueryPlanSerde.exprToProtoInternal
2625

2726
object CometBloomFilterMightContain extends CometExpressionSerde[BloomFilterMightContain] {
@@ -45,7 +44,6 @@ object CometBloomFilterMightContain extends CometExpressionSerde[BloomFilterMigh
4544
.setBloomFilterMightContain(builder)
4645
.build())
4746
} else {
48-
withFallbackReason(expr, bloomFilter, value)
4947
None
5048
}
5149
}

spark/src/main/scala/org/apache/comet/serde/CometScalarFunction.scala

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,13 @@ package org.apache.comet.serde
2222
import org.apache.spark.sql.catalyst.expressions.{Attribute, Expression}
2323

2424
import org.apache.comet.serde.ExprOuterClass.Expr
25-
import org.apache.comet.serde.QueryPlanSerde.{exprToProtoInternal, optExprWithFallbackReason, scalarFunctionExprToProto}
25+
import org.apache.comet.serde.QueryPlanSerde.{exprToProtoInternal, scalarFunctionExprToProto}
2626

2727
/** Serde for scalar function. */
2828
case class CometScalarFunction[T <: Expression](name: String) extends CometExpressionSerde[T] {
2929
override def convert(expr: T, inputs: Seq[Attribute], binding: Boolean): Option[Expr] = {
3030
val childExpr = expr.children.map(exprToProtoInternal(_, inputs, binding))
3131
val optExpr = scalarFunctionExprToProto(name, childExpr: _*)
32-
optExprWithFallbackReason(optExpr, expr, expr.children: _*)
32+
optExpr
3333
}
3434
}

spark/src/main/scala/org/apache/comet/serde/CometSortOrder.scala

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ package org.apache.comet.serde
2222
import org.apache.spark.sql.catalyst.expressions.{Ascending, Attribute, Descending, NullsFirst, NullsLast, SortOrder}
2323

2424
import org.apache.comet.CometConf
25-
import org.apache.comet.CometSparkSessionExtensions.withFallbackReason
2625
import org.apache.comet.serde.QueryPlanSerde.exprToProtoInternal
2726

2827
object CometSortOrder extends CometExpressionSerde[SortOrder] {
@@ -66,7 +65,6 @@ object CometSortOrder extends CometExpressionSerde[SortOrder] {
6665
.setSortOrder(sortOrderBuilder)
6766
.build())
6867
} else {
69-
withFallbackReason(expr, expr.child)
7068
None
7169
}
7270
}

0 commit comments

Comments
 (0)