You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@spark.apache.org by sz...@apache.org on 2026/08/24 18:29:20 UTC
(spark) branch branch-4.x updated: [SPARK-58941][SDP] Sort schema inference flows by identifier parts to avoid dotted-name collisions
This is an automated email from the ASF dual-hosted git repository.
szehon-ho 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 9c2e941c4fd8 [SPARK-58941][SDP] Sort schema inference flows by identifier parts to avoid dotted-name collisions
9c2e941c4fd8 is described below
commit 9c2e941c4fd8a201b90460bd53b7cc4801e39ea2
Author: Andreas Neumann <an...@apache.org>
AuthorDate: Mon Aug 24 11:28:34 2026 -0700
[SPARK-58941][SDP] Sort schema inference flows by identifier parts to avoid dotted-name collisions
### What changes were proposed in this pull request?
`SchemaInferenceUtils.inferSchemaFromFlows` merges the flows that write to a table in a
deterministic order so that, when two flows emit a column whose names differ only in case, the
surviving spelling is well-defined and every caller agrees on it. It established that order by
sorting on `flow.identifier.unquotedString`.
`TableIdentifier.unquotedString` joins the identifier's name parts with an **unescaped** `.`. That
mapping is not one-to-one: two structurally distinct identifiers whose parts contain dots (a dot is
legal inside a back-tick-quoted schema or flow name) can render to the same string -- e.g.
`` `c`.`a.b`.`x` `` and `` `c`.`a`.`b.x` `` both render to `c.a.b.x`. Because `sortBy` is stable,
colliding keys fall back to the incoming `flows` order, which is the nondeterministic
flow-resolution completion order the sort was meant to remove, so the surviving column casing could
flip between runs.
This PR sorts on the identifier's parts `(catalog, database, table)` instead. Comparing the parts
tuple is injective -- two distinct identifiers are two distinct tuples, so they can never collide.
It also preserves the existing order for dotless identifiers (a shorter part sorts first, exactly
as the `.` separator, 0x2E, ordered a joined string), unlike `TableIdentifier.quotedString`, which
would additionally reorder some ordinary pairs because `` ` `` (0x60) sorts after name characters.
Flow identifiers are always fully qualified (`assertIsFullyQualifiedForCreate`), so all three parts
are present and the ordering never depends on an absent catalog/database.
This is a follow-up to SPARK-58517, which added the sort.
### Why are the changes needed?
The sort exists to guarantee a deterministic surviving column spelling. `unquotedString` is a lossy
key, so under identifiers that contain dots the guarantee silently breaks: the merge order reverts
to the nondeterministic completion order of concurrent flow resolution, and a case-only column
spelling can flip between runs of an unchanged pipeline. On the non-merging evolution paths
`diffSchemas` keys column identity on the exact name, so a run-to-run flip surfaces as a
`deleteColumn` + `addColumn` for a column that only changed case. Sorting on the identifier parts is
injective and removes the collision.
### Does this PR introduce _any_ user-facing change?
No. The merge order changes only for identifiers with dotted (back-tick-quoted) name parts, and the
change is confined to the unreleased `master` / `branch-4.x`. For ordinary (dotless) identifiers the
order is unchanged.
### How was this patch tested?
Added a unit test to `SchemaInferenceUtilsSuite` that builds two resolved flows whose identifiers
(`` `c`.`a.b`.`x` `` and `` `c`.`a`.`b.x` ``) would collide under a dot-joined key but stay distinct
when sorted on their parts, each carrying a case-only-differing column, and asserts the inferred
schema is identical regardless of the order the flows are passed in. The test fails with the
previous `unquotedString` key and passes with the parts key.
Ran `build/sbt 'pipelines/testOnly *SchemaInferenceUtilsSuite'` (13 tests, all passed).
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Opus 4.8
Closes #58223 from anew/sdp-sort-flows-quoted-identifier.
Authored-by: Andreas Neumann <an...@apache.org>
Signed-off-by: Szehon Ho <sz...@gmail.com>
(cherry picked from commit abc55fe2309e21c55b83b8293b7392674831502b)
Signed-off-by: Szehon Ho <sz...@gmail.com>
---
.../sql/pipelines/util/SchemaInferenceUtils.scala | 6 +-
.../pipelines/graph/MaterializeTablesSuite.scala | 14 +---
.../pipelines/util/SchemaInferenceUtilsSuite.scala | 88 +++++++++++++++++++++-
3 files changed, 94 insertions(+), 14 deletions(-)
diff --git a/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/util/SchemaInferenceUtils.scala b/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/util/SchemaInferenceUtils.scala
index 10b3202a61af..fd6068415074 100644
--- a/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/util/SchemaInferenceUtils.scala
+++ b/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/util/SchemaInferenceUtils.scala
@@ -122,7 +122,9 @@ object SchemaInferenceUtils {
* with how the rest of the engine resolves those names.
*
* When flows differ only in column casing, the surviving spelling is the one from the flow with
- * the lowest identifier: `flows` is merged in sorted identifier order, not in the order given.
+ * the lowest identifier: `flows` is merged in sorted identifier order, not in the order given. We
+ * sort on the identifier's parts (catalog, database, table) to avoid collisions for identifiers
+ * whose parts contain dots.
* Sorting here rather than at the call sites keeps every caller agreeing on the result, since the
* schemas they derive are compared against each other -- the graph's inferred schema materializes
* the table, while [[org.apache.spark.sql.pipelines.graph.VirtualTableInput]] produces the schema
@@ -151,7 +153,7 @@ object SchemaInferenceUtils {
)
val inferredSchema = flows
- .sortBy(_.identifier.unquotedString)
+ .sortBy(f => (f.identifier.catalog, f.identifier.database, f.identifier.table))
.map(_.schema)
.fold(new StructType()) { (schemaSoFar, schema) =>
try {
diff --git a/sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/graph/MaterializeTablesSuite.scala b/sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/graph/MaterializeTablesSuite.scala
index a4df9867e2ab..916169c4de2d 100644
--- a/sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/graph/MaterializeTablesSuite.scala
+++ b/sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/graph/MaterializeTablesSuite.scala
@@ -1275,16 +1275,10 @@ abstract class MaterializeTablesSuite extends BaseCoreExecutionTest {
val graph = ctx.resolveToDataflowGraph()
val inferredSchemas = graph.inferSchemas(spark.sessionState.conf.caseSensitiveAnalysis)
- val (targetIdentifier, inferred) = inferredSchemas.head
- val lowestIdentifierFlowValueField =
- graph.resolvedFlowsTo(targetIdentifier)
- .sortBy(_.identifier.unquotedString)
- .head
- .schema
- .fieldNames(1)
- // The two spellings must fold into a single column, and the lowest flow identifier
- // supplies the surviving spelling.
- assert(inferred.fieldNames.toSeq === Seq("id", lowestIdentifierFlowValueField))
+ val (_, inferred) = inferredSchemas.head
+ // The two spellings fold into a single column, and the lowest flow identifier supplies the
+ // surviving spelling: `f1` sorts before `f2`, so `value` wins.
+ assert(inferred.fieldNames.toSeq === Seq("id", "value"))
}
}
}
diff --git a/sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/util/SchemaInferenceUtilsSuite.scala b/sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/util/SchemaInferenceUtilsSuite.scala
index e6aed97f35dd..92e1c700b99b 100644
--- a/sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/util/SchemaInferenceUtilsSuite.scala
+++ b/sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/util/SchemaInferenceUtilsSuite.scala
@@ -17,11 +17,66 @@
package org.apache.spark.sql.pipelines.util
-import org.apache.spark.{SparkException, SparkFunSuite}
+import scala.util.Success
+
+import org.apache.spark.SparkException
+import org.apache.spark.sql.{QueryTest, Row}
+import org.apache.spark.sql.catalyst.TableIdentifier
import org.apache.spark.sql.connector.catalog.TableChange
+import org.apache.spark.sql.pipelines.graph.{
+ FlowFunction,
+ FlowFunctionResult,
+ Input,
+ QueryContext,
+ QueryOrigin,
+ ResolvedFlow,
+ StreamingFlow,
+ UntypedFlow
+}
+import org.apache.spark.sql.test.SharedSparkSession
import org.apache.spark.sql.types._
-class SchemaInferenceUtilsSuite extends SparkFunSuite {
+class SchemaInferenceUtilsSuite extends QueryTest with SharedSparkSession {
+
+ /** A [[FlowFunction]] that throws if invoked; the inferSchemaFromFlows test builds resolved
+ * flows directly. */
+ private val noOpFlowFunction: FlowFunction = new FlowFunction {
+ override def call(
+ allInputs: Set[TableIdentifier],
+ availableInputs: Seq[Input],
+ configuration: Map[String, String],
+ queryContext: QueryContext,
+ queryOrigin: QueryOrigin): FlowFunctionResult =
+ throw new UnsupportedOperationException(
+ "noOpFlowFunction.call should not be invoked from SchemaInferenceUtilsSuite tests")
+ }
+
+ private val queryContext = QueryContext(currentCatalog = Some("c"), currentDatabase = Some("d"))
+
+ /** A resolved flow with the given identifier and output schema, writing to `destination`. */
+ private def resolvedFlow(
+ identifier: TableIdentifier,
+ destination: TableIdentifier,
+ schema: StructType): ResolvedFlow = {
+ val df = spark.createDataFrame(spark.sparkContext.emptyRDD[Row], schema)
+ val flow = UntypedFlow(
+ identifier = identifier,
+ destinationIdentifier = destination,
+ func = noOpFlowFunction,
+ queryContext = queryContext,
+ sqlConf = Map.empty,
+ once = false,
+ origin = QueryOrigin.empty)
+ new StreamingFlow(
+ flow,
+ FlowFunctionResult(
+ requestedInputs = Set.empty,
+ batchInputs = Set.empty,
+ streamingInputs = Set.empty,
+ usedExternalInputs = Set.empty,
+ dataFrame = Success(df),
+ sqlConf = Map.empty))
+ }
test("determineColumnChanges - adding new columns") {
val currentSchema = new StructType()
@@ -398,4 +453,33 @@ class SchemaInferenceUtilsSuite extends SparkFunSuite {
assert(!changes.exists(_.isInstanceOf[TableChange.DeleteColumn]))
}
}
+
+ test("inferSchemaFromFlows folds a case-only column to the same spelling regardless of flow " +
+ "order, even when identifier names contain dots") {
+ // The merge order decides which spelling of a case-only-differing column survives, so it must
+ // not depend on the incoming flow order (the nondeterministic flow-resolution completion
+ // order). The two identifiers below differ only in where the dot falls, so a dot-joined sort
+ // key would render them identical; sorting on the identifier parts keeps them distinct.
+ val destination = TableIdentifier("t", Some("d"), Some("c"))
+ val flowA = resolvedFlow(
+ identifier = TableIdentifier("x", Some("a.b"), Some("c")),
+ destination = destination,
+ schema = new StructType().add("id", IntegerType).add("value", StringType))
+ val flowB = resolvedFlow(
+ identifier = TableIdentifier("b.x", Some("a"), Some("c")),
+ destination = destination,
+ schema = new StructType().add("id", IntegerType).add("Value", StringType))
+
+ // The lower identifier (flowB: database "a" precedes "a.b") supplies the surviving spelling, in
+ // either input order.
+ val expected = new StructType().add("id", IntegerType).add("Value", StringType)
+ Seq(Seq(flowA, flowB), Seq(flowB, flowA)).foreach { flows =>
+ val inferred = SchemaInferenceUtils.inferSchemaFromFlows(
+ tableIdentifier = destination,
+ flows = flows,
+ userSpecifiedSchema = None,
+ sessionCaseSensitive = false)
+ assert(inferred === expected, s"unexpected schema for input order $flows")
+ }
+ }
}
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@spark.apache.org
For additional commands, e-mail: commits-help@spark.apache.org