sunchao commented on code in PR #7343:
URL: https://github.com/apache/datafusion-comet/pull/7343#discussion_r4434286070
##########
spark/src/main/scala/org/apache/comet/serde/operator/CometNativeScan.scala:
##########
@@ -327,6 +333,62 @@ object CometNativeScan extends
CometOperatorSerde[CometScanExec] with CometTypeS
}
}
+ /**
+ * Apply Spark's Parquet pushdown settings before serializing scan
predicates. Unsupported
+ * conjuncts may be dropped, including inside OR, but never below NOT:
weakening a negated
+ * predicate could prune rows Spark would read and hide an error in the
enclosing filter.
+ *
+ * Public because execution-time scalar-subquery filters are serialized from
Spark's package.
+ */
+ def filterForParquetPushdown(filter: Expression, conf: SQLConf):
Option[Expression] = {
+ // Exclude the whole predicate before rewriting: removing make_date could
leave a metadata
+ // filter that skips its required overflow. Independent predicates remain
eligible.
+ if (filter.exists(_.isInstanceOf[MakeDate])) {
+ return None
+ }
+
+ val pushDownDate = conf.parquetFilterPushDownDate
+ val pushDownTimestamp = conf.parquetFilterPushDownTimestamp
+ val pushDownDecimal = conf.parquetFilterPushDownDecimal
+ val pushDownStringPredicate = conf.parquetFilterPushDownStringPredicate
+ val inFilterThreshold = conf.parquetFilterPushDownInFilterThreshold
+
+ def usesDisabledSetting(expression: Expression): Boolean =
expression.exists {
+ case _: StartsWith | _: EndsWith | _: Contains =>
!pushDownStringPredicate
+ case e =>
+ e.dataType match {
+ case DateType => !pushDownDate
+ case TimestampType | TimestampNTZType => !pushDownTimestamp
+ case _: DecimalType => !pushDownDecimal
+ case _ => false
+ }
+ }
+
+ def select(expression: Expression, canPartialPushDown: Boolean):
Option[Expression] =
+ expression match {
+ case And(left, right) =>
+ (select(left, canPartialPushDown), select(right,
canPartialPushDown)) match {
+ case (Some(l), Some(r)) => Some(And(l, r))
+ case (l, r) if canPartialPushDown => l.orElse(r)
Review Comment:
**[P2] Check whether Spark can translate the whole sibling predicate before
simplifying it**
Use one Parquet row group with `a=b=0`, `d=DATE '2020-01-01'`, and one
`y=6000000` among otherwise valid years. With
`spark.sql.parquet.filterPushdown.date=false` and Comet row filtering off, this
returns `0` on this head. Spark and the base production code both raise
`ARITHMETIC_OVERFLOW`.
```sql
SELECT count(*) FROM t
WHERE unix_date(make_date(y,1,1)) > 0
AND ((months_between(d, DATE '2020-01-01') > 0 AND a=1) OR b=1)
```
The date-producing conjunct is already excluded. Spark’s V1 translation
rejects the separate `OR` because it cannot translate `months_between`. Here
`l.orElse(r)` strips that expression and sends `a=1 OR b=1` to Parquet, which
discards the group. Restoring only the date pushdown setting makes this head
raise again. Please gate the sibling on Spark’s V1 translation before applying
Parquet’s partial rewrite. A simpler example with default Spark settings is `s
LIKE 'zzz_%'` on an all-`'aaa'` group. Spark leaves this complex pattern above
the scan and raises, while Comet prunes the group. That case also reproduces on
the base.
##########
spark/src/main/scala/org/apache/comet/serde/operator/CometNativeScan.scala:
##########
@@ -327,6 +333,62 @@ object CometNativeScan extends
CometOperatorSerde[CometScanExec] with CometTypeS
}
}
+ /**
+ * Apply Spark's Parquet pushdown settings before serializing scan
predicates. Unsupported
+ * conjuncts may be dropped, including inside OR, but never below NOT:
weakening a negated
+ * predicate could prune rows Spark would read and hide an error in the
enclosing filter.
+ *
+ * Public because execution-time scalar-subquery filters are serialized from
Spark's package.
+ */
+ def filterForParquetPushdown(filter: Expression, conf: SQLConf):
Option[Expression] = {
+ // Exclude the whole predicate before rewriting: removing make_date could
leave a metadata
+ // filter that skips its required overflow. Independent predicates remain
eligible.
+ if (filter.exists(_.isInstanceOf[MakeDate])) {
+ return None
+ }
+
+ val pushDownDate = conf.parquetFilterPushDownDate
+ val pushDownTimestamp = conf.parquetFilterPushDownTimestamp
+ val pushDownDecimal = conf.parquetFilterPushDownDecimal
+ val pushDownStringPredicate = conf.parquetFilterPushDownStringPredicate
+ val inFilterThreshold = conf.parquetFilterPushDownInFilterThreshold
+
+ def usesDisabledSetting(expression: Expression): Boolean =
expression.exists {
+ case _: StartsWith | _: EndsWith | _: Contains =>
!pushDownStringPredicate
+ case e =>
+ e.dataType match {
+ case DateType => !pushDownDate
+ case TimestampType | TimestampNTZType => !pushDownTimestamp
Review Comment:
**[P2] Account for the Parquet timestamp encoding when retaining metadata
filters**
On Spark 4.1.3, write one row group with `nt=TIMESTAMP_NTZ '2020-01-01'`
throughout and one overflowing year. For `WHERE unix_date(make_date(y,1,1)) > 0
AND nt < TIMESTAMP_NTZ '2000-01-01'`, Spark raises `ARITHMETIC_OVERFLOW`. With
Comet row filtering off and default Spark pushdown settings, the native scan
returns count `0`. Disabling only `spark.sql.parquet.filterPushdown.timestamp`
restores the error.
Spark’s Parquet comparison accepts UTC-adjusted timestamp encodings. An NTZ
column has `isAdjustedToUTC=false`, so enabling timestamp pushdown does not
make this predicate eligible. This mismatch also exists on the base. Please
account for Spark’s physical-type eligibility before retaining the sibling for
a throwing date filter.
##########
spark/src/main/scala/org/apache/comet/rules/MakeDateEvaluation.scala:
##########
@@ -240,8 +244,17 @@ private[rules] object MakeDateEvaluation {
case broadcast: CometBroadcastExchangeExec => broadcast.originalPlan
case _ => node
}
- val evaluatesDate = containsMakeDate(original.expressions)
+ val evaluatedExpressions = original match {
+ // Residual data filters run in the enclosing FilterExec, not in the
reader.
+ case scan: FileSourceScanExec => scan.partitionFilters
Review Comment:
**[P2] Preserve Spark’s dictionary pruning when a residual date filter can
throw**
In one dictionary-encoded Parquet row group, alternate `k` between `1` and
`3`, and put `y=6000000` on the final row. For `WHERE
unix_date(make_date(y,1,1)) > 0 AND k=2`, Spark returns count `0`. The
dictionary rules out `2` even though it falls within the min/max range. With
the native scan, this head raises `ARITHMETIC_OVERFLOW` with Comet row
filtering both on and off. The base returned `0` with row filtering on.
Disabling only `spark.hadoop.parquet.filter.dictionary.enabled` makes Spark
raise too. As a control, `k=5` returns `0` in both readers through min/max
pruning. Please retain Spark’s reader for affected scans when native pruning
cannot preserve which rows reach the throwing filter, or provide equivalent
dictionary pruning.
##########
spark/src/test/scala/org/apache/comet/exec/CometNativeReaderSuite.scala:
##########
@@ -1568,6 +1571,310 @@ class CometNativeReaderSuite extends CometTestBase with
AdaptiveSparkPlanHelper
}
}
+ test("make_date predicates preserve evaluation order and errors with
row-level pushdown") {
+ withSQLConf(
+ SQLConf.ANSI_ENABLED.key -> "false",
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
+ CometConf.COMET_PARQUET_ROW_FILTER_PUSHDOWN_ENABLED.key -> "true") {
+ withTempPath { path =>
+ withSQLConf(CometConf.COMET_ENABLED.key -> "false") {
+ spark
+ .range(1024)
+ .coalesce(1)
+ .selectExpr(
+ "CAST(IF(id = 1023, 6000000, 2024) AS INT) AS y",
+ "IF(id = 1023, '', sha2(CAST(id AS STRING), 512)) AS mask",
+ "IF(id = 1023, 0, 1) AS keep")
+ .write
+ .parquet(path.toString)
+ }
+ withTempView("make_date_filters") {
+
spark.read.parquet(path.toString).createOrReplaceTempView("make_date_filters")
+ val datePredicate = "unix_date(make_date(y, 1, 1)) > 0"
+ val masked = sql(
+ s"SELECT count(*) FROM make_date_filters WHERE length(mask) > 0
AND $datePredicate")
+ assert(
+ find(masked.queryExecution.executedPlan)(
+ _.isInstanceOf[CometNativeScanExec]).isDefined)
+ checkSparkAnswer(masked)
+ assert(masked.collect().toSeq == Seq(Row(1023L)))
+
+ // A later mask must not hide an overflow that Spark evaluates
first. The unmasked
+ // predicate must also preserve Spark's structured error outside
Parquet's RowFilter.
+ Seq(
+ datePredicate,
+ s"$datePredicate AND length(mask) > 0",
+ // OR keeps the date predicate in Spark; its serializable sibling
must not
+ // become a row filter that hides an error before Spark evaluates
the OR.
+ s"(y = 0 OR $datePredicate) AND keep = 1").foreach { predicate =>
+ val df = sql(s"SELECT count(*) FROM make_date_filters WHERE
$predicate")
+ assert(
+
find(df.queryExecution.executedPlan)(_.isInstanceOf[CometNativeScanExec]).isDefined)
+ val (sparkError, cometError) = checkSparkAnswerMaybeThrows(df)
+ val failures = Seq(sparkError, cometError).map { error =>
+ val failure = error.getOrElse(fail(s"Expected overflow for
$predicate"))
+ val chain = causeChain(failure)
+ assert(!chain.exists(_.isInstanceOf[CometNativeException]),
failure.toString)
+ chain.collect { case e: SparkThrowable with Throwable => e
}.lastOption.getOrElse {
+ fail(s"Expected a SparkThrowable for $predicate: $failure")
+ }
+ }
+ val expected = failures.head
+ val actual = failures(1)
+ assert(expected.getErrorClass == "ARITHMETIC_OVERFLOW")
+ assert(actual.getClass == expected.getClass)
+ assert(actual.getErrorClass == expected.getErrorClass)
+ assert(actual.getSqlState == expected.getSqlState)
+ }
+ }
+ }
+ }
+ }
+
+ Seq(
+ "partition column" -> "unix_date(make_date(y, m, 1)) > 0",
+ "nondeterministic bound" -> "unix_date(make_date(y, 1, 1)) >
rand(42)").foreach {
+ case (shape, datePredicate) =>
+ test(s"make_date predicates outside scan data filters preserve errors:
$shape") {
+ withSQLConf(
+ SQLConf.ANSI_ENABLED.key -> "false",
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
+ CometConf.COMET_PARQUET_ROW_FILTER_PUSHDOWN_ENABLED.key -> "true") {
+ withTempPath { path =>
+ withSQLConf(CometConf.COMET_ENABLED.key -> "false") {
+ spark
+ .range(0L, 1024L, 1L, 1)
+ .selectExpr(
+ "CAST(IF(id = 1023, 6000000, 2024) AS INT) AS y",
+ "IF(id = 1023, '', sha2(CAST(id AS STRING), 512)) AS mask",
+ "1 AS m")
+ .write
+ .partitionBy("m")
+ .parquet(path.toString)
+ }
+ withTempView("make_date_residual_filters") {
+ spark.read
+ .parquet(path.toString)
+ .createOrReplaceTempView("make_date_residual_filters")
+ val df = sql(
+ "SELECT count(*) FROM make_date_residual_filters " +
+ s"WHERE $datePredicate AND length(mask) > 0")
+ val scan =
find(df.queryExecution.executedPlan)(_.isInstanceOf[CometNativeScanExec])
+ .getOrElse(fail("Expected a native Parquet scan"))
+ .asInstanceOf[CometNativeScanExec]
+ // Spark omitted the date predicate before Comet saw the scan's
data filters.
+
assert(!scan.dataFilters.exists(_.exists(_.isInstanceOf[MakeDate])))
+ val (sparkError, cometError) = checkSparkAnswerMaybeThrows(df)
+ val failures = Seq(sparkError, cometError).map { error =>
+ val failure = error.getOrElse(fail(s"Expected overflow for
$shape"))
+ val chain = causeChain(failure)
+ assert(!chain.exists(_.isInstanceOf[CometNativeException]),
failure.toString)
+ chain
+ .collect { case e: SparkThrowable with Throwable => e }
+ .lastOption
+ .getOrElse {
+ fail(s"Expected a SparkThrowable: $failure")
+ }
+ }
+ assert(failures.head.getErrorClass == "ARITHMETIC_OVERFLOW")
+ assert(failures(1).getClass == failures.head.getClass)
+ assert(failures(1).getErrorClass == failures.head.getErrorClass)
+ assert(failures(1).getSqlState == failures.head.getSqlState)
+ }
+ }
+ }
+ }
+ }
+
+ test("make_date sibling metadata pruning respects Parquet type settings") {
+ withSQLConf(
+ SQLConf.ANSI_ENABLED.key -> "false",
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
+ withTempPath { path =>
+ withSQLConf(CometConf.COMET_ENABLED.key -> "false") {
+ spark
+ .range(0L, 1024L, 1L, 1)
+ .selectExpr(
+ "CAST(IF(id = 1023, 6000000, 2024) AS INT) AS y",
+ "DATE '2020-01-01' AS d",
+ "CAST(1 AS DECIMAL(10, 2)) AS amount")
+ .write
+ .parquet(path.toString)
+ }
+ withTempView("make_date_metadata") {
+
spark.read.parquet(path.toString).createOrReplaceTempView("make_date_metadata")
+ val literalPredicates = Seq(
+ SQLConf.PARQUET_FILTER_PUSHDOWN_DATE_ENABLED.key -> "d < DATE
'1900-01-01'",
+ SQLConf.PARQUET_FILTER_PUSHDOWN_DECIMAL_ENABLED.key ->
+ "amount < CAST(0 AS DECIMAL(10, 2))")
+ // Spark 4 also pushes scalar subqueries, which are serialized after
planning.
+ val predicates = literalPredicates ++ (if (isSpark40Plus) {
+ Seq(
+
SQLConf.PARQUET_FILTER_PUSHDOWN_DATE_ENABLED.key ->
+ "d < (SELECT DATE
'1900-01-01' + CAST(min(id) AS INT) FROM range(1))")
+ } else {
+ Seq.empty
+ })
+ for ((setting, predicate) <- predicates; rowPushdown <- Seq(false,
true)) {
+ withSQLConf(
+ setting -> "false",
+ CometConf.COMET_PARQUET_ROW_FILTER_PUSHDOWN_ENABLED.key ->
rowPushdown.toString) {
+ val query = s"""SELECT count(*) FROM make_date_metadata
+ |WHERE unix_date(make_date(y, 1, 1)) > 0 AND
$predicate""".stripMargin
+ val df = sql(query)
+ val scan =
find(df.queryExecution.executedPlan)(_.isInstanceOf[CometNativeScanExec])
+ .getOrElse(fail("Expected a native Parquet scan"))
+ .asInstanceOf[CometNativeScanExec]
+ if (predicate.contains("SELECT")) {
+
assert(scan.dataFilters.exists(_.exists(_.isInstanceOf[ScalarSubquery])))
+ }
+ val (sparkError, cometError) = checkSparkAnswerMaybeThrows(df)
+ val failures = Seq(sparkError, cometError).map { error =>
+ val failure = error.getOrElse(fail(s"Expected overflow with
$setting=false"))
+ val chain = causeChain(failure)
+ assert(!chain.exists(_.isInstanceOf[CometNativeException]),
failure.toString)
+ chain
+ .collect { case e: SparkThrowable with Throwable => e }
+ .lastOption
+ .getOrElse {
+ fail(s"Expected a SparkThrowable: $failure")
+ }
+ }
+ assert(failures.head.getErrorClass == "ARITHMETIC_OVERFLOW")
+ assert(failures(1).getClass == failures.head.getClass)
+ assert(failures(1).getErrorClass == failures.head.getErrorClass)
+ assert(failures(1).getSqlState == failures.head.getSqlState)
+
+ // When Spark does permit the sibling predicate, both readers
may skip this
+ // entire row group, including its overflowing year.
+ withSQLConf(setting -> "true") {
+ checkSparkAnswer(sql(query))
+ assert(sql(query).collect().toSeq == Seq(Row(0L)))
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ test("make_date disjunctions are not weakened before Parquet metadata
pruning") {
+ withSQLConf(
+ SQLConf.ANSI_ENABLED.key -> "false",
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
+ SQLConf.PARQUET_FILTER_PUSHDOWN_DATE_ENABLED.key -> "false") {
+ withTempPath { path =>
+ withSQLConf(CometConf.COMET_ENABLED.key -> "false") {
+ spark
+ .range(0L, 1024L, 1L, 1)
+ .selectExpr(
+ "CAST(IF(id = 1023, 6000000, 2024) AS INT) AS y",
+ "0 AS a",
+ "0 AS b",
+ "0 AS keep")
+ .write
+ .parquet(path.toString)
+ }
+ withTempView("make_date_disjunction") {
+
spark.read.parquet(path.toString).createOrReplaceTempView("make_date_disjunction")
+ val query = """SELECT count(*) FROM make_date_disjunction
+ |WHERE ((unix_date(make_date(y, 1, 1)) > 0 AND a = 1) OR b =
1)""".stripMargin
+ for (rowPushdown <- Seq(false, true)) {
+ withSQLConf(
+ CometConf.COMET_PARQUET_ROW_FILTER_PUSHDOWN_ENABLED.key ->
rowPushdown.toString) {
+ val df = sql(query)
+ assert(
+ find(df.queryExecution.executedPlan)(
+ _.isInstanceOf[CometNativeScanExec]).isDefined)
+ // Removing make_date would leave a = 1 OR b = 1, whose
statistics discard the
+ // entire row group before Spark can observe its overflowing
year.
+ val (sparkError, cometError) = checkSparkAnswerMaybeThrows(df)
Review Comment:
**[P3] Share the structured-error assertion across the reader tests**
Five new blocks repeat the same checks for the exception chain, class, error
condition, and SQLSTATE. `CometTestBase.checkSparkError` already implements
these comparisons. Its full-native-plan assertion is the part these mixed
Spark-filter/native-scan tests cannot use. Could you extract the comparison
from that helper and call it after each test’s native-scan assertion? That
would keep the Spark-version-specific error checks in one place.
##########
native/core/src/parquet/parquet_exec.rs:
##########
@@ -106,12 +109,27 @@ pub(crate) fn init_datasource_exec(
rebase_from_file_metadata: bool,
datetime_rebase_mode_in_read: &str,
int96_rebase_mode_in_read: &str,
+ disable_row_filter_pushdown: bool,
) -> Result<Arc<DataSourceExec>, ExecutionError> {
// Computed once and reused below for `try_pushdown_filters`.
`copied_config()` clones only
// `SessionConfig` (an `Arc<ConfigOptions>` plus a small extensions map);
`SessionContext::
// state()` would clone the whole `SessionState`, including the function
registries (~250+
// UDFs) and optimizer rules, on every scan init.
- let session_config = session_ctx.copied_config();
+ let mut session_config = session_ctx.copied_config();
+ if disable_row_filter_pushdown
+ || data_filters
+ .as_ref()
+ .is_some_and(|filters| filters.iter().any(contains_make_date))
+ {
+ // RowFilter can reorder predicates and flatten their Spark errors.
Even pushing only
+ // a later sibling could hide a make_date error that Spark evaluates
first. Keep all
+ // row filtering above this scan, while retaining metadata pruning for
other predicates.
+ session_config
+ .options_mut()
+ .execution
+ .parquet
+ .pushdown_filters = false;
Review Comment:
**[P2] Use the effective scan setting when planning floating-point
comparisons**
With one Parquet row group where `f=42D` throughout and one `y=6000000`,
count rows satisfying `unix_date(make_date(y,1,1)) > 0 AND f < 0D`. Spark and
the base return `0` with Comet row filtering enabled. This head raises
`ARITHMETIC_OVERFLOW`. Switching only
`spark.comet.parquet.rowFilterPushdown.enabled` to `false` restores `0`.
The planner has already normalized the floating-point operand using the
session-wide setting by the time this line disables row filtering. Parquet can
no longer use the plain column comparison for statistics pruning. Please choose
the operand representation after accounting for the per-scan guard.
##########
spark/src/main/scala/org/apache/comet/serde/operator/CometNativeScan.scala:
##########
@@ -308,6 +309,11 @@ object CometNativeScan extends
CometOperatorSerde[CometScanExec] with CometTypeS
hadoopConf = hadoopConf,
conf = scan.conf) match {
case Some(commonBuilder) =>
+ // Original filters include unsupported expressions and unresolved
subqueries omitted
+ // from native predicates. A pushed sibling must not hide their
make_date errors.
+ commonBuilder.setDisableRowFilterPushdown(
+
scan.wrapped.getTagValue(CometScanRule.DISABLE_ROW_FILTER_PUSHDOWN_TAG).isDefined
||
+ scan.dataFilters.exists(_.exists(_.isInstanceOf[MakeDate])))
Review Comment:
**[P2] Keep sibling row filtering for dates that cannot throw**
With ANSI off, `make_date(2024,m,d)` over `INT` columns cannot overflow, and
an invalid month yields NULL. The blanket guard still disables row filtering
for `WHERE unix_date(make_date(2024,m,d)) > 0 AND y=6000000`.
I checked one 128-row group with `(y,m,d)=(2024,1,1)` on its first 127 rows
and `(6000000,13,1)` on its last. With Comet row filtering enabled, both
revisions return count `0`. The base filters all 128 rows inside the scan. This
head filters zero there and emits all 128 to the parent. Please reuse the
bounded-year and safe-argument check when deciding whether sibling row
filtering would change evaluation.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]