Hengrui Hu created SPARK-60179:
----------------------------------

             Summary:  Cast.needsTimeZone misses complex-to-STRING casts 
containing a timestamp leaf
                 Key: SPARK-60179
                 URL: https://issues.apache.org/jira/browse/SPARK-60179
             Project: Spark
          Issue Type: Bug
          Components: SQL
    Affects Versions: 4.2.0
            Reporter: Hengrui Hu


h2. Summary

{{Add.canonicalized}} and {{Multiply.canonicalized}} flatten and reorder 
adjacent operands
({{Expression.buildCanonicalizedPlan}} -> {{orderCommutative}} -> 
{{gatherCommutative}}) with no
exception for FLOAT/DOUBLE. Floating-point addition and multiplication are 
commutative but *not
associative*, so {{(a + b) + c}} and {{a + (b + c)}} canonicalize to the same 
expression while
evaluating to different values.

Every consumer of semantic equality is therefore allowed to substitute one for 
the other. Two of
them return wrong results today: the cached-plan lookup in {{CacheManager}} 
({{sameResult}}) and
subexpression elimination ({{EquivalentExpressions.ExpressionEquals}} -> 
{{semanticEquals}}).

h2. Reproduction

Measured on current master ({{5.0.0-SNAPSHOT}}), {{local[4]}}, AQE disabled. 
One Parquet row:

{code:sql}
-- a = 1.0E16, b = -1.0E16, c = 1.0, all DOUBLE
-- (a + b) + c = 1.0      a + (b + c) = 0.0
{code}

*1. Subexpression elimination, same query*

{code:sql}
SELECT (a + b) + c AS x, a + (b + c) AS y FROM t
{code}

{noformat}
cse=true  wholeStage=true  -> x=1.0 y=1.0     <-- y is wrong
cse=true  wholeStage=false -> x=1.0 y=1.0     <-- y is wrong
cse=false wholeStage=true  -> x=1.0 y=0.0
cse=false wholeStage=false -> x=1.0 y=0.0
{noformat}

The physical plan still contains both expressions; only the evaluation is 
shared. Setting
{{spark.sql.subexpressionElimination.enabled=false}} restores the correct 
answer, which isolates
the mechanism.

*2. Cached-plan reuse, across queries*

{code:scala}
val d1 = spark.sql("select (a+b)+c as v from t")
d1.cache(); d1.collect()                       // [1.0], correct
spark.sql("select a+(b+c) as v from t").collect()   // [1.0], expected [0.0]
d1.unpersist()
spark.sql("select a+(b+c) as v from t").collect()   // [0.0], correct again
{code}

The second query's plan is served from the cache entry built for the first:

