peter-toth commented on code in PR #58339:
URL: https://github.com/apache/spark/pull/58339#discussion_r3880214651
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -648,7 +660,8 @@ case class KeyedPartitioning(
// and the other side is re-shuffled using this spec, the two
`KeyedPartitioning`s carry the
// same keys in a different order and
`PartitioningCollection.fromPartitionings` rejects them.
val projectedPartitioning =
- new KeyedPartitioning(projectedExpressions, projectedKeys, isGrouped =
false).toGrouped
+ new KeyedPartitioning(projectedExpressions, projectedKeys, isGrouped =
false,
+ mayContainUnknownPartitionKeys =
mayContainUnknownPartitionKeys).toGrouped
Review Comment:
**Finding 10.** This projects the declared keys and carries the marker onto
the projected set — the same coarsening finding 1 fixed in
`projectKeyedPartitionings`. Two full keys collapse onto one projected key, so
a key that was *outside* the declared set can be *inside* the projected one,
and `areKeysCompatible`'s subset test reads as a guarantee that no longer
holds. I missed this site in round 1; my list of producers named the
projection, the union and the reducers, and not this one.
Measured on `948df55` with
`spark.sql.sources.v2.bucketing.shuffle.enabled=true` and
`spark.sql.sources.v2.bucketing.allowKeysSubsetOfPartitionKeys.enabled=true`,
AQE off as in the new tests:
```sql
-- a keyed (id, k) = {(1,x),(2,x),(3,x),(4,x)}; u keyed id = {1,2,3,4}
-- t v1 parquet = (1,z),(2,z),(3,z),(4,z) -- every (id,k) is out-of-set, no
id is
SELECT r.id, r.k, u.data
FROM (SELECT t.id AS id, t.k AS k FROM testcat.ns.a a RIGHT OUTER JOIN t
ON a.id = t.id AND a.k = t.k) r
JOIN testcat.ns.u u ON r.id = u.id
```
`r.k` stays in the outer select list, so the projection keeps both positions
and your new guard in `projectKeyedPartitionings` does not fire. Expected 4
rows, **0 returned** — the same as base, so this shape is not fixed. The second
join storage-partitions on the coarsened `{1,2,3,4}`: `GroupPartitions
JoinKeyPositions: [0] ExpectedPartitionKeys: 4` on both sides, and no exchange
between them.
`GroupPartitionsExec.outputPartitioning` coarsens the same way one hop later
(`sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala:88-90`):
with `joinKeyPositions` defined it declares the *projected* keys and keeps the
marker, and only the `reducers` case drops the claim.
On the fix: please do not route it through `isNarrowed`. The hazard is about
keys that are not in the declared list at all — an out-of-set full key
projecting *into* the projected declared set — while the flag only describes
how distinct the declared keys are among themselves. Today's `isNarrowed` would
happen to be true here because it records provenance, but #58351 redefines it
as actual key collapse and renames it `isCollapsed`, and in the repro above the
four declared keys stay four distinct projected keys, so it reads `false`. A
direct test is both clearer and stable across that change: refuse the marker
path whenever `joinKeyPositions.length < expressions.length`.
`EnsureRequirements.createKeyedShuffleSpec.tryCreate` is the natural gate — it
already returns an `Option` and still holds the unprojected arity, so returning
`None` sends the child down the ordinary shuffle path. Doing it here by
returning the unprojected `result` instead would be shorter, but it risks `crea
tePartitioning`'s `clustering(positionSet.head)` on a position that maps to no
clustering key, which is why the projection exists.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala:
##########
@@ -71,10 +71,23 @@ case class GroupPartitionsExec(
// can only differ in `expressions`; their `partitionKeys` reference
is shared (enforced by
// `PartitioningCollection`), so `groupedPartitions` is computed only
once.
val partitionKeys = groupedPartitions.map(_._1)
+ // When `reducers` is defined, `partitionKeys` above are the *reduced*
keys: an
+ // out-of-set key of a partitioning that may contain unknown partition
keys can reduce
+ // into the declared set (e.g. identity keys {0, 1, 2, 3} holding an
out-of-set id=4,
+ // reduced by `bucket(4, id)`, declares {0, 1, 2, 3} again), so the
reduced keyed claim
+ // cannot be trusted and must be dropped entirely -- not just the
marker, because the
+ // regrouped data layout no longer matches the child's declared keys
either.
+ if (reducers.isDefined && p.exists {
+ case k: KeyedPartitioning => k.mayContainUnknownPartitionKeys
+ case _ => false
+ }) {
+ return UnknownPartitioning(0)
Review Comment:
**Finding 11.** Two problems on this line.
`UnknownPartitioning(0)` claims zero partitions while this node produces
`groupedPartitions.size` of them. Every other operator that gives up on
describing its partitioning reports the real count —
`PartitioningPreservingUnaryExecNode` uses
`UnknownPartitioning(child.outputPartitioning.numPartitions)`, `ShuffledJoin`'s
`FullOuter` arm the same. The `0` breaks `PartitioningCollection`'s
uniform-`numPartitions` requirement as soon as an inner join above this node
builds one from both sides.
Measured on `948df55` with
`spark.sql.sources.v2.bucketing.shuffle.enabled=true` and
`spark.sql.sources.v2.bucketing.allowCompatibleTransforms.enabled=true`:
```sql
-- a keyed bucket(4, id), ids 0..3; t v1 parquet, ids 0..3; u keyed
bucket(2, id), ids 0..3
SELECT * FROM testcat.ns.a a JOIN t ON a.id = t.id JOIN testcat.ns.u u ON
a.id = u.id
ORDER BY a.id
```
master returns the four rows; this commit throws:
```
java.lang.IllegalArgumentException: requirement failed:
PartitioningCollection requires all of its partitionings have the same
numPartitions.
at
org.apache.spark.sql.catalyst.plans.physical.PartitioningCollection.<init>(partitioning.scala:859)
at
org.apache.spark.sql.catalyst.plans.physical.PartitioningCollection$.fromPartitionings(partitioning.scala:978)
at
org.apache.spark.sql.execution.joins.ShuffledJoin.outputPartitioning(ShuffledJoin.scala:73)
at
...EnsureRequirements.ensureDistributionAndOrdering(EnsureRequirements.scala:69)
```
The left side is `GroupPartitions ... Reducers: [BucketReducer(2)]` over the
first join, reporting 0; the right side is a `GroupPartitionsExec` over `u`
reporting 2. The `ORDER BY` is only there to make something above the top join
ask for its `outputPartitioning`; a third join or an aggregate does it too.
```suggestion
return UnknownPartitioning(groupedPartitions.size)
```
The second problem is why the guard fires at all. It needs `reducers`
defined *and* a flagged KP in the child's partitioning, and finding 5's fix
makes those nearly exclusive: when the spec is flagged, `areKeysCompatible` now
requires `isSameFunction` per position, and a same-function pair has no reducer
(`BucketFunction.reducer` returns `null` when `gcd == thisNumBuckets`,
`DaysFunction.reducer(DaysFunction)` returns `null`). The only path I found is
the one above — the first join is `InnerLike`, so its `outputPartitioning` is a
`PartitioningCollection` holding `a`'s unflagged KP *and* the re-shuffled
side's flagged one, `createKeyedShuffleSpec`'s `collectFirst` picks the
unflagged one so `areKeysCompatible` never sees a marker and the reducer is
computed, while `p.exists` here still finds the flagged sibling. In that shape
the marker is spurious: the inner join has already dropped every row whose key
is outside the declared set, so the reduced claim is sound. Worth scoping the
predicate to the KP the reduction is actually about, or dropping the branch
and recording in the scaladoc why a flagged spec cannot carry reducers (a
third-party `ReducibleFunction` returning a self-reducer is the remaining hole).
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -538,12 +538,23 @@ case class CoalescedNullAwareHashPartitioning(
* partitioning can only satisfy `ClusteredDistribution` by
being grouped, and
* `groupedSatisfies` refuses that unless the config is
enabled, regardless of
* `requireAllClusterKeysForDistribution`.
+ * @param mayContainUnknownPartitionKeys Whether the data may contain rows
whose partition key is
+ * not among the declared `partitionKeys`.
This happens when a side
+ * is re-shuffled onto this partitioning (see
+ * `KeyedShuffleSpec.createPartitioning`):
`KeyGroupedPartitioner`
+ * silently routes keys outside the declared
set to arbitrary
+ * partitions, so only the declared keys are
guaranteed to be
+ * co-located. Such a partitioning is unsound
to storage-partition
+ * join against a side whose partition keys
are not a subset of the
+ * declared keys -- see
`KeyedShuffleSpec.areKeysCompatible`.
Review Comment:
**Finding 13.** This `@param` qualifies an invariant that the `== Partition
Keys ==` section states flatly a hundred lines above
(`sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:463`):
"partition `i` holds key `partitionKeys(i)`. A consumer must therefore treat
the given order as authoritative rather than re-derive one." That paragraph is
where a consumer looks for the layout contract, so one sentence there — a
partition may also hold rows whose key is not declared at all when
`mayContainUnknownPartitionKeys` is set — keeps the two in sync. #58351
restructures this class doc around key collapse, so land the sentence wherever
the layout paragraph ends up after the rebase.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/AliasAwareOutputExpression.scala:
##########
@@ -131,6 +131,16 @@ trait PartitioningPreservingUnaryExecNode extends
UnaryExecNode
if (projectablePositions.isEmpty) return LazyList.empty
+ // If any input KP may contain unknown partition keys, its keyed claim
only holds while the
+ // declared key set survives verbatim: the claim asserts that rows outside
the declared keys
+ // cannot exist, and dropping a key position coarsens the declared set so
that an out-of-set
+ // key can land inside it (see
`KeyedPartitioning.mayContainUnknownPartitionKeys`). Drop the
+ // keyed claim entirely in that case.
+ if (projectablePositions.length < numPositions &&
+ kps.exists(_.mayContainUnknownPartitionKeys)) {
Review Comment:
**Finding 12.** `kps` can hold a flagged and an unflagged
`KeyedPartitioning` at the same time: `PartitioningCollection`'s invariant only
requires a shared `partitionKeys` reference and equal arity, and
`ShuffledJoin.outputPartitioning` builds exactly that for `InnerLike` — the
keyed side's own KP plus the re-shuffled side's flagged one. In that collection
the marker is spurious: the inner join has already dropped every row whose key
is outside the declared set, so the surviving rows do obey the declared layout.
`exists` drops a keyed claim that is sound.
Measured on `948df55` with only
`spark.sql.sources.v2.bucketing.shuffle.enabled=true`:
```sql
-- a keyed (id, k) = {(1,x)..(4,x)}; t v1 parquet = (1,x)..(4,x); u keyed
id = {1..4}
SELECT r.id, u.data
FROM (SELECT t.id AS id FROM testcat.ns.a a JOIN t ON a.id = t.id AND a.k =
t.k) r
JOIN testcat.ns.u u ON r.id = u.id
```
Four correct rows on master and here, but master plans one shuffle (the
first join's one-side shuffle) and this commit plans two — the second join
loses its storage-partitioned join.
Filtering `kps` to the unflagged ones before this guard, and keeping the
drop when none remain, would preserve it. The all-flagged case genuinely has to
drop: with both join sides re-shuffled onto equal declared keys, an out-of-set
key hashes to the same index on both sides, so the join can emit matched rows
whose key is not declared. The `p.exists` in `GroupPartitionsExec` has the same
scoping question (finding 11).
One thing to watch when this meets #58351: that PR normalizes `isCollapsed`
across a collection by OR and adds a `require` that the members agree. Please
do not extend either to this marker. `isCollapsed` describes the shared
physical layout, so every member naming that layout is equally coarse; this
marker describes which *rows* the side that produced it may hold, and an inner
join filters those rows away. Requiring agreement would make the mixed
collection above impossible to represent and would lock in the lost SPJ.
--
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]