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 f8ba02f9f7aa [SPARK-57625][SDP] Manage Auxiliary Table Lifecycle in
DatasetManager 1/2
f8ba02f9f7aa is described below
commit f8ba02f9f7aaf31e3a2bb620ab2f351d3e6dd9b3
Author: Anish Mahto <[email protected]>
AuthorDate: Wed Jul 1 11:58:32 2026 -0700
[SPARK-57625][SDP] Manage Auxiliary Table Lifecycle in DatasetManager 1/2
### What changes were proposed in this pull request?
Step 1/2 for managing auxiliary table lifecycle in `DatasetManager`. Pure
refactor that extracts catalog table creation and evolution logic into their
own helpers so that auxiliary tables can reuse them.
### Why are the changes needed?
Refactor before implementing auxiliary table full refresh, creation, and
schema evolution in `DatasetManager`.
### Does this PR introduce _any_ user-facing change?
No.
### How was this patch tested?
Existing tests
### Was this patch authored or co-authored using generative AI tooling?
Co-authored with Claude Opus 4.8.
Closes #56684 from
AnishMahto/SPARK-57625-extract-table-creation-and-evolution.
Authored-by: Anish Mahto <[email protected]>
Signed-off-by: Szehon Ho <[email protected]>
(cherry picked from commit bdf6d814ee0930ab7b20c13c84d27d44d8251c4e)
Signed-off-by: Szehon Ho <[email protected]>
---
.../spark/sql/pipelines/graph/DatasetManager.scala | 146 +++++++++++++++----
.../pipelines/graph/MaterializeTablesSuite.scala | 161 ++++++++++++++++++++-
2 files changed, 273 insertions(+), 34 deletions(-)
diff --git
a/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/DatasetManager.scala
b/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/DatasetManager.scala
index 456edca8d1e2..3c813dd997de 100644
---
a/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/DatasetManager.scala
+++
b/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/graph/DatasetManager.scala
@@ -29,16 +29,18 @@ import org.apache.spark.sql.classic.SparkSession
import org.apache.spark.sql.connector.catalog.{
CatalogV2Util,
Identifier,
+ Table => V2Table,
TableCatalog,
TableChange,
TableInfo
}
import
org.apache.spark.sql.connector.catalog.CatalogV2Util.v2ColumnsToStructType
-import org.apache.spark.sql.connector.expressions.{ClusterByTransform,
Expressions}
+import org.apache.spark.sql.connector.expressions.{ClusterByTransform,
Expressions, Transform}
import org.apache.spark.sql.execution.command.CreateViewCommand
import org.apache.spark.sql.pipelines.graph.QueryOrigin.ExceptionHelpers
import org.apache.spark.sql.pipelines.util.SchemaInferenceUtils.diffSchemas
import org.apache.spark.sql.pipelines.util.SchemaMergingUtils
+import org.apache.spark.sql.types.StructType
/**
* `DatasetManager` is responsible for materializing tables in the catalog
based on the given
@@ -278,11 +280,7 @@ object DatasetManager extends Logging {
val allTransforms = partitioning ++ clustering
- val existingTableOpt = if (catalog.tableExists(identifier)) {
- Some(catalog.loadTable(identifier))
- } else {
- None
- }
+ val existingTableOpt = loadTableIfExists(catalog, identifier)
// Error if partitioning/clustering doesn't match
existingTableOpt.foreach { existingTable =>
@@ -298,8 +296,14 @@ object DatasetManager extends Logging {
}
}
+ // A streaming table on a non-full-refresh run is maintained
incrementally: its existing data is
+ // preserved and its schema is merged with (not replaced by) the schema
computed in this run.
+ // Every other case (materialized views, and any full refresh) is
recomputed from scratch:
+ // existing data is wiped and the schema is taken directly from this run's
computed schema.
+ val isTableIncrementallyUpdated = table.isStreamingTable && !isFullRefresh
+
// Wipe the data if we need to
- if ((isFullRefresh || !table.isStreamingTable) &&
existingTableOpt.isDefined) {
+ if (existingTableOpt.isDefined && !isTableIncrementallyUpdated) {
context.spark.sql(s"TRUNCATE TABLE ${table.identifier.quotedString}")
}
@@ -317,31 +321,25 @@ object DatasetManager extends Logging {
context.spark.sql(s"DROP TABLE IF EXISTS
${auxiliaryTableId.quotedString}")
}
- // Alter the table if we need to
- existingTableOpt.foreach { existingTable =>
- val existingSchema = v2ColumnsToStructType(existingTable.columns())
-
- val targetSchema = if (table.isStreamingTable && !isFullRefresh) {
- SchemaMergingUtils.mergeSchemas(existingSchema, outputSchema)
- } else {
- outputSchema
- }
-
- val columnChanges = diffSchemas(existingSchema, targetSchema)
- val setProperties = mergedProperties.map { case (k, v) =>
TableChange.setProperty(k, v) }
- catalog.alterTable(identifier, (columnChanges ++ setProperties).toArray:
_*)
- }
-
- // Create the table if we need to
- if (existingTableOpt.isEmpty) {
- catalog.createTable(
- identifier,
- new TableInfo.Builder()
- .withProperties(mergedProperties.asJava)
- .withColumns(CatalogV2Util.structTypeToV2Columns(outputSchema))
- .withPartitions(allTransforms.toArray)
- .build()
- )
+ // Create the table if absent, otherwise evolve it (schema + properties).
+ existingTableOpt match {
+ case Some(existingTable) =>
+ evolveTable(
+ catalog = catalog,
+ tableIdentifier = identifier,
+ existingTable = existingTable,
+ desiredSchema = outputSchema,
+ properties = mergedProperties,
+ mergeWithExistingSchema = isTableIncrementallyUpdated
+ )
+ case None =>
+ createTable(
+ catalog = catalog,
+ tableIdentifier = identifier,
+ schema = outputSchema,
+ properties = mergedProperties,
+ transforms = allTransforms
+ )
}
table.copy(
@@ -351,6 +349,90 @@ object DatasetManager extends Logging {
)
}
+ /** Loads the table at `identifier` from `catalog`, or `None` if it does not
exist. */
+ private def loadTableIfExists(
+ catalog: TableCatalog,
+ identifier: Identifier): Option[V2Table] = {
+ Option.when(catalog.tableExists(identifier))(catalog.loadTable(identifier))
+ }
+
+ /**
+ * Creates the table at `identifier` with the given schema, properties, and
partition/cluster
+ * transforms. Used when no table yet exists at the identifier.
+ *
+ * @param schema the schema to create the table with.
+ * @param properties the table properties to create the table with.
+ * @param transforms the partition/cluster transforms to create the table
with.
+ */
+ private def createTable(
+ catalog: TableCatalog,
+ tableIdentifier: Identifier,
+ schema: StructType,
+ properties: Map[String, String],
+ transforms: Seq[Transform]): Unit = {
+ catalog.createTable(
+ tableIdentifier,
+ new TableInfo.Builder()
+ .withProperties(properties.asJava)
+ .withColumns(CatalogV2Util.structTypeToV2Columns(schema))
+ .withPartitions(transforms.toArray)
+ .build()
+ )
+ }
+
+ /**
+ * Evolves the already-existing `existingTable` at `identifier` in place by
diffing its schema
+ * and properties, skipping the catalog `alterTable` entirely when nothing
actually changes.
+ * Partitioning/clustering cannot change in place, so no transforms are
accepted here.
+ *
+ * @param existingTable the currently materialized table.
+ * @param desiredSchema the schema the table should have as
computed in the current
+ * execution (the user-specified or inferred
schema). This is the
+ * "incoming" side and may differ from
`existingTable`'s recorded
+ * schema due to schema evolution across runs.
+ * @param properties the declared table properties to (re)set
on the table. Note
+ * that properties absent here are NOT
removed from the table (see
+ * the TODO in the body).
+ * @param mergeWithExistingSchema whether the effective schema is the merge
of the existing and
+ * desired schemas (additive evolution)
rather than the desired
+ * schema as-is.
+ */
+ private def evolveTable(
+ catalog: TableCatalog,
+ tableIdentifier: Identifier,
+ existingTable: V2Table,
+ desiredSchema: StructType,
+ properties: Map[String, String],
+ mergeWithExistingSchema: Boolean): Unit = {
+ val currentSchema = v2ColumnsToStructType(existingTable.columns())
+ val targetSchema = if (mergeWithExistingSchema) {
+ SchemaMergingUtils.mergeSchemas(currentSchema, desiredSchema)
+ } else {
+ desiredSchema
+ }
+ val columnChanges = diffSchemas(currentSchema, targetSchema)
+
+ val existingProperties = existingTable.properties()
+
+ // TODO (SPARK-57670): Property removal is intentionally not handled here:
a property dropped
+ // from the table definition between runs is left in place rather than
actually removed from the
+ // corresponding catalog table entry. Removing it reliably is hard because
we cannot distinguish
+ // a user-declared property the user dropped from a catalog/engine-managed
property (e.g. the
+ // non-reserved `clusteringColumns`, or arbitrary catalog-internal keys)
that must never be
+ // removed, and there is no record of which keys the pipeline previously
set.
+ val propertiesToSet = properties.collect {
+ case (k, v) if !Option(existingProperties.get(k)).contains(v) =>
+ TableChange.setProperty(k, v)
+ }
+
+ val allTableChanges = columnChanges ++ propertiesToSet
+
+ // If there are no table changes to evolve with, avoid the no-op
round-trip alter altogether.
+ if (allTableChanges.nonEmpty) {
+ catalog.alterTable(tableIdentifier, allTableChanges.toArray: _*)
+ }
+ }
+
/**
* Some fields on the [[Table]] object are represented as reserved table
properties by the catalog
* APIs. This method creates a table properties map that merges the
user-provided table properties
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 29d85e9b4439..46e2d6d9ae63 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
@@ -17,11 +17,19 @@
package org.apache.spark.sql.pipelines.graph
+import scala.collection.mutable
import scala.jdk.CollectionConverters._
import org.apache.spark.SparkThrowable
-import org.apache.spark.sql.{AnalysisException, SQLContext}
-import org.apache.spark.sql.connector.catalog.{CatalogV2Util, Identifier,
TableCatalog}
+import org.apache.spark.sql.{AnalysisException, Row, SQLContext}
+import org.apache.spark.sql.connector.catalog.{
+ CatalogV2Util,
+ Identifier,
+ InMemoryTableCatalog,
+ Table => V2Table,
+ TableCatalog,
+ TableChange
+}
import org.apache.spark.sql.connector.expressions.{ClusterByTransform,
Expressions, FieldReference}
import org.apache.spark.sql.execution.streaming.runtime.MemoryStream
import
org.apache.spark.sql.pipelines.graph.DatasetManager.TableMaterializationException
@@ -1083,4 +1091,153 @@ abstract class MaterializeTablesSuite extends
BaseCoreExecutionTest {
}
assert(ex.cause.isInstanceOf[AnalysisException])
}
+
+ // =============== Table evolution in catalog tests ===============
+
+ private val recordingCatalogName = "recording_cat"
+ private val recordingNamespace = "rec_ns"
+
+ /**
+ * Registers [[RecordingInMemoryTableCatalog]] under `recordingCatalogName`,
creates
+ * `recordingNamespace`, runs `body`, then tears the registration back down.
+ */
+ private def withRecordingCatalog(body: => Unit): Unit = {
+ spark.conf.set(
+ s"spark.sql.catalog.$recordingCatalogName",
+ classOf[RecordingInMemoryTableCatalog].getName
+ )
+ try {
+ spark.sql(s"CREATE NAMESPACE IF NOT EXISTS
$recordingCatalogName.$recordingNamespace")
+ body
+ } finally {
+ spark.sessionState.catalogManager.reset()
+
spark.sessionState.conf.unsetConf(s"spark.sql.catalog.$recordingCatalogName")
+ }
+ }
+
+ /**
+ * Materializes a single streaming table under the recording
catalog/namespace with the given
+ * schema and properties.
+ */
+ private def materializeStreamingTable(
+ name: String,
+ schema: StructType,
+ properties: Map[String, String]): Unit = {
+ // All nulls dummy row, compatible with any schema type
+ val row = Row.fromSeq(Seq.fill(schema.length)(null))
+ val df = spark.createDataFrame(spark.sparkContext.parallelize(Seq(row)),
schema)
+ materializeGraph(
+ new TestGraphRegistrationContext(spark) {
+ registerTable(
+ name,
+ query = Option(dfFlowFunc(df)),
+ specifiedSchema = Option(schema),
+ properties = properties,
+ catalog = Option(recordingCatalogName),
+ database = Option(recordingNamespace)
+ )
+ }.resolveToDataflowGraph(),
+ storageRoot = storageRoot
+ )
+ }
+
+ private def recordingCatalog: RecordingInMemoryTableCatalog =
+ spark.sessionState.catalogManager
+ .catalog(recordingCatalogName)
+ .asInstanceOf[RecordingInMemoryTableCatalog]
+
+ private def loadTableFromRecordingCatalog(name: String): V2Table = {
+ val catalog = spark.sessionState.catalogManager
+ .catalog(recordingCatalogName)
+ .asInstanceOf[TableCatalog]
+ catalog.loadTable(Identifier.of(Array(recordingNamespace), name))
+ }
+
+ test("re-materializing an unchanged table does not issue an alterTable") {
+ withRecordingCatalog {
+ val schema = new StructType().add("id", IntegerType).add("value",
StringType)
+ val props = Map("p.a" -> "1", "p.b" -> "2")
+ // Creating the table issues no alter, and re-materializing the
unchanged table is a no-op,
+ // so no alter is ever recorded.
+ materializeStreamingTable("t", schema, props)
+ assert(recordingCatalog.recordedAlters.isEmpty)
+ materializeStreamingTable("t", schema, props)
+ assert(recordingCatalog.recordedAlters.isEmpty)
+ }
+ }
+
+ test("re-materializing with changed/new properties issues an alterTable that
sets them") {
+ withRecordingCatalog {
+ val schema = new StructType().add("id", IntegerType).add("value",
StringType)
+ // Creating the table issues no alter; re-materializing with
changed/added properties issues
+ // exactly one alter that sets them.
+ materializeStreamingTable("t", schema, Map("p.a" -> "1"))
+ assert(recordingCatalog.recordedAlters.isEmpty)
+ materializeStreamingTable("t", schema, Map("p.a" -> "2", "p.new" -> "n"))
+ assert(recordingCatalog.recordedAlters.size == 1)
+
+ val changes = recordingCatalog.recordedAlters.flatten
+ assert(changes.forall(_.isInstanceOf[TableChange.SetProperty]))
+ val set = changes.collect {
+ case s: TableChange.SetProperty => s.property() -> s.value()
+ }.toMap
+ assert(set == Map("p.a" -> "2", "p.new" -> "n"))
+
+ val table = loadTableFromRecordingCatalog("t")
+ assert(table.properties().get("p.a") == "2")
+ assert(table.properties().get("p.new") == "n")
+ }
+ }
+
+ test("re-materializing with an added column issues an alterTable") {
+ withRecordingCatalog {
+ // Creating the table issues no alter; re-materializing with an added
column issues exactly
+ // one alter that adds it.
+ materializeStreamingTable("t", new StructType().add("id", IntegerType),
Map("p.a" -> "1"))
+ assert(recordingCatalog.recordedAlters.isEmpty)
+ materializeStreamingTable(
+ "t",
+ new StructType().add("id", IntegerType).add("value", StringType),
+ Map("p.a" -> "1")
+ )
+ assert(recordingCatalog.recordedAlters.size == 1)
+
+ val changes = recordingCatalog.recordedAlters.flatten
+ assert(changes.exists(_.isInstanceOf[TableChange.AddColumn]))
+
+ assert(
+ loadTableFromRecordingCatalog("t").columns() sameElements
+ CatalogV2Util.structTypeToV2Columns(
+ new StructType().add("id", IntegerType).add("value", StringType)
+ )
+ )
+ }
+ }
+
+ test("re-materializing with a dropped property neither removes it nor issues
an alterTable") {
+ withRecordingCatalog {
+ val schema = new StructType().add("id", IntegerType)
+ // This test locks in the current buggy behavior where dropped
properties do not materialize
+ // against the catalog table entity. See SPARK-57670.
+ materializeStreamingTable("t", schema, Map("p.keep" -> "v", "p.stale" ->
"old"))
+ assert(recordingCatalog.recordedAlters.isEmpty)
+ materializeStreamingTable("t", schema, Map("p.keep" -> "v"))
+ assert(recordingCatalog.recordedAlters.isEmpty)
+
+ assert(loadTableFromRecordingCatalog("t").properties().get("p.stale") ==
"old")
+ }
+ }
+}
+
+/**
+ * An [[InMemoryTableCatalog]] that records every `alterTable` invocation
while still applying it,
+ * so tests can assert whether materialization issued an alter or skipped it
as a no-op.
+ */
+class RecordingInMemoryTableCatalog extends InMemoryTableCatalog {
+ val recordedAlters: mutable.ArrayBuffer[Seq[TableChange]] =
mutable.ArrayBuffer.empty
+
+ override def alterTable(ident: Identifier, changes: TableChange*): V2Table =
{
+ recordedAlters += changes.toSeq
+ super.alterTable(ident, changes: _*)
+ }
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]