fix: reject casts involving non-default collated strings - #5302
fix: reject casts involving non-default collated strings#5302stantheman0128 wants to merge 4 commits into
Conversation
CometCast.isSupported matches string casts against DataTypes.StringType, the singleton default-collation instance. A non-default-collation StringType (e.g. STRING COLLATE UTF8_LCASE) correctly fails that equality check today and falls back to Spark, but that was implicit and untested: there was no isStringCollationType guard like the other string-touching serdes use, and no test pinning the fallback down. Adds CometCastCollatedStringSuite under spark-4.x (collation is a Spark 4.0+ feature, shared across every 4.x profile, unlike TimeType in apache#4490 which is 4.1-only) asserting isSupported returns Unsupported for every collated-string pair across LEGACY/TRY/ANSI, plus two Compatible() sanity baselines (same-collation identity cast, and default-collation identity cast) documenting the boundary this issue is not about: an identity cast is a byte-for-byte no-op regardless of collation, so Compatible() there is correct, not a gap. Closes apache#4489
Rewraps the Scaladoc comment block to match what 'mvn spotless:apply' (scalafmt) produces. Verified via a real mvn test -Pspark-4.1 run in WSL (spotless:check now passes; 7/7 tests still pass).
andygrove
left a comment
There was a problem hiding this comment.
Thanks for picking this up. The analysis of why StringType equality produces the fallback today is accurate and clearly written, and the writeup in the PR description made this easy to follow. A few things I'd like to work through before merge.
Unsupported on CometCast does not mean "falls back to Spark"
CometCast mixes in CodegenDispatchFallback (CometCast.scala:38). In QueryPlanSerde.scala:869-886, an Unsupported support level first goes to dispatchIfFallback, and the fallback reason is only recorded if the dispatcher declines. spark.comet.exec.scalaUDF.codegen.enabled defaults to true (CometConf.scala:364), and CometBatchKernelCodegen deliberately admits ResolvedCollation (CometBatchKernelCodegen.scala:156-157), so on default config a collated cast most likely stays inside the Comet pipeline running Spark's own doGenCode rather than falling back.
That outcome is still result-correct, so this is not a Comet bug. But every test name in the suite says "falls back", and the PR description says Comet "correctly falls back to Spark", and neither is quite right. Could those be reworded to say the cast has no native path?
Could these live in CometCollationSuite?
CometCollationSuite (spark/src/test/spark-4.0/org/apache/spark/sql/CometCollationSuite.scala) is already the home for collation fallback tests across #1947, #4051, and #4646. Reusing it would also avoid a new registration in two workflow files.
If the reason for a separate suite is that CometCollationSuite lives in spark-4.0 and so does not run on the 4.1 or 4.2 profiles, that is a good catch. In that case, would moving CometCollationSuite itself to spark-4.x be the better change? That gets the whole existing collation suite running on 4.1 and 4.2 as well.
End-to-end coverage is what the issue asked for, and it looks reachable
Issue #4489 asks for tests asserting that the cast falls back and does not run native, and this suite only exercises isSupported in isolation. The datetime tests in CometCollationSuite show that expression-level collation is reachable end to end from a plain-string Parquet column, and that the serde's reason surfaces because getSupportLevel runs before children are serialized. Would something like this work?
withSQLConf(CometConf.COMET_SCALA_UDF_CODEGEN_ENABLED.key -> "false") {
checkSparkAnswerAndFallbackReason(
"SELECT CAST(_1 COLLATE utf8_lcase AS INT) FROM tbl",
"Cast from StringType(UTF8_LCASE) to IntegerType is not supported")
}That verifies the runtime outcome and the reason string together, which is a lot stronger than asserting on the matrix alone. It would also be worth adding the COMET_SCALA_UDF_CODEGEN_ENABLED=true counterpart with checkSparkAnswerAndOperator so the dispatcher path is pinned down too. If the cast turns out not to be reachable end to end because something upstream short-circuits, could you document why in the same style as the join tests in CometCollationSuite?
The identity-cast baselines conflict with the guard the issue prefers
CometCast already mixes in CometTypeShim (CometCast.scala:37), which gives you hasNonDefaultStringCollation. That helper already walks nested element, key, value, and field types (spark/src/main/spark-4.x/org/apache/comet/shims/CometTypeShim.scala:41-48). So option 1 from the issue is about three lines at the top of isSupported, above the fromType == toType shortcut:
if (hasNonDefaultStringCollation(fromType) || hasNonDefaultStringCollation(toType)) {
return unsupported(fromType, toType)
}Would you consider adding that here rather than leaving it for a follow-up? Two reasons. The issue title is "implicit and untested", and this PR only addresses the second half. And assert(CometCast.isSupported(lcase, lcase, None, evalMode) == Compatible()) would fail once that guard exists, so whoever adds it later has to choose between weakening the guard and deleting your test. Adding the guard now and dropping that baseline avoids that.
Nested collated types are the part that is only safe by accident
Because the fromType == toType shortcut at CometCast.scala:189 runs before any pattern matching, CAST(ARRAY<STRING COLLATE UTF8_LCASE> AS ARRAY<STRING COLLATE UTF8_LCASE>) returns Compatible(), and the same holds for a struct with a collated field or a map with a collated key. serializeDataType maps every StringType to proto type id 7 (QueryPlanSerde.scala:565), so the collation is silently dropped from the proto with no warning. Identity is byte-safe so results are right today, but this is the same implicit behavior the issue describes and it is the case hasNonDefaultStringCollation was written for.
Since the suite is specifically about pinning the matrix down, it would be worth covering the nested paths as well: ArrayType(lcase) -> ArrayType(unicode), a StructType with a collated field, a MapType with a collated key, and the array-to-string recursion at CometCast.scala:203. Those go through different code than the scalar catch-all.
Small correction in the suite Scaladoc
The header says the fallback happens "via canCastFromString/canCastToString's own catch-all when only one side is collated". That holds for lcase -> StringType and StringType -> lcase, but IntegerType -> lcase actually exits through canCastFromInt's catch-all at CometCast.scala:410-411, since (_, DataTypes.StringType) does not match a collated target. Worth correcting, since the explanation is the main value of this file.
CI
CI has not run yet on this branch. All three workflow runs are sitting in action_required waiting on approval, so I will get those going. The registration itself looks correct to me. java-test passes the list as -DwildcardSuites, so the suite is simply not matched on the 3.4 and 3.5 profiles rather than failing, which matches how CometWidthBucketSuite is already handled.
CometCast.isSupported only failed to match a collated StringType because DataTypes.StringType is the default-collation singleton and Scala pattern equality compares the whole instance. The fromType == toType shortcut let identity casts through regardless, including nested ones such as ARRAY<STRING COLLATE UTF8_LCASE>, and serializeDataType maps every StringType to one proto type id, so the collation was dropped from the plan with no warning. Reject collated source and target types up front using the existing CometTypeShim.hasNonDefaultStringCollation, the same helper the array, collection, datetime, map, predicate, and string serdes already use. It walks nested element, key, value, and field types, and is stubbed to false on Spark 3.x where collation does not exist. Rework CometCastCollatedStringSuite accordingly. Unsupported means there is no native path rather than a fallback to Spark, since CometCast mixes in CodegenDispatchFallback, so the test names and the Scaladoc now say that. Adds nested coverage for arrays, structs, and maps, over-block checks for default-collation casts, and end-to-end coverage of both settings of spark.comet.exec.scalaUDF.codegen.enabled. Closes apache#4489
Working out which pairs actually changed answer turned up three that were Compatible on main and are not identity casts, so the guard's over-block surface was wider than the first revision of this suite described. A struct whose collated field is unchanged while a sibling field is cast answered Compatible, because the field zip answered per field and the collated field matched the fromType == toType shortcut. A map with an unchanged collated key and a cast value did the same through the key. ArrayType(NullType) -> ArrayType(lcase) answered Compatible through the elementType == NullType branch, which runs ahead of everything else. The struct case also gives the suite its first end-to-end test that fails without the guard. The sibling field changes type, so the cast survives SimplifyCasts and the collated field rides along inside it. A scalar identity cast cannot be reached from SQL at all, since SimplifyCasts drops a cast whose child already has the target type and the query arrives at the planner as a bare Collate. There is a comment in the suite recording that, in the style of the unreachable join tests in CometCollationSuite.
|
Thanks, this was a genuinely useful review. I had the dispatch semantics backwards and that wording is now fixed everywhere it was copied.
You are right, and I should have caught this from the repo itself. The guard Added, three lines, above the Nested types Covered: Working through which pairs the guard actually changes turned up three more that were The remaining nested pairs already returned Scaladoc correction Fixed. The End-to-end coverage Added both, your query and the One thing I would rather flag myself than let read better than it is. Those two are guard-invariant. I tried to add one that does, using the identity pair the guard actually changed, and it turns out that hits the case you asked me to document. So the scalar identity pairs stay pinned at the A struct turned out to be the way in. When a sibling field changes type the cast survives Local runs are the Why these are Scala tests and not SQL fixtures The The bulk of the file asserts on The three end-to-end tests were closer to workable as a fixture, but I did run the existing fixtures as part of the blast radius. One gap I did not close
Moving I looked at this and I do not think it fits inside this PR. Three things came up. There is already a second copy at The two copies differ by exactly the #4051 join block, and Spark 4.1 looks like the reason. There is no So the move is worth doing, but it means reconciling two divergent copies and deciding what happens to the #4051 join tests on 4.1 and later. Happy to file an issue and take it as a follow-up if you agree that is the right shape. One thing that fell out of the above and may deserve its own issue. If Spark 4.1 normalizes collated join keys to binary before the exec is constructed, is Comet's collated-join guard from #4051 still reachable on 4.1 and later, and could Comet legitimately accept those joins natively there? I did not chase it far enough to be sure, but it did not look like something the current tests would tell us. For this PR I kept the cast tests in their own |
Which issue does this PR close?
Closes #4489.
Rationale for this change
Spark 4.0 carries collation metadata on
StringType, butserializeDataTypemaps everyStringTypeto a single proto type id (QueryPlanSerde.scala:565), so the collation is dropped on the way into the native plan with no warning. Nothing inCometCaststopped that from happening.CometCast.isSupportedmatches string casts throughcase (DataTypes.StringType, _)andcase (_, DataTypes.StringType). Those only fail to match a collatedStringTypebecauseDataTypes.StringTypeis the default-collation singleton and Scala pattern equality compares the whole instance. The right answer fell out of an accident of pattern matching rather than a check anyone wrote, which is what the issue title means by "implicit". ThefromType == toTypeshortcut atCometCast.scala:189ran ahead of all of it, so identity casts on collated types were reportedCompatible()regardless.CAST(ARRAY<STRING COLLATE UTF8_LCASE> AS ARRAY<STRING COLLATE UTF8_LCASE>)is the clearest example. Results are right today because the cast is a byte-level no-op, but the plan reached the native side with the collation stripped and nothing recording that.Seven other places already use
CometTypeShim.hasNonDefaultStringCollationfor this exact purpose (arrays.scala,collectionOperations.scala,datetime.scala,maps.scala,predicates.scala,strings.scala,CometExprShim4x.scala).CometCastmixes inCometTypeShimalready and simply never called it.What changes are included in this PR?
Adds the guard to
CometCast.isSupported, above thefromType == toTypeshortcut so identity casts are checked too.hasNonDefaultStringCollationwalks nested element, key, value, and field types, and is stubbed tofalseon Spark 3.x where collation does not exist, so the guard compiles away to a constant on the 3.x profiles.Adds
CometCastCollatedStringSuiteunderspark/src/test/spark-4.x, which every 4.x profile compiles. It covers the scalar matrix in both directions and between two collations, the nested cases (array element, struct field, map key, map value, and the array-to-string recursion), and it checks that default-collation casts are stillCompatibleso the guard cannot quietly over-block.Three pairs that the guard newly blocks are worth calling out, because they were
Compatiblebefore and are not identity casts. A struct whose collated field is unchanged while a sibling field is cast came outCompatible, because the field zip answered per field and the collated field hit the identity shortcut.MapType(lcase, IntegerType) -> MapType(lcase, LongType)did the same through the key.ArrayType(NullType) -> ArrayType(lcase)wasCompatiblethrough theelementType == NullTypebranch, which runs ahead of everything else. Each of those let a collated type reach the native plan on a sibling's cast, and each now has a test.Three end-to-end tests run a query and assert what the planner does with the answer. Two use
CAST(_1 COLLATE utf8_lcase AS INT)under both settings ofspark.comet.exec.scalaUDF.codegen.enabled. The third casts a struct carrying a collated field, which is the shape that actually exercises the new guard end to end. A scalar identity cast cannot be reached from SQL, because Spark'sSimplifyCastsdrops a cast whose child already has the target type, and there is a comment in the suite recording that.One correction to an earlier revision of this PR.
Unsupporteddoes not mean the query falls back to Spark.CometCastmixes inCodegenDispatchFallback, soexprToProtoInternaloffers the expression to the JVM codegen dispatcher before recording any fallback reason (QueryPlanSerde.scala:869-886).spark.comet.exec.scalaUDF.codegen.enableddefaults to true andCometBatchKernelCodegenadmitsResolvedCollation, so under default config a collated cast usually stays inside the Comet pipeline running Spark's owndoGenCode. The test names and the suite Scaladoc now say the cast has no native path instead.Registers the suite in
pr_build_linux.ymlandpr_build_macos.yml.How are these changes tested?
Run on Linux against a debug
libcomet.sobuilt from this branch. On thespark-4.1profile (Spark 4.1.3, Scala 2.13.17, JDK 17):The guard is production code, so the suites in its blast radius were run as well. On
spark-4.1:Per-suite from the surefire XML:
CometNativeCastSuite175 tests,CometCollationSuite19 tests,CometSqlFileTestSuitethe remainder, all withfailures="0" errors="0".And on
spark-4.0(Spark 4.0.4, JDK 17), which is where the fullCometCollationSuiteincluding the #4051 join tests lives:CometCastCollatedStringSuite20 tests,CometNativeCastSuite175 tests,CometCollationSuite24 tests, allfailures="0" errors="0".mvn spotless:check -Pspark-4.1 -pl spark,commonpasses with the spotless index removed first, so the result is a real check rather than a cache hit: 407 Scala files and 54 Java files, 0 needing changes.Scope of what was run locally: the
spark-4.0andspark-4.1profiles.spark-3.4,spark-3.5, andspark-4.2were not run here, so CI is the first place those execute. On the 3.x profiles the guard is inert by construction, becauseCometTypeShim.hasNonDefaultStringCollationis afalseliteral there (spark/src/main/spark-3.x/org/apache/comet/shims/CometTypeShim.scala:31).CometSqlFileTestSuitewas run on 4.1 only, not on 4.0.spark/src/test/resources/sql-tests/expressions/string/collation.sqlis the largest existing test in the blast radius, since it casts a default-collation column to a collated target on nearly every query. Those pairs keep the same answer and the same reason string under the guard, because the source type there is the defaultStringTypeandunsupported(fromType, toType)formats to the same text either way.