viirya commented on code in PR #59879:
URL: https://github.com/apache/spark/pull/59879#discussion_r4408359339
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/regexpExpressions.scala:
##########
@@ -84,33 +84,92 @@ 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`. 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 {
+ /**
+ * Counts the `%` wildcards in a LIKE pattern, skipping escaped characters.
Each one becomes a
+ * `.*`, and a failed match tries every combination of their lengths.
+ */
+ def countLikeWildcards(pattern: String, escapeChar: Char): Int = {
+ var count = 0
+ var i = 0
+ while (i < pattern.length) {
+ val c = pattern.charAt(i)
+ if (c == escapeChar) {
+ i += 1
+ } else if (c == '%') {
+ count += 1
+ }
+ i += 1
+ }
+ count
+ }
+
+ /**
+ * Counts the repeating quantifiers (`*`, `+` and `{n,m}`) in a regex, which
are what make a
+ * failed match backtrack. Escaped characters, quoted text, character
classes and the braces of
+ * `\p{..}`, `\x{..}` and `\N{..}` are skipped, and the `+` or `?` that
makes a quantifier
+ * possessive or lazy is not counted again.
+ */
+ def countRegexQuantifiers(regex: String): Int = {
+ var count = 0
+ var classDepth = 0
+ var i = 0
+ // Moves `i` past a `+` or `?` that modifies the quantifier ending at `i`.
+ def skipModifier(): Unit = {
+ if (i + 1 < regex.length && (regex.charAt(i + 1) == '+' ||
regex.charAt(i + 1) == '?')) {
+ i += 1
+ }
+ }
+ while (i < regex.length) {
+ regex.charAt(i) match {
+ case '\\' if i + 1 < regex.length =>
+ i += 1
+ if (regex.charAt(i) == 'Q') {
+ val end = regex.indexOf("\\E", i)
+ i = if (end < 0) regex.length else end + 1
+ } else if ("pPxN".indexOf(regex.charAt(i)) >= 0 &&
+ i + 1 < regex.length && regex.charAt(i + 1) == '{') {
+ val end = regex.indexOf('}', i)
+ i = if (end < 0) regex.length else end
+ }
+ case '[' => classDepth += 1
+ case ']' if classDepth > 0 => classDepth -= 1
+ case '*' | '+' if classDepth == 0 =>
+ count += 1
+ skipModifier()
+ case '{' if classDepth == 0 =>
Review Comment:
Good catch. Counting the `{n}` after `)` once would not survive item 3,
though: with the anchored threshold of 3, `^(.*?,){11}P` would count 2 and be
cheap again. So a repeated group that holds a counted quantifier now makes the
pattern expensive outright, whatever the anchor, since the repetition
multiplies the factors inside it. This covers `{n}`, `{n,m}` with an upper
bound of at least 2, `*` and `+`, and also `(a+)+b` and `^(a+)+b`. A repeated
group without a quantifier inside, such as `(ab){3}.*`, is not affected.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/regexpExpressions.scala:
##########
@@ -84,33 +84,92 @@ 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`. 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 {
+ /**
+ * Counts the `%` wildcards in a LIKE pattern, skipping escaped characters.
Each one becomes a
+ * `.*`, and a failed match tries every combination of their lengths.
+ */
+ def countLikeWildcards(pattern: String, escapeChar: Char): Int = {
+ var count = 0
+ var i = 0
+ while (i < pattern.length) {
+ val c = pattern.charAt(i)
+ if (c == escapeChar) {
+ i += 1
+ } else if (c == '%') {
+ count += 1
+ }
+ i += 1
+ }
+ count
+ }
+
+ /**
+ * Counts the repeating quantifiers (`*`, `+` and `{n,m}`) in a regex, which
are what make a
+ * failed match backtrack. Escaped characters, quoted text, character
classes and the braces of
+ * `\p{..}`, `\x{..}` and `\N{..}` are skipped, and the `+` or `?` that
makes a quantifier
+ * possessive or lazy is not counted again.
+ */
+ def countRegexQuantifiers(regex: String): Int = {
+ var count = 0
+ var classDepth = 0
+ var i = 0
+ // Moves `i` past a `+` or `?` that modifies the quantifier ending at `i`.
+ def skipModifier(): Unit = {
+ if (i + 1 < regex.length && (regex.charAt(i + 1) == '+' ||
regex.charAt(i + 1) == '?')) {
+ i += 1
+ }
+ }
+ while (i < regex.length) {
+ regex.charAt(i) match {
+ case '\\' if i + 1 < regex.length =>
+ i += 1
+ if (regex.charAt(i) == 'Q') {
+ val end = regex.indexOf("\\E", i)
+ i = if (end < 0) regex.length else end + 1
+ } else if ("pPxN".indexOf(regex.charAt(i)) >= 0 &&
+ i + 1 < regex.length && regex.charAt(i + 1) == '{') {
+ val end = regex.indexOf('}', i)
+ i = if (end < 0) regex.length else end
+ }
+ case '[' => classDepth += 1
Review Comment:
Done both: the character after `\c` is skipped, and a `[` that is still open
at the end makes the pattern expensive, which covers the `(?x)` comment case.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/regexpExpressions.scala:
##########
@@ -534,6 +598,10 @@ case class RLike(left: Expression, right: Expression)
extends StringRegexExpress
override def escape(v: String): String = v
override def matches(regex: Pattern, str: String): Boolean =
regex.matcher(str).find(0)
+ // Like the `%` of LIKE, but `find` also tries every start position, which
costs one more
+ // factor of the input length. An anchor does not bound the backtracking
within a match.
+ override protected def isExpensivePattern(str: String): Boolean =
+ StringRegexExpression.countRegexQuantifiers(str) >= 2
Review Comment:
You're right, and my round-1 reply misread my own numbers: `^.*a.*a.*a.*b`
taking as long as `.*a.*a.*b` shows exactly that the anchor removes one factor.
On JDK 17 I see `^.*a.*a.*b` at 42 ms and `\A.*a.*a.*b` at 41 ms on 400 `a`s,
against 40 ms for LIKE `'%a%a%b'` and 4.2 s for `.*a.*a.*b`.
A pattern that starts with `^` or `\A` now uses the threshold of 3, unless a
leading inline flag mentions `m` or the pattern has a `|` outside any group.
`^v[0-9]+_.*` is now cheap (0.2 ms on 2000 characters), while `^a|.*b.*c` and
`(?m)^.*a.*b` stay expensive. LIKE goes through the same path as a regex that
must match the whole input, so both use 3.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/regexpExpressions.scala:
##########
@@ -84,33 +84,92 @@ 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`. 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 {
+ /**
+ * Counts the `%` wildcards in a LIKE pattern, skipping escaped characters.
Each one becomes a
+ * `.*`, and a failed match tries every combination of their lengths.
+ */
+ def countLikeWildcards(pattern: String, escapeChar: Char): Int = {
+ var count = 0
+ var i = 0
+ while (i < pattern.length) {
+ val c = pattern.charAt(i)
+ if (c == escapeChar) {
+ i += 1
+ } else if (c == '%') {
+ count += 1
+ }
+ i += 1
+ }
+ count
+ }
+
+ /**
+ * Counts the repeating quantifiers (`*`, `+` and `{n,m}`) in a regex, which
are what make a
+ * failed match backtrack. Escaped characters, quoted text, character
classes and the braces of
+ * `\p{..}`, `\x{..}` and `\N{..}` are skipped, and the `+` or `?` that
makes a quantifier
+ * possessive or lazy is not counted again.
+ */
+ def countRegexQuantifiers(regex: String): Int = {
+ var count = 0
+ var classDepth = 0
+ var i = 0
+ // Moves `i` past a `+` or `?` that modifies the quantifier ending at `i`.
+ def skipModifier(): Unit = {
Review Comment:
Done. `?` now skips its modifier like the others, a fixed `{n}` is consumed
together with its modifier, and a possessive quantifier is not counted, so
`a?+b?+`, `a{3}+b`, `a*+b*+` and `.*+a.*+b` are cheap (`.*+a.*+b` takes 35 ms
on 2000 characters). A `{n,m}` is counted only when its range is unbounded or
spans at least 10. On 400 characters, three `.{0,9}` took 2.8 ms and three
`.{0,50}` took 120 ms, so `a{1,9}b{1,9}` moved to the cheap cases and
`.{0,50}a.{0,50}b` is in the expensive ones.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/regexpExpressions.scala:
##########
@@ -84,33 +84,92 @@ 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`. 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 {
+ /**
+ * Counts the `%` wildcards in a LIKE pattern, skipping escaped characters.
Each one becomes a
+ * `.*`, and a failed match tries every combination of their lengths.
+ */
+ def countLikeWildcards(pattern: String, escapeChar: Char): Int = {
Review Comment:
Done. `Like` now passes `escape(str)` to the same `isExpensiveRegex` that
`RLike` uses, with `matchesWhole = true`, and `countLikeWildcards` is gone. A
bad escape throws inside the existing `try` and counts as expensive, which the
test now covers. `isExpensivePattern` still defaults to `true` in
`StringRegexExpression`, so a subclass outside Spark keeps the 4.2.0 answer.
##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/RegexpExpressionsSuite.scala:
##########
@@ -735,4 +735,46 @@ 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")
{
+ val str = $"s".string.at(0)
+ val pattern = $"p".string.at(1)
+
+ // The subject string is irrelevant; a constant pattern is checked. Two or
more repeating
+ // quantifiers make an RLIKE expensive, anchored or not.
+ Seq("^1", "magic", "a.b", "^a.*b", "2024-.*", "\\d{2,}", "a{3}b*", "a*+b",
"(?i)^abc.*",
+ "a\\+b\\*", "[+*{]x+", "\\p{Lu}+", "\\x{41}.*", "\\Q.*+\\E.*").foreach {
p =>
+ assert(!RLike(str, Literal(p)).expensive, p)
+ }
+ Seq(".*a.*b", "^.*a.*a.*a.*b", "\\b.*a.*b", "(a+)+b", "a{2,}b+",
"\\d+\\s*", "a+?b.*",
+ "a{1,9}b{1,9}").foreach { p =>
Review Comment:
Added all of them, plus `^(a+)+b`, `^((.*a)b){2}`, the `(?x)` comment case,
the anchored cases from item 3 and the possessive and range cases from item 4.
`[a-z&&[^b]]+x` is in the cheap list.
--
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]