peter-toth commented on code in PR #58229: URL: https://github.com/apache/spark/pull/58229#discussion_r4480733462
########## sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/ConvertViewToMaterializedCTE.scala: ########## @@ -0,0 +1,297 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.spark.sql.catalyst.optimizer + +import scala.collection.mutable + +import org.apache.spark.sql.catalyst.TableIdentifier +import org.apache.spark.sql.catalyst.expressions.{Alias, Attribute} +import org.apache.spark.sql.catalyst.plans.logical._ +import org.apache.spark.sql.catalyst.rules.Rule +import org.apache.spark.sql.internal.SQLConf + +/** + * Rewrites multiple references to the same view into a single `CTERelationDef` with multiple + * `CTERelationRef`s, so that the view's underlying plan is computed once (through exchange + * reuse at the physical layer) instead of once per reference. + * + * The rule runs first in `FinishAnalysis`, before `EliminateResolvedHint`, + * `EliminateSubqueryAliases` and `EliminatePipeOperators` strip analysis-only nodes from + * the plan. Those rules are applied to the main plan before its subquery plans, so a + * definition body taken from a first-visited subquery occurrence may still hold such + * nodes; running the conversion first lets the cleanup rules reach the body at the top + * level, wherever it was taken from. `EliminateView` runs after the cleanup rules: no + * `View` nodes remain and every reference site holds an independent copy of the view's + * plan. + * + * A join hint written at the top of a view body binds to the join(s) the view takes part + * in (see `SubqueryHintPropagationSuite`): `EliminateResolvedHint` extracts hints from + * Join children by walking down unary nodes, and processes the body inline today so the + * hint reaches the consuming join. After this rule the body lives inside a `CTERelationDef` + * the walk cannot reach, so the rule strips those top-of-body hints itself and re-attaches + * them above every reference site. Hints deeper in the body bind to the body's own joins + * and are left in place. + * + * A converted definition keeps `forceSkipInline = true` only when at least two references + * survive the rewrite; otherwise `InlineCTE` would immediately flatten it back into + * duplicated subtrees (the definition body is deterministic in every case we convert), + * making the rule a no-op. Definitions with fewer than two surviving references keep the + * flag off and are inlined: their occurrences do not all survive the rewrite (a later + * occurrence's body is dropped, taking references to nested converted views with it), and + * some references may sit inside unreferenced user-written CTEs - materializing either + * way would add a shuffle that exchange reuse cannot deduplicate. + * + * Only deterministic, batch views are eligible: a multi-reference CTE guarantees that its + * definition is evaluated exactly once (even for non-deterministic definitions), while + * multiple references to a non-deterministic view are evaluated independently today. + * Converting such views would change query results. + * + * A view body may contain correlated subqueries whose outer references resolve to relations + * inside the same body (e.g. `t WHERE x IN (SELECT y FROM s WHERE s.k = t.k)`). The view is + * analyzed standalone when it is created, so an outer reference that does not resolve inside + * the body fails view analysis and can never escape to the outer query. The converted + * definition contains the whole body, so internal correlations resolve within it and these + * bodies are safe to convert. `InlineCTE`'s rejection of boundary-crossing outer references + * is only a generic safety net for non-view `forceSkipInline` producers. + * + * Each reference site gains a shuffle boundary added by `ReplaceCTERefWithRepartition` and + * deduplicated by exchange reuse, so the conversion trades recomputation for a + * shuffle plus reuse; it is therefore gated behind + * [[SQLConf.CONVERT_VIEW_TO_MATERIALIZED_CTE]] and off by default. + */ +object ConvertViewToMaterializedCTE extends Rule[LogicalPlan] { + + override def apply(plan: LogicalPlan): LogicalPlan = { + if (!SQLConf.get.getConf(SQLConf.CONVERT_VIEW_TO_MATERIALIZED_CTE)) return plan + // FinishAnalysis re-runs the rule batch on Subquery roots and requires the result + // to stay a Subquery; the top-level pass already covers views inside subqueries. + if (plan.isInstanceOf[Subquery]) return plan + val occurrences = plan.collectWithSubqueries { case v: View => v } + if (occurrences.length < 2) return plan + + // Admit an identifier only when every occurrence of it forms one qualifying group. + // Grouping is by identifier first because the rewrite below matches occurrences by + // identifier: an identifier carried by occurrences with divergent bodies (e.g. one + // occurrence resolved against a view definition that was replaced after another + // occurrence captured it), or whose occurrences fail qualification for any other + // reason, must not convert at all - otherwise the rewrite would rebind those + // occurrences against a definition their plan does not match, silently changing + // query results. + // + // Inner CTE definition ids are normalized before the bodies are compared: a view + // body is re-analyzed per occurrence, and the re-analysis re-substitutes the body's + // inner CTEs, minting a fresh `CTERelationDef` id each time (e.g. for a SQL view + // whose body has a WITH clause over a persistent table). The re-minted ids are the + // only difference between such occurrences' bodies, so the normalization lets them + // group together; bodies that differ in anything else still stay apart. + val qualifiedIdentifiers = occurrences + .groupBy(_.desc.identifier) + .collect { + case (identifier, occs) + if qualifies(occs) && occs.map(occ => + normalizeCteIds(occ.child).canonicalized).distinct.length == 1 => identifier Review Comment: The file-listing half has a root cause outside this PR, now tracked as [SPARK-60930](https://issues.apache.org/jira/browse/SPARK-60930). `InMemoryFileIndex.equals` compares only the root paths, so a stale and a fresh scan of one path canonicalize equal. On master that already makes exchange reuse return 10 instead of 15 for the full outer join above. I prototyped an `equals` that also compares the listed files. With it, the `UNION ALL` above returns 25 with the config on, because the two occurrences no longer group. So this half needs no change in this PR, and it can follow SPARK-60930. The hint half is this PR's own, and the `hintsOf` comparison above is its fix. It only changes which join carries the hint, not the rows, so I'd treat it as Non-blocking. -- 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]
