dongjoon-hyun commented on code in PR #59879:
URL: https://github.com/apache/spark/pull/59879#discussion_r4405059859


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/regexpExpressions.scala:
##########
@@ -84,33 +84,32 @@ abstract class StringRegexExpression extends 
BinaryExpression
     }
   }
 
-  override def expensive: Boolean = hasExpensiveChild || _expensiveRegex
-
-  // Heuristic, not designed to be perfect. Look for things likely to have
-  // back tracking.
-  private val detectExpensiveRegexPattern = Pattern.compile("\\+\\*\\{")
-
-  private lazy val _expensiveRegex = {
-    // A quick heuristic for expensive a pattern is.
-    left match {
-      case StringLiteral(str) =>
-        // If we have a clear start limited back tracking required.
-        if (str.startsWith("^") || str.startsWith("\\b")) {
-          false
-        } else if (detectExpensiveRegexPattern.matcher(str).matches()) {
-          // Greedy matching can be tricky.
-          true
-        } else {
-          // Default to pushdown for now.
-          false
-        }
-      case _ =>
-        true // per row regex compilation.
-    }
+  override def expensive: Boolean = hasExpensiveChild || _expensivePattern
+
+  // Heuristic, not designed to be perfect. A pattern that is not foldable is 
compiled for every
+  // row; a foldable one is left to `isExpensivePattern`.
+  private lazy val _expensivePattern = if (right.foldable) {
+    val str = right.eval()

Review Comment:
   **1. [Medium] Evaluating the pattern here can fail the optimizer**
   
   `PushPredicateThroughNonJoin` calls `expensive` (`Optimizer.scala:2628`), 
and before this change it never evaluated anything. L92 now evaluates any 
foldable pattern. That includes a pattern which `ConstantFolding` deliberately 
leaves unfolded because it throws inside a conditional branch 
(`expressions.scala:67-72`, `FAILED_TO_EVALUATE`). For example, with ANSI on:
   
   ```sql
   SELECT * FROM (
     SELECT IF(s IS NULL, NULL, s RLIKE CAST(1/0 AS STRING)) AS f FROM t
   ) WHERE f
   ```
   
   `If.expensive` goes through `hasExpensiveChild` to `RLike.expensive`, and 
L92 throws `DIVIDE_BY_ZERO` inside the optimizer. `PushDownPredicates` also 
runs before `ConstantFolding` in each iteration (`Optimizer.scala:108`, `139`), 
so the first iteration sees unfolded patterns even outside conditional branches.
   
   The codegen paths at L204 and L548 evaluate the pattern too, so with 
whole-stage codegen on, this query already fails at execution. What changes:
   
   - `EXPLAIN` and `queryExecution.optimizedPlan` now fail.
   - With `spark.sql.codegen.wholeStage=false`, the expression codegen error 
falls back to the interpreter 
(`CodeGeneratorWithInterpretedFallback.scala:52-54`). The query used to succeed 
when the branch was not taken, and now it fails.
   
   Separately, L92 evaluates the plan's own instance. `ConstantFolding.tryFold` 
evaluates `freshCopyIfContainsStatefulExpression()` instead 
(`expressions.scala:65`), because a stateful foldable pattern such as 
`regexp_replace('a+b', 'x', 'y')` (`RegExpReplace`, L756) mutates its caches 
when evaluated.
   
   Could L91-96 evaluate a fresh copy and treat a pattern that fails to 
evaluate as expensive?
   
   ```scala
   private lazy val _expensivePattern = !right.foldable || {
     try {
       val str = right.freshCopyIfContainsStatefulExpression().eval()
       str != null && isExpensivePattern(str.toString)
     } catch {
       case NonFatal(_) => true
     }
   }
   ```



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/regexpExpressions.scala:
##########
@@ -84,33 +84,32 @@ abstract class StringRegexExpression extends 
BinaryExpression
     }
   }
 
