szehon-ho commented on code in PR #57669:
URL: https://github.com/apache/spark/pull/57669#discussion_r3692318405


##########
sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/graph/AutoCdcScd2ColumnEvolutionSuite.scala:
##########
@@ -0,0 +1,278 @@
+/*
+ * 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.pipelines.graph
+
+import org.apache.spark.sql.Row
+import org.apache.spark.sql.classic.DataFrame
+import org.apache.spark.sql.execution.streaming.runtime.MemoryStream
+import org.apache.spark.sql.functions
+import org.apache.spark.sql.pipelines.autocdc.{
+  ChangeArgs,
+  ColumnSelection,
+  ScdType,
+  UnqualifiedColumnName
+}
+import org.apache.spark.sql.pipelines.utils.{ExecutionTest, 
TestGraphRegistrationContext}
+import org.apache.spark.sql.test.SharedSparkSession
+
+/**
+ * End-to-end tests for SCD Type 2 AutoCDC column-schema evolution across 
runs: a microbatch that
+ * is narrower than the already-evolved target (a source column dropped, a 
nested struct/array field
+ * dropped, or the `COLUMNS` selection narrowed) must reconcile correctly 
instead of failing the
+ * internal union.
+ *
+ * These exercise the fix for SPARK-58418. Before it, 
`Scd2ForeachBatchHandler.reconcileMicrobatch`
+ * unioned the microbatch with the affected target/aux rows without 
`allowMissingColumns`, so a
+ * narrower microbatch failed with NUM_COLUMNS_MISMATCH (top-level) or 
INCOMPATIBLE_COLUMN_TYPE
+ * (nested). The contract asserted here is additive-tolerant, matching SCD1: 
records already written
+ * keep their values for the no-longer-emitted column, and only records opened 
by the narrower
+ * microbatch carry null for it.
+ *
+ * Changing the effective *tracked-history* column set is a distinct, 
separately-scoped concern
+ * (SPARK-58452 / SPARK-58391) and is deliberately not exercised here: every 
scenario keeps the
+ * effective tracked set unchanged across runs, so the only thing evolving is 
the set of user
+ * columns the flow emits. The column-selection test therefore drops a column 
that is *not* in the
+ * tracked set (an explicit `TRACK HISTORY ON (name)` flow dropping the 
non-tracked `email`), so it
+ * stays valid once the track-history drift guard (SPARK-58391) lands.
+ */
+class AutoCdcScd2ColumnEvolutionSuite
+    extends ExecutionTest
+    with SharedSparkSession
+    with AutoCdcGraphExecutionTestMixin {
+
+  /** The SCD2 target's `_cdc_metadata` struct value for a given 
recordStartAt. */
+  private def scd2Meta(recordStartAt: Long): Row = Row(recordStartAt)
+
+  /**
+   * Build a single-flow SCD2 pipeline that tracks history on exactly 
`trackColumns` (an explicit
+   * `TRACK HISTORY ON (...)`), so a column outside that set can be dropped 
without changing the
+   * tracked set. The mixin's `singleAutoCdcFlowPipeline` does not expose a 
track-history knob, so
+   * the flow is built inline here.
+   */
+  private def scd2FlowTracking(
+      sourceDf: DataFrame,
+      keys: Seq[String],
+      trackColumns: Seq[String]): TestGraphRegistrationContext =
+    new TestGraphRegistrationContext(spark) {
+      registerTable("target", catalog = Some(catalog), database = 
Some(namespace))
+      registerFlow(AutoCdcFlow(
+        identifier = fullyQualifiedIdentifier("auto_cdc_flow", Some(catalog), 
Some(namespace)),
+        destinationIdentifier =
+          fullyQualifiedIdentifier("target", Some(catalog), Some(namespace)),
+        func = dfFlowFunc(sourceDf),
+        queryContext =
+          QueryContext(currentCatalog = Some(catalog), currentDatabase = 
Some(namespace)),
+        origin = QueryOrigin.empty,
+        changeArgs = ChangeArgs(
+          keys = keys.map(UnqualifiedColumnName(_)),
+          sequencing = functions.col("version"),
+          storedAsScdType = ScdType.Type2,
+          trackHistorySelection = Some(ColumnSelection.IncludeColumns(
+            trackColumns.map(UnqualifiedColumnName(_)))))))
+    }
+
+  test("a source column dropped between runs is preserved on existing records 
and null on new " +
+    "ones") {
+    val session = spark
+    import session.implicits._
+
+    spark.sql(
+      s"CREATE TABLE $catalog.$namespace.target " +
+      s"(id INT NOT NULL, name STRING, email STRING, version BIGINT NOT NULL, 
$scd2MetadataDdl)"
+    )
+
+    // Shared stream; run #2 projects `email` away so the microbatch is 
narrower than the target.
+    // Track history on `name` only, so the dropped `email` is a non-tracked 
column and dropping it
+    // is column-schema narrowing rather than a tracked-set change (robust 
once SPARK-58391 lands).
+    val stream = MemoryStream[(Int, String, String, Long)]
+    def buildCtx(includeEmail: Boolean): TestGraphRegistrationContext = {
+      val df = stream.toDF().toDF("id", "name", "email", "version")
+      scd2FlowTracking(
+        sourceDf = if (includeEmail) df else df.drop("email"),
+        keys = Seq("id"),
+        trackColumns = Seq("name"))
+    }
+
+    // Run #1 (wide): key=1 opens a record carrying email=a@x.
+    stream.addData((1, "alice", "a@x", 1L))
+    runPipeline(buildCtx(includeEmail = true))
+    checkAnswer(
+      spark.table(s"$catalog.$namespace.target"),
+      Seq(Row(1, "alice", "a@x", 1L, 1L, null, scd2Meta(1L)))
+    )
+
+    // Run #2 (narrow): update key=1 (closes its record) + insert key=2. The 
dropped `email` is
+    // preserved on key=1's now-closed record and is null on the newly-opened 
records.
+    stream.addData((1, "alice2", "ignored", 2L), (2, "bob", "ignored", 1L))
+    runPipeline(buildCtx(includeEmail = false))
+    checkAnswer(
+      spark.table(s"$catalog.$namespace.target"),
+      Seq(
+        Row(1, "alice", "a@x", 1L, 1L, 2L, scd2Meta(1L)),
+        Row(1, "alice2", null, 2L, 2L, null, scd2Meta(2L)),
+        Row(2, "bob", null, 1L, 1L, null, scd2Meta(1L))
+      )
+    )
+  }
+
+  test("dropping a non-tracked column from the COLUMNS selection preserves it 
on existing " +
+    "records and leaves it null on new ones") {
+    val session = spark
+    import session.implicits._
+
+    spark.sql(
+      s"CREATE TABLE $catalog.$namespace.target " +
+      s"(id INT NOT NULL, name STRING, email STRING, version BIGINT NOT NULL, 
$scd2MetadataDdl)"
+    )
+
+    // The flow tracks history on `name` only, so `email` is a 
selected-but-not-tracked column.
+    // Dropping `email` from the selection is therefore pure column-schema 
narrowing: the effective
+    // tracked set ({name}) is unchanged, so this stays column evolution 
rather than a tracked-set
+    // change even once the track-history drift guard (SPARK-58391) lands.
+    val stream = MemoryStream[(Int, String, String, Long)]
+    def buildCtx(includeEmail: Boolean): TestGraphRegistrationContext = {
+      val df = stream.toDF().toDF("id", "name", "email", "version")
+      scd2FlowTracking(
+        sourceDf = if (includeEmail) df else df.drop("email"),
+        keys = Seq("id"),
+        trackColumns = Seq("name"))
+    }
+
+    // Run #1: `email` selected; key=1 carries email=a@x.
+    stream.addData((1, "alice", "a@x", 1L))
+    runPipeline(buildCtx(includeEmail = true))
+    checkAnswer(
+      spark.table(s"$catalog.$namespace.target"),
+      Seq(Row(1, "alice", "a@x", 1L, 1L, null, scd2Meta(1L)))
+    )
+
+    // Run #2: drop the non-tracked `email`. Because `name` (the sole tracked 
column) changes, this
+    // opens a new record; key=1's closed record keeps a@x, and the new 
records carry null.
+    stream.addData((1, "alice2", "ignored", 2L), (2, "bob", "ignored", 1L))
+    runPipeline(buildCtx(includeEmail = false))
+    checkAnswer(
+      spark.table(s"$catalog.$namespace.target"),
+      Seq(
+        Row(1, "alice", "a@x", 1L, 1L, 2L, scd2Meta(1L)),
+        Row(1, "alice2", null, 2L, 2L, null, scd2Meta(2L)),
+        Row(2, "bob", null, 1L, 1L, null, scd2Meta(1L))
+      )
+    )
+  }
+
+  test("a late narrower event weaves into history without rewriting existing 
records") {
+    val session = spark
+    import session.implicits._
+
+    spark.sql(
+      s"CREATE TABLE $catalog.$namespace.target " +
+      s"(id INT NOT NULL, name STRING, email STRING, version BIGINT NOT NULL, 
$scd2MetadataDdl)"
+    )
+
+    // Track history on `name` only, so dropping `email` is column narrowing 
with an unchanged
+    // tracked set (robust once SPARK-58391 lands).
+    val stream = MemoryStream[(Int, String, String, Long)]
+    def buildCtx(includeEmail: Boolean): TestGraphRegistrationContext = {
+      val df = stream.toDF().toDF("id", "name", "email", "version")
+      scd2FlowTracking(
+        sourceDf = if (includeEmail) df else df.drop("email"),
+        keys = Seq("id"),
+        trackColumns = Seq("name"))
+    }
+
+    // Run #1 (wide): two distinct-name records for key=1 at seq 10 and 30.
+    stream.addData((1, "alice", "a@x", 10L), (1, "alicia", "b@x", 30L))
+    runPipeline(buildCtx(includeEmail = true))
+    checkAnswer(
+      spark.table(s"$catalog.$namespace.target"),
+      Seq(
+        Row(1, "alice", "a@x", 10L, 10L, 30L, scd2Meta(10L)),
+        Row(1, "alicia", "b@x", 30L, 30L, null, scd2Meta(30L))
+      )
+    )
+
+    // Run #2 (narrow): a late event at seq=20 with a new name bisects the 
seq=10 record. The
+    // pre-existing records keep their email values; the newly-inserted seq=20 
record has null.
+    stream.addData((1, "annie", "ignored", 20L))
+    runPipeline(buildCtx(includeEmail = false))
+    checkAnswer(
+      spark.table(s"$catalog.$namespace.target"),
+      Seq(
+        Row(1, "alice", "a@x", 10L, 10L, 20L, scd2Meta(10L)),
+        Row(1, "annie", null, 20L, 20L, 30L, scd2Meta(20L)),
+        Row(1, "alicia", "b@x", 30L, 30L, null, scd2Meta(30L))
+      )
+    )
+  }
+
+  test("a nested struct field dropped between runs is preserved on existing 
records and null on " +

Review Comment:
   This asserts that dropping a nested struct field succeeds on SCD2 (preserved 
on existing rows, `null` on new ones). SCD1 rejects the same shape with 
`INCOMPATIBLE_DATA_FOR_TABLE.CANNOT_FIND_DATA` 
(`AutoCdcScd1SchemaEvolutionSuite`), because the MERGE source is still missing 
the nested field.
   
   Here `allowMissingColumns` pads before the MERGE, so SCD2 becomes more 
permissive than SCD1 for nested subtractive evolution. The PR text's "matching 
SCD1" claim holds for top-level columns only. If nested drop is intentional for 
SCD2, please call that out explicitly; if parity is the goal, this case should 
fail the same way as SCD1.



##########
sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/graph/AutoCdcScd2ColumnEvolutionSuite.scala:
##########
@@ -0,0 +1,278 @@
+/*
+ * 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.pipelines.graph
+
+import org.apache.spark.sql.Row
+import org.apache.spark.sql.classic.DataFrame
+import org.apache.spark.sql.execution.streaming.runtime.MemoryStream
+import org.apache.spark.sql.functions
+import org.apache.spark.sql.pipelines.autocdc.{
+  ChangeArgs,
+  ColumnSelection,
+  ScdType,
+  UnqualifiedColumnName
+}
+import org.apache.spark.sql.pipelines.utils.{ExecutionTest, 
TestGraphRegistrationContext}
+import org.apache.spark.sql.test.SharedSparkSession
+
+/**
+ * End-to-end tests for SCD Type 2 AutoCDC column-schema evolution across 
runs: a microbatch that
+ * is narrower than the already-evolved target (a source column dropped, a 
nested struct/array field
+ * dropped, or the `COLUMNS` selection narrowed) must reconcile correctly 
instead of failing the
+ * internal union.
+ *
+ * These exercise the fix for SPARK-58418. Before it, 
`Scd2ForeachBatchHandler.reconcileMicrobatch`
+ * unioned the microbatch with the affected target/aux rows without 
`allowMissingColumns`, so a
+ * narrower microbatch failed with NUM_COLUMNS_MISMATCH (top-level) or 
INCOMPATIBLE_COLUMN_TYPE
+ * (nested). The contract asserted here is additive-tolerant, matching SCD1: 
records already written
+ * keep their values for the no-longer-emitted column, and only records opened 
by the narrower
+ * microbatch carry null for it.
+ *
+ * Changing the effective *tracked-history* column set is a distinct, 
separately-scoped concern
+ * (SPARK-58452 / SPARK-58391) and is deliberately not exercised here: every 
scenario keeps the
+ * effective tracked set unchanged across runs, so the only thing evolving is 
the set of user
+ * columns the flow emits. The column-selection test therefore drops a column 
that is *not* in the
+ * tracked set (an explicit `TRACK HISTORY ON (name)` flow dropping the 
non-tracked `email`), so it
+ * stays valid once the track-history drift guard (SPARK-58391) lands.
+ */
+class AutoCdcScd2ColumnEvolutionSuite
+    extends ExecutionTest
+    with SharedSparkSession
+    with AutoCdcGraphExecutionTestMixin {
+
+  /** The SCD2 target's `_cdc_metadata` struct value for a given 
recordStartAt. */
+  private def scd2Meta(recordStartAt: Long): Row = Row(recordStartAt)
+
+  /**
+   * Build a single-flow SCD2 pipeline that tracks history on exactly 
`trackColumns` (an explicit
+   * `TRACK HISTORY ON (...)`), so a column outside that set can be dropped 
without changing the
+   * tracked set. The mixin's `singleAutoCdcFlowPipeline` does not expose a 
track-history knob, so
+   * the flow is built inline here.
+   */
+  private def scd2FlowTracking(
+      sourceDf: DataFrame,
+      keys: Seq[String],
+      trackColumns: Seq[String]): TestGraphRegistrationContext =
+    new TestGraphRegistrationContext(spark) {
+      registerTable("target", catalog = Some(catalog), database = 
Some(namespace))
+      registerFlow(AutoCdcFlow(
+        identifier = fullyQualifiedIdentifier("auto_cdc_flow", Some(catalog), 
Some(namespace)),
+        destinationIdentifier =
+          fullyQualifiedIdentifier("target", Some(catalog), Some(namespace)),
+        func = dfFlowFunc(sourceDf),
+        queryContext =
+          QueryContext(currentCatalog = Some(catalog), currentDatabase = 
Some(namespace)),
+        origin = QueryOrigin.empty,
+        changeArgs = ChangeArgs(
+          keys = keys.map(UnqualifiedColumnName(_)),
+          sequencing = functions.col("version"),
+          storedAsScdType = ScdType.Type2,
+          trackHistorySelection = Some(ColumnSelection.IncludeColumns(
+            trackColumns.map(UnqualifiedColumnName(_)))))))
+    }
+
+  test("a source column dropped between runs is preserved on existing records 
and null on new " +
+    "ones") {
+    val session = spark
+    import session.implicits._
+
+    spark.sql(
+      s"CREATE TABLE $catalog.$namespace.target " +
+      s"(id INT NOT NULL, name STRING, email STRING, version BIGINT NOT NULL, 
$scd2MetadataDdl)"
+    )
+
+    // Shared stream; run #2 projects `email` away so the microbatch is 
narrower than the target.
+    // Track history on `name` only, so the dropped `email` is a non-tracked 
column and dropping it
+    // is column-schema narrowing rather than a tracked-set change (robust 
once SPARK-58391 lands).
+    val stream = MemoryStream[(Int, String, String, Long)]
+    def buildCtx(includeEmail: Boolean): TestGraphRegistrationContext = {
+      val df = stream.toDF().toDF("id", "name", "email", "version")
+      scd2FlowTracking(
+        sourceDf = if (includeEmail) df else df.drop("email"),
+        keys = Seq("id"),
+        trackColumns = Seq("name"))
+    }
+
+    // Run #1 (wide): key=1 opens a record carrying email=a@x.
+    stream.addData((1, "alice", "a@x", 1L))
+    runPipeline(buildCtx(includeEmail = true))
+    checkAnswer(
+      spark.table(s"$catalog.$namespace.target"),
+      Seq(Row(1, "alice", "a@x", 1L, 1L, null, scd2Meta(1L)))
+    )
+
+    // Run #2 (narrow): update key=1 (closes its record) + insert key=2. The 
dropped `email` is
+    // preserved on key=1's now-closed record and is null on the newly-opened 
records.
+    stream.addData((1, "alice2", "ignored", 2L), (2, "bob", "ignored", 1L))
+    runPipeline(buildCtx(includeEmail = false))
+    checkAnswer(
+      spark.table(s"$catalog.$namespace.target"),
+      Seq(
+        Row(1, "alice", "a@x", 1L, 1L, 2L, scd2Meta(1L)),
+        Row(1, "alice2", null, 2L, 2L, null, scd2Meta(2L)),
+        Row(2, "bob", null, 1L, 1L, null, scd2Meta(1L))
+      )
+    )
+  }
+
+  test("dropping a non-tracked column from the COLUMNS selection preserves it 
on existing " +
+    "records and leaves it null on new ones") {
+    val session = spark
+    import session.implicits._
+
+    spark.sql(
+      s"CREATE TABLE $catalog.$namespace.target " +
+      s"(id INT NOT NULL, name STRING, email STRING, version BIGINT NOT NULL, 
$scd2MetadataDdl)"
+    )
+
+    // The flow tracks history on `name` only, so `email` is a 
selected-but-not-tracked column.
+    // Dropping `email` from the selection is therefore pure column-schema 
narrowing: the effective
+    // tracked set ({name}) is unchanged, so this stays column evolution 
rather than a tracked-set
+    // change even once the track-history drift guard (SPARK-58391) lands.
+    val stream = MemoryStream[(Int, String, String, Long)]
+    def buildCtx(includeEmail: Boolean): TestGraphRegistrationContext = {
+      val df = stream.toDF().toDF("id", "name", "email", "version")
+      scd2FlowTracking(
+        sourceDf = if (includeEmail) df else df.drop("email"),

Review Comment:
   This test is named for narrowing the `COLUMNS` selection, but it never sets 
`ChangeArgs.columnSelection` — it narrows with `df.drop("email")` the same way 
as the source-column-drop test above (the two bodies are identical after 
stripping comments). That leaves the documented narrowed-`COLUMNS` path 
untested.
   
   Please keep a fixed full source DF and vary `columnSelection = 
Some(IncludeColumns(...))` instead (with `trackHistorySelection` on `name` so 
the dropped column stays out of the tracked set). SCD1 already separates these 
two knobs in `AutoCdcScd1SchemaEvolutionSuite`.



-- 
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]

Reply via email to