This is an automated email from the ASF dual-hosted git repository.
jose-torres pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/master by this push:
new 4933473944cd [SPARK-57222][SDP] Implement SCD2 Batch Processor;
Decompose affected rows
4933473944cd is described below
commit 4933473944cdf4e2543e80e7346bffb584a26f9f
Author: AnishMahto <[email protected]>
AuthorDate: Mon Jul 6 09:45:42 2026 -0700
[SPARK-57222][SDP] Implement SCD2 Batch Processor; Decompose affected rows
Approved AutoCDC SPIP:
https://lists.apache.org/thread/j6sj9wo9odgdpgzlxtvhoy7szs0jplf7
--------
### What changes were proposed in this pull request?
**Preamble:**
The SCD type 2 flow is a foreachBatch streaming query on an input
change-data-feed, and is responsible for reconciling the incoming change data
onto some target table that follows SCD2 replication semantics.
SCD2 flows also maintain an "auxiliary" table to keep track of
early-arriving out-of-order received events state. Each microbatch will need to
reconcile against this auxiliary table as well, and update the auxiliary
table's state appropriately for future microbatches.
**Decompose affected rows:**
Given the set of affected rows in the current microbatch execution -
incoming rows in the microbatch, affected rows from aux table, affected rows
from target table - the first step in microbatch reconciliation is decomposing
closed historical rows that are being bisected by the microbatch.
A closed historical row is a row in the target table that has a non-null
start-at and end-at. It's possible an incoming upsert/delete in the microbatch
lands with a sequence in between an existing closed row's start/end at (i.e is
a late-arriving event), bisecting it.
Decomposing a closed row means exactly this - bisecting the closed interval
into a left and right end point, called the decomposed head and tail of the
original closed row respectively. The head represents some past upsert event,
the tail represents some past delete or upsert-overtake event.
Once a closed row is decomposed into its end points, it can either coalesce
with other endpoints/events from the full set of affected rows to form a new
historical row, or it can be demoted back to the aux table as a tombstone or
no-op upsert.
**Assert well formed rows post-decomposition**
Given the decomposed affected rows, assert each classifies into one of the
known row-types; tombstone, synthetic decomposition tail, or upsert
representing row (which can be further classified as a valid open or closed
upsert).
**Drop redundant rows post-decomposition:**
Decomposition transforms a single row into two synthetic children rows
(decomposition head and tail), and its possible the resulting decomposition
tail is made logically redundant by the incoming microbatch.
It's also possible that there are duplicate events by key+sequencing,
either both introduced in the same microbatch or across the incoming and a past
microbatch. Post-decomposition is a good time to reconcile these duplicates,
because by this point we have the maximal possible set of rows that will be
merged back into the target/aux tables.
We must drop redundant rows prior to reconciling the new start/end-ats for
this decomposed set of microbatch-affected rows, to:
1. Prevent zero width rows (start at == end at) from ever appearing in the
target table.
2. Ensure reconciliation of a row's start/end at is fully derived from at
most one other row in the decomposed set.
### Why are the changes needed?
Needed for core SCD2 reconciliation logic.
### Does this PR introduce _any_ user-facing change?
No, SCD2 is unreleased.
### How was this patch tested?
Unit tests added to `Scd2BatchProcessorSuite`.
### Was this patch authored or co-authored using generative AI tooling?
Co-authored with Claude Opus 4.7
Closes #56311 from AnishMahto/SPARK-57222-SCD2-decompose-affected-rows.
Authored-by: AnishMahto <[email protected]>
Signed-off-by: Jose Torres <[email protected]>
---
.../sql/pipelines/autocdc/Scd2BatchProcessor.scala | 420 ++++++++++++-
.../autocdc/Scd2BatchProcessorSuite.scala | 693 +++++++++++++++++++++
2 files changed, 1108 insertions(+), 5 deletions(-)
diff --git
a/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/autocdc/Scd2BatchProcessor.scala
b/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/autocdc/Scd2BatchProcessor.scala
index 2815420836e0..b0c511958ab7 100644
---
a/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/autocdc/Scd2BatchProcessor.scala
+++
b/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/autocdc/Scd2BatchProcessor.scala
@@ -20,9 +20,11 @@ package org.apache.spark.sql.pipelines.autocdc
import org.apache.spark.SparkException
import org.apache.spark.sql.{functions => F}
import org.apache.spark.sql.Column
+import org.apache.spark.sql.catalyst.expressions.{CreateMap, If, Literal,
RaiseError}
import org.apache.spark.sql.catalyst.util.QuotingUtils
-import org.apache.spark.sql.classic.DataFrame
-import org.apache.spark.sql.types.{DataType, StructField, StructType}
+import org.apache.spark.sql.classic.{DataFrame, ExpressionUtils}
+import org.apache.spark.sql.expressions.{Window, WindowSpec}
+import org.apache.spark.sql.types.{BooleanType, DataType, StringType,
StructField, StructType}
import org.apache.spark.util.ArrayImplicits._
/**
@@ -49,6 +51,60 @@ case class Scd2BatchProcessor(
*/
private lazy val keysRaw: Seq[String] = changeArgs.keys.map(_.name)
+ /**
+ * WindowSpec that sorts CDC event rows in ascending order per key, by event
origination
+ * sequence time (i.e, record start at).
+ */
+ private[autocdc] val orderChronologicallyPerKeyWindow: WindowSpec = {
+ val recordStartAtCol = Scd2BatchProcessor.recordStartAtOf(
+ F.col(AutoCdcReservedNames.cdcMetadataColName)
+ )
+ val startAtCol = F.col(Scd2BatchProcessor.startAtColName)
+ val endAtCol = F.col(Scd2BatchProcessor.endAtColName)
+ val row = Scd2IntervalColumns(recordStartAtCol, startAtCol, endAtCol)
+
+ // Order by effective recordStartAt. The decomposition-tail fallback -
tails carry a null
+ // recordStartAt and order by their endAt instead - is encapsulated in
+ // Scd2IntervalColumns.effectiveRecordStartAt, so no tail special-casing
is needed here.
+ val effectiveRecordStartAt = row.effectiveRecordStartAt.asc
+
+ val orderDecompositionTailsFirst =
RowClassifier.isDecompositionTail(row).desc
+
+ val orderUpsertRepresentingRowsFirst =
RowClassifier.isUpsertRepresentingRow(row).desc
+
+ Window
+ .partitionBy(keysQuoted.map(F.col): _*)
+ .orderBy(
+ // Primary sort key: the source-CDC sequence time. Users are required
to guarantee
+ // events emitted by their source have a unique sequence per key;
violating this is
+ // publicly documented as undefined behavior.
+ //
+ // Decomposition tails are synthetic rows created during
reconciliation; they have a
+ // null recordStartAt and use their endAt as their _effective_
recordStartAt. Under
+ // a compliant source they are the only rows that may legitimately tie
with another
+ // row on effective recordStartAt: a tail inherits its endAt from its
parent closed
+ // row, and that endAt is by construction the recordStartAt of the
event that
+ // originally closed the run, so the tail ties with that closing event
whenever
+ // both appear in the same partition.
+ effectiveRecordStartAt,
+ // Among rows tied on effective recordStartAt, decomposition tails
sort first so a
+ // tail can detect its own redundancy via LEAD(1): if the next row is
a non-tail at
+ // the same instant, the synthetic close the tail encodes is already
represented by
+ // that event and the tail is dropped downstream.
+ //
+ // Any tiebreaking beyond this rule only meaningfully fires when the
user's source
+ // has emitted two or more events at the same sequence, violating the
uniqueness
+ // contract above. Behavior in that case is publicly undefined and the
remaining
+ // tiebreaker clauses exist as a best-effort to keep retries and
replays deterministic.
+ orderDecompositionTailsFirst,
+ // Upsert-representing rows sort before tombstones because rows detect
if they are being
+ // bisected by LEAD(1). This allows upserts to match against
same-sequence deletes, an
+ // arbitrary but deterministic convention. When this happens, the
delete event will survive
+ // and persist as a tombstone in the auxiliary table.
+ orderUpsertRepresentingRowsFirst
+ )
+ }
+
/**
* Reconcile a CDC microbatch into the canonical form the auxiliary- and
target-table merges
* consume.
@@ -340,6 +396,249 @@ case class Scd2BatchProcessor(
affectedRowsFromTargetTable
}
+
+ /**
+ * For every closed non-tombstone row in the input dataframe whose immediate
window-order
+ * successor (in the same per-key partition, per
[[orderChronologicallyPerKeyWindow]])
+ * has `recordStartAt` strictly less than its `endAt` (in other words the
row is being
+ * bisected by its neighbor), replace that row by a "head" + "tail" pair:
+ * - head: copies the parent row exactly, except [[endAtColName]] is set
to null.
+ * - tail: copies the parent row exactly, except [[startAtColName]] is set
to null and
+ * [[recordStartAtFieldName]] inside [[cdcMetadataColName]] is set to
null. All user
+ * data columns are inherited from the parent as-is.
+ *
+ * Decomposition tails are uniquely identified by [[recordStartAtFieldName]]
= null and
+ * are temporary: downstream reconciliation drops them when a coincident
non-tail row
+ * already represents the same closure, or promotes them to tombstones in
the aux table
+ * otherwise.
+ *
+ * All other input rows pass through unchanged ("no-op decompose"). This
includes:
+ * 1. Open rows ([[endAtColName]] = null): incoming upserts, no-op
continuations, etc.
+ * 2. Tombstones ([[startAtColName]] = [[endAtColName]]): protected from
decomposition
+ * even though they qualify as "closed" in the broader sense, because
their interval
+ * is degenerate.
+ * 3. Closed non-tombstone rows whose successor's
[[recordStartAtFieldName]] is `>=`
+ * this row's [[endAtColName]]: the closing event already coincides
with or follows
+ * the run boundary, so there is nothing to bisect.
+ *
+ * Bisection detection is implemented via `LEAD(1)` over
[[orderChronologicallyPerKeyWindow]]:
+ * because the window orders by effective recordStartAt ascending, examining
only the
+ * immediate successor is sufficient to decide whether any other row in the
partition has
+ * a recordStartAt within `[recordStartAt, endAt)`.
+ *
+ * Decomposing closed rows that are being bisected gives us that chance to
form new
+ * closed intervals using the incoming microbatch events, later in
reconciliation.
+ *
+ * @param rowsToDecomposePerKey
+ * a dataframe conforming to the canonical SCD2 row schema
+ * `[user_cols..., [[startAtColName]], [[endAtColName]],
[[cdcMetadataColName]]]`, where
+ * [[cdcMetadataColName]] conforms to [[cdcMetadataColSchema]].
Decomposition tails
+ * (rows with [[recordStartAtFieldName]] = null) MUST NOT be present on
input - they are
+ * produced exclusively by this function.
+ * @return
+ * a dataframe with the same schema as the input. Every closed
non-tombstone row that
+ * was bisected has been replaced by its head + tail pair; every other row
is carried
+ * through as-is. Each output row can be classified as one of:
{decomposition head,
+ * decomposition tail, instantaneous delete, open upsert,
closed-and-unbisected row}. It's
+ * possible that some of the returned decomposition tails are logically
redundant, as
+ * deletion markers that are immediately overtaken by a succeeding row.
+ */
+ private[autocdc] def decomposeOutOfOrderRows(rowsToDecomposePerKey:
DataFrame): DataFrame = {
+ val recordStartAtField =
+
Scd2BatchProcessor.recordStartAtOf(F.col(AutoCdcReservedNames.cdcMetadataColName))
+ val startAtCol = F.col(Scd2BatchProcessor.startAtColName)
+ val endAtCol = F.col(Scd2BatchProcessor.endAtColName)
+ val nextRecordStartAt = F.col(Scd2BatchProcessor.nextRecordStartAtColName)
+
+ // Track the next (in sorted order) row's recordStartAt in a temporary
column.
+ val rowsToDecomposeWithWindowCols = rowsToDecomposePerKey.withColumn(
+ Scd2BatchProcessor.nextRecordStartAtColName,
+ F.lead(recordStartAtField, 1).over(orderChronologicallyPerKeyWindow)
+ )
+
+ val isClosedUpsertRow = RowClassifier.isClosedUpsert(
+ Scd2IntervalColumns(recordStartAtField, startAtCol, endAtCol)
+ )
+ val nextRowBisectsCurrentRow =
+ nextRecordStartAt.isNotNull && nextRecordStartAt < endAtCol
+ val rowShouldDecompose = isClosedUpsertRow && nextRowBisectsCurrentRow
+
+ val originalCols: Seq[String] = rowsToDecomposePerKey.columns.toSeq
+ val originalSchema: StructType = rowsToDecomposePerKey.schema
+ def withOriginalSchemaPreserved(fieldName: String, expr: Column): Column =
{
+ val f = originalSchema(fieldName)
+ expr.cast(f.dataType).as(f.name, f.metadata)
+ }
+
+ // Constructs the head of a row post-decomposition.
+ def constructDecomposedRowHead: Column = {
+ val fields = originalCols.map {
+ case colName if colName == Scd2BatchProcessor.endAtColName =>
+ // End-at is opened (set to null); every other column is inherited
as-is from the
+ // original parent row.
+ withOriginalSchemaPreserved(colName, F.lit(null))
+ case colName =>
+ withOriginalSchemaPreserved(colName, F.col(colName))
+ }
+ F.struct(fields: _*)
+ }
+
+ // Constructs the tail of a row post-decomposition.
+ def constructDecomposedRowTail: Column = {
+ val fields = originalCols.map {
+ case colName if colName == Scd2BatchProcessor.startAtColName =>
+ // Start-at is opened (set to null), every other column is inherited
as-is from the
+ // original parent row.
+ withOriginalSchemaPreserved(colName, F.lit(null))
+ case colName if colName == AutoCdcReservedNames.cdcMetadataColName =>
+ withOriginalSchemaPreserved(
+ colName,
+ Scd2BatchProcessor.constructCdcMetadataCol(
+ recordStartAt = F.lit(null).cast(resolvedSequencingType),
+ sequencingType = resolvedSequencingType
+ )
+ )
+ case colName =>
+ withOriginalSchemaPreserved(colName, F.col(colName))
+ }
+ F.struct(fields: _*)
+ }
+
+ // No-op decomposition carries over the row exactly as-is.
+ def constructNoopDecomposedRow: Column = {
+ val fields = originalCols.map(colName =>
+ withOriginalSchemaPreserved(colName, F.col(colName))
+ )
+ F.struct(fields: _*)
+ }
+
+ // If a row is bisected by its window-order successor, decompose it into a
head + tail
+ // pair. Otherwise pass through as a single-element array so the explode
below is uniform.
+ val perRowDecompositionResults = F
+ .when(
+ rowShouldDecompose,
+ F.array(constructDecomposedRowHead, constructDecomposedRowTail)
+ )
+ .otherwise(F.array(constructNoopDecomposedRow))
+
+ rowsToDecomposeWithWindowCols
+ // The output schema matches the input schema exactly; no extra columns
are projected.
+ .withColumn(
+ Scd2BatchProcessor.decompositionExplodedColName,
+ F.explode(perRowDecompositionResults)
+ )
+ .select(F.col(s"${Scd2BatchProcessor.decompositionExplodedColName}.*"))
+ }
+
+ /**
+ * Asserts that every row in `decomposedRowsPerKey` conforms to one of the
four canonical
+ * post-decomposition shapes - instantaneous delete, open upsert, closed
upsert, or
+ * decomposition tail - and is otherwise a structural identity transform.
+ *
+ * @param decomposedRowsPerKey
+ * the output of [[decomposeOutOfOrderRows]]: a dataframe conforming to
the canonical
+ * SCD2 row schema `[user_cols..., [[startAtColName]], [[endAtColName]],
+ * [[cdcMetadataColName]]]`.
+ * @return
+ * a dataframe with the exact same schema and rows as the input. Failing
the
+ * well-formedness check is treated as an internal-invariant violation: at
execution
+ * time, the first ill-formed row encountered aborts the query with a
SparkRuntimeException
+ */
+ private[autocdc] def assertWellFormedRowsPostDecomposition(
+ decomposedRowsPerKey: DataFrame,
+ batchId: Long
+ ): DataFrame = {
+ val recordStartAtField =
+
Scd2BatchProcessor.recordStartAtOf(F.col(AutoCdcReservedNames.cdcMetadataColName))
+ val startAtCol = F.col(Scd2BatchProcessor.startAtColName)
+ val endAtCol = F.col(Scd2BatchProcessor.endAtColName)
+
+ val row = Scd2IntervalColumns(recordStartAtField, startAtCol, endAtCol)
+ val isWellFormedRow =
+ RowClassifier.isDecompositionTail(row) ||
+ RowClassifier.isDeleteRepresentingRow(row) ||
+ RowClassifier.isUpsertRepresentingRow(row)
+
+ def stringOrNullLit(c: Column): Column = F.coalesce(c.cast(StringType),
F.lit("null"))
+ val malformedRowDiagnostic = F.concat(
+ F.lit(
+ s"During SCD2 reconciliation of microbatch [id=${batchId}],
encountered a " +
+ "post-decomposition row of unexpected shape:"
+ ),
+ F.lit(s" ${Scd2BatchProcessor.recordStartAtFieldName}="),
+ stringOrNullLit(recordStartAtField),
+ F.lit(s", ${Scd2BatchProcessor.startAtColName}="),
+ stringOrNullLit(startAtCol),
+ F.lit(s", ${Scd2BatchProcessor.endAtColName}="),
+ stringOrNullLit(endAtCol),
+ F.lit(".")
+ )
+
+ val internalErrorOnMalformed = ExpressionUtils.column(
+ If(
+ predicate = ExpressionUtils.expression(isWellFormedRow),
+ trueValue = Literal(null, BooleanType),
+ falseValue = RaiseError(
+ Literal("INTERNAL_ERROR"),
+ CreateMap(Seq(
+ Literal("message"),
+ ExpressionUtils.expression(malformedRowDiagnostic)
+ )),
+ BooleanType
+ )
+ )
+ )
+ decomposedRowsPerKey.filter(internalErrorOnMalformed.isNull)
+ }
+
+ /**
+ * Drops rows that are redundant within the per-key chronological window
output by
+ * [[decomposeOutOfOrderRows]]. The redundancy criterion is a single rule:
+ *
+ * A row is redundant whenever it sorts before some other row with the
same effective
+ * recordStartAt under [[orderChronologicallyPerKeyWindow]]; equivalently,
only the
+ * trailing row in any such tie survives.
+ *
+ * The window's tiebreakers (see [[orderChronologicallyPerKeyWindow]]) put
the more
+ * informative row last in every meaningful collision: a decomposition tail
drops in
+ * favor of the coincident real event that already encodes the same closure,
and an
+ * upsert drops in favor of a same-sequence tombstone (delete wins over
upsert at the
+ * same instant).
+ *
+ * @param decomposedRowsPerKey
+ * the output of [[decomposeOutOfOrderRows]]: a dataframe conforming to
the canonical
+ * SCD2 row schema `[user_cols..., [[startAtColName]], [[endAtColName]],
+ * [[cdcMetadataColName]]]`.
+ * @return
+ * a dataframe conforming to the same schema as the input. Per key, no two
rows in the
+ * returned dataframe will share the same effective record-start-at.
+ */
+ private[autocdc] def dropRedundantRowsPostDecomposition(
+ decomposedRowsPerKey: DataFrame): DataFrame = {
+ val recordStartAtField =
+
Scd2BatchProcessor.recordStartAtOf(F.col(AutoCdcReservedNames.cdcMetadataColName))
+ val startAtCol = F.col(Scd2BatchProcessor.startAtColName)
+ val endAtCol = F.col(Scd2BatchProcessor.endAtColName)
+ val effectiveRecordStartAt =
+ Scd2IntervalColumns(recordStartAtField, startAtCol,
endAtCol).effectiveRecordStartAt
+
+ val withNextEffectiveRecordStartAt = decomposedRowsPerKey.withColumn(
+ Scd2BatchProcessor.nextEffectiveRecordStartAtColName,
+ F.lead(effectiveRecordStartAt, 1).over(orderChronologicallyPerKeyWindow)
+ )
+ val nextEffectiveRecordStartAt =
+
withNextEffectiveRecordStartAt.col(Scd2BatchProcessor.nextEffectiveRecordStartAtColName)
+
+ // A row is redundant whenever it ties with its immediate window-order
successor on
+ // effective recordStartAt. Window tiebreakers ensure the *leading* row in
any such tie
+ // is the one to drop.
+ val isRedundantAtSameEffectiveSequence =
+ effectiveRecordStartAt <=> nextEffectiveRecordStartAt
+
+ withNextEffectiveRecordStartAt
+ .filter(!isRedundantAtSameEffectiveSequence)
+ .drop(Scd2BatchProcessor.nextEffectiveRecordStartAtColName)
+ }
}
/**
@@ -386,7 +685,9 @@ case class Scd2BatchProcessor(
* representation of the CDC flow's source table.
*
* Lifetime invariant: As per the SCD2 contract, the target table should never
have overlapping
- * rows by [startAt, endAt) intervals.
+ * rows by [startAt, endAt) intervals. The target table also never persists a
zero-width
+ * upsert-representing row (a non-tombstone with `startAt == endAt`); such
rows are dropped
+ * during reconciliation.
*
* -------------
* Concept: aux table.
@@ -404,7 +705,7 @@ case class Scd2BatchProcessor(
* -------------
* Concept: decomposition tail.
*
- * A transient and synthetic row produced by the batch processor during
reconciliation (not
+ * A temporary and synthetic row produced by the batch processor during
reconciliation (not
* from the CDC source) when a previously-closed historical row [START_AT=X,
END_AT=Y] is
* bisected by a late-arriving event. The bisected row is split into a head
* [START_AT=X, END_AT=null] - inheriting the original row's data and
`__RECORD_START_AT` -
@@ -498,9 +799,40 @@ object Scd2BatchProcessor {
/**
* Name of temporary column projected onto microbatch to compute the min
sequencing value per
* key within the microbatch.
+ *
+ * Temporary in that the column has no observable side effect or persistence
across microbatches.
+ * Package level visiblity for unit-testing.
*/
private[autocdc] val minSequenceColName: String =
s"${AutoCdcReservedNames.prefix}min_sequence"
+ /**
+ * Name of temporary column projected onto intermediary dataframe during
decomposition to track
+ * the next row's record start at when in sorted in chronological order as
per
+ * [[orderChronologicallyPerKeyWindow]].
+ *
+ * Temporary in that the column has no observable side effect or persistence
across microbatches.
+ */
+ private val nextRecordStartAtColName: String =
+ s"${AutoCdcReservedNames.prefix}next_record_start_at"
+
+ /**
+ * Name of temporary column projected onto intermediary dataframe during
decomposition that
+ * stores the child rows that result from decomposing a parent row.
+ *
+ * Temporary in that the column has no observable side effect or persistence
across microbatches.
+ */
+ private val decompositionExplodedColName: String =
+ s"${AutoCdcReservedNames.prefix}decompose_output"
+
+ /**
+ * Name of temporary column used by [[dropRedundantRowsPostDecomposition]]
to reference the
+ * next row's effective recordStartAt in chronologically sorted order.
+ *
+ * Temporary in that the column has no observable side effect or persistence
across microbatches.
+ */
+ private[autocdc] val nextEffectiveRecordStartAtColName: String =
+ s"${AutoCdcReservedNames.prefix}next_effective_record_start_at"
+
/**
* Name of the temporary column used to identify the sequence associated
with the anchor
* row found in the auxiliary table for the incoming microbatch. Since
sequences must be unique
@@ -520,7 +852,7 @@ object Scd2BatchProcessor {
StructType(
Seq(
// The sequence value of the originating CDC event for this row.
Nullable because
- // decomposition tails, which are transient and synthetically
constructed during
+ // decomposition tails, which are temporarily and synthetically
constructed during
// reconciliation, have a null record start at.
StructField(recordStartAtFieldName, sequencingType, nullable = true)
)
@@ -547,3 +879,81 @@ object Scd2BatchProcessor {
F.struct(cdcMetadataFieldsInOrder.toImmutableArraySeq: _*)
}
}
+
+/**
+ * The three columns that locate a row on the SCD2 timeline: its source
record-start sequence
+ * (`recordStartAt`, null only for decomposition tails) and the bounds of its
visible interval
+ * [`startAt`, `endAt`). [[RowClassifier]] classifies a row purely from this
triple.
+ */
+private[autocdc] case class Scd2IntervalColumns(
+ recordStartAt: Column,
+ startAt: Column,
+ endAt: Column) {
+
+ /**
+ * The row's effective ordering position. Decomposition tails carry no
`recordStartAt` and
+ * fall back to their closing sequence (`endAt`), the same convention used by
+ * [[Scd2BatchProcessor.orderChronologicallyPerKeyWindow]].
+ */
+ def effectiveRecordStartAt: Column = F.coalesce(recordStartAt, endAt)
+}
+
+object RowClassifier {
+
+ /**
+ * Synthetic right boundary created by splitting a closed row, temporarily
present during
+ * microbatch reconciliation but never materializes in the target or aux
tables.
+ */
+ private[autocdc] def isDecompositionTail(row: Scd2IntervalColumns): Column =
+ row.recordStartAt.isNull && row.startAt.isNull && row.endAt.isNotNull
+
+ /**
+ * Upsert row that is currently open in the visible timeline. Hidden no-op
upserts are
+ * also open until reconciliation decides whether they should stay hidden.
+ */
+ private[autocdc] def isOpenUpsert(row: Scd2IntervalColumns): Column =
+ row.recordStartAt.isNotNull &&
+ row.startAt.isNotNull &&
+ row.endAt.isNull &&
+ // startAt < recordStartAt implies this row belongs to but is not the
head of some
+ // upsert-run, else this is the head of a run.
+ row.startAt <= row.recordStartAt
+
+ /**
+ * Upsert row whose visible interval has already been closed by a strictly
later event;
+ * the historical counterpart to [[isOpenUpsert]].
+ *
+ * Notably, a zero-width [startAt, endAt) interval is not considered a valid
closed upsert.
+ */
+ private[autocdc] def isClosedUpsert(row: Scd2IntervalColumns): Column =
+ row.recordStartAt.isNotNull &&
+ row.startAt.isNotNull &&
+ row.endAt.isNotNull &&
+ row.recordStartAt < row.endAt &&
+ row.startAt < row.endAt &&
+ // startAt <= recordStartAt covers both the run-head case (startAt ==
recordStartAt)
+ // and the no-op-continuation case (startAt < recordStartAt).
+ row.startAt <= row.recordStartAt
+
+ /**
+ * Any row that semantically encodes an upsert event.
+ */
+ private[autocdc] def isUpsertRepresentingRow(row: Scd2IntervalColumns):
Column =
+ isOpenUpsert(row) || isClosedUpsert(row)
+
+ /**
+ * Delete-representing row, encoded as an instantaneous (zero-width)
interval at `recordStartAt`
+ * (`startAt == endAt == recordStartAt`). Never materializes in the target
table.
+ *
+ * User-data column values on these rows are not part of the SCD2 contract:
they may reflect
+ * the originating delete event, the values of the upsert whose
closed-interval row was bisected
+ * (when promoted from a decomposition tail), or be null altogether.
Reconciliation does not
+ * consume these values for any semantic decision.
+ */
+ private[autocdc] def isDeleteRepresentingRow(row: Scd2IntervalColumns):
Column =
+ row.recordStartAt.isNotNull &&
+ row.startAt.isNotNull &&
+ row.endAt.isNotNull &&
+ row.startAt === row.recordStartAt &&
+ row.endAt === row.recordStartAt
+}
diff --git
a/sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/autocdc/Scd2BatchProcessorSuite.scala
b/sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/autocdc/Scd2BatchProcessorSuite.scala
index c2dee5f9f33a..c29aa50e9e72 100644
---
a/sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/autocdc/Scd2BatchProcessorSuite.scala
+++
b/sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/autocdc/Scd2BatchProcessorSuite.scala
@@ -17,6 +17,7 @@
package org.apache.spark.sql.pipelines.autocdc
+import org.apache.spark.{SparkException, SparkRuntimeException}
import org.apache.spark.sql.{functions => F, Column, QueryTest, Row}
import org.apache.spark.sql.classic.DataFrame
import org.apache.spark.sql.internal.SQLConf
@@ -108,6 +109,105 @@ class Scd2BatchProcessorSuite extends QueryTest with
SharedSparkSession {
resolvedSequencingType = LongType
)
+ // =============== orderChronologicallyPerKeyWindow tests ===============
+
+ test("orderChronologicallyPerKeyWindow sorts both decomposition tails and
non-tails by " +
+ "effective recordStartAt ascending") {
+ val processor = processorWithKeys(Seq("id"))
+ val userSchema = new StructType().add("id", IntegerType).add("value",
StringType)
+
+ // Three rows at distinct effective recordStartAts. The decomposition tail
+ // (recordStartAt = null, endAt = 10) takes its endAt as its effective
ordering sequence,
+ // so the expected per-key window order is 5, tail(10), 15.
+ val df = targetTableOf(userSchema)(
+ Row(1, "v15", 15L, null, Row(15L)),
+ Row(1, "tail", null, 10L, Row(null)),
+ Row(1, "v5", 5L, null, Row(5L))
+ )
+
+ val withRn = df.withColumn(
+ "rn", F.row_number().over(processor.orderChronologicallyPerKeyWindow)
+ )
+
+ checkAnswer(
+ df = withRn,
+ expectedAnswer = Seq(
+ Row(1, "v5", 5L, null, Row(5L), 1),
+ Row(1, "tail", null, 10L, Row(null), 2),
+ Row(1, "v15", 15L, null, Row(15L), 3)
+ )
+ )
+ }
+
+ test("orderChronologicallyPerKeyWindow tiebreakers: tails before non-tails,
then " +
+ "upsert-representing rows before tombstones, when effective recordStartAt
ties") {
+ val processor = processorWithKeys(Seq("id"))
+ val userSchema = new StructType().add("id", IntegerType).add("value",
StringType)
+
+ // Each key isolates one tiebreaker rule under test. All rows in a
partition tie at
+ // effective recordStartAt = 10, so only the tiebreakers determine order.
+ // key=1: tails-first - tail vs tombstone, tail must come first.
+ // key=2: upsert-representing-first (open variant) - open upsert vs
tombstone.
+ // key=3: upsert-representing-first (closed variant) - closed run head
vs tombstone.
+ val df = targetTableOf(userSchema)(
+ Row(1, "tomb", 10L, 10L, Row(10L)),
+ Row(1, "tail", null, 10L, Row(null)),
+ Row(2, "tomb", 10L, 10L, Row(10L)),
+ Row(2, "open", 10L, null, Row(10L)),
+ Row(3, "tomb", 10L, 10L, Row(10L)),
+ Row(3, "closed", 10L, 20L, Row(10L))
+ )
+
+ val withRn = df.withColumn(
+ "rn", F.row_number().over(processor.orderChronologicallyPerKeyWindow)
+ )
+
+ checkAnswer(
+ df = withRn,
+ expectedAnswer = Seq(
+ Row(1, "tail", null, 10L, Row(null), 1),
+ Row(1, "tomb", 10L, 10L, Row(10L), 2),
+ Row(2, "open", 10L, null, Row(10L), 1),
+ Row(2, "tomb", 10L, 10L, Row(10L), 2),
+ Row(3, "closed", 10L, 20L, Row(10L), 1),
+ Row(3, "tomb", 10L, 10L, Row(10L), 2)
+ )
+ )
+ }
+
+ test("orderChronologicallyPerKeyWindow orders rows independently per key") {
+ val processor = processorWithKeys(Seq("id"))
+ val userSchema = new StructType().add("id", IntegerType).add("value",
StringType)
+
+ // Two keys with interleaved input order. Each key's window must be sorted
independently
+ // by effective recordStartAt - rows from key 2 must not influence
row_number positions
+ // of rows for key 1, and vice versa.
+ val df = targetTableOf(userSchema)(
+ Row(1, "k1-15", 15L, null, Row(15L)),
+ Row(2, "k2-7", 7L, null, Row(7L)),
+ Row(1, "k1-5", 5L, null, Row(5L)),
+ Row(2, "k2-3", 3L, null, Row(3L)),
+ Row(1, "k1-10", 10L, null, Row(10L)),
+ Row(2, "k2-20", 20L, null, Row(20L))
+ )
+
+ val withRn = df.withColumn(
+ "rn", F.row_number().over(processor.orderChronologicallyPerKeyWindow)
+ )
+
+ checkAnswer(
+ df = withRn,
+ expectedAnswer = Seq(
+ Row(1, "k1-5", 5L, null, Row(5L), 1),
+ Row(1, "k1-10", 10L, null, Row(10L), 2),
+ Row(1, "k1-15", 15L, null, Row(15L), 3),
+ Row(2, "k2-3", 3L, null, Row(3L), 1),
+ Row(2, "k2-7", 7L, null, Row(7L), 2),
+ Row(2, "k2-20", 20L, null, Row(20L), 3)
+ )
+ )
+ }
+
// =============== preprocessMicrobatch tests ===============
test("preprocessMicrobatch appends framework columns __START_AT, __END_AT, "
+
@@ -1217,4 +1317,597 @@ class Scd2BatchProcessorSuite extends QueryTest with
SharedSparkSession {
assert(result.collect().isEmpty)
}
+ // =============== decomposeOutOfOrderRows tests ===============
+
+ test("decomposeOutOfOrderRows passes through open rows, tombstones, and " +
+ "last-in-partition closed rows") {
+ val processor = processorWithKeys(Seq("id"))
+ val userSchema = new StructType()
+ .add("id", IntegerType)
+ .add("value", StringType)
+ .add("amount", IntegerType)
+
+ // None of these rows is eligible for decomposition:
+ // - "open": endAt is null, so it is not a closed row
+ // - "tomb": startAt == endAt, so it is excluded by the strict `<`
closed check
+ // - "last": closed, but is the last row in its window partition (no
successor)
+ val df = targetTableOf(userSchema)(
+ Row(1, "open", 100, 5L, null, Row(5L)),
+ Row(1, "tomb", 200, 10L, 10L, Row(10L)),
+ Row(1, "last", 300, 15L, 25L, Row(15L))
+ )
+
+ val result = processor.decomposeOutOfOrderRows(df)
+
+ checkAnswer(
+ df = result,
+ expectedAnswer = Seq(
+ Row(1, "open", 100, 5L, null, Row(5L)),
+ Row(1, "tomb", 200, 10L, 10L, Row(10L)),
+ Row(1, "last", 300, 15L, 25L, Row(15L))
+ )
+ )
+ }
+
+ test("decomposeOutOfOrderRows leaves a closed row alone when the next event
arrives at " +
+ "exactly its endAt") {
+ val processor = processorWithKeys(Seq("id"))
+ val userSchema = new StructType()
+ .add("id", IntegerType)
+ .add("value", StringType)
+ .add("amount", IntegerType)
+
+ // Closed [5, 10] would decompose if the successor's recordStartAt were
strictly less
+ // than 10. Here the successor lands at exactly 10, which means it doesn't
actually
+ // bisect the closed row and therefore shouldn't decompose it.
+ val df = targetTableOf(userSchema)(
+ Row(1, "alice", 42, 5L, 10L, Row(5L)),
+ Row(1, "bob", 99, 10L, null, Row(10L))
+ )
+
+ val result = processor.decomposeOutOfOrderRows(df)
+
+ checkAnswer(
+ df = result,
+ expectedAnswer = Seq(
+ Row(1, "alice", 42, 5L, 10L, Row(5L)),
+ Row(1, "bob", 99, 10L, null, Row(10L))
+ )
+ )
+ }
+
+ test("decomposeOutOfOrderRows splits a bisected row into head + tail, " +
+ "both inheriting parent data") {
+ val processor = processorWithKeys(Seq("id"))
+ val userSchema = new StructType()
+ .add("id", IntegerType)
+ .add("value", StringType)
+ .add("amount", IntegerType)
+
+ // Closed [5, 30] is bisected by a successor at recordStartAt = 15 (15 <
30). Expected:
+ // - head: parent with endAt set to null
+ // - tail: parent with startAt set to null and recordStartAt (in
cdcMetadata) set to null
+ // - successor: passes through
+ // Both head and tail must carry the parent's data columns (value="alice",
amount=42)
+ // identically.
+ val df = targetTableOf(userSchema)(
+ Row(1, "alice", 42, 5L, 30L, Row(5L)),
+ Row(1, "bob", 99, 15L, null, Row(15L))
+ )
+
+ val result = processor.decomposeOutOfOrderRows(df)
+
+ checkAnswer(
+ df = result,
+ expectedAnswer = Seq(
+ Row(1, "alice", 42, 5L, null, Row(5L)), // head
+ Row(1, "alice", 42, null, 30L, Row(null)), // tail
+ Row(1, "bob", 99, 15L, null, Row(15L)) // bisecting successor
+ )
+ )
+ }
+
+ test("decomposeOutOfOrderRows triggers on any kind of bisecting successor") {
+ val processor = processorWithKeys(Seq("id"))
+ val userSchema = new StructType()
+ .add("id", IntegerType)
+ .add("value", StringType)
+ .add("amount", IntegerType)
+
+ // Three independent keys, each with a closed parent [5, 50] bisected by a
successor
+ // of a different kind. All three parents must decompose, regardless of
the successor's
+ // own row kind (the bisection check looks only at recordStartAt <
parent.endAt).
+ val df = targetTableOf(userSchema)(
+ // Key 1: bisected by an open upsert.
+ Row(1, "alice", 1, 5L, 50L, Row(5L)),
+ Row(1, "bob", 2, 10L, null, Row(10L)),
+
+ // Key 2: bisected by a tombstone.
+ Row(2, "carol", 3, 5L, 50L, Row(5L)),
+ Row(2, "dave", 4, 20L, 20L, Row(20L)),
+
+ // Key 3: bisected by another closed non-tombstone.
+ Row(3, "eve", 5, 5L, 50L, Row(5L)),
+ Row(3, "frank", 6, 30L, 40L, Row(30L))
+ )
+
+ val result = processor.decomposeOutOfOrderRows(df)
+
+ checkAnswer(
+ df = result,
+ expectedAnswer = Seq(
+ // Key 1.
+ Row(1, "alice", 1, 5L, null, Row(5L)),
+ Row(1, "alice", 1, null, 50L, Row(null)),
+ Row(1, "bob", 2, 10L, null, Row(10L)),
+ // Key 2.
+ Row(2, "carol", 3, 5L, null, Row(5L)),
+ Row(2, "carol", 3, null, 50L, Row(null)),
+ Row(2, "dave", 4, 20L, 20L, Row(20L)),
+ // Key 3.
+ Row(3, "eve", 5, 5L, null, Row(5L)),
+ Row(3, "eve", 5, null, 50L, Row(null)),
+ Row(3, "frank", 6, 30L, 40L, Row(30L))
+ )
+ )
+ }
+
+ test("decomposeOutOfOrderRows uses chronological window order, not input
order") {
+ val processor = processorWithKeys(Seq("id"))
+ val userSchema = new StructType()
+ .add("id", IntegerType)
+ .add("value", StringType)
+ .add("amount", IntegerType)
+
+ // Same data as the basic split test but with the input rows shuffled into
a
+ // non-chronological order. The window orders rows by effective
recordStartAt, so the
+ // result must still recognize that [5, 30] is bisected by the row at
recordStartAt = 15.
+ val df = targetTableOf(userSchema)(
+ Row(1, "bob", 99, 15L, null, Row(15L)), // appears first in input
+ Row(1, "alice", 42, 5L, 30L, Row(5L)) // appears last in input but
lower in window
+ )
+
+ val result = processor.decomposeOutOfOrderRows(df)
+
+ checkAnswer(
+ df = result,
+ expectedAnswer = Seq(
+ Row(1, "alice", 42, 5L, null, Row(5L)),
+ Row(1, "alice", 42, null, 30L, Row(null)),
+ Row(1, "bob", 99, 15L, null, Row(15L))
+ )
+ )
+ }
+
+ test("decomposeOutOfOrderRows isolates per-key partitions") {
+ val processor = processorWithKeys(Seq("id"))
+ val userSchema = new StructType()
+ .add("id", IntegerType)
+ .add("value", StringType)
+ .add("amount", IntegerType)
+
+ // Key 1 has a bisecting pair. Key 2 only contains a single closed row
whose recordStartAt
+ // happens to coincide with key 1's parent. The window partitions by key,
so key 1's
+ // bisecting successor must NOT bleed into key 2's partition.
+ val df = targetTableOf(userSchema)(
+ // Key 1: closed [5, 30] bisected by recordStartAt = 15.
+ Row(1, "alice", 42, 5L, 30L, Row(5L)),
+ Row(1, "bob", 99, 15L, null, Row(15L)),
+
+ // Key 2: a single closed [5, 30] with no successor in its own partition.
+ Row(2, "carol", 7, 5L, 30L, Row(5L))
+ )
+
+ val result = processor.decomposeOutOfOrderRows(df)
+
+ checkAnswer(
+ df = result,
+ expectedAnswer = Seq(
+ // Key 1 decomposes.
+ Row(1, "alice", 42, 5L, null, Row(5L)),
+ Row(1, "alice", 42, null, 30L, Row(null)),
+ Row(1, "bob", 99, 15L, null, Row(15L)),
+ // Key 2 passes through.
+ Row(2, "carol", 7, 5L, 30L, Row(5L))
+ )
+ )
+ }
+
+ test("decomposeOutOfOrderRows handles a cascade of consecutive bisected
rows") {
+ val processor = processorWithKeys(Seq("id"))
+ val userSchema = new StructType()
+ .add("id", IntegerType)
+ .add("value", StringType)
+ .add("amount", IntegerType)
+
+ // A nested cascade: each closed row (except the innermost) is bisected by
the next.
+ // Decomposition decisions are independent and based on each row's
immediate successor:
+ // [5, 30] bisected by [10, 25] -> decomposes
+ // [10, 25] bisected by [15, 20] -> decomposes
+ // [15, 20] is the last row -> passes through
+ val df = targetTableOf(userSchema)(
+ Row(1, "outer", 1, 5L, 30L, Row(5L)),
+ Row(1, "middle", 2, 10L, 25L, Row(10L)),
+ Row(1, "inner", 3, 15L, 20L, Row(15L))
+ )
+
+ val result = processor.decomposeOutOfOrderRows(df)
+
+ checkAnswer(
+ df = result,
+ expectedAnswer = Seq(
+ // outer decomposes.
+ Row(1, "outer", 1, 5L, null, Row(5L)),
+ Row(1, "outer", 1, null, 30L, Row(null)),
+ // middle decomposes.
+ Row(1, "middle", 2, 10L, null, Row(10L)),
+ Row(1, "middle", 2, null, 25L, Row(null)),
+ // inner passes through.
+ Row(1, "inner", 3, 15L, 20L, Row(15L))
+ )
+ )
+ }
+
+ test("decomposeOutOfOrderRows returns empty for empty input") {
+ val processor = processorWithKeys(Seq("id"))
+ val userSchema = new StructType()
+ .add("id", IntegerType)
+ .add("value", StringType)
+ .add("amount", IntegerType)
+
+ val df = targetTableOf(userSchema)()
+
+ val result = processor.decomposeOutOfOrderRows(df)
+
+ assert(result.collect().isEmpty)
+ assert(result.columns.toSeq == df.columns.toSeq)
+ }
+
+ test("decomposeOutOfOrderRows preserves per-column schema attributes through
" +
+ "head + tail decomposition") {
+ val processor = processorWithKeys(Seq("id"))
+
+ def commentMetadata(comment: String): Metadata =
+ new MetadataBuilder().putString("comment", comment).build()
+
+ val cdcMetadataInnerSchema = new StructType().add(
+ Scd2BatchProcessor.recordStartAtFieldName,
+ LongType,
+ nullable = true,
+ metadata = commentMetadata("inner __RECORD_START_AT")
+ )
+
+ val schema = new StructType()
+ .add("id", IntegerType, nullable = false, metadata =
commentMetadata("user key"))
+ .add("value", StringType, nullable = true, metadata =
commentMetadata("user data"))
+ .add(
+ Scd2BatchProcessor.startAtColName, LongType, nullable = true,
+ metadata = commentMetadata("framework __START_AT"))
+ .add(
+ Scd2BatchProcessor.endAtColName, LongType, nullable = true,
+ metadata = commentMetadata("framework __END_AT"))
+ .add(
+ AutoCdcReservedNames.cdcMetadataColName, cdcMetadataInnerSchema,
nullable = false,
+ metadata = commentMetadata("framework _cdc_metadata"))
+
+ // Closed [5, 30] bisected by recordStartAt = 15.
+ val df = microbatchOf(schema)(
+ Row(1, "alice", 5L, 30L, Row(5L)),
+ Row(1, "bob", 15L, null, Row(15L))
+ )
+
+ val result = processor.decomposeOutOfOrderRows(df)
+
+ // Output schema must match the input field-for-field on name, dataType
(which carries
+ // inner-struct field metadata), nullable, and outer metadata.
+ schema.fields.zip(result.schema.fields).foreach { case (in, out) =>
+ assert(in.name == out.name)
+ assert(in.dataType == out.dataType)
+ assert(in.nullable == out.nullable)
+ assert(in.metadata == out.metadata)
+ }
+ }
+
+ // =============== assertWellFormedRowsPostDecomposition tests
===============
+
+ test("assertWellFormedRowsPostDecomposition passes through a dataframe whose
rows match all " +
+ "four canonical shapes") {
+ val processor = processorWithKeys(Seq("id"))
+ val userSchema = new StructType().add("id", IntegerType).add("value",
StringType)
+
+ // One row of each canonical shape.
+ // tombstone: startAt = endAt = recordStartAt
+ // open upsert: endAt is null, startAt and recordStartAt non-null
+ // closed upsert: startAt < endAt, recordStartAt non-null
+ // decomposition tail: startAt and recordStartAt null, endAt non-null
+ val df = targetTableOf(userSchema)(
+ Row(1, "tomb", 10L, 10L, Row(10L)),
+ Row(2, "open", 20L, null, Row(20L)),
+ Row(3, "closed", 30L, 40L, Row(30L)),
+ Row(4, "tail", null, 50L, Row(null))
+ )
+
+ checkAnswer(
+ df = processor.assertWellFormedRowsPostDecomposition(df, batchId = 0),
+ expectedAnswer = Seq(
+ Row(1, "tomb", 10L, 10L, Row(10L)),
+ Row(2, "open", 20L, null, Row(20L)),
+ Row(3, "closed", 30L, 40L, Row(30L)),
+ Row(4, "tail", null, 50L, Row(null))
+ )
+ )
+ }
+
+ test("assertWellFormedRowsPostDecomposition throws on invalid row shape") {
+ val processor = processorWithKeys(Seq("id"))
+ val userSchema = new StructType().add("id", IntegerType).add("value",
StringType)
+
+ // recordStartAt = null + startAt = 5 + endAt = 10 matches none of the
canonical shapes:
+ // the decomposition-tail kind rejects on startAt non-null, while the
tombstone, open
+ // upsert, and closed upsert kinds all reject on recordStartAt null.
+ val df = targetTableOf(userSchema)(
+ Row(1, "malformed", 5L, 10L, Row(null))
+ )
+
+ val wrapper = intercept[SparkException] {
+ processor.assertWellFormedRowsPostDecomposition(df, batchId =
0).collect()
+ }
+ assert(wrapper.getCause.isInstanceOf[SparkRuntimeException],
+ s"Expected SparkRuntimeException cause, got ${wrapper.getCause}")
+ val cause = wrapper.getCause.asInstanceOf[SparkRuntimeException]
+ assert(cause.getCondition == "INTERNAL_ERROR")
+ assert(cause.getMessage.contains("post-decomposition"))
+ assert(cause.getMessage.contains(Scd2BatchProcessor.startAtColName))
+ assert(cause.getMessage.contains(Scd2BatchProcessor.endAtColName))
+ }
+
+ test("assertWellFormedRowsPostDecomposition does not introduce extra
columns") {
+ val processor = processorWithKeys(Seq("id"))
+ val userSchema = new StructType().add("id", IntegerType).add("value",
StringType)
+
+ val df = targetTableOf(userSchema)(
+ Row(1, "open", 10L, null, Row(10L))
+ )
+
+ val result = processor.assertWellFormedRowsPostDecomposition(df, batchId =
0)
+
+ assert(result.schema == df.schema)
+ }
+
+ // =============== dropRedundantRowsPostDecomposition tests ===============
+
+ test("dropRedundantRowsPostDecomposition passes through events with distinct
sequences") {
+ val processor = processorWithKeys(Seq("id"))
+ val userSchema = new StructType().add("id", IntegerType).add("value",
StringType)
+
+ // Distinct effective recordStartAts within the dataframe, so no
redundancies - identity
+ // transformation expected.
+ val df = targetTableOf(userSchema)(
+ Row(1, "v5", 5L, null, Row(5L)),
+ Row(1, "v10", 10L, null, Row(10L)),
+ Row(1, "v15", 15L, 20L, Row(15L))
+ )
+
+ val result = processor.dropRedundantRowsPostDecomposition(df)
+
+ checkAnswer(
+ df = result,
+ expectedAnswer = Seq(
+ Row(1, "v5", 5L, null, Row(5L)),
+ Row(1, "v10", 10L, null, Row(10L)),
+ Row(1, "v15", 15L, 20L, Row(15L))
+ )
+ )
+ }
+
+ test("dropRedundantRowsPostDecomposition drops a same-event duplicate") {
+ val processor = processorWithKeys(Seq("id"))
+ val userSchema = new StructType().add("id", IntegerType).add("value",
StringType)
+
+ // Two open upserts tied on effective recordStartAt = 10. The
chronologically-leading
+ // copy is dropped; exactly one copy survives. We do not assert which
user-data variant
+ // survives because the window's tiebreaker among truly-identical rows is
intentionally
+ // undefined.
+ val df = targetTableOf(userSchema)(
+ Row(1, "first", 10L, null, Row(10L)),
+ Row(1, "second", 10L, null, Row(10L))
+ )
+
+ val result = processor.dropRedundantRowsPostDecomposition(df)
+ val survivors = result.collect()
+
+ assert(survivors.length == 1)
+ assert(Set("first", "second").contains(survivors.head.getString(1)))
+ }
+
+ test("dropRedundantRowsPostDecomposition drops an open upsert overtaken at
the same instant") {
+ val processor = processorWithKeys(Seq("id"))
+ val userSchema = new StructType().add("id", IntegerType).add("value",
StringType)
+
+ // Open upsert at recordStartAt=10 followed by a tombstone at the same
instant
+ // (effectiveRecordStartAt=10). The upsert opens at an instant the
tombstone already
+ // overtakes, leaving it 0-width: it ties with its successor on effective
sequence
+ // and is dropped. The tombstone (last in partition) survives.
+ val df = targetTableOf(userSchema)(
+ Row(1, "open", 10L, null, Row(10L)),
+ Row(1, "tomb", 10L, 10L, Row(10L))
+ )
+
+ val result = processor.dropRedundantRowsPostDecomposition(df)
+
+ checkAnswer(
+ df = result,
+ expectedAnswer = Seq(
+ Row(1, "tomb", 10L, 10L, Row(10L))
+ )
+ )
+ }
+
+ test("dropRedundantRowsPostDecomposition drops a decomposition tail
coincident with " +
+ "an incoming event") {
+ val processor = processorWithKeys(Seq("id"))
+ val userSchema = new StructType().add("id", IntegerType).add("value",
StringType)
+
+ // Decomposition tail closing at endAt=30 followed by a non-tail event at
+ // recordStartAt=30. Both have effective recordStartAt = 30. The synthetic
delete the
+ // tail encodes is already represented by the coincident event, so the
leading tail is
+ // dropped. The event survives.
+ val df = targetTableOf(userSchema)(
+ Row(1, "tail", null, 30L, Row(null)),
+ Row(1, "event", 30L, null, Row(30L))
+ )
+
+ val result = processor.dropRedundantRowsPostDecomposition(df)
+
+ checkAnswer(
+ df = result,
+ expectedAnswer = Seq(
+ Row(1, "event", 30L, null, Row(30L))
+ )
+ )
+ }
+
+ test("dropRedundantRowsPostDecomposition preserves rows when the next event
arrives at " +
+ "a strictly later instant") {
+ val processor = processorWithKeys(Seq("id"))
+ val userSchema = new StructType().add("id", IntegerType).add("value",
StringType)
+
+ // Open upsert at recordStartAt=10 and decomposition tail closing at
endAt=30 are each
+ // followed by a row at a strictly later effective sequence. No row ties
with its
+ // successor on effective recordStartAt, so the redundancy filter doesn't
fire. Every
+ // row survives.
+ val df = targetTableOf(userSchema)(
+ Row(1, "open", 10L, null, Row(10L)),
+ Row(1, "next1", 15L, null, Row(15L)),
+ Row(1, "tail", null, 30L, Row(null)),
+ Row(1, "next2", 35L, null, Row(35L))
+ )
+
+ val result = processor.dropRedundantRowsPostDecomposition(df)
+
+ checkAnswer(
+ df = result,
+ expectedAnswer = Seq(
+ Row(1, "open", 10L, null, Row(10L)),
+ Row(1, "next1", 15L, null, Row(15L)),
+ Row(1, "tail", null, 30L, Row(null)),
+ Row(1, "next2", 35L, null, Row(35L))
+ )
+ )
+ }
+
+ test("dropRedundantRowsPostDecomposition preserves the last row in every
per-key partition") {
+ val processor = processorWithKeys(Seq("id"))
+ val userSchema = new StructType().add("id", IntegerType).add("value",
StringType)
+
+ // Each key has a single row; LEAD(1) is null at the end of every
partition, so the
+ // redundancy filter is vacuous. If per-key isolation were broken, key 1's
row would
+ // see key 2's row as its successor and tie on effective sequence; proper
partitioning
+ // preserves both.
+ val df = targetTableOf(userSchema)(
+ Row(1, "v10", 10L, null, Row(10L)),
+ Row(2, "v10", 10L, null, Row(10L))
+ )
+
+ val result = processor.dropRedundantRowsPostDecomposition(df)
+
+ checkAnswer(
+ df = result,
+ expectedAnswer = Seq(
+ Row(1, "v10", 10L, null, Row(10L)),
+ Row(2, "v10", 10L, null, Row(10L))
+ )
+ )
+ }
+
+ test("dropRedundantRowsPostDecomposition cascades across consecutive
duplicate rows") {
+ val processor = processorWithKeys(Seq("id"))
+ val userSchema = new StructType().add("id", IntegerType).add("value",
StringType)
+
+ // Three identical open upserts followed by a distinct event. Each
adjacent same-effective
+ // pair ties: the chronologically-leading two copies are dropped, leaving
exactly one
+ // duplicate plus the distinct event. We don't assert which user-data
variant of the
+ // duplicate survives.
+ val df = targetTableOf(userSchema)(
+ Row(1, "dup1", 5L, null, Row(5L)),
+ Row(1, "dup2", 5L, null, Row(5L)),
+ Row(1, "dup3", 5L, null, Row(5L)),
+ Row(1, "different", 10L, null, Row(10L))
+ )
+
+ val result = processor.dropRedundantRowsPostDecomposition(df)
+ val survivors = result.collect()
+
+ assert(survivors.length == 2)
+ val dupSurvivor = survivors.find(_.getLong(2) == 5L)
+ assert(dupSurvivor.isDefined)
+ assert(Set("dup1", "dup2", "dup3").contains(dupSurvivor.get.getString(1)))
+ assert(survivors.exists(r => r.getString(1) == "different"))
+ }
+
+ test("dropRedundantRowsPostDecomposition: same-class collisions reduce to
exactly one row") {
+ val processor = processorWithKeys(Seq("id"))
+ val userSchema = new StructType().add("id", IntegerType).add("value",
StringType)
+
+ // Each key encodes one same-class collision at effective recordStartAt =
10. The window
+ // does not sub-order rows within a class, so which row survives is
undefined. Hence we only
+ // assert: (a) exactly one row survives per key, and (b) the survivor is
indeed one of the
+ // inputs.
+ // key=1: two open upsert rows
+ // key=2: open upsert + closed run head (both upsert-representing).
+ // key=3: two closed run heads (structurally impossible under SCD2
invariants but defensively
+ // covered).
+ // key=4: two tombstones.
+ val df = targetTableOf(userSchema)(
+ Row(1, "openA", 10L, null, Row(10L)),
+ Row(1, "openB", 10L, null, Row(10L)),
+ Row(2, "open", 10L, null, Row(10L)),
+ Row(2, "closed", 10L, 20L, Row(10L)),
+ Row(3, "closedA", 10L, 20L, Row(10L)),
+ Row(3, "closedB", 10L, 20L, Row(10L)),
+ Row(4, "tombA", 10L, 10L, Row(10L)),
+ Row(4, "tombB", 10L, 10L, Row(10L))
+ )
+
+ val expectedSurvivorsPerKey: Map[Int, Set[String]] = Map(
+ 1 -> Set("openA", "openB"),
+ 2 -> Set("open", "closed"),
+ 3 -> Set("closedA", "closedB"),
+ 4 -> Set("tombA", "tombB")
+ )
+
+ val survivors = processor.dropRedundantRowsPostDecomposition(df).collect()
+ assert(survivors.length == 4)
+
+ expectedSurvivorsPerKey.foreach { case (k, validValues) =>
+ val perKey = survivors.filter(_.getInt(0) == k)
+ assert(perKey.length == 1)
+ assert(validValues.contains(perKey.head.getString(1)))
+ }
+ }
+
+ test("dropRedundantRowsPostDecomposition collapses many rows tied on
effective " +
+ "recordStartAt to the trailing one") {
+ val processor = processorWithKeys(Seq("id"))
+ val userSchema = new StructType().add("id", IntegerType).add("value",
StringType)
+
+ // All three rows tie at effectiveRecordStartAt=10. Window tiebreakers
order them as
+ // tail (tails-first), open upsert (upsert-representing-first), tombstone.
The
+ // redundancy filter then drops every row whose successor shares its
effective sequence,
+ // leaving only the trailing tombstone.
+ val df = targetTableOf(userSchema)(
+ Row(1, "tail", null, 10L, Row(null)),
+ Row(1, "open", 10L, null, Row(10L)),
+ Row(1, "tomb", 10L, 10L, Row(10L))
+ )
+
+ val result = processor.dropRedundantRowsPostDecomposition(df)
+
+ checkAnswer(
+ df = result,
+ expectedAnswer = Seq(
+ Row(1, "tomb", 10L, 10L, Row(10L))
+ )
+ )
+ }
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]