Skip to content

Commit d223e8f

Browse files
HungHung
authored andcommitted
fix: narrow replace native-safe subset for expression-boundary cases
1 parent 3b0d9d4 commit d223e8f

3 files changed

Lines changed: 117 additions & 22 deletions

File tree

docs/source/contributor-guide/expression-audits/string_funcs.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,8 +169,8 @@
169169

170170
- Spark 3.4.3 (audited 2026-05-27): identical to 3.5.8.
171171
- Spark 3.5.8 (audited 2026-05-27): baseline. `StringReplace(src, search, replace)`; when `search` is empty, Spark returns `src` unchanged (short-circuit on `search.numBytes == 0`). DataFusion `replace` instead inserts `replace` between every character.
172-
- Comet evaluates `replace` natively by default when `search` is a non-empty `UTF8_BINARY` literal.
173-
- Empty literal search, non-literal search, and non-default collations stay on the JVM codegen dispatcher.
172+
- Comet evaluates `replace` natively by default when `search` is a short, well-formed, non-empty `UTF8_BINARY` literal and `replace` is a short well-formed literal or a column.
173+
- Empty, malformed, or oversized literals, a non-literal / non-column replacement (including expressions that can throw), and non-default collations stay on the JVM codegen dispatcher. The kernel match is not enough: `CometLiteral` is not byte-preserving, DataFusion evaluates every child before `replace`, and large scalars overflow Arrow `Utf8` offsets when broadcast.
174174
- Users can still opt into the native (potentially incompatible) path for remaining cases via `spark.comet.expression.StringReplace.allowIncompatible=true`.
175175
- Spark 4.0.1 (audited 2026-05-27): routes through `CollationSupport.StringReplace.exec`; semantics unchanged for `UTF8_BINARY`. Non-default collations not honoured by Comet ([#4496](https://github.com/apache/datafusion-comet/issues/4496)).
176176

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

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@
1919

2020
package org.apache.comet.serde
2121

22-
import org.apache.spark.sql.catalyst.expressions.{Attribute, Base64, BitLength, Cast, Concat, ConcatWs, Contains, Elt, Empty2Null, EndsWith, Expression, FindInSet, FormatNumber, FormatString, GetJsonObject, InitCap, Left, Length, Levenshtein, Like, Literal, Lower, Mask, OctetLength, Overlay, RegExpExtract, RegExpExtractAll, RegExpInStr, RegExpReplace, Right, RLike, SoundEx, StartsWith, StringLocate, StringLPad, StringRepeat, StringReplace, StringRPad, StringSplit, StringTranslate, Substring, SubstringIndex, ToCharacter, ToNumber, TryToNumber, UnBase64, Upper}
22+
import java.nio.charset.StandardCharsets
23+
import java.util.Arrays
24+
25+
import org.apache.spark.sql.catalyst.expressions.{Attribute, Base64, BitLength, BoundReference, Cast, Concat, ConcatWs, Contains, Elt, Empty2Null, EndsWith, Expression, FindInSet, FormatNumber, FormatString, GetJsonObject, InitCap, Left, Length, Levenshtein, Like, Literal, Lower, Mask, OctetLength, Overlay, RegExpExtract, RegExpExtractAll, RegExpInStr, RegExpReplace, Right, RLike, SoundEx, StartsWith, StringLocate, StringLPad, StringRepeat, StringReplace, StringRPad, StringSplit, StringTranslate, Substring, SubstringIndex, ToCharacter, ToNumber, TryToNumber, UnBase64, Upper}
2326
import org.apache.spark.sql.types.{BinaryType, DataTypes, IntegerType, LongType, StringType}
2427
import org.apache.spark.unsafe.types.UTF8String
2528

@@ -180,37 +183,66 @@ object CometStringReplace
180183
with NativeOptInAvailable {
181184

182185
/**
183-
* Native DataFusion `replace` differs from Spark only when the search string is empty (Spark
184-
* returns `src` unchanged; DataFusion inserts the replacement between every character). That
185-
* case is decidable at plan time when `search` is a literal.
186+
* The DataFusion `replace` kernel matches Spark only for a non-empty search string. Kernel
187+
* compatibility is not enough: `CometLiteral` serializes strings via `UTF8String.toString`
188+
* (malformed UTF-8 becomes U+FFFD), DataFusion evaluates every child before `replace` (so a
189+
* NULL `src` does not skip a throwing replacement), and scalar literals are broadcast into
190+
* Arrow `Utf8` arrays that overflow 32-bit offsets on a large batch.
186191
*
187-
* The native kernel is also byte-level `UTF8_BINARY` only, so non-default collations stay on
188-
* the dispatcher. https://github.com/apache/datafusion-comet/issues/4496
192+
* The default native path is therefore limited to a plan-time subset that avoids those
193+
* boundaries. Non-default collations stay on the dispatcher.
194+
* https://github.com/apache/datafusion-comet/issues/4496
189195
*/
190-
private def nativeSafeSearchSubset(expr: StringReplace): Boolean = {
196+
private def nativeSafeSubset(expr: StringReplace): Boolean = {
191197
val children = expr.children
192198
if (children.length != 3) {
193199
return false
194200
}
195-
val searchIsNonEmptyLiteral = children(1) match {
196-
case Literal(v: UTF8String, _) => v != null && v.numBytes() > 0
201+
val searchIsSafe = children(1) match {
202+
case Literal(v: UTF8String, _) => isNativeSafeStringLiteral(v, allowEmpty = false)
203+
case _ => false
204+
}
205+
val replacementIsSafe = children(2) match {
206+
case Literal(null, _) => true
207+
case Literal(v: UTF8String, _) => isNativeSafeStringLiteral(v, allowEmpty = true)
208+
case _: Attribute | _: BoundReference => true
197209
case _ => false
198210
}
199211
val utf8BinaryCollation =
200212
!children.exists(c => QueryPlanSerde.isStringCollationType(c.dataType))
201-
utf8BinaryCollation && searchIsNonEmptyLiteral
213+
utf8BinaryCollation && searchIsSafe && replacementIsSafe
214+
}
215+
216+
/**
217+
* `CometLiteral` encodes a string as `UTF8String.toString`, so only literals whose bytes
218+
* survive that round-trip can be sent natively. The size cap keeps a broadcast scalar under
219+
* Arrow `Utf8`'s 32-bit offset limit at `spark.comet.batchSize` rows.
220+
*/
221+
private def isNativeSafeStringLiteral(v: UTF8String, allowEmpty: Boolean): Boolean = {
222+
if (v == null) {
223+
return false
224+
}
225+
if (!allowEmpty && v.numBytes() == 0) {
226+
return false
227+
}
228+
val maxBytes = Int.MaxValue / math.max(CometConf.COMET_BATCH_SIZE.get(), 1)
229+
if (v.numBytes() > maxBytes) {
230+
return false
231+
}
232+
Arrays.equals(v.getBytes, v.toString.getBytes(StandardCharsets.UTF_8))
202233
}
203234

204235
override def getCompatibleNotes(): Seq[String] =
205236
Seq(
206-
"When `search` is a non-empty `UTF8_BINARY` literal, Comet evaluates `replace` natively " +
237+
"When `search` is a short, well-formed, non-empty `UTF8_BINARY` literal and `replace` " +
238+
"is a short well-formed literal or a column, Comet evaluates `replace` natively " +
207239
"by default.")
208240

209241
override def getIncompatibleReasons(): Seq[String] =
210242
Seq("Produces different results from Spark when the search string is empty")
211243

212244
override def getSupportLevel(expr: StringReplace): SupportLevel =
213-
if (CometConf.isExprAllowIncompat(getExprConfigName(expr)) || nativeSafeSearchSubset(expr)) {
245+
if (CometConf.isExprAllowIncompat(getExprConfigName(expr)) || nativeSafeSubset(expr)) {
214246
Compatible()
215247
} else {
216248
Compatible(nativeOptIn =
@@ -221,14 +253,13 @@ object CometStringReplace
221253
expr: StringReplace,
222254
inputs: Seq[Attribute],
223255
binding: Boolean): Option[Expr] = {
224-
if (CometConf.isExprAllowIncompat(getExprConfigName(expr)) || nativeSafeSearchSubset(expr)) {
225-
// Native DataFusion `replace` matches Spark when search is a non-empty UTF8_BINARY
226-
// literal (the common case, selected by default) and when the user has opted in.
256+
if (CometConf.isExprAllowIncompat(getExprConfigName(expr)) || nativeSafeSubset(expr)) {
257+
// Native path for the plan-time safe subset, or when the user has opted in.
227258
super.convert(expr, inputs, binding)
228259
} else {
229-
// Empty literal search, non-literal search, or a non-default collation: run Spark's
230-
// own generated code inside the Comet pipeline. Falls back to Spark when the
231-
// codegen dispatcher is disabled.
260+
// Empty / malformed / oversized search, a throwing or non-column replacement, or a
261+
// non-default collation: run Spark's own generated code inside the Comet pipeline.
262+
// Falls back to Spark when the codegen dispatcher is disabled.
232263
CometScalaUDF.emitJvmCodegenDispatch(expr, inputs, binding)
233264
}
234265
}

spark/src/test/scala/org/apache/comet/CometCodegenSuite.scala

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -331,8 +331,8 @@ class CometCodegenSuite
331331

332332
test("replace routes native vs JVM codegen dispatcher based on non-empty literal search") {
333333
withTable("t") {
334-
sql("CREATE TABLE t (s STRING, search STRING) USING parquet")
335-
sql("INSERT INTO t VALUES ('hello world', 'world'), ('abcabc', 'world')")
334+
sql("CREATE TABLE t (s STRING, search STRING, r STRING) USING parquet")
335+
sql("INSERT INTO t VALUES ('hello world', 'world', 'comet'), ('abcabc', 'world', 'comet')")
336336

337337
withSQLConf(
338338
CometConf.COMET_SCALA_UDF_CODEGEN_ENABLED.key -> "true",
@@ -349,6 +349,14 @@ class CometCodegenSuite
349349
!explainNative.contains("JVM codegen dispatcher: replace"),
350350
s"expected native path for non-empty literal search, got:\n$explainNative")
351351

352+
val dfColReplace = sql("SELECT replace(s, 'world', r) FROM t")
353+
checkSparkAnswerAndOperator(dfColReplace)
354+
val explainColReplace =
355+
new ExtendedExplainInfo().generateExtendedInfo(dfColReplace.queryExecution.executedPlan)
356+
assert(
357+
!explainColReplace.contains("JVM codegen dispatcher: replace"),
358+
s"expected native path for column replacement, got:\n$explainColReplace")
359+
352360
val dfEmptySearch = sql("SELECT replace(s, '', 'comet') FROM t")
353361
checkSparkAnswerAndOperator(dfEmptySearch)
354362
val explainEmptySearch =
@@ -381,6 +389,62 @@ class CometCodegenSuite
381389
}
382390
}
383391

392+
test("replace stays on dispatcher for expression-boundary incompatibilities") {
393+
withSQLConf(
394+
CometConf.COMET_SCALA_UDF_CODEGEN_ENABLED.key -> "true",
395+
CometConf.COMET_EXPLAIN_CODEGEN_ENABLED.key -> "true",
396+
CometConf.COMET_EXEC_PROJECT_ENABLED.key -> "true",
397+
CometConf.COMET_EXTENDED_EXPLAIN_FORMAT.key ->
398+
CometConf.COMET_EXTENDED_EXPLAIN_FORMAT_VERBOSE) {
399+
400+
def assertDispatcher(df: org.apache.spark.sql.DataFrame, clue: String): Unit = {
401+
checkSparkAnswerAndOperator(df)
402+
val explain =
403+
new ExtendedExplainInfo().generateExtendedInfo(df.queryExecution.executedPlan)
404+
assert(explain.contains("JVM codegen dispatcher: replace"), s"$clue, got:\n$explain")
405+
}
406+
407+
// Malformed search: CometLiteral would normalize 0xFF to U+FFFD, incorrectly matching
408+
// a well-formed U+FFFD in the source.
409+
withTable("t") {
410+
sql("CREATE TABLE t (s STRING) USING parquet")
411+
sql("INSERT INTO t VALUES ('\uFFFD'), ('ok')")
412+
assertDispatcher(
413+
sql("SELECT replace(s, CAST(X'FF' AS STRING), 'x') FROM t"),
414+
"expected dispatcher path for malformed search literal")
415+
}
416+
417+
// Malformed replacement has the same serialization hazard.
418+
withTable("t") {
419+
sql("CREATE TABLE t (s STRING) USING parquet")
420+
sql("INSERT INTO t VALUES ('a'), ('b')")
421+
assertDispatcher(
422+
sql("SELECT replace(s, 'a', CAST(X'FF' AS STRING)) FROM t"),
423+
"expected dispatcher path for malformed replacement literal")
424+
}
425+
426+
// Spark skips replacement evaluation when src is NULL; native evaluates every child.
427+
withSQLConf(SQLConf.ANSI_ENABLED.key -> "true") {
428+
withTable("t") {
429+
sql("CREATE TABLE t (s STRING, n INT) USING parquet")
430+
sql("INSERT INTO t VALUES (NULL, 0), ('a', 1)")
431+
assertDispatcher(
432+
sql("SELECT replace(s, 'a', CAST(1 / n AS STRING)) FROM t"),
433+
"expected dispatcher path for throwing replacement expression")
434+
}
435+
}
436+
437+
// A 256 KiB scalar replacement overflows Arrow Utf8 offsets when broadcast to 8192 rows.
438+
withTable("t") {
439+
sql("CREATE TABLE t (s STRING) USING parquet")
440+
sql("INSERT INTO t VALUES ('hello')")
441+
assertDispatcher(
442+
sql("SELECT replace(s, 'notfound', repeat('x', 262144)) FROM t"),
443+
"expected dispatcher path for oversized replacement literal")
444+
}
445+
}
446+
}
447+
384448
test("codegen dispatch fallback reasons name the expression") {
385449
// Flag-off short-circuit tags the expression `<name>: <reason>` so distinct expressions
386450
// don't collapse in the `Set[String]` roll-up.

0 commit comments

Comments
 (0)