spark-test commented on code in PR #60197:
URL: https://github.com/apache/spark/pull/60197#discussion_r4500852569
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/ExistingRDD.scala:
##########
@@ -117,7 +116,9 @@ case class LogicalRDD(
session :: originStats :: originConstraints :: Boolean.box(fromCheckpoint)
:: Nil
override def newInstance(): LogicalRDD.this.type = {
- val rewrite = Utils.toMap(output, output.map(_.newInstance()))
+ // Keyed by exprId, because the attributes in the partitioning, the
ordering, the stats and
+ // the constraints can differ from `output` in qualifier or nullability.
+ val rewrite = AttributeMap(output.zip(output.map(_.newInstance())))
Review Comment:
**Non-blocking (P2):** The same lookup miss survives one step earlier, where
a checkpoint's constraints and column stats are first built.
`LogicalRDD.fromDataset`, used by `checkpoint`, `localCheckpoint` and the
`foreachBatch` sink, re-keys them from the optimized plan onto the analyzed
output through `buildOutputAssocForRewrite`, which still returns a
full-equality `Map` (`.toMap`). `UnionLoopExec` (recursive CTEs) does the same
through `buildOutputAssocForRewriteWithNewOutput`. When the optimizer changes
the root expression IDs, a constraint attribute that differs from the optimized
output only in qualifier or nullability misses that map and keeps the source
plan's expression ID. No self-join or renewal is needed:
```scala
val df0 = spark.range(16).as("t").where($"t.id" > 5)
val cp = df0.select($"id".as("id")).localCheckpoint()
df0.join(cp, df0("id") === cp("id") + 1, "right_outer").where(cp("id") > 0)
```
`RemoveRedundantAliases` strips the root alias `t.id#0 AS id#1`, and
`RemoveNoopOperators` then drops the Project, so the optimized output is `id#0`
while the checkpoint's output is `id#1`. The constraint `t.id#0 > 5` misses
`Map(id#0 -> id#1)` and stays on `df0`'s column. With default configurations,
`EliminateOuterJoin` reads it as a null filter on the left side and turns the
join into an INNER join, so `Row(null, 15)` is dropped and only 9 of the
expected 10 rows come back. That is the PR's issue 2, reached through a join
with the source instead of a self-join. Column stats go through the same map,
so they also land on stale expression IDs and CBO cannot use them. (Traced
through the source at this head; not executed.)
**Recommended change:** Key the association that LogicalRDD's companion
helpers return by expression ID. After the existing positional name and type
checks pass, the helpers return an AttributeMap keyed by the optimized plan's
output attributes, so the stats and constraints rewrite moves every attribute
that shares an expression ID with an optimized output column onto the
LogicalRDD's own output, whatever its qualifier or nullability. Add a
checkpoint regression test, alongside the existing Dataset checkpoint tests,
for a checkpoint whose same-name root alias the optimizer removes, joined with
its source Dataset.
**Why this works:** The helpers already pair the optimized output with the
LogicalRDD output by position; only the lookup that follows compares full
attribute equality. Keying that lookup by expression ID gives checkpoint
creation and recursive-CTE iterations the same keying that renewal now uses.
Constraints and column stats can then no longer keep the source plan's
expression IDs, so a later join with the source cannot read them as predicates
over its own columns.
**Scope:** Expression-ID-keyed association in LogicalRDD's stats and
constraints rewrite helpers, plus a checkpoint regression test that joins a
checkpoint with its source.
**Compatibility:** When the optimized plan keeps the analyzed output's
expression IDs, the rewritten stats and constraints reference the same
expression IDs as today's; attributes that differ from the output only in
qualifier or nullability now take the output's. Outputs that fail the name and
type checks still drop stats and constraints with the existing warning.
**Risks:** Recursive CTE iterations can now keep constraints that previously
landed on stale or dangling expression IDs, so the optimizer may infer or prune
filters from them in later iterations. These constraints describe the previous
iteration's rows, which are exactly the new relation's rows, so results should
not change. Recursive CTE coverage should confirm this, and reverting the
helper keying rolls it back. Checkpoints and foreachBatch micro-batches whose
optimized root expression IDs differ from the analyzed output now carry their
constraints and column stats on their own columns, also in queries whose
results were already correct. The optimizer can then prune filters those
constraints imply, infer filters onto joined relations, and use the column
stats in cost-based estimates. These plans keep their results, and reverting
the helper keying rolls them back. Attributes that already had the right
expression ID only take the analyzed output's qualifier and nullability, which
canonical comparisons ignore.
**Constraints:** Keep the existing positional name and type checks and the
give-up path that drops stats and constraints with a warning when the outputs
do not line up. Change only how the validated association is keyed, so that an
output with repeated expression IDs is not newly rejected by the size check.
Keep the helpers' Map[Attribute, Attribute] result type, which
rewriteStatsAndConstraints and the existing constraint and stats tests in
DataFrameSuite and StatisticsCollectionSuite consume.
**Exclusions:**
- This repair leaves out remapping the executed plan's outputPartitioning
and outputOrdering in fromDataset onto the analyzed output when the optimizer
changes the root expression IDs. That is a separate pre-existing gap: those
fields are not re-keyed at all.
**Success:** When a checkpoint's stats and constraints come from an
optimized plan whose root expression IDs differ from the analyzed output, they
reference only the checkpoint's own output expression IDs, including attributes
that differ in qualifier or nullability. A right-outer join between such a
checkpoint and its source Dataset, filtered on the checkpoint's column, keeps
its null-extended rows. Recursive-CTE iterations re-key stats and constraints
onto the recursive reference's output the same way.
**Verification:**
- **Regression:** Checkpoint a Dataset whose optimized root output has
different expression IDs from its analyzed output and whose constraints
reference an output column through a differently qualified attribute, such as a
range aliased as t, filtered on t.id, then selecting id as id. Right-outer join
it with the source Dataset on an offset key, filter on the checkpoint's column,
and check that the unmatched checkpoint row is returned with a null left side.
- **Behavior:** When the optimized plan's root expression IDs differ from
the analyzed output, the checkpoint's constraints and column stats reference
only expression IDs in its own output, including attributes that differ from
the optimized output in qualifier or nullability.
- **Compatibility:** Existing coverage of LogicalRDD constraint and stats
re-keying, and of recursive CTE results, keeps its results.
##########
sql/core/src/test/scala/org/apache/spark/sql/DatasetSuite.scala:
##########
@@ -1956,6 +1956,20 @@ class DatasetSuite extends SharedSparkSession
}
}
+ test("SPARK-61088: self-join of a checkpointed Dataset should not use the
partitioning " +
+ "and constraints of the other copy") {
+ withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
+ // The partitioning and the constraints refer to `t.id`, while the
output is `s.id`.
+ val cp = spark.range(16).as("t").where($"t.id" >
5).repartition($"t.id").as("s")
Review Comment:
**Nit (P3):** This checkpoint ends in a `ShuffleExchangeExec`, which does
not override `outputOrdering`, so the checkpoint captures no ordering and the
`outputOrdering` rewrite in `newInstance` never sees a mismatched attribute.
The new code comment and the PR description both list the ordering among the
re-keyed fields, but a change that broke only that transform would still pass
both assertions here.
A stale ordering is its own wrong-result path. `spark.range(0, 16, 1,
1).as("s").localCheckpoint()` has no exchange, so AQE does not wrap it, and it
keeps `RangeExec`'s `SinglePartition` and its ordering on the unqualified `id`.
In `broadcast(cp.as("x")).join(cp.as("y"), $"x.id" % 2 === $"y.id" % 2)`
followed by `count(lit(1)).over(Window.partitionBy($"x.id"))`,
`SinglePartition` already satisfies the window's distribution, so only the
renewed copy's ordering decides whether a Sort is added. With a stale ordering,
the window counts each run of equal `x.id` separately instead of all 8 matching
rows.
Asserting that the renewed copy's `outputOrdering` refers only to its own
output, or checking a result over a single-partition checkpoint like this one,
would close the gap. A result check over a multi-partition range would not,
because the re-keyed partitioning already forces an Exchange and a Sort there.
**Verification:**
- **Regression:** With only the ordering re-keying reverted, the new case
fails, while the existing partitioning and constraints assertions still pass.
- **Behavior:** The renewed copy of a checkpoint that captured an ordering
on a qualifier-mismatched attribute reports that ordering on its own renewed
output column.
--
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]