This is an automated email from the ASF dual-hosted git repository.

jose-torres 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 4d3ca4b7c8c1 [SPARK-57150][SDP] SCD1 Out-of-order Convergence Suite
4d3ca4b7c8c1 is described below

commit 4d3ca4b7c8c193ff96fe034280aa1cfc17d89627
Author: AnishMahto <[email protected]>
AuthorDate: Tue Jun 23 08:37:27 2026 -0700

    [SPARK-57150][SDP] SCD1 Out-of-order Convergence Suite
    
    ### What changes were proposed in this pull request?
    A key feature of SDP's AutoCDC implementation is that it supports 
reconciling out-of-order (by sequence) events. This support also adds 
significant complexity to the reconciliation logic as it requires 
cross-microbatch stateful tracking in the auxiliary table, and is prone to 
breaking as the implementation evolves over time.
    
    Introduce an A/B style test suite to execute the implementation on both a 
sequence-sorted single-microbatch event stream and the same events on a 
shuffled multi-microbatch event stream. If out-of-order processing is correct, 
then the SCD1 implementation should produce the same target tables for both 
runs.
    
    Data is randomly generated, but with a constant seed for reproducibility.
    
    ### Why are the changes needed?
    Test correctness of the AutoCDC SCD1 out-of-order reconciliation algorithm. 
Prevents signal for existing implementation's correctness and helps prevent 
regressions in future iterations.
    
    ### Does this PR introduce _any_ user-facing change?
    No
    
    ### How was this patch tested?
    Test only change.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    Co-authored with Claude Opus 4.7
    
    Closes #56214 from AnishMahto/SPARK-57150-SCD1-OOO-convergence-suite.
    
    Authored-by: AnishMahto <[email protected]>
    Signed-off-by: Jose Torres <[email protected]>
    (cherry picked from commit c87b8dca5775e64ec4b0e2626902d9a7d89a2b28)
    Signed-off-by: Jose Torres <[email protected]>
---
 .../AutoCdcScd1OutOfOrderConvergenceSuite.scala    | 207 +++++++++++++++++++++
 1 file changed, 207 insertions(+)

diff --git 
a/sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/graph/AutoCdcScd1OutOfOrderConvergenceSuite.scala
 
