This is an automated email from the ASF dual-hosted git repository.
LuciferYang pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/branch-4.x by this push:
new 1652e6cd801b [SPARK-57600][SQL] Declarative Pipelines should isolate
per-flow SQL confs during parallel flow resolution
1652e6cd801b is described below
commit 1652e6cd801bdfc3390e908fca1d3c85506565e3
Author: YangJie <[email protected]>
AuthorDate: Mon Jun 29 11:43:31 2026 +0800
[SPARK-57600][SQL] Declarative Pipelines should isolate per-flow SQL confs
during parallel flow resolution
### What changes were proposed in this pull request?
Declarative Pipelines resolves flows in parallel on a shared `SparkSession`
(`DataflowGraphTransformer`, parallelism 10).
`FlowAnalysis.createFlowFunctionFromLogicalPlan` applied each flow's per-flow
SQL confs by mutating that shared session's conf and restoring it afterwards.
Because the session is shared, concurrent flows interleave those set/restore
operations, so a flow can be analyzed under another flow's confs or have its
own conf restored out from under it.
This gives each flow a private `SQLConf` instead: clone the session's conf,
apply the flow's overrides to the clone, and install it for the analyzing
thread with `SQLConf.withExistingConf` while that flow is analyzed (the
analyzer reads conf through `SQLConf.get`). Analysis still runs on the shared
session, so the catalog, current catalog/database, temp views, and the resolved
DataFrames are all left on that session; only the confs the analyzer reads are
isolated per flow.
### Why are the changes needed?
When more than one flow sets per-flow confs, parallel resolution can
analyze a flow under another flow's confs, producing non-deterministic and
occasionally incorrect analysis and schema inference.
### Does this PR introduce _any_ user-facing change?
No.
### How was this patch tested?
Added three tests in `ConnectValidPipelineSuite`:
- one checks that a flow's per-flow conf is what the analyzer reads during
analysis, but does not leak onto the session the pipeline is run from;
- one resolves several flows in parallel, each setting a different value
for the same conf, and asserts every flow's analysis observes its own value;
- one resolves a graph through `resolveToDataflowGraph()` with a per-flow
`spark.sql.caseSensitive` override and checks that analysis actually honors it:
a column reference resolves under the default but fails for the flow that turns
on case sensitivity.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Claude Opus 4.8)
Closes #56662 from LuciferYang/sdp-flow-resolution-conf-isolation.
Authored-by: YangJie <[email protected]>
Signed-off-by: yangjie01 <[email protected]>
(cherry picked from commit 7e83ff95ea61c9b011a3614a65223095b81d56a6)
Signed-off-by: yangjie01 <[email protected]>
---
.../spark/sql/pipelines/graph/FlowAnalysis.scala | 26 ++--
.../sql/pipelines/graph/FlowAnalysisContext.scala | 29 ++---
.../graph/ConnectValidPipelineSuite.scala | 142 +++++++++++++++++++++
3 files changed, 174 insertions(+), 23 deletions(-)
diff --git
a/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/FlowAnalysis.scala
b/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/FlowAnalysis.scala
index 7e174f2b3107..beb31eb80897 100644
---
a/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/FlowAnalysis.scala
+++
b/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/FlowAnalysis.scala
@@ -24,6 +24,7 @@ import org.apache.spark.sql.catalyst.{AliasIdentifier,
TableIdentifier}
import org.apache.spark.sql.catalyst.analysis.{CTESubstitution,
UnresolvedRelation}
import org.apache.spark.sql.catalyst.plans.logical.{LogicalPlan, SubqueryAlias}
import org.apache.spark.sql.classic.{DataFrame, DataFrameReader, Dataset,
DataStreamReader, SparkSession}
+import org.apache.spark.sql.internal.SQLConf
import
org.apache.spark.sql.pipelines.graph.GraphIdentifierManager.{ExternalDatasetIdentifier,
InternalDatasetIdentifier}
@@ -44,17 +45,23 @@ object FlowAnalysis {
confs: Map[String, String],
queryContext: QueryContext,
queryOrigin: QueryOrigin) => {
+ // Flows are resolved in parallel on a shared session, so applying
per-flow confs by mutating
+ // that session's conf would race across flows. Instead, give each flow
a private SQLConf
+ // (a clone of the session's conf plus this flow's overrides) and
install it for the analyzing
+ // thread via SQLConf.withExistingConf. Analysis still runs on the
shared session, so its
+ // catalog and the resolved DataFrames are unaffected; only the confs
the analyzer reads are
+ // isolated per flow.
+ val spark = SparkSession.active
val ctx = FlowAnalysisContext(
allInputs = allInputs,
availableInputs = availableInputs,
queryContext = queryContext,
- spark = SparkSession.active
+ spark = spark,
+ flowConf = spark.sessionState.conf.clone()
)
- val df = try {
+ val df = SQLConf.withExistingConf(ctx.flowConf) {
confs.foreach { case (k, v) => ctx.setConf(k, v) }
Try(FlowAnalysis.analyze(ctx, plan))
- } finally {
- ctx.restoreOriginalConf()
}
FlowFunctionResult(
requestedInputs = ctx.requestedInputs.toSet,
@@ -71,9 +78,12 @@ object FlowAnalysis {
* Constructs an analyzed [[DataFrame]] from a [[LogicalPlan]] by resolving
Pipelines specific
* TVFs and datasets that cannot be resolved directly by Catalyst.
*
- * This function shouldn't call any singleton as it will break concurrent
access to graph
- * analysis; or any thread local variables as graph analysis and this
function will use
- * different threads in python repl.
+ * This runs on the flow-resolution thread pool, which may differ from the
thread that defined
+ * the flow (e.g. in a Python REPL), so it must not depend on ambient
singletons or thread-locals
+ * carried over from that defining thread. The one piece of per-flow state
it relies on - the
+ * flow's SQL confs - is installed on the analyzing thread by
+ * [[createFlowFunctionFromLogicalPlan]] via `SQLConf.withExistingConf`, so
the Catalyst analysis
+ * this triggers reads them through `SQLConf.get`.
*
* @param plan The [[LogicalPlan]] defining a flow.
* @return An analyzed [[DataFrame]].
@@ -229,7 +239,7 @@ object FlowAnalysis {
// pass through trivially as their [[VirtualTableInput.load]] honors
`asStreaming` by
// construction. The check only ever fires for flows.
val incompatibleViewReadCheck =
- ctx.spark.conf.get("pipelines.incompatibleViewCheck.enabled",
"true").toBoolean
+ ctx.flowConf.getConfString("pipelines.incompatibleViewCheck.enabled",
"true").toBoolean
if (incompatibleViewReadCheck && isStreamingRead && !inputDF.isStreaming) {
throw new AnalysisException(
diff --git
a/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/FlowAnalysisContext.scala
b/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/FlowAnalysisContext.scala
index e5f7cddc4d32..bff470c8a20c 100644
---
a/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/FlowAnalysisContext.scala
+++
b/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/FlowAnalysisContext.scala
@@ -21,6 +21,7 @@ import scala.collection.mutable
import org.apache.spark.sql.catalyst.TableIdentifier
import org.apache.spark.sql.classic.SparkSession
+import org.apache.spark.sql.internal.SQLConf
/**
* A context used when evaluating a `Flow`'s query into a concrete DataFrame.
@@ -30,7 +31,12 @@ import org.apache.spark.sql.classic.SparkSession
* @param queryContext The context of the query being evaluated.
* @param requestedInputs A mutable buffer populated with names of all
inputs that were
* requested.
- * @param spark the spark session to be used.
+ * @param spark The (shared) spark session to be used.
+ * @param flowConf A private [[SQLConf]] holding this flow's
per-flow confs. It is
+ * installed for the analyzing thread via
`SQLConf.withExistingConf`
+ * (see
`FlowAnalysis.createFlowFunctionFromLogicalPlan`) so per-flow
+ * confs stay isolated from concurrently resolving
flows and from the
+ * shared session, without cloning the session.
* @param externalInputs The names of external inputs that were used to
evaluate
* the flow's query.
*/
@@ -43,6 +49,7 @@ private[pipelines] case class FlowAnalysisContext(
requestedInputs: mutable.HashSet[TableIdentifier] = mutable.HashSet.empty,
shouldLowerCaseNames: Boolean = false,
spark: SparkSession,
+ flowConf: SQLConf,
externalInputs: mutable.HashSet[TableIdentifier] = mutable.HashSet.empty
) {
@@ -50,20 +57,12 @@ private[pipelines] case class FlowAnalysisContext(
val availableInput: Map[TableIdentifier, Input] =
availableInputs.map(i => i.identifier -> i).toMap
- /** The confs set in this context that should be undone when exiting this
context. */
- private val confsToRestore = mutable.HashMap[String, Option[String]]()
-
- /** Sets a Spark conf within this context that will be undone by
`restoreOriginalConf`. */
+ /**
+ * Sets a Spark conf for this flow's analysis. It is set on the per-flow
[[flowConf]], which is
+ * active for the analyzing thread only, so it does not leak to other flows
or to the shared
+ * session.
+ */
def setConf(key: String, value: String): Unit = {
- if (!confsToRestore.contains(key)) {
- confsToRestore.put(key, spark.conf.getOption(key))
- }
- spark.conf.set(key, value)
- }
-
- /** Restores the Spark conf to its state when this context was creating by
undoing confs set. */
- def restoreOriginalConf(): Unit = confsToRestore.foreach {
- case (k, Some(v)) => spark.conf.set(k, v)
- case (k, None) => spark.conf.unset(k)
+ flowConf.setConfString(key, value)
}
}
diff --git
a/sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/graph/ConnectValidPipelineSuite.scala
b/sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/graph/ConnectValidPipelineSuite.scala
index 58a6dff709c7..014e9e1d5a60 100644
---
a/sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/graph/ConnectValidPipelineSuite.scala
+++
b/sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/graph/ConnectValidPipelineSuite.scala
@@ -20,7 +20,9 @@ package org.apache.spark.sql.pipelines.graph
import org.apache.spark.sql.catalyst.TableIdentifier
import org.apache.spark.sql.catalyst.analysis.UnresolvedRelation
import org.apache.spark.sql.catalyst.plans.logical.Union
+import org.apache.spark.sql.classic.DataFrame
import org.apache.spark.sql.execution.streaming.runtime.MemoryStream
+import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.pipelines.autocdc.{ChangeArgs, ScdType,
UnqualifiedColumnName}
import org.apache.spark.sql.pipelines.utils.{PipelineTest,
TestGraphRegistrationContext}
import org.apache.spark.sql.test.SharedSparkSession
@@ -561,4 +563,144 @@ class ConnectValidPipelineSuite extends PipelineTest with
SharedSparkSession {
s"Flow ${identifier.unquotedString} has the wrong schema"
)
}
+
+ test("per-flow confs are visible to the analyzer but do not leak onto the
run session") {
+ val key = "pipelines.test.flowConfIsolation"
+ assert(spark.conf.getOption(key).isEmpty)
+
+ val inputId = TableIdentifier("conf_observer")
+ // (conf the analyzer reads via SQLConf.get, conf on the run session)
captured during load().
+ var observed: (Option[String], Option[String]) = null
+ val runSession = spark
+ val observingInput = new Input {
+ override def identifier: TableIdentifier = inputId
+ override def origin: QueryOrigin = QueryOrigin()
+ override def load(asStreaming: Boolean): DataFrame = {
+ observed = (SQLConf.get.getAllConfs.get(key),
runSession.conf.getOption(key))
+ runSession.range(1).toDF()
+ }
+ }
+
+ val result = FlowAnalysis
+
.createFlowFunctionFromLogicalPlan(UnresolvedRelation(Seq("conf_observer")))
+ .call(
+ allInputs = Set(inputId),
+ availableInputs = Seq(observingInput),
+ configuration = Map(key -> "flowValue"),
+ queryContext = QueryContext(currentCatalog = None, currentDatabase =
None),
+ queryOrigin = QueryOrigin())
+
+ assert(result.dataFrame.isSuccess, s"flow analysis failed:
${result.dataFrame}")
+ assert(observed != null, "input.load was not invoked during analysis")
+ val (analyzerConf, runConf) = observed
+ // The per-flow conf is what the analyzer reads ...
+ assert(analyzerConf.contains("flowValue"))
+ // ... but it must not leak onto the session the pipeline is run from.
+ assert(
+ !runConf.contains("flowValue"),
+ "per-flow conf leaked onto the run session during flow analysis")
+ // ... and nothing is left behind on the run session afterwards.
+ assert(spark.conf.getOption(key).isEmpty)
+ }
+
+ test("per-flow confs stay isolated when flows are resolved in parallel") {
+ val key = "pipelines.test.flowConfIsolation"
+ assert(spark.conf.getOption(key).isEmpty)
+
+ val numFlows = 8
+ val runSession = spark
+ // The conf value each flow's analyzer reads for `key`.
+ val observed = new java.util.concurrent.ConcurrentHashMap[Int, String]()
+ val errors = new java.util.concurrent.ConcurrentLinkedQueue[Throwable]()
+ // Rendezvous so every flow is mid-analysis - its per-flow conf already
applied - at the same
+ // time. That is exactly when applying confs to a shared session would let
one flow observe
+ // another flow's value.
+ val barrier = new java.util.concurrent.CyclicBarrier(numFlows)
+
+ def observingInput(i: Int): Input = new Input {
+ override def identifier: TableIdentifier =
TableIdentifier(s"conf_observer_$i")
+ override def origin: QueryOrigin = QueryOrigin()
+ override def load(asStreaming: Boolean): DataFrame = {
+ barrier.await(60, java.util.concurrent.TimeUnit.SECONDS)
+ observed.put(i, SQLConf.get.getConfString(key, "<unset>"))
+ runSession.range(1).toDF()
+ }
+ }
+
+ val threads = (0 until numFlows).map { i =>
+ val t = new Thread(() => {
+ try {
+ val result = FlowAnalysis
+
.createFlowFunctionFromLogicalPlan(UnresolvedRelation(Seq(s"conf_observer_$i")))
+ .call(
+ allInputs = Set(TableIdentifier(s"conf_observer_$i")),
+ availableInputs = Seq(observingInput(i)),
+ configuration = Map(key -> s"flowValue_$i"),
+ queryContext = QueryContext(currentCatalog = None,
currentDatabase = None),
+ queryOrigin = QueryOrigin())
+ result.dataFrame.failed.foreach(errors.add)
+ } catch {
+ case t: Throwable => errors.add(t)
+ }
+ })
+ t.setName(s"flow-conf-isolation-$i")
+ t.start()
+ t
+ }
+ threads.foreach(_.join(120000))
+
+ assert(errors.isEmpty, s"flow analysis threads failed:
${errors.toArray.mkString(", ")}")
+ assert(
+ observed.size() == numFlows,
+ s"only ${observed.size()} of $numFlows flows recorded a conf")
+ (0 until numFlows).foreach { i =>
+ assert(
+ observed.get(i) == s"flowValue_$i",
+ s"flow $i observed '${observed.get(i)}' instead of its own per-flow
conf")
+ }
+ // Nothing leaks onto the run session.
+ assert(spark.conf.getOption(key).isEmpty)
+ }
+
+ test("per-flow confs reach the analyzer through the full
resolveToDataflowGraph() path") {
+ val caseSensitiveKey = SQLConf.CASE_SENSITIVE.key
+ // Pin the session default so the test is self-contained under the shared
session. The per-flow
+ // override below is applied to the flow's own conf, never to this session
conf.
+ withSQLConf(caseSensitiveKey -> "false") {
+ // With case-insensitive resolution `SELECT Foo FROM src` matches the
`foo` column. Setting
+ // spark.sql.caseSensitive=true on the consumer flow makes that flow's
analysis
+ // case-sensitive, so `Foo` no longer matches `foo`. Driving this through
+ // resolveToDataflowGraph() exercises a per-flow conf on the full
resolution path (not just a
+ // direct FlowAnalysis call) and shows it is consumed by Catalyst
analysis, not merely stored
+ // where SQLConf.get can read it. Cross-flow isolation under concurrency
is covered by the
+ // parallel test above.
+
+ // Baseline: no per-flow conf, so `Foo` matches `foo` and the graph
resolves.
+ val resolved = new TestGraphRegistrationContext(spark) {
+ registerPersistedView("src", query =
dfFlowFunc(spark.range(1).toDF("foo")))
+ registerPersistedView("consumer", query = sqlFlowFunc(spark, "SELECT
Foo FROM src"))
+ }.resolveToDataflowGraph()
+ assert(resolved.resolved, "pipeline should resolve under the default
case-insensitive conf")
+
+ // Same query, but the consumer flow sets spark.sql.caseSensitive=true,
so `Foo` no longer
+ // matches `foo` and analysis of that flow fails.
+ val unresolved = new TestGraphRegistrationContext(spark) {
+ registerPersistedView("src", query =
dfFlowFunc(spark.range(1).toDF("foo")))
+ registerPersistedView(
+ "consumer",
+ query = sqlFlowFunc(spark, "SELECT Foo FROM src"),
+ sqlConf = Map(caseSensitiveKey -> "true"))
+ }.resolveToDataflowGraph()
+ assert(!unresolved.resolved, "case-sensitive consumer flow should fail
to resolve")
+ val ex = intercept[UnresolvedPipelineException] {
+ unresolved.validate()
+ }
+ assertAnalysisException(
+ ex.directFailures(fullyQualifiedIdentifier("consumer")),
+ "UNRESOLVED_COLUMN.WITH_SUGGESTION")
+
+ // The per-flow conf must not leak onto the run session.
+ assert(spark.conf.get(caseSensitiveKey) == "false")
+ }
+ }
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]