-  override def expensive: Boolean = hasExpensiveChild || _expensiveRegex
-
-  // Heuristic, not designed to be perfect. Look for things likely to have
-  // back tracking.
-  private val detectExpensiveRegexPattern = Pattern.compile("\\+\\*\\{")
-
-  private lazy val _expensiveRegex = {
-    // A quick heuristic for expensive a pattern is.
-    left match {
-      case StringLiteral(str) =>
-        // If we have a clear start limited back tracking required.
-        if (str.startsWith("^") || str.startsWith("\\b")) {
-          false
-        } else if (detectExpensiveRegexPattern.matcher(str).matches()) {
-          // Greedy matching can be tricky.
-          true
-        } else {
-          // Default to pushdown for now.
-          false
-        }
-      case _ =>
-        true // per row regex compilation.
-    }
+  override def expensive: Boolean = hasExpensiveChild || _expensivePattern
+
+  // Heuristic, not designed to be perfect. A pattern that is not foldable is 
compiled for every
+  // row; a foldable one is left to `isExpensivePattern`.
+  private lazy val _expensivePattern = if (right.foldable) {
+    val str = right.eval()
+    str != null && isExpensivePattern(str.toString)
+  } else {
+    true
   }
+
+  /** Whether matching against the constant pattern `str` is likely to be 
expensive. */
+  protected def isExpensivePattern(str: String): Boolean
 }
 
 private[catalyst] object StringRegexExpression {
+  // Quantifiers, which are what make a regex backtrack.
+  private val quantifierPattern = Pattern.compile("[+*{]")

Review Comment:
   **4. [Low] The `[+*{]` check also matches characters that are not 
quantifiers**
   
   `find()` on `[+*{]` also matches:
   
   - escaped literals, e.g. `a\+b`
   - characters inside a class, e.g. `[+*]`
   - the braces of `\p{...}` and `\x{...}`, e.g. `\p{Lu}`
   
   None of these can backtrack, but all are classed expensive. An inline flag 
also hides the anchor: `(?i)^abc.*` is expensive while `^abc.*` is cheap.
   
   For these patterns, a filter on the alias is still not pushed. So the 
partition pruning and data filter pushdown that this PR restores are still lost 
for them. If the heuristic stays text-based, could it skip escaped characters 
and character classes, and allow leading inline flags before `^`?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/regexpExpressions.scala:
##########
@@ -84,33 +84,32 @@ abstract class StringRegexExpression extends 
BinaryExpression
     }
   }
 
-  override def expensive: Boolean = hasExpensiveChild || _expensiveRegex
-
-  // Heuristic, not designed to be perfect. Look for things likely to have
-  // back tracking.
-  private val detectExpensiveRegexPattern = Pattern.compile("\\+\\*\\{")
-
-  private lazy val _expensiveRegex = {
-    // A quick heuristic for expensive a pattern is.
-    left match {
-      case StringLiteral(str) =>
-        // If we have a clear start limited back tracking required.
-        if (str.startsWith("^") || str.startsWith("\\b")) {
-          false
-        } else if (detectExpensiveRegexPattern.matcher(str).matches()) {
-          // Greedy matching can be tricky.
-          true
-        } else {
-          // Default to pushdown for now.
-          false
-        }
-      case _ =>
-        true // per row regex compilation.
-    }
+  override def expensive: Boolean = hasExpensiveChild || _expensivePattern
+
+  // Heuristic, not designed to be perfect. A pattern that is not foldable is 
compiled for every
+  // row; a foldable one is left to `isExpensivePattern`.
+  private lazy val _expensivePattern = if (right.foldable) {
+    val str = right.eval()
+    str != null && isExpensivePattern(str.toString)
+  } else {
+    true
   }
+
+  /** Whether matching against the constant pattern `str` is likely to be 
expensive. */
+  protected def isExpensivePattern(str: String): Boolean

Review Comment:
   **5. [Low] The new abstract member breaks subclasses outside Spark**
   
   `StringRegexExpression` is a public, non-sealed abstract class. A subclass 
compiled against 4.2.0 does not implement `isExpensivePattern`. It would fail 
with `AbstractMethodError` the first time the optimizer reaches L93 for it, and 
it no longer compiles against this branch.
   
   Catalyst is not a public API, but this would also reach 4.2.x if it is 
backported. Could this have a default instead, e.g. `= true` (what 4.2.0 
answers for a column LIKE/RLIKE) or the regex check, with `Like` and `RLike` 
overriding it?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/regexpExpressions.scala:
##########
@@ -84,33 +84,32 @@ abstract class StringRegexExpression extends 
BinaryExpression
     }
   }
 
