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 1baff34d3090 [SPARK-57322][SDP] Implement SCD2 Batch Processor; 
Reconcile StartAt/EndAt
1baff34d3090 is described below

commit 1baff34d30904a0b7bce8c857216a47c3e9690d7
Author: Andreas Neumann <[email protected]>
AuthorDate: Thu Jul 16 19:56:16 2026 -0700

    [SPARK-57322][SDP] Implement SCD2 Batch Processor; Reconcile StartAt/EndAt
    
    Approved AutoCDC SPIP: 
https://lists.apache.org/thread/j6sj9wo9odgdpgzlxtvhoy7szs0jplf7
    
    ------------
    
    This is a stacked PR. Review incremental diff here: 
https://github.com/AnishMahto/spark/compare/SPARK-57222-SCD2-decompose-affected-rows...SPARK-57322-reconcile-start-end-at
    
    ### 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.
    
    **Reconcile StartAt/EndAt**:
    
    Once we have all of the relevant rows for microbatch SCD2 reconciliation 
decomposed, we can begin setting the start/end-at for incoming microbatch rows, 
and rewriting existing start/end-at of existing rows in the aux/target tables 
to account for late arriving events.
    
    When iterating on the affected microbatch rows in chronologically sorted 
order _after decomposition_, we can reconcile a row's end-at by looking at its 
next neighbor, which naturally succeeds the row.
    
    Similarly we may need to rewrite both existing and new upsert-representing 
rows' start-at depending on whether the upsert row is a now a run-head or not. 
This can be done by looking backwards to identify the upsert run's head in 
chronological sorted order.
    
    ### Why are the changes needed?
    SCD2 core implementation.
    
    ### Does this PR introduce _any_ user-facing change?
    No, this is core implementation of an unreleased feature (AutoCDC SCD2).
    
    ### How was this patch tested?
    Unit tested in `Scd2BatchProcessorSuite`.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    Co-authored with Claude Opus 4.7/4.8
    
    Closes #57192 from anew/SPARK-57322-reconcile-rebased.
    
    Authored-by: Andreas Neumann <[email protected]>
    Signed-off-by: Jose Torres <[email protected]>
---
 .../spark/sql/pipelines/autocdc/ChangeArgs.scala   |  48 +-
 .../sql/pipelines/autocdc/Scd2BatchProcessor.scala | 283 +++++++-
 .../sql/pipelines/autocdc/ChangeArgsSuite.scala    |  42 +-
 .../autocdc/Scd2BatchProcessorSuite.scala          | 757 ++++++++++++++++++++-
 4 files changed, 1104 insertions(+), 26 deletions(-)

diff --git 
a/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/autocdc/ChangeArgs.scala
 
b/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/autocdc/ChangeArgs.scala
index 49636acc1f8f..15aa535994f9 100644
--- 
a/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/autocdc/ChangeArgs.scala
+++ 
b/sql/pipelines/src/main/scala/org/apache/spark/sql/pipelines/autocdc/ChangeArgs.scala
@@ -17,6 +17,7 @@
 
 package org.apache.spark.sql.pipelines.autocdc
 
+import org.apache.spark.SparkException
 import org.apache.spark.sql.{AnalysisException, Column}
 import org.apache.spark.sql.catalyst.parser.CatalystSqlParser
 import org.apache.spark.sql.catalyst.util.QuotingUtils
@@ -153,23 +154,34 @@ object ScdType {
 /**
  * Configuration for an AutoCDC flow.
  *
- * @param keys            The column(s) that uniquely identify a row in the 
source data.
- * @param sequencing      Expression ordering CDC events to correctly resolve 
out-of-order
- *                        arrivals. Must be a sortable type.
- * @param deleteCondition Expression that marks a source row as a DELETE. When 
None, all
- *                        rows are treated as upserts.
- * @param storedAsScdType The SCD strategy these args should be applied to.
- * @param columnSelection Which source columns to select in the target table. 
None means
- *                        all columns.
+ * @param keys                   The column(s) that uniquely identify a row in 
the source data.
+ * @param sequencing             Expression ordering CDC events to correctly 
resolve out-of-order
+ *                               arrivals. Must be a sortable type.
+ * @param deleteCondition        Expression that marks a source row as a 
DELETE. When None, all
+ *                               rows are treated as upserts.
+ * @param storedAsScdType        The SCD strategy these args should be applied 
to.
+ * @param columnSelection        Which source columns to select in the target 
table. None means
+ *                               all columns.
+ * @param trackHistorySelection  SCD2 only. Selects the selected user-data 
columns whose values
+ *                               define a run: two consecutive upsert events 
for the same key are
+ *                               coalesced into the same run iff they agree on 
every selected
+ *                               tracking column. None means every eligible 
selected user column
+ *                               (i.e. every selected source column that is 
neither a key nor a
+ *                               framework column) is considered tracked. Must 
be None under SCD1,
+ *                               which has no run concept and therefore no 
history-tracking columns.
+ *                               See the "run of upsert events" concept in the 
`Scd2BatchProcessor`
+ *                               scaladoc for the precise definition of a run.
  */
 case class ChangeArgs(
     keys: Seq[UnqualifiedColumnName],
     sequencing: Column,
     storedAsScdType: ScdType,
     deleteCondition: Option[Column] = None,
-    columnSelection: Option[ColumnSelection] = None
+    columnSelection: Option[ColumnSelection] = None,
+    trackHistorySelection: Option[ColumnSelection] = None
 ) {
   ChangeArgs.validateNonEmptyKeys(keys)
+  ChangeArgs.validateTrackHistoryOnlyForScd2(storedAsScdType, 
trackHistorySelection)
 }
 
 object ChangeArgs {
@@ -186,4 +198,22 @@ object ChangeArgs {
       )
     }
   }