b/sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/graph/AutoCdcScd1OutOfOrderConvergenceSuite.scala
new file mode 100644
index 000000000000..fb3a179f25c7
--- /dev/null
+++ 
b/sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/graph/AutoCdcScd1OutOfOrderConvergenceSuite.scala
@@ -0,0 +1,207 @@
+/*
+ * 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 scala.collection.mutable.ArrayBuffer
+import scala.util.Random
+
+import org.apache.spark.sql.execution.streaming.runtime.MemoryStream
+import org.apache.spark.sql.functions
+import org.apache.spark.sql.pipelines.autocdc.{ColumnSelection, 
UnqualifiedColumnName}
+import 
org.apache.spark.sql.pipelines.graph.AutoCdcScd1OutOfOrderConvergenceSuite.SourceRow
+import org.apache.spark.sql.pipelines.utils.{ExecutionTest, 
TestGraphRegistrationContext}
+import org.apache.spark.sql.test.SharedSparkSession
+
+object AutoCdcScd1OutOfOrderConvergenceSuite {
+  /**
+   * A single CDC event in the source stream.
+   *
+   * @param key       Identity column (the AutoCDC `keys`).
+   * @param name      Data column (nullable string).
+   * @param amount    Data column (nullable int).
+   * @param active    Data column (nullable boolean).
+   * @param sequence  Sequencing value (the AutoCDC `sequencing` expression).
+   * @param isDelete  Drives the AutoCDC `deleteCondition`; `true` marks the 
event as a delete,
+   *                  `false` as an upsert. Excluded from the target via 
`columnSelection`.
+   */
+  case class SourceRow(
+      key: Int,
+      name: Option[String],
+      amount: Option[Int],
+      active: Option[Boolean],
+      sequence: Long,
+      isDelete: Boolean)
+}
+
+/**
+ * Differential test for the SCD1 AutoCDC merge's order-invariance property: 
feeding the same
+ * randomly-generated CDC event stream as a single sorted micro-batch and as 
several shuffled
+ * micro-batches must converge to the same target table contents.
+ */
+class AutoCdcScd1OutOfOrderConvergenceSuite
+    extends ExecutionTest
+    with SharedSparkSession
+    with AutoCdcGraphExecutionTestMixin {
+
+  // Distinct keys in the generated event stream.
+  private val numDistinctKeys: Int = 5
+  // Upper bound on unique events (one per sequence) generated per key, before 
intentionally
+  // duplicating some events.
+  private val maxUniqueEventsPerKey: Int = 80
+  // Probability an event is a delete; (1 - this) is the upsert probability.
+  private val deleteEventProbability: Double = 0.20
+  // Probability an event is immediately re-emitted with the same sequence and 
payload.
+  private val duplicateEventProbability: Double = 0.15
+  // Probability an optional payload column is non-null; (1 - this) is the 
null probability.
+  private val nonNullProbability: Double = 0.75
+  // Number of microbatches the out-of-order pipeline splits the shuffled 
events across.
+  private val numOutOfOrderBatches: Int = 8
+
+  // System property used to pin the test seed for reproduction. If unset, the 
suite generates a
+  // fresh seed on each run and reports it in the failure message so a failing 
seed can be replayed
+  // by setting this property. Mirrors the convention used by 
`RandomDataGenerator` and other Spark
+  // suites that expose tunables via `spark.sql.test.<feature>` system 
properties.
+  private val seedSystemProperty: String =
+    "spark.sql.test.autocdc.scd1OutOfOrderConvergenceSeed"
+
+  private def resolveTestSeed(): Long = {
+    
Option(System.getProperty(seedSystemProperty)).map(_.toLong).getOrElse(Random.nextLong())
+  }
+
+  private val keyColumn: String = "key"
+  private val nameColumn: String = "name"
+  private val amountColumn: String = "amount"
+  private val activeColumn: String = "active"
+  private val sequenceColumn: String = "sequence"
+  private val isDeleteColumn: String = "is_delete"
+
+  private val sourceColumnNames: Seq[String] =
+    Seq(keyColumn, nameColumn, amountColumn, activeColumn, sequenceColumn, 
isDeleteColumn)
+
+  private def randomUpsertOrDelete(
+      rand: Random, key: Int, sequence: Long, isDelete: Boolean): SourceRow = {
+    val colorPalette = Seq("red", "blue", "green", "yellow")
+    SourceRow(
+      key = key,
+      name = Option.when(rand.nextDouble() < nonNullProbability)(
+        colorPalette(rand.nextInt(colorPalette.length))),
+      amount = Option.when(rand.nextDouble() < 
nonNullProbability)(rand.nextInt(100)),
+      active = Option.when(rand.nextDouble() < 
nonNullProbability)(rand.nextBoolean()),
+      sequence = sequence,
+      isDelete = isDelete
+    )
+  }
+
+  private def generateRandomCdcEventStream(rand: Random): Seq[SourceRow] = {
+    var nextSequence: Long = 0L
+    val events = ArrayBuffer.empty[SourceRow]
+    (0 until numDistinctKeys).foreach { key =>
+      val numUniqueEventsForKey = rand.between(1, maxUniqueEventsPerKey + 1)
+      (0 until numUniqueEventsForKey).foreach { _ =>
+        val isDelete = rand.nextDouble() < deleteEventProbability
+        val event = randomUpsertOrDelete(rand, key, nextSequence, isDelete)
+        nextSequence += 1
+        events += event
+        if (rand.nextDouble() < duplicateEventProbability) {
+          events += event
+        }
+      }
+    }
+    events.sortBy(_.sequence).toSeq
+  }
+
+  /** Build a pipeline context with a single SCD1 AutoCDC flow reading from 
`stream`. */
+  private def buildPipelineContext(
+      targetTable: String,
+      stream: MemoryStream[SourceRow]): TestGraphRegistrationContext = {
+    new TestGraphRegistrationContext(spark) {
+      registerTable(targetTable, catalog = Some(catalog), database = 
Some(namespace))
+      registerFlow(autoCdcFlow(
+        name = s"${targetTable}_flow",
+        target = targetTable,
+        query = dfFlowFunc(stream.toDF().toDF(sourceColumnNames: _*)),
+        keys = Seq(keyColumn),
+        sequencing = functions.col(sequenceColumn),
+        deleteCondition = Some(functions.col(isDeleteColumn) === true),
+        columnSelection = Some(ColumnSelection.ExcludeColumns(
+          Seq(UnqualifiedColumnName(isDeleteColumn))
+        ))
+      ))
+    }
+  }
+
+  private def createTargetTable(targetTable: String): Unit = {
+    spark.sql(
+      s"CREATE TABLE $catalog.$namespace.$targetTable (" +
+      s"`$keyColumn` INT NOT NULL, " +
+      s"`$nameColumn` STRING, " +
+      s"`$amountColumn` INT, " +
+      s"`$activeColumn` BOOLEAN, " +
+      s"`$sequenceColumn` BIGINT NOT NULL, " +
+      s"$cdcMetadataDdl)"
+    )
+  }
+
+  private def assertTargetsConverge(inOrderTable: String, outOfOrderTable: 
String): Unit = {
+    checkAnswer(
+      spark.table(s"$catalog.$namespace.$outOfOrderTable"),
+      spark.table(s"$catalog.$namespace.$inOrderTable")
+    )
+  }
+
+  private def runConvergenceTest(seed: Long): Unit = {
+    val session = spark
+    import session.implicits._
+
+    val rand = new Random(seed)
+    val sortedEventStream = generateRandomCdcEventStream(rand)
+    val shuffledEventStream = rand.shuffle(sortedEventStream)
+
+    withClue(
+      s"\nseed=$seed (rerun with -D$seedSystemProperty=$seed to reproduce)\n" +
+      s"events (${sortedEventStream.size} total, sorted by sequence):\n" +
+      sortedEventStream.map(r => s"  $r").mkString("\n") + "\n"
+    ) {
+      val inOrderTable = "inorder_target"
+      val outOfOrderTable = "outoforder_target"
+      createTargetTable(inOrderTable)
+      createTargetTable(outOfOrderTable)
+
+      val inOrderStream = MemoryStream[SourceRow]
+      val inOrderCtx = buildPipelineContext(inOrderTable, inOrderStream)
+      inOrderStream.addData(sortedEventStream: _*)
+      runPipeline(inOrderCtx)
+
+      val outOfOrderStream = MemoryStream[SourceRow]
+      val outOfOrderCtx = buildPipelineContext(outOfOrderTable, 
outOfOrderStream)
+      val totalEvents = shuffledEventStream.size
+      (0 until numOutOfOrderBatches).foreach { batchIndex =>
+        val batchStart = batchIndex * totalEvents / numOutOfOrderBatches
+        val batchEnd = (batchIndex + 1) * totalEvents / numOutOfOrderBatches
+        outOfOrderStream.addData(shuffledEventStream.slice(batchStart, 
batchEnd): _*)
+        runPipeline(outOfOrderCtx)
+      }
+
+      assertTargetsConverge(inOrderTable, outOfOrderTable)
+    }
+  }
+
+  test("SCD1 merge converges across micro-batch shuffling for randomly 
generated CDC events") {
+    runConvergenceTest(resolveTestSeed())
+  }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to