Wrong rows from a subquery expression in the select list over a sorted LIMIT or OFFSET (PushProjectionThroughLimitAndOffset)
I maintainer di solito rispondono entro 2 giorni
Valutazione
- Difficoltà
- 4/5
- Tempo stimato
- 3-5 giorni
- Idoneità per principianti
- 18/100
Direzione di ricerca
Start with the rule PushProjectionThroughLimitAndOffset in Spark's Catalyst optimizer, where the projectList.forall(_.deterministic) guard lets a Project containing a subquery expression move below a Limit or Offset. Run spark_slice_join_repro.py from the issue to reproduce the wrong rows, then check the optimized plan with the rule active and excluded. Done means a subquery expression in the select list over a sorted LIMIT or OFFSET returns the rows of the slice, and the repro tallies all read right.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Descrizione
What happens
A SELECT whose select list holds a subquery expression (EXISTS, IN (subquery), or a correlated scalar subquery) and that reads
a sorted LIMIT or OFFSET from a CTE, a FROM subquery or a view returns rows that are not in the slice. No error is raised.
-- p(id, name, age): (1,'a',30) (2,'b',20) (3,'c',40) (4,'d',60) (5,'e',10) (6,'f',50)
-- k(a, b): (1,2) (1,3) (1,5) (2,3)
WITH s AS (SELECT id, name FROM p ORDER BY age DESC LIMIT 3)
SELECT name, (SELECT count(*) FROM k WHERE k.a = id) AS c FROM s;
By age DESC the names are d f c a b e, so the slice is d, f, c and none of them has a row in k.
- Expected:
(c,0) (d,0) (f,0) - Returned, with every setting at its default, 5 runs of 5:
(a,3) (b,1) (c,0)
The cause is in the optimizer. PushProjectionThroughLimitAndOffset moves a Project below a Limit or an Offset when
projectList.forall(_.deterministic). A project list that holds a subquery expression passes that guard. The subquery expression is
rewritten into a join afterwards (RewritePredicateSubquery for EXISTS and IN, the scalar subquery rewrite for the others), and the join
is placed under the moved Project, which is now under the Limit or Offset. The optimized plan becomes
Offset 3
+- Project [name, exists AS e]
+- Join ExistenceJoin(exists), (a = id)
:- Project [id, name]
: +- Sort [age DESC NULLS LAST], true
: +- LogicalRDD [id, name, age]
+- Project [a]
+- LogicalRDD [a, b]
where the query asks for the join over the Offset. The Sort is now below a join, and a sort-merge or shuffled-hash join re-partitions
its input by the join key, so the Limit or Offset is applied to rows in an unrelated order. With a broadcast hash join the stream side
happens to keep its order, which hides the fault for the EXISTS form on small tables when adaptive execution is on; it does not hide it for
the correlated scalar form, and not for tables above the broadcast threshold. The LIMIT form also loses TakeOrderedAndProject: the whole input is sorted and joined.
Expected
The slice is computed first and the subquery expression is evaluated for the rows of the slice, as written. Either the rule should not move a
Project whose list contains a subquery expression, or it should run only after subquery expressions have been rewritten into joins.
Observed
Spark 4.1.3, local[1], spark.sql.shuffle.partitions=2, 5 runs a cell, all 5 the same. "rule excluded" is
spark.sql.optimizer.excludedRules=org.apache.spark.sql.catalyst.optimizer.PushProjectionThroughLimitAndOffset.
statement (slice is SELECT id, name FROM p ORDER BY age DESC ...) |
expected | defaults (adaptive on) | adaptive off | adaptive on, autoBroadcastJoinThreshold=-1 |
either, rule excluded |
|---|---|---|---|---|---|
WITH s AS (... LIMIT 3) SELECT name, (SELECT count(*) FROM k WHERE k.a = id) FROM s |
c0 d0 f0 | a3 b1 c0 | a3 b1 c0 | a3 b1 c0 | right |
WITH s AS (... OFFSET 3) SELECT name, (SELECT count(*) FROM k WHERE k.a = id) FROM s |
a3 b1 e0 | a3 c0 f0 | a3 c0 f0 | d0 e0 f0 | right |
WITH s AS (... LIMIT 3) SELECT name, EXISTS (SELECT 1 FROM k WHERE k.a = id) FROM s |
cF dF fF | right | bT dF eF | aT bT cF | right |
WITH s AS (... OFFSET 3) SELECT name, EXISTS (SELECT 1 FROM k WHERE k.a = id) FROM s |
aT bT eF | right | aT cF fF | dF eF fF | right |
SELECT name, EXISTS (...) FROM (... LIMIT 3) AS s |
cF dF fF | right | bT dF eF | aT bT cF | right |
WITH s AS (... OFFSET 3) SELECT name FROM s WHERE EXISTS (...) (control) |
a b | right | right | right | right |
SELECT name, EXISTS (...) FROM p ORDER BY age DESC OFFSET 3 (control) |
aT bT eF | right | right | right | right |
IN (subquery) and NOT EXISTS in the select list, and the EXISTS select list under DISTINCT, an outer ORDER BY or an
aggregate (count_if(EXISTS (...))), behave like the EXISTS rows. A correlated max behaves like the count rows. MERGE and SHUFFLE_HASH join hints inside the subquery make the EXISTS rows wrong
under the default settings too; a BROADCAST hint hides them. Explicit joins of the slice, and EXISTS or IN in WHERE, are right.
At a size above the broadcast threshold the EXISTS form is wrong with default settings: with p at 200,000 rows and k at 2,000,000 rows (parquet),
WITH s AS (SELECT id, name FROM p ORDER BY age DESC LIMIT 1000) SELECT name, EXISTS (SELECT 1 FROM k WHERE k.a = id) FROM s
returned 1,000 rows of which 4 were among the true top 1,000, in every run; OFFSET 199000 returned 1,000 rows of which 8 were right.
The same statements are wrong on a Databricks SQL warehouse (version 2026.39) as well, there under a broadcast hash join too: its plan shows no Sort under the join.
The script below is self-contained (PySpark only, tables inline). It prints the tallies above and the plans.
spark_slice_join_repro.py
"""Apache Spark 4.1.3: a subquery expression in the select list over a sorted LIMIT or OFFSET returns the wrong rows.
The optimizer rule PushProjectionThroughLimitAndOffset moves a Project below a Limit or an Offset whenever the
project list is deterministic. A project list that holds a subquery expression (EXISTS, IN, a correlated scalar
subquery) is deterministic, so it is moved too. The subquery is rewritten into a join afterwards, and that join
then sits between the Sort and the Limit or Offset. A shuffled join does not keep the sort order, so the slice
is taken from rows in some other order. Self-contained: pyspark only, tables inline.
python spark_slice_join_repro.py (Java 17; PYSPARK_PYTHON set to this Python)
p by age descending is d f c a b e. LIMIT 3 keeps d f c; OFFSET 3 keeps a b e. k.a holds 1 1 1 2, the ids of a and b.
Each statement is run 5 times under each setting and the answers are tallied; then the optimized and physical
plans of one statement are printed with the rule active and with it excluded.
"""
import collections
import pyspark
from pyspark.sql import SparkSession
P = [(1, "a", 30), (2, "b", 20), (3, "c", 40), (4, "d", 60), (5, "e", 10), (6, "f", 50)]
K = [(1, 2), (1, 3), (1, 5), (2, 3)]
spark = (SparkSession.builder.master("local[1]").appName("slice-join")
.config("spark.ui.enabled", "false").config("spark.sql.shuffle.partitions", "2").getOrCreate())
spark.sparkContext.setLogLevel("OFF")
spark.createDataFrame(P, "id BIGINT, name STRING, age BIGINT").createOrReplaceTempView("p")
spark.createDataFrame(K, "a BIGINT, b BIGINT").createOrReplaceTempView("k")
EXISTS = "EXISTS (SELECT 1 FROM k WHERE k.a = id)"
COUNT = "(SELECT count(*) FROM k WHERE k.a = id)"
LIMIT = "SELECT id, name FROM p ORDER BY age DESC LIMIT 3"
OFFSET = "SELECT id, name FROM p ORDER BY age DESC OFFSET 3"
CASES = [
("count over LIMIT 3", f"WITH s AS ({LIMIT}) SELECT name, {COUNT} AS c FROM s", [("c", 0), ("d", 0), ("f", 0)]),
("count over OFFSET 3", f"WITH s AS ({OFFSET}) SELECT name, {COUNT} AS c FROM s", [("a", 3), ("b", 1), ("e", 0)]),
("EXISTS over LIMIT 3", f"WITH s AS ({LIMIT}) SELECT name, {EXISTS} AS e FROM s", [("c", False), ("d", False), ("f", False)]),
("EXISTS over OFFSET 3", f"WITH s AS ({OFFSET}) SELECT name, {EXISTS} AS e FROM s", [("a", True), ("b", True), ("e", False)]),
("EXISTS over LIMIT 3, FROM subquery", f"SELECT name, {EXISTS} AS e FROM ({LIMIT}) AS s", [("c", False), ("d", False), ("f", False)]),
("control: EXISTS in WHERE over OFFSET 3", f"WITH s AS ({OFFSET}) SELECT name FROM s WHERE {EXISTS}", [("a",), ("b",)]),
("control: one SELECT, ORDER BY and OFFSET 3 last", f"SELECT name, {EXISTS} AS e FROM p ORDER BY age DESC OFFSET 3", [("a", True), ("b", True), ("e", False)]),
]
RULE = "org.apache.spark.sql.catalyst.optimizer.PushProjectionThroughLimitAndOffset"
SETTINGS = [
("defaults (adaptive on)", {}),
("adaptive off", {"spark.sql.adaptive.enabled": "false"}),
("adaptive on, autoBroadcastJoinThreshold=-1", {"spark.sql.autoBroadcastJoinThreshold": "-1"}),
("adaptive off, rule excluded", {"spark.sql.adaptive.enabled": "false", "spark.sql.optimizer.excludedRules": RULE}),
("adaptive on, autoBroadcastJoinThreshold=-1, rule excluded",
{"spark.sql.autoBroadcastJoinThreshold": "-1", "spark.sql.optimizer.excludedRules": RULE}),
]
KEYS = ["spark.sql.adaptive.enabled", "spark.sql.autoBroadcastJoinThreshold", "spark.sql.optimizer.excludedRules"]
RUNS = 5
def apply(conf):
for key in KEYS:
spark.conf.unset(key)
for key, value in conf.items():
spark.conf.set(key, value)
print("pyspark", pyspark.__version__, "| spark", spark.version)
for label, query, want in CASES:
print(f"\n{label}\n {query}\n expected: {want}")
for setting, conf in SETTINGS:
apply(conf)
seen = collections.Counter(str(sorted(tuple(r) for r in spark.sql(query).collect())) for _ in range(RUNS))
verdict = "right" if set(seen) == {str(want)} else "WRONG"
print(f" {verdict:5} {setting}: " + " ; ".join(f"{n} of {RUNS}: {rows}" for rows, n in seen.most_common()))
label, query, _ = CASES[3]
for setting, conf in (SETTINGS[1], SETTINGS[3]):
apply(conf)
df = spark.sql(query)
df.collect()
print(f"\n===== {label}; {setting} =====")
df.explain(mode="extended")
spark.stop()
Plans: EXISTS over OFFSET 3, adaptive off (wrong)
== Optimized Logical Plan ==
Offset 3
+- Project [name#1, exists#858 AS e#856]
+- Join ExistenceJoin(exists#858), (a#3L = id#0L)
:- Project [id#0L, name#1]
: +- Sort [age#2L DESC NULLS LAST], true
: +- LogicalRDD [id#0L, name#1, age#2L], false
+- Project [a#3L]
+- LogicalRDD [a#3L, b#4L], false
== Physical Plan ==
CollectLimit -1, 3
+- *(6) Project [name#1, exists#858 AS e#856]
+- *(6) SortMergeJoin [id#0L], [a#3L], ExistenceJoin(exists#858)
:- *(3) Sort [id#0L ASC NULLS FIRST], false, 0
: +- Exchange hashpartitioning(id#0L, 2), ENSURE_REQUIREMENTS, [plan_id=27123]
: +- *(2) Project [id#0L, name#1]
: +- *(2) Sort [age#2L DESC NULLS LAST], true, 0
: +- Exchange rangepartitioning(age#2L DESC NULLS LAST, 2), ENSURE_REQUIREMENTS, [plan_id=27118]
: +- *(1) Scan ExistingRDD[id#0L,name#1,age#2L]
+- *(5) Sort [a#3L ASC NULLS FIRST], false, 0
+- Exchange hashpartitioning(a#3L, 2), ENSURE_REQUIREMENTS, [plan_id=27129]
+- *(4) Project [a#3L]
+- *(4) Scan ExistingRDD[a#3L,b#4L]
Plans: the same statement with the rule excluded (right)
== Optimized Logical Plan ==
Project [name#1, exists#862 AS e#860]
+- Join ExistenceJoin(exists#862), (a#3L = id#0L)
:- Offset 3
: +- Project [id#0L, name#1]
: +- Sort [age#2L DESC NULLS LAST], true
: +- LogicalRDD [id#0L, name#1, age#2L], false
+- Project [a#3L]
+- LogicalRDD [a#3L, b#4L], false
== Physical Plan ==
*(7) Project [name#1, exists#862 AS e#860]
+- *(7) SortMergeJoin [id#0L], [a#3L], ExistenceJoin(exists#862)
:- *(4) Sort [id#0L ASC NULLS FIRST], false, 0
: +- Exchange hashpartitioning(id#0L, 2), ENSURE_REQUIREMENTS, [plan_id=27224]
: +- *(3) GlobalLimit -1, 3
: +- *(3) Project [id#0L, name#1]
: +- *(3) Sort [age#2L DESC NULLS LAST], false, 0
: +- Exchange SinglePartition, ENSURE_REQUIREMENTS, [plan_id=27218]
: +- *(2) Project [id#0L, name#1, age#2L]
: +- *(2) Sort [age#2L DESC NULLS LAST], true, 0
: +- Exchange rangepartitioning(age#2L DESC NULLS LAST, 2), ENSURE_REQUIREMENTS, [plan_id=27213]
: +- *(1) Scan ExistingRDD[id#0L,name#1,age#2L]
+- *(6) Sort [a#3L ASC NULLS FIRST], false, 0
+- Exchange hashpartitioning(a#3L, 2), ENSURE_REQUIREMENTS, [plan_id=27230]
+- *(5) Project [a#3L]
+- *(5) Scan ExistingRDD[a#3L,b#4L]
Workaround
- Session:
SET spark.sql.optimizer.excludedRules=org.apache.spark.sql.catalyst.optimizer.PushProjectionThroughLimitAndOffset(appended to any existing value). Every statement above is right with it. On 3.4 to 4.0 the rule is namedPushProjectionThroughLimit. - Text, where the conf cannot be set: put an operator between the slice and its reader that the rule does not cross, for example
WITH s AS (SELECT * FROM (SELECT id, name FROM p ORDER BY age DESC LIMIT 3) DISTRIBUTE BY id) SELECT name, EXISTS (...) FROM s.
This was right under every setting above and keepsTakeOrderedAndProjectfor the LIMIT./*+ REPARTITION(1) */and/*+ COALESCE(1) */over the slice work as well,
unlessspark.sql.optimizer.disableHints=true. A filter such asWHERE rand() >= 0, an outerOFFSET 0, or a second CTE over the slice do not help.
Versions
- Spark 4.1.3 (PySpark, local mode), Java 17, Python 3.12: LIMIT and OFFSET forms wrong.
- Spark 4.0.1 and 3.5.6 (PySpark, local mode): the LIMIT forms are wrong the same way and are right with
PushProjectionThroughLimitexcluded; the OFFSET forms are right. - By the source, the LIMIT case dates from 3.4.0 (SPARK-40501 added
PushProjectionThroughLimit) and the OFFSET case from 4.1.0 (SPARK-51584 added the Offset case; SPARK-51635 merged the two rules). The rule is unchanged on master as of this report. - Databricks SQL 2026.39: wrong for the EXISTS and scalar count forms over
OFFSET 3andLIMIT 3.
- Lingua principale
- Scala
- Stelle
- 44.2k
- Fork
- 29.5k
- Merge medio
- 8h 43m
- PR unite (30g)
- 1
Preparare l'ambiente
- Nessun Dockerfile né file Docker Compose
- Ha un modello di pull request
- Leggi la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di apache/spark
-
[BUILD] Bump Ivy up to 2.6.0Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 67/100
I maintainer di solito rispondono entro 2 giorni
-
[SQL][CONNECT] SparkConnectClient may drop metadata (auth headers) on retried/reattached RPCs if metadata is a non-list iterableForse già presa @zainmirzaaa l’ha presa 60 giorni fa. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 78/100
I maintainer di solito rispondono entro 2 giorni
-
[CORE] bug report: collision of resource profile idForse già presa @ybapat l’ha presa 85 giorni fa. Aperta
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 82/100
apache/spark#57498 · 1 commento ·
I maintainer di solito rispondono entro 2 giorni
-
[BUILD] Drop oro dependencyAperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 76/100
apache/spark#57209 · 7 commenti ·
I maintainer di solito rispondono entro 2 giorni
-
Difficoltà 4/5 3-5 giorni Idoneità per principianti 35/100
apache/spark#59659 · 1 commento ·
I maintainer di solito rispondono entro 2 giorni
Tutte le issue di apache/spark
Issue simili
-
Difficoltà 1/5 1-3 ore Idoneità per principianti 91/100
com-vitthalmirji/toon4s#117 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 75/100
propensive/soundness#2294 ·
I maintainer di solito rispondono entro 1 giorno
-
area:scale audit priority:P2
Difficoltà 2/5 1-3 ore Idoneità per principianti 76/100
KD5UJC-XYZ/scarab#231 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 70/100
SpinalHDL/VexiiRiscv#198 ·
I maintainer di solito rispondono entro 3 giorni
-
nativeLink relinks every build: IO.write leaves stale trailing bytes in build-checksumForse già presa @kimmoeklund l’ha presa 1 giorno fa. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 78/100
scala-native/scala-native#5111 ·
I maintainer di solito rispondono entro 1 giorno