You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@spark.apache.org by we...@apache.org on 2026/08/17 17:31:10 UTC
(spark) branch branch-4.x updated: [SPARK-57956][SQL] Fix AQE incorrectly eliminating a global LIMIT over a non-materialized query stage
This is an automated email from the ASF dual-hosted git repository.
cloud-fan pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/branch-4.x by this push:
new bc51e9a16de6 [SPARK-57956][SQL] Fix AQE incorrectly eliminating a global LIMIT over a non-materialized query stage
bc51e9a16de6 is described below
commit bc51e9a16de6f58110d3c4b4d09ca754ddb5e510
Author: Vaibhav Garg <89...@users.noreply.github.com>
AuthorDate: Tue Aug 18 01:29:36 2026 +0800
[SPARK-57956][SQL] Fix AQE incorrectly eliminating a global LIMIT over a non-materialized query stage
### What changes were proposed in this pull request?
This PR fixes `LogicalQueryStage.maxRows` to only use runtime row counts when the underlying query stage is materialized. Before materialization, `computeStats()` returns `None` and the code falls back to `logicalPlan.stats`, which is a cost estimate, not a hard upper bound. That estimate was being surfaced as `maxRows`, causing `EliminateLimits` to incorrectly remove a global LIMIT.
The fix adds a materialization check: if the stage is materialized, use the runtime `rowCount` (an exact upper bound). Otherwise, fall back to `logicalPlan.maxRows`, which is always a sound upper bound derived from plan structure rather than cardinality estimates.
### Why are the changes needed?
With AQE enabled, a root global LIMIT can be dropped during adaptive re-optimization, returning too many rows. For example:
```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
```
This query returns 2 rows instead of 1.
The root cause: `EliminateLimits` removes a `GlobalLimit` when `child.maxRows <= limit`. The child here is a `Project` over a `LeftOuter Join` whose left input is a `LogicalQueryStage` wrapping `Offset 1 +- Range`. Before that stage materializes, `computeStats()` falls back to `logicalPlan.stats` and yields `rowCount = 0`. `Join.maxRows` for `LeftOuter` is `max(left * right, left)`, which correctly propagates the 0 upward. `EliminateLimits` then sees `maxRows = 0 <= 1` and drops the LIMIT.
The failure is timing-dependent because it depends on whether the stage has materialized when re-optimization runs. This was introduced by SPARK-36424 (AQE EliminateLimits support).
### Does this PR introduce _any_ user-facing change?
No API or behavior change. This fixes a correctness bug where queries with a LIMIT over certain AQE plans could return too many rows.
### How was this patch tested?
Added a regression test in `AdaptiveQueryExecSuite`: "SPARK-57956: AQE must not eliminate a global LIMIT over a row-increasing outer join". Because the trigger is timing-dependent, the test runs the query 20 times and asserts each result has exactly 1 row.
Verified:
- Without the fix, the test fails (returns 2 rows).
- With the fix, the test passes.
- The existing test "SPARK-36424: Support eliminate limits in AQE Optimizer" still passes, confirming that legitimate limit elimination over materialized aggregate stages is preserved.
Ran `AdaptiveQueryExecSuite` locally. Full test matrix relies on GitHub Actions.
### Was this patch authored or co-authored using generative AI tooling?
Generated using Kiro (Claude Opus 4.8)
Closes #57347 from vboo123/SPARK-57956.
Lead-authored-by: Vaibhav Garg <89...@users.noreply.github.com>
Co-authored-by: Vaibhav Garg <ga...@amazon.com>
Co-authored-by: Wenchen Fan <we...@databricks.com>
Signed-off-by: Wenchen Fan <we...@databricks.com>
(cherry picked from commit 5623395102680617162ec8e22e67485f44aa095e)
Signed-off-by: Wenchen Fan <we...@databricks.com>
---
.../sql/execution/adaptive/LogicalQueryStage.scala | 16 ++++++++-
.../adaptive/AdaptiveQueryExecSuite.scala | 42 ++++++++++++++++++++--
2 files changed, 54 insertions(+), 4 deletions(-)
diff --git a/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/LogicalQueryStage.scala b/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/LogicalQueryStage.scala
index 62e00d1ea6ed..09fc97bb46fa 100644
--- a/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/LogicalQueryStage.scala
+++ b/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/LogicalQueryStage.scala
@@ -71,7 +71,21 @@ case class LogicalQueryStage(
physicalStats.getOrElse(logicalPlan.stats)
}
- override def maxRows: Option[Long] = stats.rowCount.map(_.min(Long.MaxValue).toLong)
+ override def maxRows: Option[Long] = {
+ // A query stage's `rowCount` is an exact, valid upper bound only when it comes from the
+ // runtime statistics of a materialized stage. Checking `isMaterialized` alone is not enough:
+ // `computeStats()` can still fall back to `logicalPlan.stats` (a cost estimate, with
+ // `isRuntime = false`) when the physical-stage lookup yields no statistics, and that estimate
+ // can under-count (e.g., returning 0). Treating such an estimate as a hard upper bound lets
+ // rules such as EliminateLimits wrongly drop a LIMIT and change the result cardinality
+ // (SPARK-57956). Only trust `rowCount` when the stats are materialized runtime stats;
+ // otherwise fall back to `logicalPlan.maxRows`, which is always a sound upper bound.
+ if (isMaterialized && stats.isRuntime) {
+ stats.rowCount.map(_.min(Long.MaxValue).toLong)
+ } else {
+ logicalPlan.maxRows
+ }
+ }
override def isMaterialized: Boolean = physicalPlan.exists {
case s: QueryStageExec => s.isMaterialized
diff --git a/sql/core/src/test/scala/org/apache/spark/sql/execution/adaptive/AdaptiveQueryExecSuite.scala b/sql/core/src/test/scala/org/apache/spark/sql/execution/adaptive/AdaptiveQueryExecSuite.scala
index f84f3ad5cb6c..f17b56fda894 100644
--- a/sql/core/src/test/scala/org/apache/spark/sql/execution/adaptive/AdaptiveQueryExecSuite.scala
+++ b/sql/core/src/test/scala/org/apache/spark/sql/execution/adaptive/AdaptiveQueryExecSuite.scala
@@ -31,10 +31,10 @@ import org.apache.spark.scheduler.{SparkListener, SparkListenerEvent, SparkListe
import org.apache.spark.shuffle.sort.SortShuffleManager
import org.apache.spark.sql.{DataFrame, Dataset, Row, SparkSession}
import org.apache.spark.sql.catalyst.InternalRow
-import org.apache.spark.sql.catalyst.expressions.{Ascending, Attribute, AttributeReference, EqualTo, IsNull, Or, SortOrder}
-import org.apache.spark.sql.catalyst.optimizer.{BuildLeft, BuildRight}
+import org.apache.spark.sql.catalyst.expressions.{Ascending, Attribute, AttributeReference, EqualTo, IsNull, Literal, Or, SortOrder}
+import org.apache.spark.sql.catalyst.optimizer.{BuildLeft, BuildRight, EliminateLimits}
import org.apache.spark.sql.catalyst.plans.{Inner, LeftAnti}
-import org.apache.spark.sql.catalyst.plans.logical.{Aggregate, Join, JoinHint, LocalRelation, LogicalPlan}
+import org.apache.spark.sql.catalyst.plans.logical.{Aggregate, GlobalLimit, Join, JoinHint, LeafNode, LocalRelation, LogicalPlan, Statistics}
import org.apache.spark.sql.catalyst.plans.physical.{CoalescedNullAwareHashPartitioning, SinglePartition}
import org.apache.spark.sql.classic.Strategy
import org.apache.spark.sql.execution._
@@ -63,6 +63,16 @@ import org.apache.spark.tags.SlowSQLTest
import org.apache.spark.util.ArrayImplicits._
import org.apache.spark.util.Utils
+/**
+ * A leaf whose cost estimate under-counts its structural row bound. Used by SPARK-57956 to verify
+ * that an unmaterialized query stage exposes the structural `maxRows` instead of an estimate.
+ */
+private case class UnderCountLeaf(output: Seq[Attribute]) extends LeafNode {
+ override def maxRows: Option[Long] = Some(2L)
+ override def computeStats(): Statistics =
+ Statistics(sizeInBytes = BigInt(1), rowCount = Some(BigInt(0)))
+}
+
@SlowSQLTest
class AdaptiveQueryExecSuite
extends SharedSparkSession
@@ -3398,6 +3408,32 @@ class AdaptiveQueryExecSuite
}
}
+ test("SPARK-57956: unmaterialized query stage exposes structural maxRows, not the estimate") {
+ // Build a LogicalQueryStage whose underlying stage is never materialized. Its computeStats()
+ // falls back to the logical plan's cost estimate - here an under-count of 0 rows - which must
+ // NOT be promoted to a hard maxRows bound. Otherwise EliminateLimits would drop a LIMIT that
+ // still needs to be applied once the stage runs (SPARK-57956).
+ val output = Seq(AttributeReference("a", IntegerType)())
+ val logical = UnderCountLeaf(output)
+ val scan = LocalTableScanExec(output, Nil, None)
+ val exchange = BroadcastExchangeExec(
+ HashedRelationBroadcastMode(output, isNullAware = false), scan)
+ val queryStage = LogicalQueryStage(logical, BroadcastQueryStageExec(0, exchange, exchange))
+
+ assert(!queryStage.isMaterialized)
+ // The under-counted estimate is what computeStats() surfaces...
+ assert(queryStage.stats.rowCount.contains(BigInt(0)))
+ // ...but maxRows must remain the structural bound (2), not the estimate (0).
+ assert(queryStage.maxRows.contains(2L),
+ "an unmaterialized stage must expose its structural maxRows, not the row-count estimate")
+
+ // Since the structural bound (2) exceeds the limit (1), the LIMIT must be retained. With the
+ // estimate wrongly promoted (maxRows = 0), EliminateLimits would instead drop it.
+ val limited = GlobalLimit(Literal(1), queryStage)
+ assert(EliminateLimits(limited).isInstanceOf[GlobalLimit],
+ "LIMIT must be retained when the child's structural row bound exceeds the limit")
+ }
+
test("SPARK-48037: Fix SortShuffleWriter lacks shuffle write related metrics " +
"resulting in potentially inaccurate data") {
withTable("t3") {
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@spark.apache.org
For additional commands, e-mail: commits-help@spark.apache.org