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


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/regexpExpressions.scala:
##########
@@ -84,33 +84,186 @@ 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 ||
+    !SQLConf.get.getConf(SQLConf.ESTIMATE_REGEX_PATTERN_COST) || 
_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`. Like 
`ConstantFolding`, evaluate a fresh
+  // copy so that a stateful pattern keeps its state.
+  private lazy val _expensivePattern = !right.foldable || {
+    try {
+      val str = right.freshCopyIfContainsStatefulExpression().eval()
+      str != null && isExpensivePattern(str.toString)
+    } catch {
+      // E.g. a pattern that `ConstantFolding` left in a conditional branch 
because it throws.
+      case NonFatal(_) => true
     }
   }
+
+  /** Whether matching against the constant pattern `str` is likely to be 
expensive. */
+  protected def isExpensivePattern(str: String): Boolean = true
 }
 
 private[catalyst] object StringRegexExpression {
+  /**
+   * Whether matching `regex` is likely to be expensive. A failed match tries 
every combination of
+   * the lengths that its repeating quantifiers take, so each one costs a 
factor of the input
+   * length. `find` also tries every start position, which costs one more 
factor unless the regex
+   * is anchored at the start. So two quantifiers are expensive, and three 
when the regex is
+   * anchored or must match the whole input, as the one a LIKE pattern 
becomes. Repeating a group
+   * that holds a quantifier or an alternation multiplies the factors, so it 
is always expensive.
+   *
+   * @param matchesWhole whether `regex` must match the whole input rather 
than be found in it.
+   */
+  def isExpensiveRegex(regex: String, matchesWhole: Boolean): Boolean = {
+    val (quantifiers, topLevelAlternation) = scanQuantifiers(regex)
+    val anchored = matchesWhole || (!topLevelAlternation && 
startsWithAnchor(regex))
+    quantifiers >= (if (anchored) 3 else 2)
+  }
+
+  // Leading inline flags, e.g. `(?i)` or `(?s-m)`.
+  private val leadingFlags = """(?:\(\?[a-zA-Z]*(?:-[a-zA-Z]*)?\))*""".r
+
+  // Whether `regex` starts with `^` or `\A`. A leading flag that mentions 
MULTILINE, under which
+  // `^` also matches after every line terminator, makes it not anchored.
+  private def startsWithAnchor(regex: String): Boolean = {
+    val flags = leadingFlags.findPrefixOf(regex).getOrElse("")
+    val rest = regex.substring(flags.length)
+    !flags.contains('m') && (rest.startsWith("^") || rest.startsWith("\\A"))
+  }
+
+  // An inline flag, e.g. `(?x)` or `(?ix:`, that turns on COMMENTS mode, 
which lets whitespace
+  // and comments separate a group from its quantifier.
+  private val commentsFlag = Pattern.compile("""\(\?[a-zA-Z]*x""")
+
+  // The product of the choices of the uncounted ranges that makes them count 
as one quantifier.
+  private val smallRangesLimit = 1000
+
+  /**
+   * Counts the repeating quantifiers (`*`, `+` and `{n,m}`) in a regex that 
can backtrack, and
+   * tells whether the regex has a `|` outside any group. Escaped characters, 
quoted text,
+   * character classes and the braces of `\p{..}`, `\x{..}` and `\N{..}` are 
skipped. These
+   * quantifiers are not counted:
+   *  - a possessive one, which never backtracks;
+   *  - one that ends the regex, which a failed match never backtracks into;
+   *  - a `{n,m}` that spans fewer than 10, which adds a small factor. The 
choices of such ranges
+   *    multiply, so they count as one quantifier each time their product 
reaches 1000.
+   * The count is `Int.MaxValue` for a repeated group that holds a quantifier 
that can backtrack or
+   * a `|`, for a regex in COMMENTS mode, and for a `[` that is never closed, 
since the scan cannot
+   * follow them.
+   */
+  private def scanQuantifiers(regex: String): (Int, Boolean) = {
+    var count = 0
+    var smallRanges = 1
+    var classDepth = 0
+    var topLevelAlternation = false
+    // Whether each open group holds a choice that a repetition of it would 
multiply, innermost
+    // first.
+    var groups = List.empty[Boolean]
+    var i = 0
+    def markGroup(): Unit = groups = groups match {
+      case _ :: outer => true :: outer
+      case Nil => Nil
+    }
+    // Handles the quantifier that ends at `i`, which repeats its operand 
`min` to `max` times,
+    // and moves `i` past its modifier.
+    def quantifier(min: Int, max: Int): Unit = {
+      val possessive = i + 1 < regex.length && regex.charAt(i + 1) == '+'
+      if (i + 1 < regex.length && (regex.charAt(i + 1) == '+' || 
regex.charAt(i + 1) == '?')) {
+        i += 1
+      }
+      if (!possessive && max > min && i + 1 < regex.length) {
+        markGroup()
+        if (max - min >= 10) {
+          count += 1
+        } else {
+          smallRanges *= max - min + 1
+          if (smallRanges >= smallRangesLimit) {
+            count += 1
+            smallRanges = 1
+          }
+        }
+      }
+    }
+    while (i < regex.length) {
+      regex.charAt(i) match {
+        case '\\' if i + 1 < regex.length =>
+          i += 1
+          regex.charAt(i) match {
+            case 'Q' =>
+              val end = regex.indexOf("\\E", i)
+              i = if (end < 0) regex.length else end + 1
+            // `\cX` is a control character, and `X` may be `[`.
+            case 'c' => i += 1
+            case c if "pPxN".indexOf(c) >= 0 &&
+                i + 1 < regex.length && regex.charAt(i + 1) == '{' =>
+              val end = regex.indexOf('}', i)
+              i = if (end < 0) regex.length else end
+            case _ =>
+          }
+        case '[' =>
+          classDepth += 1
+          // A `]` right after `[` or `[^` is a literal.
+          if (i + 1 < regex.length && regex.charAt(i + 1) == '^') i += 1
+          if (i + 1 < regex.length && regex.charAt(i + 1) == ']') i += 1
+        case ']' if classDepth > 0 => classDepth -= 1
+        case _ if classDepth > 0 =>
+        case '(' =>
+          if (matcherAt(commentsFlag, regex, i).lookingAt()) {
+            return (Int.MaxValue, topLevelAlternation)
+          }
+          groups = false :: groups
+        case ')' if groups.nonEmpty =>
+          val holdsChoice = groups.head
+          groups = groups.tail
+          if (holdsChoice) {
+            if (repeats(regex, i + 1)) return (Int.MaxValue, 
topLevelAlternation)
+            markGroup()
+          }
+        case '|' => if (groups.isEmpty) topLevelAlternation = true else 
markGroup()
+        case '*' => quantifier(0, Int.MaxValue)
+        case '+' => quantifier(1, Int.MaxValue)
+        // `?` adds at most a factor of 2 and is not counted.
+        case '?' => quantifier(0, 0)

Review Comment:
   **1. [Medium] Two `?` in a repeated group blow up, but `?` is never counted**
   
   The comment above says that "`?` adds at most a factor of 2", and that is 
true for one `?`. But the factors of several `?` multiply, like those of the 
small ranges. A group that holds two of them can split a run of characters in 
exponentially many ways, the same way `(a|aa)` does. These patterns count 0 and 
are classed cheap:
   
   | Pattern | Input (no match) | JDK 25 | JDK 17 |
   |---|---|---|---|
   | `(a?a?){16}b` | 32 `a`s | 8.1 s | 8.4 s |
   | `^(a?a?){16}b` | 32 `a`s | 0.62 s | 0.65 s |
   | `^(.?.?){16}x` | 32 `a`s | 0.67 s | 0.67 s |
   | `^(.?.?){20}x` | 40 `a`s | over 20 s | over 20 s |
   | `a?` 24 times, then `b` | 24 `a`s | 1.1 s | 1.2 s |
   
   `a{0,1}` 24 times, then `b`, takes about as long. But it counts 2 and is 
classed expensive, because `{0,1}` goes through the small-range product. My 
round-3 comment was wrong to say that `?` can stay out. It measured only one 
`?` per group, and `(a?){25}a{25}b` does take 0 ms.
   
   Could `?` take the same path as `{0,1}`? The `?` that opens `(?:`, `(?=`, 
`(?<name>` or `(?i)` would have to be skipped first:
   
   ```scala
           case '(' =>
             ...
             groups = false :: groups
             // The `?` of `(?:`, `(?=`, `(?<name>` or `(?i)` is not a 
quantifier.
             if (i + 1 < regex.length && regex.charAt(i + 1) == '?') i += 1
           ...
           case '?' => quantifier(0, 1)
   ```
   
   `(a?){25}a{25}b` would then be MAX, although it takes 0 ms. That errs on the 
same side as round-3 item 8.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/regexpExpressions.scala:
##########
@@ -84,33 +84,186 @@ 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 ||

Review Comment:
   **2. [Low] A LIKE/RLIKE inside another expression keeps the answer of the 
first conf value**
   
   `expensive` now reads `estimateRegexPatternCost` on every call. But 
`Expression.hasExpensiveChild` is a `lazy val`, so `Not`, `And`, `If` and every 
other parent compute their `expensive` only once per instance, under whatever 
value the conf had at that time. Analyzed plans share their expression 
instances: a DataFrame built on another reuses them, and so does every 
streaming query started from it.
   
   For example, take a DataFrame `base` that projects `NOT (s RLIKE '^1') AS p` 
over a stream-stream join, and `q = base.where($"p")`. Suppose `q.explain()` 
runs, or a query from `q` with a new checkpoint runs a batch, both under the 
default `true`. Then `Not.hasExpensiveChild` is cached as `false`. A query from 
`q` that restarts from a 4.2 checkpoint gets `false` for the conf, but `NOT (s 
RLIKE '^1')` still reports cheap, so its filter is pushed below the join. That 
is the placement the offset-log default is there to keep. A bare `s RLIKE '^1'` 
reads the conf again and stays above, so the two forms disagree.
   
   Could the conf be checked where the answer is used, instead of inside 
`expensive`? For example, in `PushPredicateThroughNonJoin`:
   
   ```scala
     // `hasExpensiveChild` is cached, so `expensive` itself must not depend on 
the conf.
     private def isExpensive(e: Expression): Boolean = e.expensive ||
       (!conf.getConf(SQLConf.ESTIMATE_REGEX_PATTERN_COST) &&
         e.exists(_.isInstanceOf[StringRegexExpression]))
   ```
   
   This would also save a `SQLConf.get` lookup on every call.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/checkpointing/OffsetSeq.scala:
##########
@@ -207,7 +207,8 @@ object OffsetSeqMetadata extends Logging {
     ENABLE_STREAMING_SOURCE_EVOLUTION,
     STATEFUL_OPERATOR_ALWAYS_NULLABLE_OUTPUT,
     DROP_DUPLICATES_DETERMINISTIC_KEY_ORDER,
-    ALLOW_EXCEPT_ON_STREAMING_DATAFRAME
+    ALLOW_EXCEPT_ON_STREAMING_DATAFRAME,
+    ESTIMATE_REGEX_PATTERN_COST

Review Comment:
   **4. [Low] The offset log pins whether the estimate is on, not which 
estimate**
   
   Thanks for adding the conf. It answers the streaming question for 
checkpoints written by 4.2.x and 4.3.x. But a checkpoint written with `true` 
follows whatever `scanQuantifiers` does in the release that resumes it. Suppose 
a later change makes a pattern cheaper, for example by counting each lookaround 
(round-3 item 6) or each top-level alternative (item 5 below) on its own. On 
restart, the filter on such an alias then moves below a stream-stream join, and 
the rows already in the join state are never filtered. That is the problem this 
conf fixes, and there would be no value left to pin it.
   
   Could the offset log record a version of the estimate instead? For example, 
an int conf where 0 means every LIKE/RLIKE is expensive and 1 means this 
scanner. A later change that makes a pattern cheaper would then bump it. If 
that is too much for this PR, a comment in `scanQuantifiers` saying that such a 
change needs a new pin would still help.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala:
##########
@@ -670,6 +670,18 @@ object SQLConf {
     .booleanConf
     .createWithDefault(true)
 
+  val ESTIMATE_REGEX_PATTERN_COST =
+    buildConf("spark.sql.optimizer.estimateRegexPatternCost")
+    .internal()
+    .withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)
+    .doc(s"When true, ${AVOID_DOUBLE_FILTER_EVAL.key} treats a LIKE or RLIKE 
expression as " +
+      "expensive only when its pattern is likely to be expensive to match. 
When false, every " +
+      "LIKE and RLIKE expression is expensive, as in 4.2.0. A streaming query 
keeps the value " +

Review Comment:
   **7. [Nit] `as in 4.2.0` does not hold for a literal subject**
   
   4.2.0 decided by `left`. A subject that was not a literal made LIKE/RLIKE 
expensive, but a literal one, as in `'abc' RLIKE p`, was cheap. With `false` it 
is now expensive. That moves its filter up, not down, so the checkpoint default 
stays safe, but the doc could say so:
   
   ```scala
       .doc(s"When true, ${AVOID_DOUBLE_FILTER_EVAL.key} treats a LIKE or RLIKE 
expression as " +
         "expensive only when its pattern is likely to be expensive to match. 
When false, every " +
         "LIKE and RLIKE expression is expensive, as in 4.2.0 for a subject 
that is not a " +
         "literal. A streaming query keeps the value recorded in its 
checkpoint, and one " +
         "restarted from a checkpoint without it uses false.")
   ```



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/regexpExpressions.scala:
##########
@@ -84,33 +84,186 @@ 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 ||
+    !SQLConf.get.getConf(SQLConf.ESTIMATE_REGEX_PATTERN_COST) || 
_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`. Like 
`ConstantFolding`, evaluate a fresh
+  // copy so that a stateful pattern keeps its state.
+  private lazy val _expensivePattern = !right.foldable || {
+    try {
+      val str = right.freshCopyIfContainsStatefulExpression().eval()
+      str != null && isExpensivePattern(str.toString)
+    } catch {
+      // E.g. a pattern that `ConstantFolding` left in a conditional branch 
because it throws.
+      case NonFatal(_) => true
     }
   }
+
+  /** Whether matching against the constant pattern `str` is likely to be 
expensive. */
+  protected def isExpensivePattern(str: String): Boolean = true
 }
 
 private[catalyst] object StringRegexExpression {
+  /**
+   * Whether matching `regex` is likely to be expensive. A failed match tries 
every combination of
+   * the lengths that its repeating quantifiers take, so each one costs a 
factor of the input
+   * length. `find` also tries every start position, which costs one more 
factor unless the regex
+   * is anchored at the start. So two quantifiers are expensive, and three 
when the regex is
+   * anchored or must match the whole input, as the one a LIKE pattern 
becomes. Repeating a group
+   * that holds a quantifier or an alternation multiplies the factors, so it 
is always expensive.
+   *
+   * @param matchesWhole whether `regex` must match the whole input rather 
than be found in it.
+   */
+  def isExpensiveRegex(regex: String, matchesWhole: Boolean): Boolean = {
+    val (quantifiers, topLevelAlternation) = scanQuantifiers(regex)
+    val anchored = matchesWhole || (!topLevelAlternation && 
startsWithAnchor(regex))
+    quantifiers >= (if (anchored) 3 else 2)
+  }
+
+  // Leading inline flags, e.g. `(?i)` or `(?s-m)`.
+  private val leadingFlags = """(?:\(\?[a-zA-Z]*(?:-[a-zA-Z]*)?\))*""".r
+
+  // Whether `regex` starts with `^` or `\A`. A leading flag that mentions 
MULTILINE, under which
+  // `^` also matches after every line terminator, makes it not anchored.
+  private def startsWithAnchor(regex: String): Boolean = {
+    val flags = leadingFlags.findPrefixOf(regex).getOrElse("")
+    val rest = regex.substring(flags.length)
+    !flags.contains('m') && (rest.startsWith("^") || rest.startsWith("\\A"))
+  }
+
+  // An inline flag, e.g. `(?x)` or `(?ix:`, that turns on COMMENTS mode, 
which lets whitespace
+  // and comments separate a group from its quantifier.
+  private val commentsFlag = Pattern.compile("""\(\?[a-zA-Z]*x""")
+
+  // The product of the choices of the uncounted ranges that makes them count 
as one quantifier.
+  private val smallRangesLimit = 1000
+
+  /**
+   * Counts the repeating quantifiers (`*`, `+` and `{n,m}`) in a regex that 
can backtrack, and
+   * tells whether the regex has a `|` outside any group. Escaped characters, 
quoted text,
+   * character classes and the braces of `\p{..}`, `\x{..}` and `\N{..}` are 
skipped. These
+   * quantifiers are not counted:
+   *  - a possessive one, which never backtracks;
+   *  - one that ends the regex, which a failed match never backtracks into;
+   *  - a `{n,m}` that spans fewer than 10, which adds a small factor. The 
choices of such ranges
+   *    multiply, so they count as one quantifier each time their product 
reaches 1000.
+   * The count is `Int.MaxValue` for a repeated group that holds a quantifier 
that can backtrack or
+   * a `|`, for a regex in COMMENTS mode, and for a `[` that is never closed, 
since the scan cannot
+   * follow them.
+   */
+  private def scanQuantifiers(regex: String): (Int, Boolean) = {
+    var count = 0
+    var smallRanges = 1
+    var classDepth = 0
+    var topLevelAlternation = false
+    // Whether each open group holds a choice that a repetition of it would 
multiply, innermost
+    // first.
+    var groups = List.empty[Boolean]
+    var i = 0
+    def markGroup(): Unit = groups = groups match {
+      case _ :: outer => true :: outer
+      case Nil => Nil
+    }
+    // Handles the quantifier that ends at `i`, which repeats its operand 
`min` to `max` times,
+    // and moves `i` past its modifier.
+    def quantifier(min: Int, max: Int): Unit = {
+      val possessive = i + 1 < regex.length && regex.charAt(i + 1) == '+'
+      if (i + 1 < regex.length && (regex.charAt(i + 1) == '+' || 
regex.charAt(i + 1) == '?')) {
+        i += 1
+      }
+      if (!possessive && max > min && i + 1 < regex.length) {
+        markGroup()
+        if (max - min >= 10) {
+          count += 1
+        } else {
+          smallRanges *= max - min + 1
+          if (smallRanges >= smallRangesLimit) {

Review Comment:
   **3. [Low] The small-range product drops what is left at each reset**
   
   Thanks for adding the product. But the reset at 1000 drops the overshoot, so 
a product of 9000 counts the same as 1000, and a remainder below 1000 never 
counts. So these patterns count 1 and are classed cheap, while `.*a.*b`, which 
counts 2, is faster:
   
   | Pattern | Count | Input (no match) | JDK 25 | JDK 17 |
   |---|---|---|---|---|
   | `.*a.*b` | 2 | 400 `a`s | 42 ms | 41 ms |
   | `.{0,9}a.{0,9}a.{0,8}a.*b` | 1 | 400 `a`s | 0.30 s | 0.33 s |
   | `.{0,9}a.{0,9}a.{0,8}a.{0,9}a.{0,9}a.{0,8}ab` | 1 | 400 `a`s | 3.0 s | 2.0 
s |
   | `.*a.*b` | 2 | 2000 `a`s | 5.0 s | 4.9 s |
   | `.{0,9}a.{0,9}a.{0,8}a.*b` | 1 | 2000 `a`s | 6.6 s | 6.6 s |
   
   One combination of small ranges costs more than one step of `.*`, so 1000 
combinations weigh more than one quantifier on a value of 400 or 2000 
characters. With `smallRangesLimit = 100`, both slow patterns count 2, and 
every case in the new test keeps its answer: three `.{0,9}` count 1 and stay 
cheap, and six count 3. Four `.{0,9}`, which you measured at 15 ms, would 
become expensive.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/regexpExpressions.scala:
##########
@@ -84,33 +84,186 @@ 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 ||
+    !SQLConf.get.getConf(SQLConf.ESTIMATE_REGEX_PATTERN_COST) || 
_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`. Like 
`ConstantFolding`, evaluate a fresh
+  // copy so that a stateful pattern keeps its state.
+  private lazy val _expensivePattern = !right.foldable || {
+    try {
+      val str = right.freshCopyIfContainsStatefulExpression().eval()
+      str != null && isExpensivePattern(str.toString)
+    } catch {
+      // E.g. a pattern that `ConstantFolding` left in a conditional branch 
because it throws.
+      case NonFatal(_) => true
     }
   }
+
+  /** Whether matching against the constant pattern `str` is likely to be 
expensive. */
+  protected def isExpensivePattern(str: String): Boolean = true
 }
 
 private[catalyst] object StringRegexExpression {
+  /**
+   * Whether matching `regex` is likely to be expensive. A failed match tries 
every combination of
+   * the lengths that its repeating quantifiers take, so each one costs a 
factor of the input
+   * length. `find` also tries every start position, which costs one more 
factor unless the regex
+   * is anchored at the start. So two quantifiers are expensive, and three 
when the regex is
+   * anchored or must match the whole input, as the one a LIKE pattern 
becomes. Repeating a group
+   * that holds a quantifier or an alternation multiplies the factors, so it 
is always expensive.
+   *
+   * @param matchesWhole whether `regex` must match the whole input rather 
than be found in it.
+   */
+  def isExpensiveRegex(regex: String, matchesWhole: Boolean): Boolean = {
+    val (quantifiers, topLevelAlternation) = scanQuantifiers(regex)
+    val anchored = matchesWhole || (!topLevelAlternation && 
startsWithAnchor(regex))
+    quantifiers >= (if (anchored) 3 else 2)
+  }
+
+  // Leading inline flags, e.g. `(?i)` or `(?s-m)`.
+  private val leadingFlags = """(?:\(\?[a-zA-Z]*(?:-[a-zA-Z]*)?\))*""".r
+
+  // Whether `regex` starts with `^` or `\A`. A leading flag that mentions 
MULTILINE, under which
+  // `^` also matches after every line terminator, makes it not anchored.
+  private def startsWithAnchor(regex: String): Boolean = {
+    val flags = leadingFlags.findPrefixOf(regex).getOrElse("")
+    val rest = regex.substring(flags.length)
+    !flags.contains('m') && (rest.startsWith("^") || rest.startsWith("\\A"))
+  }
+
+  // An inline flag, e.g. `(?x)` or `(?ix:`, that turns on COMMENTS mode, 
which lets whitespace
+  // and comments separate a group from its quantifier.
+  private val commentsFlag = Pattern.compile("""\(\?[a-zA-Z]*x""")
+
+  // The product of the choices of the uncounted ranges that makes them count 
as one quantifier.
+  private val smallRangesLimit = 1000
+
+  /**
+   * Counts the repeating quantifiers (`*`, `+` and `{n,m}`) in a regex that 
can backtrack, and
+   * tells whether the regex has a `|` outside any group. Escaped characters, 
quoted text,
+   * character classes and the braces of `\p{..}`, `\x{..}` and `\N{..}` are 
skipped. These
+   * quantifiers are not counted:
+   *  - a possessive one, which never backtracks;
+   *  - one that ends the regex, which a failed match never backtracks into;
+   *  - a `{n,m}` that spans fewer than 10, which adds a small factor. The 
choices of such ranges
+   *    multiply, so they count as one quantifier each time their product 
reaches 1000.
+   * The count is `Int.MaxValue` for a repeated group that holds a quantifier 
that can backtrack or
+   * a `|`, for a regex in COMMENTS mode, and for a `[` that is never closed, 
since the scan cannot
+   * follow them.
+   */
+  private def scanQuantifiers(regex: String): (Int, Boolean) = {
+    var count = 0
+    var smallRanges = 1
+    var classDepth = 0
+    var topLevelAlternation = false
+    // Whether each open group holds a choice that a repetition of it would 
multiply, innermost
+    // first.
+    var groups = List.empty[Boolean]
+    var i = 0
+    def markGroup(): Unit = groups = groups match {
+      case _ :: outer => true :: outer
+      case Nil => Nil
+    }
+    // Handles the quantifier that ends at `i`, which repeats its operand 
`min` to `max` times,
+    // and moves `i` past its modifier.
+    def quantifier(min: Int, max: Int): Unit = {
+      val possessive = i + 1 < regex.length && regex.charAt(i + 1) == '+'
+      if (i + 1 < regex.length && (regex.charAt(i + 1) == '+' || 
regex.charAt(i + 1) == '?')) {
+        i += 1
+      }
+      if (!possessive && max > min && i + 1 < regex.length) {
+        markGroup()
+        if (max - min >= 10) {
+          count += 1
+        } else {
+          smallRanges *= max - min + 1
+          if (smallRanges >= smallRangesLimit) {
+            count += 1
+            smallRanges = 1
+          }
+        }
+      }
+    }
+    while (i < regex.length) {
+      regex.charAt(i) match {
+        case '\\' if i + 1 < regex.length =>
+          i += 1
+          regex.charAt(i) match {
+            case 'Q' =>
+              val end = regex.indexOf("\\E", i)
+              i = if (end < 0) regex.length else end + 1
+            // `\cX` is a control character, and `X` may be `[`.
+            case 'c' => i += 1
+            case c if "pPxN".indexOf(c) >= 0 &&
+                i + 1 < regex.length && regex.charAt(i + 1) == '{' =>
+              val end = regex.indexOf('}', i)
+              i = if (end < 0) regex.length else end
+            case _ =>
+          }
+        case '[' =>
+          classDepth += 1
+          // A `]` right after `[` or `[^` is a literal.
+          if (i + 1 < regex.length && regex.charAt(i + 1) == '^') i += 1
+          if (i + 1 < regex.length && regex.charAt(i + 1) == ']') i += 1
+        case ']' if classDepth > 0 => classDepth -= 1
+        case _ if classDepth > 0 =>
+        case '(' =>
+          if (matcherAt(commentsFlag, regex, i).lookingAt()) {
+            return (Int.MaxValue, topLevelAlternation)
+          }
+          groups = false :: groups
+        case ')' if groups.nonEmpty =>
+          val holdsChoice = groups.head
+          groups = groups.tail
+          if (holdsChoice) {
+            if (repeats(regex, i + 1)) return (Int.MaxValue, 
topLevelAlternation)
+            markGroup()
+          }
+        case '|' => if (groups.isEmpty) topLevelAlternation = true else 
markGroup()

Review Comment:
   **5. [Low] Quantifiers in different top-level alternatives are added up**
   
   The alternatives of a top-level `|` are tried one after another, so their 
quantifiers do not multiply, but the scanner adds them up. Also, only the last 
character counts as the end of the pattern. A quantifier that ends an 
alternative, or that sits right before the `)` of a group around the whole 
pattern, is counted, although a failed match never backtracks into it either. 
So common keyword alternations are classed expensive:
   
   | Pattern | Count | Classed | Input (no match) | JDK 25 | JDK 17 |
   |---|---|---|---|---|---|
   | `a.*b` | 1 | cheap | 2000 `a`s | 7 ms | 7 ms |
   | `a.*b\|a.*c` | 2 | expensive | 2000 `a`s | 14 ms | 15 ms |
   | `foo.*bar\|baz.*qux` | 2 | expensive | `foobaz` 333 times | 12 ms | 10 ms |
   | `foo.*bar.*` | 1 | cheap | `foo` 700 times | 18 ms | 10 ms |
   | `(foo.*bar.*)` | 2 | expensive | `foo` 700 times | 12 ms | 9 ms |
   | `a.*b.*\|x` | 2 | expensive | 2000 `a`s | 12 ms | 7 ms |
   
   A filter on such an alias stays above the projection and gets no pushdown or 
pruning, as in 4.2.0. Like round-3 item 6, this errs on the safe side, but 
keyword alternations are much more common in RLIKE filters than lookarounds. 
Counting each top-level alternative on its own and taking the largest count 
would fix the first two expensive rows. Treating a quantifier that comes right 
before a top-level `|`, or before nothing but closing `)`s, as one that ends 
the pattern would fix the other two.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/streaming/OffsetSeqLogSuite.scala:
##########
@@ -52,6 +52,20 @@ class OffsetSeqLogSuite extends SharedSparkSession {
     
assert(metadata.conf.get(SQLConf.ALLOW_EXCEPT_ON_STREAMING_DATAFRAME.key).contains("false"))
   }
 
+  test("SPARK-60632: regex pattern cost estimate is disabled for old 
checkpoints") {

Review Comment:
   **8. [Nit] No test restarts a query from a checkpoint without the key**
   
   These two tests check what `readValue` returns and what a new checkpoint 
records. Neither checks that a restarted query gets `false` in its session, or 
that the filter then stays above the projection. A test could run a batch, 
remove the key from the last offset-log entry, restart, and check where the 
RLIKE filter lands in `lastExecution.optimizedPlan`. That would cover the whole 
path, and with a `NOT (s RLIKE '^1')` alias on the same DataFrame it would also 
catch item 2. Checking `OffsetSeqMetadata.setSessionConf` on metadata without 
the key would cover the first half.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/regexpExpressions.scala:
##########
@@ -84,33 +84,186 @@ 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 ||
+    !SQLConf.get.getConf(SQLConf.ESTIMATE_REGEX_PATTERN_COST) || 
_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`. Like 
`ConstantFolding`, evaluate a fresh
+  // copy so that a stateful pattern keeps its state.
+  private lazy val _expensivePattern = !right.foldable || {
+    try {
+      val str = right.freshCopyIfContainsStatefulExpression().eval()
+      str != null && isExpensivePattern(str.toString)
+    } catch {
+      // E.g. a pattern that `ConstantFolding` left in a conditional branch 
because it throws.
+      case NonFatal(_) => true
     }
   }
+
+  /** Whether matching against the constant pattern `str` is likely to be 
expensive. */
+  protected def isExpensivePattern(str: String): Boolean = true
 }
 
 private[catalyst] object StringRegexExpression {
+  /**
+   * Whether matching `regex` is likely to be expensive. A failed match tries 
every combination of
+   * the lengths that its repeating quantifiers take, so each one costs a 
factor of the input
+   * length. `find` also tries every start position, which costs one more 
factor unless the regex
+   * is anchored at the start. So two quantifiers are expensive, and three 
when the regex is
+   * anchored or must match the whole input, as the one a LIKE pattern 
becomes. Repeating a group
+   * that holds a quantifier or an alternation multiplies the factors, so it 
is always expensive.
+   *
+   * @param matchesWhole whether `regex` must match the whole input rather 
than be found in it.
+   */
+  def isExpensiveRegex(regex: String, matchesWhole: Boolean): Boolean = {
+    val (quantifiers, topLevelAlternation) = scanQuantifiers(regex)
+    val anchored = matchesWhole || (!topLevelAlternation && 
startsWithAnchor(regex))
+    quantifiers >= (if (anchored) 3 else 2)
+  }
+
+  // Leading inline flags, e.g. `(?i)` or `(?s-m)`.
+  private val leadingFlags = """(?:\(\?[a-zA-Z]*(?:-[a-zA-Z]*)?\))*""".r
+
+  // Whether `regex` starts with `^` or `\A`. A leading flag that mentions 
MULTILINE, under which
+  // `^` also matches after every line terminator, makes it not anchored.
+  private def startsWithAnchor(regex: String): Boolean = {
+    val flags = leadingFlags.findPrefixOf(regex).getOrElse("")
+    val rest = regex.substring(flags.length)
+    !flags.contains('m') && (rest.startsWith("^") || rest.startsWith("\\A"))
+  }
+
+  // An inline flag, e.g. `(?x)` or `(?ix:`, that turns on COMMENTS mode, 
which lets whitespace
+  // and comments separate a group from its quantifier.
+  private val commentsFlag = Pattern.compile("""\(\?[a-zA-Z]*x""")
+
+  // The product of the choices of the uncounted ranges that makes them count 
as one quantifier.
+  private val smallRangesLimit = 1000
+
+  /**
+   * Counts the repeating quantifiers (`*`, `+` and `{n,m}`) in a regex that 
can backtrack, and
+   * tells whether the regex has a `|` outside any group. Escaped characters, 
quoted text,
+   * character classes and the braces of `\p{..}`, `\x{..}` and `\N{..}` are 
skipped. These
+   * quantifiers are not counted:
+   *  - a possessive one, which never backtracks;
+   *  - one that ends the regex, which a failed match never backtracks into;
+   *  - a `{n,m}` that spans fewer than 10, which adds a small factor. The 
choices of such ranges
+   *    multiply, so they count as one quantifier each time their product 
reaches 1000.
+   * The count is `Int.MaxValue` for a repeated group that holds a quantifier 
that can backtrack or
+   * a `|`, for a regex in COMMENTS mode, and for a `[` that is never closed, 
since the scan cannot
+   * follow them.
+   */
+  private def scanQuantifiers(regex: String): (Int, Boolean) = {
+    var count = 0
+    var smallRanges = 1
+    var classDepth = 0
+    var topLevelAlternation = false
+    // Whether each open group holds a choice that a repetition of it would 
multiply, innermost
+    // first.
+    var groups = List.empty[Boolean]
+    var i = 0
+    def markGroup(): Unit = groups = groups match {
+      case _ :: outer => true :: outer
+      case Nil => Nil
+    }
+    // Handles the quantifier that ends at `i`, which repeats its operand 
`min` to `max` times,
+    // and moves `i` past its modifier.
+    def quantifier(min: Int, max: Int): Unit = {
+      val possessive = i + 1 < regex.length && regex.charAt(i + 1) == '+'
+      if (i + 1 < regex.length && (regex.charAt(i + 1) == '+' || 
regex.charAt(i + 1) == '?')) {
+        i += 1
+      }
+      if (!possessive && max > min && i + 1 < regex.length) {
+        markGroup()
+        if (max - min >= 10) {
+          count += 1
+        } else {
+          smallRanges *= max - min + 1
+          if (smallRanges >= smallRangesLimit) {
+            count += 1
+            smallRanges = 1
+          }
+        }
+      }
+    }
+    while (i < regex.length) {
+      regex.charAt(i) match {
+        case '\\' if i + 1 < regex.length =>
+          i += 1
+          regex.charAt(i) match {
+            case 'Q' =>
+              val end = regex.indexOf("\\E", i)
+              i = if (end < 0) regex.length else end + 1
+            // `\cX` is a control character, and `X` may be `[`.
+            case 'c' => i += 1
+            case c if "pPxN".indexOf(c) >= 0 &&
+                i + 1 < regex.length && regex.charAt(i + 1) == '{' =>
+              val end = regex.indexOf('}', i)
+              i = if (end < 0) regex.length else end
+            case _ =>
+          }
+        case '[' =>
+          classDepth += 1
+          // A `]` right after `[` or `[^` is a literal.
+          if (i + 1 < regex.length && regex.charAt(i + 1) == '^') i += 1
+          if (i + 1 < regex.length && regex.charAt(i + 1) == ']') i += 1
+        case ']' if classDepth > 0 => classDepth -= 1
+        case _ if classDepth > 0 =>
+        case '(' =>
+          if (matcherAt(commentsFlag, regex, i).lookingAt()) {

Review Comment:
   **9. [Nit] A `Matcher` is built at every `(`**
   
   `matcherAt(commentsFlag, regex, i)` allocates a `Matcher` and its group 
arrays for every group, although only a `(?` can match. Checking those two 
characters first keeps plain groups free:
   
   ```suggestion
             if (regex.startsWith("(?", i) && matcherAt(commentsFlag, regex, 
i).lookingAt()) {
   ```



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala:
##########
@@ -670,6 +670,18 @@ object SQLConf {
     .booleanConf
     .createWithDefault(true)
 
+  val ESTIMATE_REGEX_PATTERN_COST =
+    buildConf("spark.sql.optimizer.estimateRegexPatternCost")
+    .internal()
+    .withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)
+    .doc(s"When true, ${AVOID_DOUBLE_FILTER_EVAL.key} treats a LIKE or RLIKE 
expression as " +
+      "expensive only when its pattern is likely to be expensive to match. 
When false, every " +
+      "LIKE and RLIKE expression is expensive, as in 4.2.0. A streaming query 
keeps the value " +
+      "recorded in its checkpoint, and one restarted from a checkpoint without 
it uses false.")
+    .version("4.4.0")

Review Comment:
   **6. [Low] The version should be the first release that gets the conf**
   
   Your reply on the streaming question says that the fix is safe to backport 
to `branch-4.2` and `branch-4.3`. If it goes there, the conf first ships from 
`branch-4.2`, so `.version` should be `4.2.2`. If it goes only to `branch-4.3`, 
it should be `4.3.0`, which has not been released yet. `4.4.0` is right only if 
the change stays on `master` and `branch-4.x`.



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