+
+  /**
+   * Validates that [[ChangeArgs.trackHistorySelection]] is only set under 
SCD2. SCD1 has no run
+   * concept and therefore no history-tracking columns, so a non-None 
selection is meaningless.
+   *
+   * User-facing validation is expected to reject this at the API 
(GraphRegistration) layer; this
+   * is a defensive internal guard so downstream SCD1 code can assume the 
selection is None. Hence
+   * it raises an internal error rather than a user-facing 
[[AnalysisException]].
+   */
+  private def validateTrackHistoryOnlyForScd2(
+      storedAsScdType: ScdType,
+      trackHistorySelection: Option[ColumnSelection]): Unit = {
+    if (storedAsScdType == ScdType.Type1 && trackHistorySelection.isDefined) {
+      throw SparkException.internalError(
+        "trackHistorySelection must be None under SCD1; it has no 
history-tracking columns."
+      )
+    }
+  }
 }
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 b0c511958ab7..27b9036523f1 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
@@ -439,9 +439,12 @@ case class Scd2BatchProcessor(
    *   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.
+   *   decomposition tail, tombstone, open upsert, closed-and-unbisected row}. 
Tombstones here
+   *   are aux-table-only rows representing early-arriving deletes (SCD2 has 
no tombstone in the
+   *   target table - a delete there is just a closed history record); see the 
"tombstone" and
+   *   "aux table" concepts in this class's scaladoc. 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 =
@@ -532,8 +535,8 @@ case class Scd2BatchProcessor(
 
   /**
    * 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.
+   * post-decomposition shapes - tombstone, 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
@@ -556,7 +559,7 @@ case class Scd2BatchProcessor(
     val row = Scd2IntervalColumns(recordStartAtField, startAtCol, endAtCol)
     val isWellFormedRow =
       RowClassifier.isDecompositionTail(row) ||
-        RowClassifier.isDeleteRepresentingRow(row) ||
+        RowClassifier.isTombstone(row) ||
         RowClassifier.isUpsertRepresentingRow(row)
 
     def stringOrNullLit(c: Column): Column = F.coalesce(c.cast(StringType), 
F.lit("null"))
@@ -639,6 +642,202 @@ case class Scd2BatchProcessor(
       .filter(!isRedundantAtSameEffectiveSequence)
       .drop(Scd2BatchProcessor.nextEffectiveRecordStartAtColName)
   }
+
+  /**
+   * Recompute every row's [[startAtColName]] and [[endAtColName]] over the 
per-key chronological
+   * window so the dataframe reflects the canonical SCD2 timeline that the 
downstream aux- and
+   * target-table merges consume.
+   *
+   * Decomposition tails and tombstones round-trip unchanged. An open upsert 
may close at its
+   * successor's effective sequence (becoming closed); the closing successor 
may be either a
+   * later state-changing upsert or a delete, which is represented as an 
instantaneous tombstone
+   * (see the "tombstone" concept in this class's scaladoc), so a delete 
closes the preceding
+   * upsert at the tombstone's sequence like any other successor. A closed 
upsert may conversely
+   * have its endAt cleared when absorbed into a run as a no-op continuation 
(becoming open).
+   * [[recordStartAtFieldName]] is never modified.
+   *
+   * @param decomposedAndCleanedDf
+   *   the output of [[dropRedundantRowsPostDecomposition]]: a dataframe 
conforming to the
+   *   canonical SCD2 row schema `[user_cols..., [[startAtColName]], 
[[endAtColName]],
+   *   [[cdcMetadataColName]]]` where every row is in one of the four 
canonical post-
+   *   decomposition shapes (decomposition tail, tombstone, open upsert, 
closed upsert).
+   *   If a row is a closed upsert in the input, it is assumed to not be 
bisected by any other
+   *   row in the input.
+   * @return
+   *   a dataframe with the same schema and row count as the input, with each 
row's
+   *   [[startAtColName]] / [[endAtColName]] replaced by their reconciled 
values.
+   */
+  private[autocdc] def reconcileStartAndEndAt(
+      decomposedAndCleanedDf: DataFrame): DataFrame = {
+    val trackedHistoryColumns = 
computeTrackedHistoryColumns(decomposedAndCleanedDf)
+
+    val recordStartAtField =
+      
Scd2BatchProcessor.recordStartAtOf(F.col(AutoCdcReservedNames.cdcMetadataColName))
+    val startAtCol = F.col(Scd2BatchProcessor.startAtColName)
+    val endAtCol = F.col(Scd2BatchProcessor.endAtColName)
+
+    // Decomposition tails carry no recordStartAt of their own, so they take 
the closing
+    // sequence (`endAt`) as their effective ordering position - the same 
convention used by
+    // [[orderChronologicallyPerKeyWindow]] and 
[[dropRedundantRowsPostDecomposition]].
+    val current = Scd2IntervalColumns(recordStartAtField, startAtCol, endAtCol)
+    val previous = current.lagBy(1, orderChronologicallyPerKeyWindow)
+    val next = current.leadBy(1, orderChronologicallyPerKeyWindow)
+
+    // A row is the last in its per-key window when `LEAD(1)` has no 
successor; a constant
+    // literal is sufficient since we only care whether one exists.
+    val isLastRowInKeyWindow =
+      F.lead(F.lit(true), 1).over(orderChronologicallyPerKeyWindow).isNull
+
+    // The current row's tracked-history equality is computed against both its 
predecessor and
+    // its successor so the same window scan can decide both run-head start 
(LAG-side) and no-op
+    // continuation closure (LEAD-side) without an extra pass. The comparison 
is null-safe
+    // (`<=>`), so two rows with matching null values in the same tracked 
column register as
+    // equal. An empty tracked-history column set collapses to a constant 
`true`, which makes
+    // every consecutive upsert pair a no-op continuation - the correct 
degenerate behavior when
+    // the user tracks nothing.
+    val areTrackedColumnsEqualInPreviousRow = trackedHistoryColumns
+      .map { c =>
+        val col = F.col(QuotingUtils.quoteIdentifier(c))
+        col <=> F.lag(col, 1).over(orderChronologicallyPerKeyWindow)
+      }
+      .reduceOption(_ && _)
+      .getOrElse(F.lit(true))
+
+    val areTrackedColumnsEqualInNextRow = trackedHistoryColumns
+      .map { c =>
+        val col = F.col(QuotingUtils.quoteIdentifier(c))
+        col <=> F.lead(col, 1).over(orderChronologicallyPerKeyWindow)
+      }
+      .reduceOption(_ && _)
+      .getOrElse(F.lit(true))
+
+    // Reconciliation of start/end at is dependent on the class of row being 
reconciled. Build
+    // row classification predicates.
+    val isDecompositionTail = RowClassifier.isDecompositionTail(current)
+    val isUpsertRepresentingRow = 
RowClassifier.isUpsertRepresentingRow(current)
+
+    // From the previous row's perspective, the current row is its successor.
+    val previousIsNoOpUpsertWithCurrent =
+      RowClassifier.isNoOpUpsertContinuation(
+        row = previous,
+        next = current,
+        areTrackedColumnsEqual = areTrackedColumnsEqualInPreviousRow
+      )
+
+    // "Window-local run head" means the current row begins a new run within 
the affected
+    // window. The first row in the window is automatically considered 
local-run-head since
+    // there's no predecessor to coalesce with. A non-first row is a local run 
head iff its
+    // predecessor is not a no-op continuation that absorbs it.
+    val isWindowLocalUpsertRunHead =
+      isUpsertRepresentingRow && !previousIsNoOpUpsertWithCurrent
+    val isFirstRowInKeyWindow = previous.effectiveRecordStartAt.isNull
+    val runHeadStartAt =
+      F.when(
+        isWindowLocalUpsertRunHead,
+        // The first row in the window may be a window-local run head but not 
a global run
+        // head (e.g., an aux anchor row pulled in for left context). In that 
case, `startAt`
+        // may be strictly less than `recordStartAt`, encoding the true global 
run start, and
+        // we propagate it forward to later in-window continuations of the 
same run.
+        // For every later window-local upsert run head, `recordStartAt` is 
the run start.
+        F.when(isFirstRowInKeyWindow, startAtCol).otherwise(recordStartAtField)
+      )
+
+    // Propagate the run head's `startAt` forward to every row in the run via 
a running
+    // `last(...)` over `[unboundedPreceding, currentRow]`. `runHeadStartAt` 
is non-null
+    // only on run heads, and `ignoreNulls = true` makes intermediate rows 
inherit the most
+    // recent head's value.
+    val runStartAt =
+      F.last(runHeadStartAt, ignoreNulls = true).over(
+        orderChronologicallyPerKeyWindow.rowsBetween(
+          Window.unboundedPreceding,
+          Window.currentRow
+        )
+      )
+
+    val currentIsNoOpUpsertWithNext =
+      RowClassifier.isNoOpUpsertContinuation(
+        row = current,
+        next = next,
+        areTrackedColumnsEqual = areTrackedColumnsEqualInNextRow
+      )
+
+    val finalStartAt =
+      F.when(isDecompositionTail, F.lit(null).cast(resolvedSequencingType))
+        .when(isUpsertRepresentingRow, runStartAt)
+        .otherwise(startAtCol)
+
+    val finalEndAt =
+      F.when(isDecompositionTail, endAtCol)
+        .when(isLastRowInKeyWindow, endAtCol)
+        // A no-op continuation collapses into its run head, so its own 
visible interval is
+        // erased by clearing `endAt` to null. Note this cleared `endAt` is 
NOT by itself the
+        // aux-routing signal: the run tail is the row that represents the run 
to the user, and
+        // a tail can also carry `endAt = null` (an open run whose latest 
event is still active),
+        // so `endAt = null` alone does not mean "route to aux". Deciding 
which reconciled rows
+        // land in the target table (the run tail) versus the aux table 
(interior no-op rows)
+        // happens in a later transform (SPARK-57378), keyed off the 
reconciled run shape rather
+        // than `endAt` alone. A run therefore never fully disappears from the 
target.
+        .when(currentIsNoOpUpsertWithNext, 
F.lit(null).cast(resolvedSequencingType))
+        // The row already closes strictly before the next event (e.g., a 
tombstone or a
+        // closed upsert that ended before the next event arrived), so there 
is nothing to
+        // re-close.
+        .when(
+          RowClassifier.rowClosesStrictlyBeforeNextRow(endAtCol, 
next.effectiveRecordStartAt),
+          endAtCol)
+        .otherwise(next.effectiveRecordStartAt)
+
+    // Stage the recomputed start/end values into temporary columns first, 
then project them
+    // back over the originals via `select`. Both staged values reference the 
original
+    // `startAt`/`endAt`, so we cannot replace the originals in a single 
`withColumn` step.
+    val staged = decomposedAndCleanedDf
+      .withColumn(Scd2BatchProcessor.finalStartAtColName, finalStartAt)
+      .withColumn(Scd2BatchProcessor.finalEndAtColName, finalEndAt)
+
+    // Determine columns for selection from staged dataframe using the 
original input dataframe's
+    // schema. The final startAt/endAt should be remapped, all other columns 
should pass through
+    // as-is.
+    val outputColumns = decomposedAndCleanedDf.columns.map {
+      case col if col == Scd2BatchProcessor.startAtColName =>
+        val startAtMetadata = decomposedAndCleanedDf.schema(col).metadata
+        F.col(Scd2BatchProcessor.finalStartAtColName)
+          .as(Scd2BatchProcessor.startAtColName, startAtMetadata)
+      case col if col == Scd2BatchProcessor.endAtColName =>
+        val endAtMetadata = decomposedAndCleanedDf.schema(col).metadata
+        F.col(Scd2BatchProcessor.finalEndAtColName)
+          .as(Scd2BatchProcessor.endAtColName, endAtMetadata)
+      case col =>
+        F.col(QuotingUtils.quoteIdentifier(col))
+    }
+    staged.select(outputColumns.toImmutableArraySeq: _*)
+  }
+
+  /**
+   * Return the schema field names of columns selected for history-tracking on 
`df`:
+   * the eligible user-data columns (those not in [[ChangeArgs.keys]] or the 
framework
+   * reserved set) filtered through [[ChangeArgs.trackHistorySelection]].
+   */
+  private def computeTrackedHistoryColumns(df: DataFrame): Seq[String] = {
+    val conf = df.sparkSession.sessionState.conf
+    val resolver = conf.resolver
+
+    val keyColNames = changeArgs.keys.map(_.name)
+    val reservedColNames = Scd2BatchProcessor.reservedFrameworkColNames
+
+    val eligibleSchema = StructType(df.schema.fields.filterNot { field =>
+      reservedColNames.exists(resolver(_, field.name)) ||
+        keyColNames.exists(resolver(_, field.name))
+    })
+
+    ColumnSelection
+      .applyToSchema(
+        schemaName = "trackHistorySelection",
+        schema = eligibleSchema,
+        columnSelection = changeArgs.trackHistorySelection,
+        caseSensitive = conf.caseSensitiveAnalysis
+      )
+      .fieldNames
+      .toImmutableArraySeq
+  }
 }
 
 /**
@@ -833,6 +1032,18 @@ object Scd2BatchProcessor {
   private[autocdc] val nextEffectiveRecordStartAtColName: String =
     s"${AutoCdcReservedNames.prefix}next_effective_record_start_at"
 
+  /**
+   * Names of temporary columns used by [[reconcileStartAndEndAt]] to stage 
the recomputed
+   * [[startAtColName]] / [[endAtColName]] values before projecting them back 
over the originals.
+   *
+   * Temporary in that the columns have no observable side effect or 
persistence across
+   * microbatches.
+   */
+  private val finalStartAtColName: String =
+    s"${AutoCdcReservedNames.prefix}final_start_at"
+  private val finalEndAtColName: String =
+    s"${AutoCdcReservedNames.prefix}final_end_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
@@ -896,6 +1107,20 @@ private[autocdc] case class Scd2IntervalColumns(
    * [[Scd2BatchProcessor.orderChronologicallyPerKeyWindow]].
    */
   def effectiveRecordStartAt: Column = F.coalesce(recordStartAt, endAt)
+
+  /** The same interval columns read from the row `offset` positions earlier 
in `window`. */
+  def lagBy(offset: Int, window: WindowSpec): Scd2IntervalColumns =
+    Scd2IntervalColumns(
+      F.lag(recordStartAt, offset).over(window),
+      F.lag(startAt, offset).over(window),
+      F.lag(endAt, offset).over(window))
+
+  /** The same interval columns read from the row `offset` positions later in 
`window`. */
+  def leadBy(offset: Int, window: WindowSpec): Scd2IntervalColumns =
+    Scd2IntervalColumns(
+      F.lead(recordStartAt, offset).over(window),
+      F.lead(startAt, offset).over(window),
+      F.lead(endAt, offset).over(window))
 }
 
 object RowClassifier {
@@ -942,18 +1167,50 @@ object RowClassifier {
     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.
+   * Tombstone (delete-boundary) row, encoded as an instantaneous interval at
+   * `recordStartAt`. Never materializes in the target table, only in the aux 
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.
+   * User-data column values on tombstones 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 the tombstone was 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 =
+  private[autocdc] def isTombstone(row: Scd2IntervalColumns): Column =
     row.recordStartAt.isNotNull &&
       row.startAt.isNotNull &&
       row.endAt.isNotNull &&
       row.startAt === row.recordStartAt &&
       row.endAt === row.recordStartAt
+
+  /**
+   * Whether a row closes (`endAt`) strictly before the next 
chronologically-ordered row for the
+   * same key begins (`nextEffectiveRecordStartAt`), leaving a gap in the 
visible timeline.
+   */
+  private[autocdc] def rowClosesStrictlyBeforeNextRow(
+      endAt: Column,
+      nextEffectiveRecordStartAt: Column
+  ): Column =
+    endAt.isNotNull && endAt < nextEffectiveRecordStartAt
+
+  /**
+   * Whether `row` carries no new information beyond its immediate successor 
`next` and so
+   * collapses into that successor's run instead of standing as its own 
visible interval. It is
+   * the caller's responsibility to pass `row` and `next` as successive rows 
in chronological
+   * order, and `areTrackedColumnsEqual` as true iff the two rows hold equal 
values for every
+   * tracked-history column.
+   *
+   * Returns true iff `row` and `next` are both upsert-representing, `row`'s 
interval reaches
+   * `next` without leaving a gap, and the two are tracked-history equal. It 
is false whenever
+   * there is no successor (the last row in a key window) or either row is not 
upsert-representing.
+   */
+  private[autocdc] def isNoOpUpsertContinuation(
+      row: Scd2IntervalColumns,
+      next: Scd2IntervalColumns,
+      areTrackedColumnsEqual: Column
+  ): Column =
+    isUpsertRepresentingRow(row) &&
+      isUpsertRepresentingRow(next) &&
+      !rowClosesStrictlyBeforeNextRow(row.endAt, next.effectiveRecordStartAt) 
&&
+      areTrackedColumnsEqual
 }
diff --git 
a/sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/autocdc/ChangeArgsSuite.scala
 
b/sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/autocdc/ChangeArgsSuite.scala
index 7be111003762..bf6a89652353 100644
--- 
a/sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/autocdc/ChangeArgsSuite.scala
+++ 
b/sql/pipelines/src/test/scala/org/apache/spark/sql/pipelines/autocdc/ChangeArgsSuite.scala
@@ -17,7 +17,7 @@
 
 package org.apache.spark.sql.pipelines.autocdc
 
-import org.apache.spark.SparkFunSuite
+import org.apache.spark.{SparkException, SparkFunSuite}
 import org.apache.spark.sql.{functions => F, AnalysisException, Row}
 import org.apache.spark.sql.catalyst.parser.ParseException
 import org.apache.spark.sql.test.SharedSparkSession
@@ -377,6 +377,46 @@ class ChangeArgsSuite extends SparkFunSuite with 
SharedSparkSession {
     )
   }
 
