bgiesbrecht opened a new issue, #59438:
URL: https://github.com/apache/spark/issues/59438
### 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.
```sql
-- 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.
<details>
<summary>spark_slice_join_repro.py</summary>
```python
"""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()
```
</details>
<details>
<summary>Plans: EXISTS over OFFSET 3, adaptive off (wrong)</summary>
```
== 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]
```
</details>
<details>
<summary>Plans: the same statement with the rule excluded (right)</summary>
```
== 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]
```
</details>
### 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 named `PushProjectionThroughLimit`.
- 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 keeps `TakeOrderedAndProject`
for the LIMIT. `/*+ REPARTITION(1) */` and `/*+ COALESCE(1) */` over the slice
work as well,
unless `spark.sql.optimizer.disableHints=true`. A filter such as `WHERE
rand() >= 0`, an outer `OFFSET 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 `PushProjectionThroughLimit` excluded; 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 3` and `LIMIT 3`.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]