-  override def expensive: Boolean = hasExpensiveChild || _expensiveRegex
-
-  // Heuristic, not designed to be perfect. Look for things likely to have
-  // back tracking.
-  private val detectExpensiveRegexPattern = Pattern.compile("\\+\\*\\{")
-
-  private lazy val _expensiveRegex = {
-    // A quick heuristic for expensive a pattern is.
-    left match {
-      case StringLiteral(str) =>
-        // If we have a clear start limited back tracking required.
-        if (str.startsWith("^") || str.startsWith("\\b")) {
-          false
-        } else if (detectExpensiveRegexPattern.matcher(str).matches()) {
-          // Greedy matching can be tricky.
-          true
-        } else {
-          // Default to pushdown for now.
-          false
-        }
-      case _ =>
-        true // per row regex compilation.
-    }
+  override def expensive: Boolean = hasExpensiveChild || _expensivePattern

Review Comment:
   **7. [Low] Partition pruning still depends on this per-row cost heuristic**
   
   This narrows the regression but does not remove it. With the defaults, 
`SELECT * FROM (SELECT name, part RLIKE '2024-.*' AS p FROM t) WHERE p` still 
gets `PartitionFilters: []`, because `.*` is a quantifier. Yet this is where 
double evaluation costs least: a predicate on partition columns alone is 
evaluated once per partition during listing, not once per row.
   
   As far as I can tell, `ScanOperation` cannot inline the alias either:
   
   - `InferFiltersFromConstraints` adds `isnotnull(p)` 
(`QueryPlanConstraints.scala:174`), so `p` is referenced twice.
   - The non-cheap producer is therefore kept (`patterns.scala:78`, 
`Optimizer.scala:1885`).
   
   This is not a blocker for this PR. Would a separate JIRA be worth it, to let 
partition pruning see through the alias for partition-only predicates 
regardless of `expensive`?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/regexpExpressions.scala:
##########
@@ -84,33 +84,32 @@ abstract class StringRegexExpression extends 
BinaryExpression
     }
   }
 