+  test("ChangeArgs rejects trackHistorySelection under SCD1") {
+    // SCD1 has no run concept, so a non-None trackHistorySelection is 
meaningless. User-facing
+    // validation is expected at the API layer; this defensive internal guard 
raises an internal
+    // error so downstream SCD1 code can assume the selection is None.
+    val ex = intercept[SparkException] {
+      ChangeArgs(
+        keys = Seq(UnqualifiedColumnName("id")),
+        sequencing = F.col("seq"),
+        storedAsScdType = ScdType.Type1,
+        trackHistorySelection = Some(
+          ColumnSelection.IncludeColumns(Seq(UnqualifiedColumnName("Name")))
+        )
+      )
+    }
+    assert(ex.getCondition == "INTERNAL_ERROR")
+  }
+
+  test("ChangeArgs allows trackHistorySelection under SCD2") {
+    // The same selection is valid under SCD2, where tracking columns define a 
run.
+    val args = ChangeArgs(
+      keys = Seq(UnqualifiedColumnName("id")),
+      sequencing = F.col("seq"),
+      storedAsScdType = ScdType.Type2,
+      trackHistorySelection = Some(
+        ColumnSelection.IncludeColumns(Seq(UnqualifiedColumnName("Name")))
+      )
+    )
+    assert(args.trackHistorySelection.isDefined)
+  }
+
+  test("ChangeArgs allows None trackHistorySelection under SCD1") {
+    // The default None selection is always valid under SCD1.
+    val args = ChangeArgs(
+      keys = Seq(UnqualifiedColumnName("id")),
+      sequencing = F.col("seq"),
+      storedAsScdType = ScdType.Type1
+    )
+    assert(args.trackHistorySelection.isEmpty)
+  }
+
   test("UnqualifiedColumnName lets a ParseException from the SQL parser 
propagate") {
     checkError(
       exception = intercept[ParseException] {
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 c29aa50e9e72..649126222a24 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
@@ -18,7 +18,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.{functions => F, AnalysisException, Column, 
QueryTest, Row}
 import org.apache.spark.sql.classic.DataFrame
 import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.test.SharedSparkSession
@@ -97,14 +97,18 @@ class Scd2BatchProcessorSuite extends QueryTest with 
SharedSparkSession {
    */
   private def processorWithKeys(
       keys: Seq[String],
-      deleteCondition: Option[Column] = None
+      deleteCondition: Option[Column] = None,
+      columnSelection: Option[ColumnSelection] = None,
+      trackHistorySelection: Option[ColumnSelection] = None
   ): Scd2BatchProcessor =
     Scd2BatchProcessor(
       changeArgs = ChangeArgs(
         keys = keys.map(UnqualifiedColumnName(_)),
         sequencing = F.col("seq"),
         storedAsScdType = ScdType.Type2,
-        deleteCondition = deleteCondition
+        deleteCondition = deleteCondition,
+        columnSelection = columnSelection,
+        trackHistorySelection = trackHistorySelection
       ),
       resolvedSequencingType = LongType
     )
@@ -1910,4 +1914,751 @@ class Scd2BatchProcessorSuite extends QueryTest with 
SharedSparkSession {
       )
     )
   }
+
+  // =============== reconcileStartAndEndAt tests ===============
+
+  test("reconcileStartAndEndAt: a fresh-key run head propagates its startAt to 
its " +
+    "no-op continuation") {
+    val processor = processorWithKeys(Seq("id"))
+    val userSchema = new StructType().add("id", IntegerType).add("value", 
StringType)
+
+    // Two open upserts at recordStartAt=5 and recordStartAt=10 with identical 
tracked
+    // values. The first row begins a fresh run with startAt=5. The second 
row, sharing the
+    // tracked value, is a continuation of that run and inherits the run 
head's startAt.
+    val df = targetTableOf(userSchema)(
+      Row(1, "alice", 5L, null, Row(5L)),
+      Row(1, "alice", 10L, null, Row(10L))
+    )
+
+    val result = processor.reconcileStartAndEndAt(df)
+
+    checkAnswer(
+      df = result,
+      expectedAnswer = Seq(
+        Row(1, "alice", 5L, null, Row(5L)),
+        Row(1, "alice", 5L, null, Row(10L))
+      )
+    )
+  }
+
+  test("reconcileStartAndEndAt: a continuation that began before the affected 
window " +
+    "keeps its earlier run start") {
+    val processor = processorWithKeys(Seq("id"))
+    val userSchema = new StructType().add("id", IntegerType).add("value", 
StringType)
+
+    // The first row is an aux anchor (startAt < recordStartAt), pulled in as 
left context
+    // for a run that began at startAt=2. Because the row sits at the front of 
the window,
+    // its existing startAt encodes the true global run start and must be 
preserved -
+    // and propagated to the in-window continuation.
+    val df = targetTableOf(userSchema)(
+      Row(1, "alice", 2L, null, Row(5L)),
+      Row(1, "alice", 10L, null, Row(10L))
+    )
+
+    val result = processor.reconcileStartAndEndAt(df)
+
+    checkAnswer(
+      df = result,
+      expectedAnswer = Seq(
+        Row(1, "alice", 2L, null, Row(5L)),
+        Row(1, "alice", 2L, null, Row(10L))
+      )
+    )
+  }
+
+  test("reconcileStartAndEndAt: a tracked-history transition opens a new run 
anchored " +
+    "at the new value's recordStartAt") {
+    val processor = processorWithKeys(Seq("id"))
+    val userSchema = new StructType().add("id", IntegerType).add("value", 
StringType)
+
+    // Two open upserts that disagree on the tracked column. The transition 
closes the
+    // first run at the second event's effective recordStartAt and starts a 
new run whose
+    // startAt is the new event's recordStartAt.
+    val df = targetTableOf(userSchema)(
+      Row(1, "alice", 5L, null, Row(5L)),
+      Row(1, "bob", 10L, null, Row(10L))
+    )
+
+    val result = processor.reconcileStartAndEndAt(df)
+
+    checkAnswer(
+      df = result,
+      expectedAnswer = Seq(
+        Row(1, "alice", 5L, 10L, Row(5L)),
+        Row(1, "bob", 10L, null, Row(10L))
+      )
+    )
+  }
+
+  test("reconcileStartAndEndAt: a chain of no-op continuations all share a 
single " +
+    "run start") {
+    val processor = processorWithKeys(Seq("id"))
+    val userSchema = new StructType().add("id", IntegerType).add("value", 
StringType)
+
+    // Three consecutive open upserts all agreeing on the tracked column form 
one no-op
+    // run. Every row in the run must end up with the run head's startAt.
+    val df = targetTableOf(userSchema)(
+      Row(1, "alice", 5L, null, Row(5L)),
+      Row(1, "alice", 10L, null, Row(10L)),
+      Row(1, "alice", 15L, null, Row(15L))
+    )
+
+    val result = processor.reconcileStartAndEndAt(df)
+
+    checkAnswer(
+      df = result,
+      expectedAnswer = Seq(
+        Row(1, "alice", 5L, null, Row(5L)),
+        Row(1, "alice", 5L, null, Row(10L)),
+        Row(1, "alice", 5L, null, Row(15L))
+      )
+    )
+  }
+
+  test("reconcileStartAndEndAt: a chain of three no-op continuations collapses 
then closes " +
+    "at a trailing transition") {
+    val processor = processorWithKeys(Seq("id"))
+    val userSchema = new StructType().add("id", IntegerType).add("value", 
StringType)
+
+    // Four open upserts: three tracked-equal rows (`alice`) that must all 
collapse into one
+    // run, followed by a tracked-different row (`bob`) that opens a new run. 
This exercises
+    // the interaction the classifier is easy to get wrong across a multi-row 
collapse:
+    //  - none of the two interior `alice` rows may be misclassified as a run 
head (the
+    //    LAG-side comparison must absorb each into its predecessor's run),
+    //  - all three `alice` rows must inherit the head's startAt (5),
+    //  - the last `alice` row (the run tail) must close at bob's 
recordStartAt (20), not stay
+    //    open and not close at any interior sequence.
+    val df = targetTableOf(userSchema)(
+      Row(1, "alice", 5L, null, Row(5L)),
+      Row(1, "alice", 10L, null, Row(10L)),
+      Row(1, "alice", 15L, null, Row(15L)),
+      Row(1, "bob", 20L, null, Row(20L))
+    )
+
+    val result = processor.reconcileStartAndEndAt(df)
+
+    checkAnswer(
+      df = result,
+      expectedAnswer = Seq(
+        Row(1, "alice", 5L, null, Row(5L)),
+        Row(1, "alice", 5L, null, Row(10L)),
+        Row(1, "alice", 5L, 20L, Row(15L)),
+        Row(1, "bob", 20L, null, Row(20L))
+      )
+    )
+  }
+
+  test("reconcileStartAndEndAt: default tracking treats every non-key selected 
user column " +
+    "as tracked") {
+    val processor = processorWithKeys(Seq("id"))
+    val userSchema = new StructType()
+      .add("id", IntegerType)
+      .add("name", StringType)
+      .add("status", StringType)
+
+    // Default `trackHistorySelection` (None) treats every eligible selected 
user column
+    // as tracked. With no columnSelection, both `name` and `status` are 
selected. The
+    // two rows agree on `name` but disagree on `status`, so they start 
distinct runs.
+    val df = targetTableOf(userSchema)(
+      Row(1, "alice", "active", 5L, null, Row(5L)),
+      Row(1, "alice", "inactive", 10L, null, Row(10L))
+    )
+
+    val result = processor.reconcileStartAndEndAt(df)
+
+    checkAnswer(
+      df = result,
+      expectedAnswer = Seq(
+        Row(1, "alice", "active", 5L, 10L, Row(5L)),
+        Row(1, "alice", "inactive", 10L, null, Row(10L))
+      )
+    )
+  }
+
+  test("reconcileStartAndEndAt: default tracking ignores columns excluded by 
columnSelection") {
+    val processor = processorWithKeys(
+      keys = Seq("id"),
+      columnSelection = Some(
+        ColumnSelection.IncludeColumns(
+          Seq(UnqualifiedColumnName("id"), UnqualifiedColumnName("name"))
+        )
+      )
+    )
+    val sourceSchema = new StructType()
+      .add("id", IntegerType)
+      .add("seq", LongType)
+      .add("name", StringType)
+      .add("status", StringType)
+
+    // `status` differs but is excluded from the selected target schema. 
Default tracking
+    // therefore considers only the selected non-key user column (`name`), and 
the rows
+    // collapse into one no-op run.
+    val df = microbatchOf(sourceSchema)(
+      Row(1, 5L, "alice", "active"),
+      Row(1, 10L, "alice", "inactive")
+    )
+
+    val result = 
processor.reconcileStartAndEndAt(processor.preprocessMicrobatch(df))
+
+    checkAnswer(
+      df = result,
+      expectedAnswer = Seq(
+        Row(1, "alice", 5L, null, Row(5L)),
+        Row(1, "alice", 5L, null, Row(10L))
+      )
+    )
+  }
+
+  test("reconcileStartAndEndAt: trackHistorySelection cannot include a column 
excluded by " +
+    "columnSelection") {
+    val processor = processorWithKeys(
+      keys = Seq("id"),
+      columnSelection = Some(
+        ColumnSelection.IncludeColumns(
+          Seq(UnqualifiedColumnName("id"), UnqualifiedColumnName("name"))
+        )
+      ),
+      trackHistorySelection = Some(
+        ColumnSelection.IncludeColumns(Seq(UnqualifiedColumnName("status")))
+      )
+    )
+    val sourceSchema = new StructType()
+      .add("id", IntegerType)
+      .add("seq", LongType)
+      .add("name", StringType)
+      .add("status", StringType)
+    val df = microbatchOf(sourceSchema)(
+      Row(1, 5L, "alice", "active")
+    )
+
+    val ex = intercept[AnalysisException] {
+      processor.reconcileStartAndEndAt(processor.preprocessMicrobatch(df))
+    }
+    assert(ex.getCondition == "AUTOCDC_COLUMNS_NOT_FOUND_IN_SCHEMA")
+  }
+
+  test("reconcileStartAndEndAt: ExcludeColumns excludes only the listed 
columns from " +
+    "the tracked set") {
+    val processor = processorWithKeys(
+      keys = Seq("id"),
+      trackHistorySelection = Some(
+        ColumnSelection.ExcludeColumns(Seq(UnqualifiedColumnName("status")))
+      )
+    )
+    val userSchema = new StructType()
+      .add("id", IntegerType)
+      .add("name", StringType)
+      .add("status", StringType)
+
+    // `status` is excluded from tracking, so the two rows are tracked-equal 
on the
+    // remaining columns (`name`). They should collapse into a single no-op 
run.
+    val df = targetTableOf(userSchema)(
+      Row(1, "alice", "active", 5L, null, Row(5L)),
+      Row(1, "alice", "inactive", 10L, null, Row(10L))
+    )
+
+    val result = processor.reconcileStartAndEndAt(df)
+
+    checkAnswer(
+      df = result,
+      expectedAnswer = Seq(
+        Row(1, "alice", "active", 5L, null, Row(5L)),
+        Row(1, "alice", "inactive", 5L, null, Row(10L))
+      )
+    )
+  }
+
+  test("reconcileStartAndEndAt: an empty effective tracked-history set 
collapses every " +
+    "consecutive upsert pair into one run") {
+    val processor = processorWithKeys(
+      keys = Seq("id"),
+      trackHistorySelection = Some(
+        ColumnSelection.ExcludeColumns(
+          Seq(UnqualifiedColumnName("name"), UnqualifiedColumnName("status"))
+        )
+      )
+    )
+    val userSchema = new StructType()
+      .add("id", IntegerType)
+      .add("name", StringType)
+      .add("status", StringType)
+
+    // Both user data columns are excluded, so the effective tracked set is 
empty. With
+    // nothing to compare, every consecutive upsert pair collapses into a 
single run -
+    // even when the user-visible data differs on every column.
+    val df = targetTableOf(userSchema)(
+      Row(1, "alice", "active", 5L, null, Row(5L)),
+      Row(1, "bob", "inactive", 10L, null, Row(10L))
+    )
+
+    val result = processor.reconcileStartAndEndAt(df)
+
+    checkAnswer(
+      df = result,
+      expectedAnswer = Seq(
+        Row(1, "alice", "active", 5L, null, Row(5L)),
+        Row(1, "bob", "inactive", 5L, null, Row(10L))
+      )
+    )
+  }
+
+  test("reconcileStartAndEndAt: a tombstone's startAt and endAt pass through 
unchanged") {
+    val processor = processorWithKeys(Seq("id"))
+    val userSchema = new StructType().add("id", IntegerType).add("value", 
StringType)
+
+    // An open upsert, then a tombstone at recordStartAt=10, then a new open 
upsert. The
+    // tombstone is not an upsert and must not participate in any run; its 
startAt/endAt
+    // pass through identically. The bracketing upserts close (10) and reopen 
(15) around
+    // the tombstone.
+    val df = targetTableOf(userSchema)(
+      Row(1, "alice", 5L, null, Row(5L)),
+      Row(1, "alice", 10L, 10L, Row(10L)),
+      Row(1, "alice", 15L, null, Row(15L))
+    )
+
+    val result = processor.reconcileStartAndEndAt(df)
+
+    checkAnswer(
+      df = result,
+      expectedAnswer = Seq(
+        Row(1, "alice", 5L, 10L, Row(5L)),
+        Row(1, "alice", 10L, 10L, Row(10L)),
+        Row(1, "alice", 15L, null, Row(15L))
+      )
+    )
+  }
+
+  test("reconcileStartAndEndAt: a decomposition tail's startAt stays null and 
its endAt " +
+    "passes through") {
+    val processor = processorWithKeys(Seq("id"))
+    val userSchema = new StructType().add("id", IntegerType).add("value", 
StringType)
+
+    // A head + tail pair: the head retains the run start while the tail 
(recordStartAt
+    // null) is excluded from upsert reconciliation. The tail's startAt must 
stay null
+    // and its endAt must pass through unchanged.
+    val df = targetTableOf(userSchema)(
+      Row(1, "alice", 5L, null, Row(5L)),
+      Row(1, "alice", null, 30L, Row(null))
+    )
+
+    val result = processor.reconcileStartAndEndAt(df)
+
+    checkAnswer(
+      df = result,
+      expectedAnswer = Seq(
+        Row(1, "alice", 5L, 30L, Row(5L)),
+        Row(1, "alice", null, 30L, Row(null))
+      )
+    )
+  }
+
+  test("reconcileStartAndEndAt: a bisecting event between a decomposition head 
and tail closes " +
+    "the head at the event, not at the tail boundary") {
+    val processor = processorWithKeys(Seq("id"))
+    val userSchema = new StructType().add("id", IntegerType).add("value", 
StringType)
+
+    // The three-row shape produced when a previously-closed row [5, 30] is 
bisected by a
+    // late-arriving, tracked-different event at recordStartAt=15: a 
decomposition head
+    // (open, `alice`), the bisecting event (open, `bob`), and a decomposition 
tail carrying
+    // the original right boundary 30 (recordStartAt null). The head must 
close at the
+    // bisecting event's sequence (15), NOT at the tail's boundary (30) - the 
bisecting event
+    // opens a new run in between. The bisecting event in turn closes at the 
tail boundary
+    // (30), and the tail passes through unchanged.
+    val df = targetTableOf(userSchema)(
+      Row(1, "alice", 5L, null, Row(5L)),
+      Row(1, "bob", 15L, null, Row(15L)),
+      Row(1, "alice", null, 30L, Row(null))
+    )
+
+    val result = processor.reconcileStartAndEndAt(df)
+
+    checkAnswer(
+      df = result,
+      expectedAnswer = Seq(
+        Row(1, "alice", 5L, 15L, Row(5L)),
+        Row(1, "bob", 15L, 30L, Row(15L)),
+        Row(1, "alice", null, 30L, Row(null))
+      )
+    )
+  }
+
+  test("reconcileStartAndEndAt: a closed upsert that already closes before the 
next event " +
+    "keeps its endAt") {
+    val processor = processorWithKeys(Seq("id"))
+    val userSchema = new StructType().add("id", IntegerType).add("value", 
StringType)
+
+    // A closed upsert [5, 15] is followed by a fresh-value run head at 
recordStartAt=20.
+    // The closed upsert already ended at 15 - strictly before the next event 
- so its
+    // endAt is left intact.
+    val df = targetTableOf(userSchema)(
+      Row(1, "alice", 5L, 15L, Row(5L)),
+      Row(1, "bob", 20L, null, Row(20L))
+    )
+
+    val result = processor.reconcileStartAndEndAt(df)
+
+    checkAnswer(
+      df = result,
+      expectedAnswer = Seq(
+        Row(1, "alice", 5L, 15L, Row(5L)),
+        Row(1, "bob", 20L, null, Row(20L))
+      )
+    )
+  }
+
+  test("reconcileStartAndEndAt: an open upsert closes at its successor's 
effective sequence") {
+    val processor = processorWithKeys(Seq("id"))
+    val userSchema = new StructType().add("id", IntegerType).add("value", 
StringType)
+
+    // An open upsert at recordStartAt=5 is followed by a 
tracked-history-different upsert
+    // at recordStartAt=20. The first run head must be closed at 20 because 
the run ends
+    // there.
+    val df = targetTableOf(userSchema)(
+      Row(1, "alice", 5L, null, Row(5L)),
+      Row(1, "bob", 20L, null, Row(20L))
+    )
+
+    val result = processor.reconcileStartAndEndAt(df)
+
+    checkAnswer(
+      df = result,
+      expectedAnswer = Seq(
+        Row(1, "alice", 5L, 20L, Row(5L)),
+        Row(1, "bob", 20L, null, Row(20L))
+      )
+    )
+  }
+
+  test("reconcileStartAndEndAt: a tombstone coincident with a row that already 
closes at the " +
+    "same sequence is preserved for the next transform to drop") {
+    val processor = processorWithKeys(Seq("id"))
+    val userSchema = new StructType().add("id", IntegerType).add("value", 
StringType)
+
+    // A closed upsert [5, 20] is followed by a tombstone at recordStartAt=20, 
both
+    // ending at the same sequence. Reconciliation does not collapse them - it 
leaves
+    // the now-redundant tombstone for the next transform to drop based on the
+    // shape locked in here.
+    val df = targetTableOf(userSchema)(
+      Row(1, "alice", 5L, 20L, Row(5L)),
+      Row(1, "alice", 20L, 20L, Row(20L))
+    )
+
+    val result = processor.reconcileStartAndEndAt(df)
+
+    checkAnswer(
+      df = result,
+      expectedAnswer = Seq(
+        Row(1, "alice", 5L, 20L, Row(5L)),
+        Row(1, "alice", 20L, 20L, Row(20L))
+      )
+    )
+  }
+
+  test("reconcileStartAndEndAt: a decomposition tail keeps its null 
recordStartAt for " +
+    "downstream promotion") {
+    val processor = processorWithKeys(Seq("id"))
+    val userSchema = new StructType().add("id", IntegerType).add("value", 
StringType)
+
+    // Downstream transforms identify decomposition tails by recordStartAt = 
null, so
+    // reconciliation must not synthesize a value into the tail's 
_cdc_metadata.
+    val df = targetTableOf(userSchema)(
+      Row(1, "alice", 5L, null, Row(5L)),
+      Row(1, "alice", null, 30L, Row(null))
+    )
+
+    val result = processor.reconcileStartAndEndAt(df)
+
+    val tailRows = result.collect().filter(r => r.isNullAt(2))
+    assert(tailRows.length == 1)
+    val tailCdcMetadata = tailRows.head.getStruct(4)
+    assert(tailCdcMetadata.isNullAt(0))
+  }
+
+  test("reconcileStartAndEndAt preserves the input schema, column metadata, 
and row count") {
+    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"))
+
+    // Mix of canonical post-decomposition row shapes so we exercise multiple 
reconciliation
+    // branches under the schema-preservation contract.
+    val df = microbatchOf(schema)(
+      Row(1, "alice", 5L, null, Row(5L)),
+      Row(1, "alice", null, 30L, Row(null)),
+      Row(1, "alice", 30L, 30L, Row(30L))
+    )
+
+    val result = processor.reconcileStartAndEndAt(df)
+
+    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)
+    }
+    assert(result.count() == df.count())
+  }
+
+  test("reconcileStartAndEndAt: a single-event-per-key partition reconciles 
without " +
+    "referring to neighbors") {
+    val processor = processorWithKeys(Seq("id"))
+    val userSchema = new StructType().add("id", IntegerType).add("value", 
StringType)
+
+    // Two keys, each with exactly one event - so neither partition has a 
window predecessor
+    // or successor. Reconciliation must handle the missing neighbors cleanly 
and pass the
+    // single rows through.
+    val df = targetTableOf(userSchema)(
+      Row(1, "alice", 5L, null, Row(5L)),
+      Row(2, "bob", 10L, 20L, Row(10L))
+    )
+
+    val result = processor.reconcileStartAndEndAt(df)
+
+    checkAnswer(
+      df = result,
+      expectedAnswer = Seq(
+        Row(1, "alice", 5L, null, Row(5L)),
+        Row(2, "bob", 10L, 20L, Row(10L))
+      )
+    )
+  }
+
+  test("reconcileStartAndEndAt: IncludeColumns selects only the listed columns 
as tracked") {
+    val processor = processorWithKeys(
+      keys = Seq("id"),
+      trackHistorySelection = Some(
+        ColumnSelection.IncludeColumns(Seq(UnqualifiedColumnName("name")))
+      )
+    )
+    val userSchema = new StructType()
+      .add("id", IntegerType)
+      .add("name", StringType)
+      .add("status", StringType)
+
+    // Only `name` is tracked. Two rows agreeing on name but differing on 
status are
+    // tracked-equal and should collapse into one run.
+    val df = targetTableOf(userSchema)(
+      Row(1, "alice", "active", 5L, null, Row(5L)),
+      Row(1, "alice", "inactive", 10L, null, Row(10L))
+    )
+
+    val result = processor.reconcileStartAndEndAt(df)
+
+    checkAnswer(
+      df = result,
+      expectedAnswer = Seq(
+        Row(1, "alice", "active", 5L, null, Row(5L)),
+        Row(1, "alice", "inactive", 5L, null, Row(10L))
+      )
+    )
+  }
+
+  test("reconcileStartAndEndAt: a tracked-history column whose name contains a 
dot is quoted " +
+    "and matched as a single column, not as a nested field path") {
+    // The backticks make `UnqualifiedColumnName` store the literal field name 
"user.name".
+    val processor = processorWithKeys(
+      keys = Seq("id"),
+      trackHistorySelection = Some(
+        
ColumnSelection.IncludeColumns(Seq(UnqualifiedColumnName("`user.name`")))
+      )
+    )
+    val userSchema = new StructType()
+      .add("id", IntegerType)
+      .add("user.name", StringType)
+      .add("status", StringType)
+
+    // Only the dotted column is tracked. Two rows agreeing on "user.name" but 
differing on
+    // status are tracked-equal and must collapse into a single run. Without 
quoting,
+    // `F.col("user.name")` would be parsed as a nested-field access (struct 
`user`, field
+    // `name`) and fail to resolve.
+    val df = targetTableOf(userSchema)(
+      Row(1, "alice", "active", 5L, null, Row(5L)),
+      Row(1, "alice", "inactive", 10L, null, Row(10L))
+    )
+
+    val result = processor.reconcileStartAndEndAt(df)
+
+    checkAnswer(
+      df = result,
+      expectedAnswer = Seq(
+        Row(1, "alice", "active", 5L, null, Row(5L)),
+        Row(1, "alice", "inactive", 5L, null, Row(10L))
+      )
+    )
+  }
+
+  test("reconcileStartAndEndAt: trackHistorySelection referring to an unknown 
column raises " +
+    "AUTOCDC_COLUMNS_NOT_FOUND_IN_SCHEMA") {
+    val processor = processorWithKeys(
+      keys = Seq("id"),
+      trackHistorySelection = Some(
+        
ColumnSelection.IncludeColumns(Seq(UnqualifiedColumnName("does_not_exist")))
+      )
+    )
+    val userSchema = new StructType().add("id", IntegerType).add("value", 
StringType)
+    val df = targetTableOf(userSchema)(
+      Row(1, "alice", 5L, null, Row(5L))
+    )
+
+    val ex = intercept[AnalysisException] {
+      processor.reconcileStartAndEndAt(df)
+    }
+    assert(ex.getCondition == "AUTOCDC_COLUMNS_NOT_FOUND_IN_SCHEMA")
+  }
+
+  test("reconcileStartAndEndAt: null tracked-column values are treated as 
tracked-equal") {
+    val processor = processorWithKeys(Seq("id"))
+    val userSchema = new StructType().add("id", IntegerType).add("name", 
StringType)
+
+    val df = targetTableOf(userSchema)(
+      Row(1, null, 5L, null, Row(5L)),
+      Row(1, null, 10L, null, Row(10L))
+    )
+
+    val result = processor.reconcileStartAndEndAt(df)
+
+    checkAnswer(
+      df = result,
+      expectedAnswer = Seq(
+        Row(1, null, 5L, null, Row(5L)),
+        Row(1, null, 5L, null, Row(10L))
+      )
+    )
+  }
+
+  test("reconcileStartAndEndAt: a no-op continuation followed by a tombstone 
is absorbed " +
+    "into its run and closed at the tombstone") {
+    val processor = processorWithKeys(Seq("id"))
+    val userSchema = new StructType().add("id", IntegerType).add("value", 
StringType)
+
+    val df = targetTableOf(userSchema)(
+      Row(1, "alice", 5L, null, Row(5L)),
+      Row(1, "alice", 10L, null, Row(10L)),
+      Row(1, "alice", 15L, 15L, Row(15L))
+    )
+
+    val result = processor.reconcileStartAndEndAt(df)
+
+    checkAnswer(
+      df = result,
+      expectedAnswer = Seq(
+        Row(1, "alice", 5L, null, Row(5L)),
+        Row(1, "alice", 5L, 15L, Row(10L)),
+        Row(1, "alice", 15L, 15L, Row(15L))
+      )
+    )
+  }
+
+  test("reconcileStartAndEndAt isolates per-key reconciliation across multiple 
key partitions") {
+    val processor = processorWithKeys(Seq("id"))
+    val userSchema = new StructType().add("id", IntegerType).add("value", 
StringType)
+
+    // Two keys, each with a fresh-key run head + tracked-equal continuation. 
The two
+    // partitions must reconcile independently.
+    val df = targetTableOf(userSchema)(
+      Row(1, "alice", 5L, null, Row(5L)),
+      Row(1, "alice", 10L, null, Row(10L)),
+      Row(2, "bob", 20L, null, Row(20L)),
+      Row(2, "bob", 25L, null, Row(25L))
+    )
+
+    val result = processor.reconcileStartAndEndAt(df)
+
+    checkAnswer(
+      df = result,
+      expectedAnswer = Seq(
+        Row(1, "alice", 5L, null, Row(5L)),
+        Row(1, "alice", 5L, null, Row(10L)),
+        Row(2, "bob", 20L, null, Row(20L)),
+        Row(2, "bob", 20L, null, Row(25L))
+      )
+    )
+  }
+
+  test("reconcileStartAndEndAt: a closed upsert absorbed as a no-op 
continuation has its " +
+    "endAt cleared and becomes open") {
+    val processor = processorWithKeys(Seq("id"))
+    val userSchema = new StructType().add("id", IntegerType).add("value", 
StringType)
+
+    // A closed upsert [5, 20] is immediately followed by a tracked-equal 
upsert at
+    // recordStartAt=20. The closed upsert ends exactly where the next event 
begins - not
+    // strictly before it - so it is absorbed into the run as a no-op 
continuation: its
+    // endAt is reset to null (becoming open) and it keeps the run head's 
startAt. The
+    // successor inherits the same run start. This is the documented "closed 
upsert becomes
+    // open" transition, which every other no-op-continuation test leaves as a 
no-op by
+    // starting from an already-open row.
+    val df = targetTableOf(userSchema)(
+      Row(1, "alice", 5L, 20L, Row(5L)),
+      Row(1, "alice", 20L, null, Row(20L))
+    )
+
+    val result = processor.reconcileStartAndEndAt(df)
+
+    checkAnswer(
+      df = result,
+      expectedAnswer = Seq(
+        Row(1, "alice", 5L, null, Row(5L)),
+        Row(1, "alice", 5L, null, Row(20L))
+      )
+    )
+  }
+
+  test("reconcileStartAndEndAt: tracked-equal upserts separated by a gap do 
not coalesce; " +
+    "the closed run head keeps its endAt and the successor opens a new run") {
+    val processor = processorWithKeys(Seq("id"))
+    val userSchema = new StructType().add("id", IntegerType).add("value", 
StringType)
+
+    // A closed upsert [5, 10] is followed by an open upsert at 
recordStartAt=15. Although
+    // the two rows are tracked-equal, the closed run head ends at 10 - 
strictly before the
+    // successor begins at 15 - so the gap prevents coalescing. The closed row 
is its own
+    // run head and keeps its endAt; the successor is a fresh window-local run 
head anchored
+    // at its own recordStartAt. This exercises run-head startAt propagation 
for a closed
+    // upsert, which the other run-head tests only cover with open rows.
+    val df = targetTableOf(userSchema)(
+      Row(1, "alice", 5L, 10L, Row(5L)),
+      Row(1, "alice", 15L, null, Row(15L))
+    )
+
+    val result = processor.reconcileStartAndEndAt(df)
+
+    checkAnswer(
+      df = result,
+      expectedAnswer = Seq(
+        Row(1, "alice", 5L, 10L, Row(5L)),
+        Row(1, "alice", 15L, null, Row(15L))
+      )
+    )
+  }
+
+  test("reconcileStartAndEndAt returns an empty DataFrame for empty input") {
+    val processor = processorWithKeys(Seq("id"))
+    val userSchema = new StructType().add("id", IntegerType).add("value", 
StringType)
+
+    val df = targetTableOf(userSchema)()
+
+    val result = processor.reconcileStartAndEndAt(df)
+
+    assert(result.collect().isEmpty)
+    assert(result.columns.toSeq == df.columns.toSeq)
+  }
 }


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

Reply via email to