|
17 | 17 |
|
18 | 18 | package org.apache.spark.sql.pipelines.autocdc |
19 | 19 |
|
20 | | -import org.apache.spark.sql.{functions => F} |
| 20 | +import org.apache.spark.SparkException |
| 21 | +import org.apache.spark.sql.{functions => F, AnalysisException} |
| 22 | +import org.apache.spark.sql.Column |
21 | 23 | import org.apache.spark.sql.catalyst.util.QuotingUtils |
22 | 24 | import org.apache.spark.sql.classic.DataFrame |
| 25 | +import org.apache.spark.sql.types.{DataType, StructField, StructType} |
23 | 26 | import org.apache.spark.util.ArrayImplicits._ |
24 | 27 |
|
25 | 28 | /** |
26 | 29 | * Per-microbatch processor for SCD Type 1 AutoCDC flows, complying to the specified [[changeArgs]] |
27 | 30 | * configuration. |
| 31 | + * |
| 32 | + * @param changeArgs The CDC flow configuration. |
| 33 | + * @param resolvedSequencingType The post-analysis [[DataType]] of the sequencing column, derived |
| 34 | + * from the flow's resolved DataFrame at flow setup time. |
28 | 35 | */ |
29 | | -case class Scd1BatchProcessor(changeArgs: ChangeArgs) { |
| 36 | +case class Scd1BatchProcessor( |
| 37 | + changeArgs: ChangeArgs, |
| 38 | + resolvedSequencingType: DataType) { |
| 39 | + |
30 | 40 | /** |
31 | 41 | * Deduplicate the incoming CDC microbatch by key, keeping the most recent event per key |
32 | 42 | * as ordered by [[ChangeArgs.sequencing]]. |
@@ -59,9 +69,102 @@ case class Scd1BatchProcessor(changeArgs: ChangeArgs) { |
59 | 69 | ) |
60 | 70 | .select(F.col(s"$winningRowCol.*")) |
61 | 71 | } |
| 72 | + |
| 73 | + /** |
| 74 | + * Project the CDC metadata column onto the microbatch. |
| 75 | + */ |
| 76 | + def extendMicrobatchRowsWithCdcMetadata(microbatchDf: DataFrame): DataFrame = { |
| 77 | + // Proactively validate the reserved CDC metadata column does not exist in the microbatch. |
| 78 | + validateCdcMetadataColumnNotPresent(microbatchDf) |
| 79 | + |
| 80 | + val rowDeleteSequence: Column = changeArgs.deleteCondition match { |
| 81 | + case Some(deleteCondition) => |
| 82 | + F.when(deleteCondition, changeArgs.sequencing).otherwise(F.lit(null)) |
| 83 | + case None => |
| 84 | + F.lit(null) |
| 85 | + } |
| 86 | + |
| 87 | + val rowUpsertSequence: Column = |
| 88 | + // A row that is not a delete must be an upsert, these are mutually exclusive and a complete |
| 89 | + // set of CDC event types. |
| 90 | + F.when(rowDeleteSequence.isNull, changeArgs.sequencing).otherwise(F.lit(null)) |
| 91 | + |
| 92 | + microbatchDf.withColumn( |
| 93 | + Scd1BatchProcessor.cdcMetadataColName, |
| 94 | + Scd1BatchProcessor.constructCdcMetadataCol( |
| 95 | + deleteSequence = rowDeleteSequence, |
| 96 | + upsertSequence = rowUpsertSequence, |
| 97 | + sequencingType = resolvedSequencingType |
| 98 | + ) |
| 99 | + ) |
| 100 | + } |
| 101 | + |
| 102 | + private def validateCdcMetadataColumnNotPresent(microbatchDf: DataFrame): Unit = { |
| 103 | + val ignoreColumnNameCase = |
| 104 | + !microbatchDf.sparkSession.sessionState.conf.caseSensitiveAnalysis |
| 105 | + |
| 106 | + microbatchDf.schema.fieldNames |
| 107 | + .find { fieldName => |
| 108 | + if (ignoreColumnNameCase) { |
| 109 | + fieldName.equalsIgnoreCase(Scd1BatchProcessor.cdcMetadataColName) |
| 110 | + } else { |
| 111 | + fieldName.equals(Scd1BatchProcessor.cdcMetadataColName) |
| 112 | + } |
| 113 | + } |
| 114 | + .foreach { conflictingColumnName => |
| 115 | + throw new AnalysisException( |
| 116 | + errorClass = "AUTOCDC_RESERVED_COLUMN_NAME_CONFLICT", |
| 117 | + messageParameters = Map( |
| 118 | + "caseSensitivity" -> CaseSensitivityLabels.of(!ignoreColumnNameCase), |
| 119 | + "columnName" -> conflictingColumnName, |
| 120 | + "schemaName" -> "microbatch", |
| 121 | + "reservedColumnName" -> Scd1BatchProcessor.cdcMetadataColName |
| 122 | + ) |
| 123 | + ) |
| 124 | + } |
| 125 | + } |
62 | 126 | } |
63 | 127 |
|
64 | 128 | object Scd1BatchProcessor { |
65 | 129 | // Columns prefixed with `__spark_autocdc_` are reserved for internal SDP AutoCDC processing. |
66 | | - private val winningRowColName = "__spark_autocdc_winning_row" |
| 130 | + private[autocdc] val winningRowColName: String = "__spark_autocdc_winning_row" |
| 131 | + private[autocdc] val cdcMetadataColName: String = "__spark_autocdc_metadata" |
| 132 | + |
| 133 | + private[autocdc] val cdcDeleteSequenceFieldName: String = "deleteSequence" |
| 134 | + private[autocdc] val cdcUpsertSequenceFieldName: String = "upsertSequence" |
| 135 | + |
| 136 | + /** |
| 137 | + * Schema of the CDC metadata struct column for SCD1. |
| 138 | + */ |
| 139 | + private def cdcMetadataColSchema(sequencingType: DataType): StructType = |
| 140 | + StructType( |
| 141 | + Seq( |
| 142 | + // The sequencing of the event if it represents a delete, null otherwise. |
| 143 | + StructField(cdcDeleteSequenceFieldName, sequencingType, nullable = true), |
| 144 | + // The sequencing of the event if it represents an upsert, null otherwise. |
| 145 | + StructField(cdcUpsertSequenceFieldName, sequencingType, nullable = true) |
| 146 | + ) |
| 147 | + ) |
| 148 | + |
| 149 | + /** |
| 150 | + * Construct the CDC metadata struct column for SCD1, following the exact schema and field |
| 151 | + * ordering defined by [[cdcMetadataColSchema]]. |
| 152 | + */ |
| 153 | + private[autocdc] def constructCdcMetadataCol( |
| 154 | + deleteSequence: Column, |
| 155 | + upsertSequence: Column, |
| 156 | + sequencingType: DataType): Column = { |
| 157 | + val cdcMetadataFieldsInOrder = cdcMetadataColSchema(sequencingType).fields.map { field => |
| 158 | + val value = field.name match { |
| 159 | + case `cdcDeleteSequenceFieldName` => deleteSequence |
| 160 | + case `cdcUpsertSequenceFieldName` => upsertSequence |
| 161 | + case other => |
| 162 | + throw SparkException.internalError( |
| 163 | + s"Unable to construct SCD1 CDC metadata column due to unknown `${other}` field." |
| 164 | + ) |
| 165 | + } |
| 166 | + value.cast(field.dataType).as(field.name) |
| 167 | + } |
| 168 | + F.struct(cdcMetadataFieldsInOrder.toImmutableArraySeq: _*) |
| 169 | + } |
67 | 170 | } |
0 commit comments