Yuan Yusi created SPARK-57956:
---------------------------------

             Summary: AQE can remove global LIMIT during optimization
                 Key: SPARK-57956
                 URL: https://issues.apache.org/jira/browse/SPARK-57956
             Project: Spark
          Issue Type: Bug
          Components: SQL
    Affects Versions: 4.1.2
         Environment: Apache Spark 4.1.2

PySpark 4.1.2

Java 17

Reproduced on macOS system.
            Reporter: Yuan Yusi


h1. AQE can remove global LIMIT and return too many rows
h2. What is the issue?

AQE can drop a root global {{LIMIT}} during adaptive re-optimization.

The following query ends with {{{}LIMIT 1{}}}, so it should return at most one 
row. With AQE enabled on Spark 4.1.2, it can return two rows.
{code:sql}
WITH
top AS (
SELECT id % 1 AS k
FROM range(0, 2, 1, 1)
OFFSET 1
),
b AS (
SELECT *
FROM VALUES (0), (0) AS b(k)
)
SELECT 1 AS c
FROM top LEFT JOIN b USING (k)
LIMIT 1;
{code}
This is a correctness issue, as AQE changes the SQL result cardinality.
h2. Reproduction

Enable AQE in Spark, i.e.
{code:scala}
spark.conf.set("spark.sql.adaptive.enabled", "true")
{code}
Then run the SQL query above.

Record the observed result row count and compare it to the expected row count.
h2. Expected behavior

The query has a root {{{}LIMIT 1{}}}, so every execution should return exactly 
one row.
h2. Actual behavior

AQE returns two rows for the failing executions:
{noformat}
(c=1)
(c=1)
{noformat}
The final AQE plan does not contain the root {{{}CollectLimit 1{}}}.

Initial plan:
{noformat}
CollectLimit 1
+- Project
+- SortMergeJoin ..., LeftOuter
{noformat}
Final AQE plan:
{noformat}
AdaptiveSparkPlan isFinalPlan=true
+- == Final Plan ==
ResultQueryStage
+- Project
+- BroadcastHashJoin ..., LeftOuter
{noformat}
The final plan returns both joined rows.
h2. Control run

If AQE is disabled, the query is correct.

If AQE is enabled but AQE's {{EliminateLimits}} runtime optimizer rule is 
excluded, the bug also disappears. For example:
{code:scala}
spark.conf.set(
"spark.sql.adaptive.optimizer.excludedRules",
"org.apache.spark.sql.catalyst.optimizer.EliminateLimits")
{code}
This strongly points to AQE limit elimination as the failure path.
h2. Suspected cause

The failure appears to be caused by an unsafe interaction between:
 * {{LogicalQueryStage.computeStats()}}
 * {{LogicalQueryStage.maxRows}}
 * AQE {{EliminateLimits}}

In Spark 4.1.2:
{code:scala}
Batch("Eliminate Limits", fixedPoint, EliminateLimits)
{code}
{{EliminateLimits}} removes a {{GlobalLimit}} when:
{code:scala}
child.maxRows.exists(_ <= limit)
{code}
{{LogicalQueryStage.maxRows}} is derived from runtime {{{}stats.rowCount{}}}. 
However, {{LogicalQueryStage.computeStats()}} can use the first nested 
{{QueryStageExec}} statistics even when there are physical operators above that 
stage. The Spark source already has a TODO noting this is inaccurate:
{noformat}
this is not accurate when there is other physical nodes above QueryStageExec
{noformat}
In this query, the left side of the join has one row after {{{}OFFSET 1{}}}, 
but the left outer join can output two rows because the right side has two 
matching rows. AQE can incorrectly treat the child under the root limit as 
having {{{}maxRows = 1{}}}, then remove {{{}LIMIT 1{}}}, allowing two rows to 
escape.
h2. How long has this likely been broken?

I ran a source-level {{git bisect}} between {{v3.2.4}} and {{v3.3.0}} using the 
predicate "does {{AQEOptimizer.scala}} contain AQE {{{}EliminateLimits{}}}?".

The first bad source commit is:
{noformat}
SPARK-36424: Support eliminate limits in AQE Optimizer
Commit: bb6f65acca2918a0ceb13b612d210f1b46fa1add
PR: https://github.com/apache/spark/pull/33651
{noformat}
Parent commit:
{noformat}
e051a540a10cdda42dc86a6195c0357aea8900e4
{noformat}
h2. Proposed fix

Conservative option:

Do not derive {{LogicalQueryStage.maxRows}} from runtime row count when the 
{{LogicalQueryStage}} wraps a non-direct physical subtree.

For example:
{code:scala}
override def maxRows: Option[Long] = {
if (isDirectStage) {
stats.rowCount.map(_.min(Long.MaxValue).toLong)
} else {
logicalPlan.maxRows
}
}
{code}
Safer option:
{code:scala}
override def maxRows: Option[Long] = {
if (isDirectStage) {
stats.rowCount.map(_.min(Long.MaxValue).toLong)
} else {
None
}
}
{code}
Alternative:

Disable AQE {{EliminateLimits}} for plans containing non-direct 
{{LogicalQueryStage}} nodes until runtime row-count bounds are known to be safe 
for the whole subtree.
h2. Suggested regression test

Add an AQE SQL correctness test asserting the row count is exactly one:
{code:scala}
withSQLConf(
SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true",
SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
SQLConf.ADAPTIVE_AUTO_BROADCASTJOIN_THRESHOLD.key -> "1048576") {
val rows = sql("""
WITH
top AS (
SELECT id % 1 AS k
FROM range(0, 2, 1, 1)
OFFSET 1
),
b AS (
SELECT *
FROM VALUES (0), (0) AS b(k)
)
SELECT 1 AS c
FROM top LEFT JOIN b USING (k)
LIMIT 1
""").collect()

assert(rows.length == 1)
}
{code}
The exact returned row is not important because there is no final ordering. The 
cardinality must be exactly one.
h2. Environment
 * Apache Spark 4.1.2
 * PySpark 4.1.2
 * Java 17
 * {{local[2]}}

Reproduced on macOS system.



--
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