-
Notifications
You must be signed in to change notification settings - Fork 29.3k
[SPARK-58321][SDP] Wire SCD2 AutoCDC streaming write and enable SCD2 end to end #57584
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
8511bf0
f706711
00e74d6
0e1510b
3548db9
bc381be
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,12 +22,12 @@ 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.autocdc.{ColumnSelection, ScdType, UnqualifiedColumnName} | ||
| import org.apache.spark.sql.pipelines.graph.AutoCdcOutOfOrderConvergenceSuite.SourceRow | ||
| import org.apache.spark.sql.pipelines.utils.{ExecutionTest, TestGraphRegistrationContext} | ||
| import org.apache.spark.sql.test.SharedSparkSession | ||
|
|
||
| object AutoCdcScd1OutOfOrderConvergenceSuite { | ||
| object AutoCdcOutOfOrderConvergenceSuite { | ||
| /** | ||
| * A single CDC event in the source stream. | ||
| * | ||
|
|
@@ -49,11 +49,11 @@ object AutoCdcScd1OutOfOrderConvergenceSuite { | |
| } | ||
|
|
||
| /** | ||
| * 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. | ||
| * Differential test for the AutoCDC merge's order-invariance property, for both SCD Type 1 and | ||
| * SCD Type 2: 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 | ||
| class AutoCdcOutOfOrderConvergenceSuite | ||
| extends ExecutionTest | ||
| with SharedSparkSession | ||
| with AutoCdcGraphExecutionTestMixin { | ||
|
|
@@ -77,7 +77,7 @@ class AutoCdcScd1OutOfOrderConvergenceSuite | |
| // 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" | ||
| "spark.sql.test.autocdc.outOfOrderConvergenceSeed" | ||
|
|
||
| private def resolveTestSeed(): Long = { | ||
| Option(System.getProperty(seedSystemProperty)).map(_.toLong).getOrElse(Random.nextLong()) | ||
|
|
@@ -125,10 +125,11 @@ class AutoCdcScd1OutOfOrderConvergenceSuite | |
| events.sortBy(_.sequence).toSeq | ||
| } | ||
|
|
||
| /** Build a pipeline context with a single SCD1 AutoCDC flow reading from `stream`. */ | ||
| /** Build a pipeline context with a single AutoCDC flow of `scdType` reading from `stream`. */ | ||
| private def buildPipelineContext( | ||
| targetTable: String, | ||
| stream: MemoryStream[SourceRow]): TestGraphRegistrationContext = { | ||
| stream: MemoryStream[SourceRow], | ||
| scdType: ScdType): TestGraphRegistrationContext = { | ||
| new TestGraphRegistrationContext(spark) { | ||
| registerTable(targetTable, catalog = Some(catalog), database = Some(namespace)) | ||
| registerFlow(autoCdcFlow( | ||
|
|
@@ -140,20 +141,31 @@ class AutoCdcScd1OutOfOrderConvergenceSuite | |
| deleteCondition = Some(functions.col(isDeleteColumn) === true), | ||
| columnSelection = Some(ColumnSelection.ExcludeColumns( | ||
| Seq(UnqualifiedColumnName(isDeleteColumn)) | ||
| )) | ||
| )), | ||
| scdType = scdType | ||
| )) | ||
| } | ||
| } | ||
|
|
||
| private def createTargetTable(targetTable: String): Unit = { | ||
| /** | ||
| * DDL fragment for the SCD-type-specific reserved columns a target table carries after the | ||
| * user-selected data columns: the CDC metadata column for SCD1, and the interval bounds plus | ||
| * metadata column for SCD2. The sequencing type is BIGINT here. | ||
| */ | ||
| private def reservedColumnsDdl(scdType: ScdType): String = scdType match { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I ultimately decided I don't care that SCD1 and SCD2 disagree on the encapsulation boundary for the DDL, but I'm leaving this comment as a record of the train of thought in case it jumps out at anyone.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I made this symmetric by
But digging into this, I realized that there are not many paces where the new |
||
| case ScdType.Type1 => scd1MetadataDdl | ||
| case ScdType.Type2 => scd2MetadataDdl | ||
| } | ||
|
|
||
| private def createTargetTable(targetTable: String, scdType: ScdType): 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)" | ||
| s"${reservedColumnsDdl(scdType)})" | ||
| ) | ||
| } | ||
|
|
||
|
|
@@ -164,7 +176,7 @@ class AutoCdcScd1OutOfOrderConvergenceSuite | |
| ) | ||
| } | ||
|
|
||
| private def runConvergenceTest(seed: Long): Unit = { | ||
| private def runConvergenceTest(seed: Long, scdType: ScdType): Unit = { | ||
| val session = spark | ||
| import session.implicits._ | ||
|
|
||
|
|
@@ -173,22 +185,28 @@ class AutoCdcScd1OutOfOrderConvergenceSuite | |
| val shuffledEventStream = rand.shuffle(sortedEventStream) | ||
|
|
||
| withClue( | ||
| s"\nseed=$seed (rerun with -D$seedSystemProperty=$seed to reproduce)\n" + | ||
| s"\nscdType=${scdType.label} seed=$seed " + | ||
| s"(rerun with -D$seedSystemProperty=$seed to reproduce)\n" + | ||
|
Comment on lines
+188
to
+189
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The property this message tells the developer to set is still Separately, the comment on line 193 justifies the scd-type table-name suffix as keeping the SCD1 and SCD2 tests from colliding within a run, but the mixin's
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. fixed both of them! |
||
| 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) | ||
| // Table names are scd-type-suffixed purely for readability: the SCD1 and SCD2 tests run as | ||
| // separate test cases and the mixin's afterEach resets the catalog between them, so they | ||
| // could not collide even with identical names; the suffix just makes a failing run's tables | ||
| // self-identifying. | ||
| val suffix = scdType.label.toLowerCase(java.util.Locale.ROOT) | ||
| val inOrderTable = s"inorder_target_$suffix" | ||
| val outOfOrderTable = s"outoforder_target_$suffix" | ||
| createTargetTable(inOrderTable, scdType) | ||
| createTargetTable(outOfOrderTable, scdType) | ||
|
|
||
| val inOrderStream = MemoryStream[SourceRow] | ||
| val inOrderCtx = buildPipelineContext(inOrderTable, inOrderStream) | ||
| val inOrderCtx = buildPipelineContext(inOrderTable, inOrderStream, scdType) | ||
| inOrderStream.addData(sortedEventStream: _*) | ||
| runPipeline(inOrderCtx) | ||
|
|
||
| val outOfOrderStream = MemoryStream[SourceRow] | ||
| val outOfOrderCtx = buildPipelineContext(outOfOrderTable, outOfOrderStream) | ||
| val outOfOrderCtx = buildPipelineContext(outOfOrderTable, outOfOrderStream, scdType) | ||
| val totalEvents = shuffledEventStream.size | ||
| (0 until numOutOfOrderBatches).foreach { batchIndex => | ||
| val batchStart = batchIndex * totalEvents / numOutOfOrderBatches | ||
|
|
@@ -197,11 +215,18 @@ class AutoCdcScd1OutOfOrderConvergenceSuite | |
| runPipeline(outOfOrderCtx) | ||
| } | ||
|
|
||
| // Only the user-visible target must converge. The auxiliary tables legitimately differ by | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is fine under the assumption, which I think is correct but want to confirm, that any auxiliary row generated at version N is guaranteed to stop affecting results if the ingestion durably advances to some version M not too much higher than N. (It would be a problem, to pick an exaggerated example, if some category of auxiliary row caused different results starting at N + 1000; then just checking consistency betwen the targets would not be enough to confirm that the behavior is meaningfully the same.)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed. findAffectedRowsFromAuxiliaryTable bounds aux-row participation per key to recordStartAt >= minSequenceInMicrobatch plus a single "anchor" — the aux row with the largest recordStartAt strictly below the batch's min sequence. There's no unbounded look-back (no "N+1000" case): an aux row from version N only affects microbatches adjacent to N, so once ingestion advances past it (with a newer anchor present) it stops mattering. That's what makes the target-only convergence comparison valid. |
||
| // arrival order (e.g. deletedByBatchId stamps and cross-batch GC depend on how events are | ||
| // batched), so they are not compared. | ||
| assertTargetsConverge(inOrderTable, outOfOrderTable) | ||
| } | ||
| } | ||
|
|
||
| test("SCD1 merge converges across micro-batch shuffling for randomly generated CDC events") { | ||
| runConvergenceTest(resolveTestSeed()) | ||
| runConvergenceTest(resolveTestSeed(), ScdType.Type1) | ||
| } | ||
|
|
||
| test("SCD2 merge converges across micro-batch shuffling for randomly generated CDC events") { | ||
| runConvergenceTest(resolveTestSeed(), ScdType.Type2) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If this error class is no longer used, please remove it completely (for example, from
common/utils/src/main/resources/error/error-conditions.json) to prevent confusion and to keep the error-class registry up to dateThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
good catch, thanks, fixed!