sarutak commented on code in PR #59131:
URL: https://github.com/apache/spark/pull/59131#discussion_r4131158395
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala:
##########
@@ -677,7 +677,18 @@ class Analyzer(
ResolveTranspiledPythonUDFOptions) ++
Seq(ResolveUpdateEventTimeWatermarkColumn) ++
extendedResolutionRules ++
- Seq(NameStreamingSources) : _*),
+ Seq(NameStreamingSources) : _*)
+
+ /**
+ * Runs only the "Resolution" batch. [[ResolveReferences]] uses it to
resolve a lazily planned
+ * `withColumns` in isolation from its child.
+ */
+ private lazy val resolutionBatchExecutor = new RuleExecutor[LogicalPlan] {
Review Comment:
`resolutionBatch` is declared to return `Batch`, so the
`asInstanceOf[Batch]` in `resolutionBatchExecutor.batches` looks redundant. If
it is needed because of the path-dependent `Batch` type across the two
`RuleExecutor` instances, a short comment would explain it; otherwise it can be
dropped.
##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/analysis/AnalysisSuite.scala:
##########
@@ -1032,6 +1032,75 @@ class AnalysisSuite extends AnalysisTest with Matchers {
}
}
+ private def withColumns(child: LogicalPlan, name: String, expr: Expression):
LogicalPlan =
+ Project(Seq(UnresolvedStarWithColumns(Seq(name), Seq(expr))), child)
+
+ private def fn(name: String, args: Expression*): UnresolvedFunction =
+ UnresolvedFunction(name, args, isDistinct = false)
+
+ // Analyzes the plan with the lazily planned `withColumns` resolved in
isolation, and checks
+ // that the result is the same as resolving them in the normal iterations.
+ private def checkWithColumnsResolution(plan: LogicalPlan): Unit = {
+ val expected = withSQLConf(
+ SQLConf.ANALYZER_RESOLVE_WITH_COLUMNS_IN_ISOLATION.key -> "false",
+ SQLConf.ANALYZER_MAX_ITERATIONS.key -> "1000") {
+ getAnalyzer.executeAndCheck(plan, new QueryPlanningTracker)
+ }
+ comparePlans(getAnalyzer.executeAndCheck(plan, new QueryPlanningTracker),
expected)
+ }
+
+ test("SPARK-59855: resolve a long chain of lazily planned withColumns") {
+ // Like a chain of Spark Connect `withColumn` calls. Each call adds a
column that references
+ // the column added by the previous call and takes several rules to be
resolved.
+ val chain = (1 to 120).foldLeft[LogicalPlan](testRelation) { (child, i) =>
+ val prev = UnresolvedAttribute(if (i == 1) "a" else s"c${i - 1}")
+ withColumns(child, s"c$i", fn("abs", prev + 1L))
+ }
+ withSQLConf(SQLConf.ANALYZER_RESOLVE_WITH_COLUMNS_IN_ISOLATION.key ->
"false") {
+ // The normal iterations resolve at most one `withColumns` of the chain
per iteration.
+ val message =
intercept[RuntimeException](getAnalyzer.execute(chain)).getMessage
+ assert(message.startsWith("Max iterations (100) reached for batch
Resolution"))
+ }
+ checkWithColumnsResolution(chain)
+ }
+
+ test("SPARK-59855: lazily planned withColumns that can't be resolved in
isolation") {
Review Comment:
The added tests cover the 120/200 chains, the six "cannot resolve in
isolation" cases, and error parity. How about adding cases that exercise the
resolve-in-child vs. fall-back-to-normal split explicitly?
- a `withColumns` referencing a **metadata column** (e.g.
`_metadata.file_path`);
- a Connect **plan-id reference** (`df[...]`) inside a chain (the
description says this is resolved by `resolveReferencesInProject`; a test would
pin it);
- a **SQL variable** reference;
- a **streaming** chain (the `isStreaming = p.child.isStreaming` path on the
leaf);
- optionally, a `TempResolvedColumn`-like case, to confirm the isolated run
never marks a not-fully-resolved expression as resolved. (In practice
`TempResolvedColumn` is only produced under Aggregate/Filter/Sort, not under a
`Project` over a `LocalRelation`, so this is likely already impossible but a
test would document that.)
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala:
##########
@@ -1997,6 +2010,60 @@ class Analyzer(
q.mapExpressions(resolveExpressionByPlanChildren(_, q,
includeLastResort = true))
}
+ private def resolveReferencesInProject(p: Project): Project = {
+ val resolvedBasic = p.projectList.map(resolveExpressionByPlanChildren(_,
p))
+ // Lateral column alias has higher priority than outer reference.
+ val resolvedWithLCA = resolveLateralColumnAlias(resolvedBasic)
+ val resolvedFinal = resolvedWithLCA.map(resolveColsLastResort)
+ p.copy(projectList =
+ resolvedFinal.map(e =>
aliasIfOuterReference(e.asInstanceOf[NamedExpression])))
+ }
+
+ /**
+ * Resolves a `withColumns` whose star has just been expanded.
+ *
+ * A lazily planned chain of `withColumns`, like the one Spark Connect
creates for a chain of
+ * `withColumn` calls, is a stack of Projects. A Project waits for its
child to be resolved,
+ * and then takes several rules to be resolved itself, so the normal
iterations resolve at most
+ * one Project of the chain per iteration, and a long chain hits the max
iterations limit.
+ * Instead, we resolve the Project right away: resolve its column
references against the child
+ * as usual, then run the "Resolution" batch on the expressions that are
still unresolved,
+ * over a leaf node with the same output as the child. This costs time
proportional to the
+ * width of the Project instead of the size of the child, and lets the
parent Project be
+ * resolved in the same pass. Expressions that can't be resolved this way,
e.g. a window
+ * function that needs the plan to be rewritten, are left to the normal
iterations.
+ */
+ private def resolveWithColumns(p: Project): Project = {
+ if (containsStar(p.projectList) || containsDeserializer(p.projectList) ||
+ p.projectList.exists(SubqueryExpression.hasSubquery)) {
+ p
+ } else {
+ val withReferences = resolveReferencesInProject(p)
+ val unresolved = withReferences.projectList.filterNot(_.resolved)
+ // Lateral column alias references need the whole project list to be
resolved.
+ if (unresolved.isEmpty ||
+
unresolved.exists(_.containsPattern(LATERAL_COLUMN_ALIAS_REFERENCE))) {
+ withReferences
+ } else {
+ val leaf = LocalRelation(p.child.output, isStreaming =
p.child.isStreaming)
+ try {
+ resolutionBatchExecutor.execute(Project(unresolved, leaf)) match {
+ case r @ Project(resolvedList, l) if (l eq leaf) && r.resolved &&
+ r.missingInput.isEmpty && resolvedList.length ==
unresolved.length =>
+ val resolvedIter = resolvedList.iterator
+ withReferences.copy(projectList =
withReferences.projectList.map { e =>
+ if (e.resolved) e else resolvedIter.next()
+ })
+ case _ => withReferences
+ }
+ } catch {
+ // Leave the error to the normal iterations, so that it's reported
as usual.
+ case _: AnalysisException => withReferences
Review Comment:
`resolveWithColumns` wraps the isolated run in `catch { case _:
AnalysisException => withReferences }`. But when a batch exceeds
`maxIterations`, `RuleExecutor.execute` throws a plain `RuntimeException`, not
an `AnalysisException`.
https://github.com/apache/spark/blob/b03b53dcacb42c9c31c0cd2b622d495d60c3f59f/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/rules/RuleExecutor.scala#L299
The `Resolution` batch uses `fixedPoint` with `errorOnExceed = true`, and
under `Utils.isTesting` the throw happens unconditionally. So if
`resolutionBatchExecutor.execute(Project(unresolved, leaf))` does not converge
within `maxIterations`, the `RuntimeException` escapes the `catch` and
propagates to the user, instead of falling back to the normal iterations as the
design intends. The same applies to non-`AnalysisException` errors that
coercion rules can raise (e.g. `SparkException`).
The isolated run is a best-effort optimization whose failures should always
fall back to the normal path. Suggest widening the catch:
```scala
catch { case NonFatal(_) => withReferences }
```
(or at least adding `RuntimeException`). Worth a test that forces the
isolated run to not converge and asserts it falls back rather than surfacing a
raw `RuntimeException`. Note the fallback here still runs the normal
iterations, which for a genuinely divergent case may itself hit `maxIterations`
but that is the pre-existing behavior and is the correct thing to reproduce.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/analysis/Analyzer.scala:
##########
@@ -1997,6 +2010,60 @@ class Analyzer(
q.mapExpressions(resolveExpressionByPlanChildren(_, q,
includeLastResort = true))
}
+ private def resolveReferencesInProject(p: Project): Project = {
+ val resolvedBasic = p.projectList.map(resolveExpressionByPlanChildren(_,
p))
+ // Lateral column alias has higher priority than outer reference.
+ val resolvedWithLCA = resolveLateralColumnAlias(resolvedBasic)
+ val resolvedFinal = resolvedWithLCA.map(resolveColsLastResort)
+ p.copy(projectList =
+ resolvedFinal.map(e =>
aliasIfOuterReference(e.asInstanceOf[NamedExpression])))
+ }
+
+ /**
+ * Resolves a `withColumns` whose star has just been expanded.
+ *
+ * A lazily planned chain of `withColumns`, like the one Spark Connect
creates for a chain of
+ * `withColumn` calls, is a stack of Projects. A Project waits for its
child to be resolved,
+ * and then takes several rules to be resolved itself, so the normal
iterations resolve at most
+ * one Project of the chain per iteration, and a long chain hits the max
iterations limit.
+ * Instead, we resolve the Project right away: resolve its column
references against the child
+ * as usual, then run the "Resolution" batch on the expressions that are
still unresolved,
+ * over a leaf node with the same output as the child. This costs time
proportional to the
+ * width of the Project instead of the size of the child, and lets the
parent Project be
+ * resolved in the same pass. Expressions that can't be resolved this way,
e.g. a window
+ * function that needs the plan to be rewritten, are left to the normal
iterations.
+ */
+ private def resolveWithColumns(p: Project): Project = {
+ if (containsStar(p.projectList) || containsDeserializer(p.projectList) ||
+ p.projectList.exists(SubqueryExpression.hasSubquery)) {
+ p
+ } else {
+ val withReferences = resolveReferencesInProject(p)
+ val unresolved = withReferences.projectList.filterNot(_.resolved)
+ // Lateral column alias references need the whole project list to be
resolved.
+ if (unresolved.isEmpty ||
+
unresolved.exists(_.containsPattern(LATERAL_COLUMN_ALIAS_REFERENCE))) {
+ withReferences
+ } else {
+ val leaf = LocalRelation(p.child.output, isStreaming =
p.child.isStreaming)
+ try {
+ resolutionBatchExecutor.execute(Project(unresolved, leaf)) match {
+ case r @ Project(resolvedList, l) if (l eq leaf) && r.resolved &&
+ r.missingInput.isEmpty && resolvedList.length ==
unresolved.length =>
+ val resolvedIter = resolvedList.iterator
+ withReferences.copy(projectList =
withReferences.projectList.map { e =>
Review Comment:
The re-splice is correct: `unresolved =
withReferences.projectList.filterNot(_.resolved)` and the `map { if
(e.resolved) e else resolvedIter.next() }` walk the same Seq in the same
direction, so the i-th unresolved element maps to the i-th resolved element,
and `resolvedList.length == unresolved.length` guards the count.
However, this relies on the `Resolution` batch preserving the order and
count of the top-level project list of `Project(unresolved, leaf)` (the count
is guarded, the order is not). A one-line comment stating that dependency would
help, since a future rule that reorders the top-level list would silently break
the mapping.
--
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]