{noformat}
== Physical Plan ==
InMemoryTableScan [v#49]
   +- InMemoryRelation [v#49], StorageLevel(disk, memory, deserialized, 1 
replicas)
         +- *(1) Project [((a#3 + b#4) + c#5) AS v#20]
{noformat}

*3. Directly, at the expression level*

{code:scala}
val a = AttributeReference("a", DoubleType)()
val b = AttributeReference("b", DoubleType)()
val c = AttributeReference("c", DoubleType)()
Add(Add(a, b), c).semanticEquals(Add(a, Add(b, c)))                // true
Multiply(Multiply(a, b), c).semanticEquals(Multiply(a, Multiply(b, c)))  // true
{code}

h2. Proposed fix

Floating-point {{+}} and {{*}} are commutative, so {{a + b}} and {{b + a}} may 
still be treated as
semantically equal; it is only the re-association across levels that is 
unsound. The narrow fix is
to stop flattening when the operation's {{dataType}} is {{FloatType}} or 
{{DoubleType}}, and
canonicalize just the two direct children, sorting them as today. That keeps 
commutative reuse and
rejects {{(a + b) + c}} vs {{a + (b + c)}}.

A more conservative variant is to return {{withCanonicalizedChildren}} for 
floating-point types,
mirroring the existing decimal fallback added in SPARK-40903 for a comparable 
reason (reordering
changing the result), at the cost of also losing the commutative case.

The same {{buildCanonicalizedPlan}} path produces {{MultiCommutativeOp}} above
{{spark.sql.analyzer.canonicalization.multiCommutativeOpMemoryOptThreshold}} 
operands, so the guard
belongs in {{canonicalized}} before the operands are gathered rather than in 
the reduce step.

Suggested coverage: an assertion pair in the canonicalization suite 
({{(a+b)+c}} not semantically
equal to {{a+(b+c)}} for DOUBLE, while {{a+b}} stays equal to {{b+a}}), plus 
the two end-to-end
cases above.

h2. Notes

Integer, boolean and bitwise operands are unaffected: reordering those changes 
only the order in
which ANSI errors may be raised, which Spark does not specify. For FLOAT/DOUBLE 
the returned value
itself changes.

Not a regression. {{Add.canonicalized}} / {{Multiply.canonicalized}} have the 
same shape on
branch-3.5 through master, so every maintained branch is affected; the numbers 
above were measured
on master.

Found while reviewing https://github.com/apache/spark/pull/58424, where an 
optimizer rule relies on
{{sameResult}} to prove two scans are equivalent. The gap is in the shared 
canonicalization, not in
that rule.
sdp@a4bf018d4221:~$ cat ~/hhr/spark-core-bugs/jira2-cast-needstimezone.txt
h2. Summary

{{Cast.needsTimeZone}} classifies a cast from a complex type to STRING as 
time-zone independent,
although {{Cast.castToString}} recurses into ARRAY/MAP/STRUCT and renders a 
nested TIMESTAMP with
the session {{zoneId}}. The function is not self-consistent:

{noformat}
needsTimeZone(TIMESTAMP, STRING)                  = true
needsTimeZone(ARRAY<TIMESTAMP>, ARRAY<STRING>)    = true
needsTimeZone(ARRAY<TIMESTAMP>, STRING)           = false   <-- renders with 
zoneId
needsTimeZone(MAP<INT,TIMESTAMP>, STRING)         = false   <-- renders with 
zoneId
needsTimeZone(STRUCT<t:TIMESTAMP>, STRING)        = false   <-- renders with 
zoneId
needsTimeZone(ARRAY<ARRAY<TIMESTAMP>>, ARRAY<STRING>) = false
{noformat}

The last line follows from the first: the complex-to-complex branch recurses to
{{(ARRAY<TIMESTAMP>, STRING)}} and stops there.

That the conversion really is zone dependent:

{code:scala}
val lit = Literal.create(Seq(0L), ArrayType(TimestampType))
Cast(lit, StringType, Some("UTC")).eval(null)                 // [1970-01-01 
00:00:00]
Cast(lit, StringType, Some("America/Los_Angeles")).eval(null) // [1969-12-31 
16:00:00]
{code}

h2. Consequences

*1. A zone-dependent cast is considered resolved without a time zone, and 
crashes on eval.*

{code:scala}
val c = Cast(Literal.create(Seq(0L), ArrayType(TimestampType)), StringType)  // 
no timeZoneId
c.resolved                      // true
c.contextIndependentFoldable    // true
c.eval(null)                    // java.util.NoSuchElementException: None.get
{code}

{{Cast.resolved}} only requires {{timeZoneId.isDefined}} when {{needsTimeZone}} 
is true.
{{ResolveTimeZone}} assigns the session zone unconditionally, so plans built by 
the analyzer are
covered; a {{Cast}} constructed after that point is reported as resolved and 
then fails in
{{TimeZoneAwareExpression.zoneId}}.

*2. {{Cast.canonicalized}} drops the time zone, so two casts with different 
zones compare equal.*

{{canonicalized}} calls {{basic.withTimeZone(null)}} when {{!needsTimeZone}}. 
With a shared Parquet
table holding {{ts TIMESTAMP}} and {{ats ARRAY<TIMESTAMP>}}, caching under one 
session time zone and
querying under another returns the stale rendering, because the cache lookup 
goes through
{{sameResult}}:

{code:scala}
// q = select cast(<col> as string) as v from t
spark.conf.set("spark.sql.session.timeZone", "UTC");  d1 = spark.sql(q); 
d1.cache(); d1.collect()
spark.conf.set("spark.sql.session.timeZone", "America/Los_Angeles"); 
spark.sql(q).collect()
{code}

{noformat}
col = ts   (needsTimeZone = true, control)
  plan under LA: *(1) Project [cast(ts#0 as string) AS v#31] +- FileScan parquet
  value under LA: [1969-12-31 16:00:00]                       correct, cache 
not reused

col = ats  (needsTimeZone = false)
  plan under LA: InMemoryTableScan [v#64] +- InMemoryRelation ...
  value under LA: [[1970-01-01 00:00:00]]                     WRONG, UTC 
rendering reused
  value under LA after unpersist: [[1969-12-31 16:00:00]]      correct
{noformat}

Measured on current master ({{5.0.0-SNAPSHOT}}), {{local[4]}}, AQE disabled.

*3. Downstream users of the predicate inherit the misclassification*, e.g.
{{contextIndependentFoldable}} (used by {{V2ExpressionBuilder}}), the 
partition-predicate pushdown
check in {{HiveShim}}, and {{ResolveDefaultColumnsUtil}}.

h2. Proposed fix

Add one case to {{Cast.needsTimeZone}}: a cast whose target is STRING needs the 
session time zone if
the source type recursively contains a time-zone-sensitive timestamp leaf 
(TIMESTAMP /
TIMESTAMP_LTZ(p)), e.g. via {{DataType.existsRecursively}}. The existing 
complex-to-complex
recursion then covers the nested {{ARRAY<ARRAY<TIMESTAMP>> -> ARRAY<STRING>}} 
shape as well, since
it bottoms out in the new case. TIMESTAMP_NTZ stays zone independent, 
consistent with the scalar
rule.

Since the predicate becomes stricter, casts to STRING over complex types now 
require a
{{timeZoneId}} to be resolved. Call sites that construct such a cast outside 
the analyzer need a
zone, and plan-string golden files may change.

Suggested coverage: extend the {{needsTimeZone}} assertions in 
{{CastSuiteBase}} with the
complex-to-STRING pairs above (with TIMESTAMP_NTZ as the negative control), and 
add the
cross-time-zone cache case.

h2. Notes

Not a regression. {{Cast.needsTimeZone}} has had no complex-to-STRING case 
since branch-3.5, so
every maintained branch is affected; the numbers above were measured on master.

Found while reviewing https://github.com/apache/spark/pull/58424, where an 
optimizer rule uses
{{Cast.needsTimeZone}} to decide whether a projection is repeatable across two 
evaluations. The gap
is in the shared classification, not in that rule.




--
This message was sent by Atlassian Jira
(v8.20.10#820010)

---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to