-  override def expensive: Boolean = hasExpensiveChild || _expensiveRegex
-
-  // Heuristic, not designed to be perfect. Look for things likely to have
-  // back tracking.
-  private val detectExpensiveRegexPattern = Pattern.compile("\\+\\*\\{")
-
-  private lazy val _expensiveRegex = {
-    // A quick heuristic for expensive a pattern is.
-    left match {
-      case StringLiteral(str) =>
-        // If we have a clear start limited back tracking required.
-        if (str.startsWith("^") || str.startsWith("\\b")) {
-          false
-        } else if (detectExpensiveRegexPattern.matcher(str).matches()) {
-          // Greedy matching can be tricky.
-          true
-        } else {
-          // Default to pushdown for now.
-          false
-        }
-      case _ =>
-        true // per row regex compilation.
-    }
+  override def expensive: Boolean = hasExpensiveChild || _expensivePattern
+
+  // Heuristic, not designed to be perfect. A pattern that is not foldable is 
compiled for every
+  // row; a foldable one is left to `isExpensivePattern`.
+  private lazy val _expensivePattern = if (right.foldable) {
+    val str = right.eval()
+    str != null && isExpensivePattern(str.toString)
+  } else {
+    true
   }
+
+  /** Whether matching against the constant pattern `str` is likely to be 
expensive. */
+  protected def isExpensivePattern(str: String): Boolean
 }
 
 private[catalyst] object StringRegexExpression {
+  // Quantifiers, which are what make a regex backtrack.
+  private val quantifierPattern = Pattern.compile("[+*{]")
+
+  /**
+   * Heuristic for whether a regex is likely to backtrack: one with a clear 
start limits the
+   * backtracking, otherwise any quantifier may need it.
+   */
+  def isExpensiveRegex(regex: String): Boolean =

Review Comment:
   **9. [Nit] A precompiled `Pattern` and a companion method are more than this 
needs**
   
   `isExpensiveRegex` has one caller, and the check is a search for three 
characters. Could `RLike.isExpensivePattern` hold it directly, with no 
`Pattern`?
   
   ```scala
   override protected def isExpensivePattern(str: String): Boolean =
     !str.startsWith("^") && !str.startsWith("\\b") &&
       str.exists(c => c == '+' || c == '*' || c == '{')
   ```
   
   Also, `Like` always answers `false`, so L92 evaluates every foldable LIKE 
pattern only to discard the result.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/regexpExpressions.scala:
##########
@@ -182,6 +181,9 @@ case class Like(left: Expression, right: Expression, 
escapeChar: Char)
 
   override def matches(regex: Pattern, str: String): Boolean = 
regex.matcher(str).matches()
 
+  // A LIKE pattern only has the `%` and `_` wildcards, which do not nest.
+  override protected def isExpensivePattern(str: String): Boolean = false

Review Comment:
   **2. [Medium] Every constant LIKE pattern is classed cheap, including the 
slow ones**
   
   `%` and `_` do not nest, but consecutive `%` wildcards still backtrack 
polynomially. `StringUtils.escapeLikeRegex` turns `'%a%a%a%b'` into 
`(?s).*\Qa\E.*\Qa\E.*\Qa\E.*\Qb\E`, and `Like.matches` uses `matches()`. On JDK 
25, matching one value of n `a`s with no `b` took:
   
   | n | time for one value |
   |---|---|
   | 200 | 0.13 s |
   | 400 | 2.0 s |
   | 800 | 31 s |
   
   `LikeSimplification` already rewrites the single-`%` shapes (`x%`, `%x`, 
`x%y`, `%x%`) into cheap string functions. So the LIKE expressions that are 
still `Like` here are mostly the multi-wildcard ones this heuristic should 
catch. With this change, a filter on such an alias is pushed below the 
`Project` and the LIKE runs twice per row. The new test even asserts that 
`%a%b%` is cheap (`RegexpExpressionsSuite.scala:750`).
   
   Could a LIKE pattern with several `%` (say three or more) be classed 
expensive? Or could the escaped regex go through the same check as RLIKE?



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/FileSourceStrategySuite.scala:
##########
@@ -606,6 +606,30 @@ class FileSourceStrategySuite extends SharedSparkSession {
     checkDataFilters(Set.empty)
   }
 
+  test("SPARK-60632: Filters on an aliased LIKE/RLIKE column are pushed into 
the file scan") {
+    withTempPath { path =>
+      spark.range(20)
+        .selectExpr("CAST(id AS STRING) AS name", "CAST(id % 4 AS STRING) AS 
part")
+        .write.partitionBy("part").parquet(path.getCanonicalPath)
+      val table = spark.read.parquet(path.getCanonicalPath)
+
+      def scanOf(df: DataFrame): FileSourceScanExec =
+        df.queryExecution.sparkPlan.collectFirst { case s: FileSourceScanExec 
=> s }.get
+
+      Seq("part RLIKE '^1'", "part LIKE '1_' OR part = '1'").foreach { pred =>
+        withClue(pred) {
+          val df = table.selectExpr("name", s"$pred AS p").where("p")
+          assert(scanOf(df).partitionFilters.nonEmpty)

Review Comment:
   **6. [Low] The partition test reads `part` back as an INT, and its asserts 
are weak**
   
   `part` is written as a string, but partition type inference reads `0` to `3` 
back as `IntegerType`. So the RLIKE case runs as `CAST(part AS STRING) RLIKE 
'^1'`.
   
   Every value has one digit, so `part LIKE '1_'` matches no partition and only 
`part = '1'` returns rows. The LIKE leg's result is never checked.
   
   `partitionFilters.nonEmpty` here, and `dataFilters.nonEmpty` at L628, would 
also pass if only an `isnotnull(part)` reached the scan, or if the pushed 
predicate pruned nothing.
   
   Could the test do the following?
   
   - Use non-numeric partition values, or turn off 
`spark.sql.sources.partitionColumnTypeInference.enabled`.
   - Make the LIKE match a partition.
   - Assert on the pushed predicate or on the number of selected partitions.



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/RegexpExpressionsSuite.scala:
##########
@@ -735,4 +735,28 @@ class RegexpExpressionsSuite extends SparkFunSuite with 
ExpressionEvalHelper {
     assert(extractAllCopy ne extractAll,
       "freshCopyIfContainsStatefulExpression should return a new instance for 
RegExpExtractAll")
   }
+
+  test("SPARK-60632: LIKE and RLIKE expensive heuristic looks at the pattern") 
{

Review Comment:
   **8. [Nit] No test checks that an expensive child still wins over a cheap 
pattern**
   
   Suppose `hasExpensiveChild ||` were dropped from L87. Then an RLIKE with a 
cheap constant pattern over an expensive subject, e.g. `RLike(scalaUdf(col), 
Literal("^1"))`, would become cheap. A filter on it would be pushed and the UDF 
evaluated twice, and none of the new tests would fail.
   
   Could this test cover that case? `FilterPushdownSuite` could also cover two 
more cases:
   
   - an anchored pattern with a quantifier being pushed, e.g. `^a.*b`
   - an ILIKE alias, which reaches this code as `Like(Lower(left), 
Lower(right))`



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/regexpExpressions.scala:
##########
@@ -84,33 +84,32 @@ abstract class StringRegexExpression extends 
BinaryExpression
     }
   }
 
-  override def expensive: Boolean = hasExpensiveChild || _expensiveRegex
-
-  // Heuristic, not designed to be perfect. Look for things likely to have
-  // back tracking.
-  private val detectExpensiveRegexPattern = Pattern.compile("\\+\\*\\{")
-
-  private lazy val _expensiveRegex = {
-    // A quick heuristic for expensive a pattern is.
-    left match {
-      case StringLiteral(str) =>
-        // If we have a clear start limited back tracking required.
-        if (str.startsWith("^") || str.startsWith("\\b")) {
-          false
-        } else if (detectExpensiveRegexPattern.matcher(str).matches()) {
-          // Greedy matching can be tricky.
-          true
-        } else {
-          // Default to pushdown for now.
-          false
-        }
-      case _ =>
-        true // per row regex compilation.
-    }
+  override def expensive: Boolean = hasExpensiveChild || _expensivePattern
+
+  // Heuristic, not designed to be perfect. A pattern that is not foldable is 
compiled for every
+  // row; a foldable one is left to `isExpensivePattern`.
+  private lazy val _expensivePattern = if (right.foldable) {
+    val str = right.eval()
+    str != null && isExpensivePattern(str.toString)
+  } else {
+    true
   }
+
+  /** Whether matching against the constant pattern `str` is likely to be 
expensive. */
+  protected def isExpensivePattern(str: String): Boolean
 }
 
 private[catalyst] object StringRegexExpression {
+  // Quantifiers, which are what make a regex backtrack.
+  private val quantifierPattern = Pattern.compile("[+*{]")
+
+  /**
+   * Heuristic for whether a regex is likely to backtrack: one with a clear 
start limits the
+   * backtracking, otherwise any quantifier may need it.
+   */
+  def isExpensiveRegex(regex: String): Boolean =
+    !regex.startsWith("^") && !regex.startsWith("\\b") && 
quantifierPattern.matcher(regex).find()

Review Comment:
   **3. [Low] A leading `^` or `\b` does not bound the backtracking**
   
   An anchor only removes the scan over start positions. The backtracking 
inside the match is unchanged. `^.*a.*a.*a.*b` is classed cheap here, but on 
JDK 25 it took 2.5 s on one 400-character value, about the same as without the 
anchor. `\b` is not a start anchor at all: with `find()`, the match is still 
tried at every word boundary.
   
   By contrast, the nested-group pattern in the new test, `(a+)+b`, took 0 ms 
on the same JDK, which avoids that exponential case. Could the anchor exemption 
apply only when the rest of the pattern has at most one quantifier, or be 
dropped?



##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/FilterPushdownSuite.scala:
##########
@@ -61,6 +61,10 @@ class FilterPushdownSuite extends PlanTest {
 
   val testStringRelation = LocalRelation(attrA, attrB, attrE)
 
+  // Stands in for an expensive expression: a regex whose pattern is not a 
constant is compiled
+  // for every row.
+  private def expensiveRLike(tag: String): Expression = 
$"e".rlike(Concat(Seq($"e", Literal(tag))))

Review Comment:
   **10. [Nit] This helper splits the test relation definitions**
   
   It sits between `testStringRelation` and `testArrayStringRelation`. 
`AGENTS.md` asks that a new member go with the code it belongs to, without 
splitting an existing section. Could it go after the block of relation and 
predicate `val`s?



-- 
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]

Reply via email to