fix: reject casts involving non-default collated strings - #5302
stantheman0128 wants to merge 5 commits into
Conversation
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.
|
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 |
The analysis in the description is excellent. "The right answer fell out of an accident of pattern matching rather than a check anyone wrote" is exactly the situation, and moving the check above the Two things I would like to understand. Where does the cast actually go now?
The fallback reason is generic
Could this return One smaller note
|
|
@andygrove I checked the three points on this branch. Where the cast goes: That is not the same silent drop as the native path. I can add a sentence next to The fallback reason: The 3.x shim: |
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).
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.
The guard returned the generic `Cast from $fromType to $toType is not supported` template. For an identity cast both sides print the same, so EXPLAIN read like a nonsensical refusal of a string-to-string cast with no hint that collation was the cause. Name the reason on `CometCast` and have the suite read it from production so the two cannot drift. Also document why `CometBatchKernelCodegen.isSupportedDataType` admits a collated `StringType`. The dispatcher's declared return type does go through `serializeDataType`, which flattens collation, but every collation-sensitive decision Comet makes reads the Catalyst `DataType` and blocks the operator before a native kernel sees the column.
dad4f3e to
aeda8d3
Compare
|
Thanks. I chased the first point further than my September comment did, and it turned up a correction to something I told you plus three things I did not expect to find. Where the cast actually goes The first half has not changed. The correction is the second half. I told you the dispatcher never calls It still does not change an answer on this route, for three reasons. The evaluation never reads the proto type. The kernel compiles the closure-serialized bound expression ( The Arrow output field agrees with the proto rather than contradicting it. And nothing reads the flattened type back. The values are bytes, the kernel produced them, and whether a downstream operator may treat the column collation-blind is decided per operator against the Catalyst So rejecting collated strings in Three serde-level omissions your question turned up, none of them this PR's My first draft of that comment claimed the downstream guards were exhaustive. They are not. What I can show you is three places carrying no collation test in the serde. What I cannot show you is a plan that reaches any of them, so please read the list with that limit attached. The limit matters because this repo guards collation systematically, and two of those guards sit upstream of everything below. Against that background, the three with no test.
None of the three is a confirmed divergence, and none is a confirmed reachable plan. I can file them as serde-level omissions, together or separately, and take the hash one myself if you want it fixed rather than only tracked. The fallback reason Changed, with your wording verbatim. Two notes on it. The first is a limit. The reason only reaches The second is that it closed a weakness I flagged last round. I said the scalar end-to-end tests were guard-invariant, because
The 3.x walk Already handled. On 4.x I do not think the walk is worth short-circuiting. The helper's own first case is the scalar one ( Also in this push One more end-to-end test. The struct case is the only pair where the guard changes the answer and not just the reason string, and it was covered only with the dispatcher off. It now has the dispatcher-on half too, so both settings are exercised on both the scalar and the struct case. The suite header now carries the reason these are Scala tests rather than The branch is also rebased onto current Verification Local, against a debug
Not run locally: 3.4, 3.5 and 4.2. The guard is inert on the 3.x profiles since the shim is a CI has never executed on this PR. Every workflow run it has produced so far finished without starting a single job: some are still sitting at One question still open from last round
|
andygrove
left a comment
There was a problem hiding this comment.
Thanks for chasing the dispatcher question all the way down. The guard in isSupported is what #4489 asked for, the dedicated reason reads well in EXPLAIN, and the negative control settles that the suite isn't guard-invariant.
To answer your open questions, a separate issue is right for the collated literal, since folding produces the same bytes Spark would, and the same goes for moving CometCollationSuite to spark-4.x, given the two copies and the 4.1 join-key normalization you found. Please go ahead and open both as you offered. Of the three serde omissions, the multi-key supportedSortType gap is real, and #6110 ran into the same one from the shuffle side, so I filed #6158 to cover both. I don't think the hash one is a divergence at default config. Since 4.0.1, Spark's user-facing Murmur3Hash and XxHash64 set isCollationAware = false and only hash by the collation key under the internal spark.sql.legacy.collationAwareHashFunctions flag, so raw-byte hashing matches Spark unless that flag is on.
The comment on the struct test describes the old behaviour differently from what I get from the code. It says the struct went native with the collation dropped, but Collate has no serde, so on main that query falls back with collate is not supported. The guard still changes the outcome, it just moves the query from a Spark fallback into the dispatcher. Could the comment and the PR description say that?
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:615), 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:217ran 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.Nine other places already use
CometTypeShim.hasNonDefaultStringCollationfor this exact purpose (aggregates.scala,arrays.scala,collectionOperations.scala,datetime.scala,maps.scala,predicates.scala,strings.scala,CometExprShim4x.scala,CometWindowGroupLimitExec.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.The guard returns a reason of its own instead of the generic
Cast from $fromType to $toType is not supportedtemplate, since that template prints both sides identically for an identity cast and gives no hint that collation is the cause. The string lives onCometCast.nonDefaultCollationReasonand the suite reads it from production so the two cannot drift.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.Four 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 other two cast a struct carrying a collated field, which is the shape that actually exercises the new guard end to end, again under both settings. 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:965-980).spark.comet.exec.scalaUDF.codegen.enableddefaults to true andCometBatchKernelCodegen.isSupportedDataTypeadmits everyStringTyperegardless of its collation, 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, after the 2026-09-20 rebase onto currentmain. Every figure below was measured on that tree, not on the earlier revision. On thespark-4.1profile (Spark 4.1.3, Scala 2.13.17, JDK 17):The same suite on
spark-4.0: 21 succeeded, 0 failed, 0 ignored.The guard is production code, so the suites in its blast radius were run as well. On
spark-4.0(Spark 4.0.4, JDK 17), which is where the fullCometCollationSuiteincluding the #4051 join tests lives:The same pair on
spark-4.1: 206 succeeded, 0 failed, 8 ignored.CometSqlFileTestSuitewas run in full onspark-4.1, not filtered: 550 fixtures, all passed. That covers all 16 collation fixtures,expressions/string/collation.sqlamong them.A negative control, so the suite cannot be mistaken for guard-invariant. With the guard neutralised and nothing else changed,
CometCastCollatedStringSuiteonspark-4.0fails 11 of its 21 tests, including the scalar end-to-end test and both struct end-to-end tests:./mvnw -B -Pspark-4.1 spotless:check -pl spark,commonpasses with the spotless index deleted first, so the result is a real check rather than a cache hit (Index file does not exist. Fallback to an empty index): 506 Scala files and 65 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:41).spark-4.2is a real profile in the root pom but carries nospark/src/test/spark-4.2source root, so there is noCometCollationSuiteto run against it.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 under the guard. The reason string they carry changes to the dedicated collation message, and the fixture asserts on results rather than on that text, so it passes either way. It was rerun onspark-4.1after the change.🤖 Generated with Claude Code